diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000000..7d30b303ec --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,44 @@ +--- +name: Bug report +about: Create a bug report to help us improve Loop + +--- + +**Describe the bug** +A clear and concise description of what the bug is. I.e. what you see vs what you expect to see. + +**Attach an Issue Report** +Tap the Loop settings icon on the bottom of the screen, then tap Issue Report and attach it to this ticket. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Phone** + - Hardware: [e.g. iPhone XS] + - OS Version: [e.g. iOS 12.0.1] + +**Loop Version** + - Version Number: [e.g. 1.9.2] + - Repo: [LoopKit/Loop, Katie, etc] + +**CGM** + - Device: [e.g. Dexcom G6] + - Manager app: [e.g. Dexcom App, Spike] + +**Pump** + - Manufacturer: [e.g. Medtronic] + - Model: [e.g. 723] + - Firmware version: [e.g. 2.3A 1.1 0B 0B] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000000..bbcbbe7d61 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/inactive_issues.yml b/.github/workflows/inactive_issues.yml new file mode 100644 index 0000000000..0a22a94fd0 --- /dev/null +++ b/.github/workflows/inactive_issues.yml @@ -0,0 +1,23 @@ +name: Close inactive issues +on: + schedule: + - cron: "30 1 * * *" + +jobs: + close-issues: + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + steps: + - uses: actions/stale@v5 + with: + operations-per-run: 100 + days-before-issue-stale: 30 + days-before-issue-close: 14 + stale-issue-label: "stale" + stale-issue-message: "This issue is stale because it has been open for 30 days with no activity." + close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale." + days-before-pr-stale: -1 + days-before-pr-close: -1 + repo-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 7f76a428f6..2d772d5f67 100644 --- a/.gitignore +++ b/.gitignore @@ -49,14 +49,12 @@ Pods/ # Carthage # # Add this line if you want to avoid checking in source code from Carthage dependencies. -Carthage/Checkouts/ -Carthage/Build/tvOS/ -Carthage/Build/iOS/*.bcsymbolmap -Carthage/Build/iOS/*.dSYM +Carthage/ +.gitmodules # fastlane # -# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the +# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the # screenshots whenever they are needed. # For more information about the recommended setup visit: # https://github.com/fastlane/fastlane/blob/master/docs/Gitignore.md @@ -70,3 +68,21 @@ RemoteSettings.plist # OS .DS_Store +# Framework development +Loop.xcworkspace + +# Avoid checking in override assets +LoopOverride.xcconfig +VersionOverride.xcconfig + +Loop/DerivedAssets.xcassets/* +WatchApp/DerivedAssets.xcassets/* +Loop\ Widget\ Extension/DerivedAssets.xcassets/* +# ...except, keep Contents.json +!Loop/DerivedAssets.xcassets/Contents.json +!WatchApp/DerivedAssets.xcassets/Contents.json +!Loop\ Widget\ Extension/DerivedAssets.xcassets/Contents.json + +Loop/DerivedAssetsOverride.xcassets +WatchApp/DerivedAssetsOverride.xcassets +Loop\ Widget\ Extension/DerivedAssetsOverride.xcassets diff --git a/.travis.yml b/.travis.yml index e47ca62627..d9c04f0bf2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,13 +1,20 @@ language: objective-c -osx_image: xcode8.1 -# xcode_sdk: iphonesimulator10.0 -# xcode_project: Loop.xcodeproj -# xcode_scheme: Loop -before_script: -# - carthage bootstrap -script: - # Build the app target - - xcodebuild -project Loop.xcodeproj -scheme Loop build CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO | xcpretty - # Run the test target - - xcodebuild -project Loop.xcodeproj -scheme LoopTests -destination 'name=iPhone SE' test | xcpretty - - xcodebuild -project Loop.xcodeproj -scheme DoseMathTests -destination 'name=iPhone SE' test | xcpretty +osx_image: xcode12.4 + +cache: + directories: + - Carthage + +jobs: + include: + - stage: build carthage + script: set -o pipefail && xcodebuild -project Loop.xcodeproj -target Cartfile | xcpretty + - stage: test build + script: set -o pipefail && xcodebuild -project Loop.xcodeproj -scheme Loop build CODE_SIGN_IDENTITY="" CODE_SIGNING_ALLOWED=NO | xcpretty + - # same stage; parallel + script: set -o pipefail && xcodebuild -project Loop.xcodeproj -scheme Learn build CODE_SIGN_IDENTITY="" CODE_SIGNING_ALLOWED=NO | xcpretty + - # same stage; parallel + script: set -o pipefail && xcodebuild -project Loop.xcodeproj -scheme LoopTests -destination 'platform=iOS Simulator,name=iPhone 8' test EXCLUDED_ARCHS=arm64 | xcpretty + - # same stage; parallel + script: set -o pipefail && xcodebuild -project Loop.xcodeproj -scheme DoseMathTests -destination 'name=iPhone 8' test | xcpretty + diff --git a/Cartfile b/Cartfile deleted file mode 100644 index b31582783e..0000000000 --- a/Cartfile +++ /dev/null @@ -1,7 +0,0 @@ -github "LoopKit/LoopKit" ~> 1.1.0 -github "LoopKit/xDripG5" ~> 0.8.0 -github "i-schuetz/SwiftCharts" ~> 0.5.0 -github "mddub/dexcom-share-client-swift" ~> 0.2.0 -github "mddub/G4ShareSpy" ~> 0.3.1 -github "ps2/rileylink_ios" ~> 0.13 -github "amplitude/Amplitude-iOS" ~> 3.8.5 diff --git a/Cartfile.resolved b/Cartfile.resolved deleted file mode 100644 index a1e070f364..0000000000 --- a/Cartfile.resolved +++ /dev/null @@ -1,7 +0,0 @@ -github "amplitude/Amplitude-iOS" "v3.11.1" -github "mddub/G4ShareSpy" "v0.3.1" -github "LoopKit/LoopKit" "v1.1.0" -github "i-schuetz/SwiftCharts" "0.5.1" -github "mddub/dexcom-share-client-swift" "v0.2.0" -github "ps2/rileylink_ios" "v0.13" -github "LoopKit/xDripG5" "v0.8.0" diff --git a/Carthage/Build/iOS/Amplitude.framework/Amplitude b/Carthage/Build/iOS/Amplitude.framework/Amplitude deleted file mode 100755 index d0f9c86f0d..0000000000 Binary files a/Carthage/Build/iOS/Amplitude.framework/Amplitude and /dev/null differ diff --git a/Carthage/Build/iOS/Amplitude.framework/Headers/AMPARCMacros.h b/Carthage/Build/iOS/Amplitude.framework/Headers/AMPARCMacros.h deleted file mode 100644 index eaf9024dd9..0000000000 --- a/Carthage/Build/iOS/Amplitude.framework/Headers/AMPARCMacros.h +++ /dev/null @@ -1,68 +0,0 @@ -// -// ARCMacros.h -// InnerBand -// -// For an explanation of why these work, see: -// -// http://raptureinvenice.com/arc-support-without-branches/ -// -// Created by John Blanco on 1/28/12. -// Copyright (c) 2012 Rapture In Venice. All rights reserved. -// -// NOTE: __bridge_tranfer is not included here because releasing would be inconsistent. -// Avoid it unless you're using ARC exclusively or managing it with __has_feature(objc_arc). -// - -#if !defined(__clang__) || __clang_major__ < 3 - #ifndef __bridge - #define __bridge - #endif - - #ifndef __bridge_retain - #define __bridge_retain - #endif - - #ifndef __bridge_retained - #define __bridge_retained - #endif - - #ifndef __autoreleasing - #define __autoreleasing - #endif - - #ifndef __strong - #define __strong - #endif - - #ifndef __unsafe_unretained - #define __unsafe_unretained - #endif - - #ifndef __weak - #define __weak - #endif -#endif - -#if __has_feature(objc_arc) - #define SAFE_ARC_PROP_RETAIN strong - #define SAFE_ARC_RETAIN(x) (x) - #define SAFE_ARC_RELEASE(x) - #define SAFE_ARC_AUTORELEASE(x) (x) - #define SAFE_ARC_BLOCK_COPY(x) (x) - #define SAFE_ARC_BLOCK_RELEASE(x) - #define SAFE_ARC_SUPER_DEALLOC() - #define SAFE_ARC_DISPATCH_RELEASE(x) (x) - #define SAFE_ARC_AUTORELEASE_POOL_START() @autoreleasepool { - #define SAFE_ARC_AUTORELEASE_POOL_END() } -#else - #define SAFE_ARC_PROP_RETAIN retain - #define SAFE_ARC_RETAIN(x) ([(x) retain]) - #define SAFE_ARC_RELEASE(x) ([(x) release]) - #define SAFE_ARC_AUTORELEASE(x) ([(x) autorelease]) - #define SAFE_ARC_BLOCK_COPY(x) (Block_copy(x)) - #define SAFE_ARC_BLOCK_RELEASE(x) (Block_release(x)) - #define SAFE_ARC_SUPER_DEALLOC() ([super dealloc]) - #define SAFE_ARC_DISPATCH_RELEASE(x) (dispatch_release(x)) - #define SAFE_ARC_AUTORELEASE_POOL_START() NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init]; - #define SAFE_ARC_AUTORELEASE_POOL_END() [pool release]; -#endif diff --git a/Carthage/Build/iOS/Amplitude.framework/Headers/AMPConstants.h b/Carthage/Build/iOS/Amplitude.framework/Headers/AMPConstants.h deleted file mode 100644 index b5de4235b6..0000000000 --- a/Carthage/Build/iOS/Amplitude.framework/Headers/AMPConstants.h +++ /dev/null @@ -1,36 +0,0 @@ -// -// AMPConstants.h -#import - -extern NSString *const kAMPLibrary; -extern NSString *const kAMPPlatform; -extern NSString *const kAMPVersion; -extern NSString *const kAMPEventLogDomain; -extern NSString *const kAMPEventLogUrl; -extern NSString *const kAMPDefaultInstance; -extern const int kAMPApiVersion; -extern const int kAMPDBVersion; -extern const int kAMPDBFirstVersion; -extern const int kAMPEventUploadThreshold; -extern const int kAMPEventUploadMaxBatchSize; -extern const int kAMPEventMaxCount; -extern const int kAMPEventRemoveBatchSize; -extern const int kAMPEventUploadPeriodSeconds; -extern const long kAMPMinTimeBetweenSessionsMillis; -extern const int kAMPMaxStringLength; -extern const int kAMPMaxPropertyKeys; - -extern NSString *const IDENTIFY_EVENT; -extern NSString *const AMP_OP_ADD; -extern NSString *const AMP_OP_APPEND; -extern NSString *const AMP_OP_CLEAR_ALL; -extern NSString *const AMP_OP_PREPEND; -extern NSString *const AMP_OP_SET; -extern NSString *const AMP_OP_SET_ONCE; -extern NSString *const AMP_OP_UNSET; - -extern NSString *const AMP_REVENUE_PRODUCT_ID; -extern NSString *const AMP_REVENUE_QUANTITY; -extern NSString *const AMP_REVENUE_PRICE; -extern NSString *const AMP_REVENUE_REVENUE_TYPE; -extern NSString *const AMP_REVENUE_RECEIPT; diff --git a/Carthage/Build/iOS/Amplitude.framework/Headers/AMPDatabaseHelper.h b/Carthage/Build/iOS/Amplitude.framework/Headers/AMPDatabaseHelper.h deleted file mode 100644 index d140b4eb90..0000000000 --- a/Carthage/Build/iOS/Amplitude.framework/Headers/AMPDatabaseHelper.h +++ /dev/null @@ -1,40 +0,0 @@ -// -// AMPDatabaseHelper.h -// Amplitude -// -// Created by Daniel Jih on 9/9/15. -// Copyright (c) 2015 Amplitude. All rights reserved. -// - -@interface AMPDatabaseHelper : NSObject - -@property (nonatomic, strong, readonly) NSString *databasePath; - -+ (AMPDatabaseHelper*)getDatabaseHelper; -+ (AMPDatabaseHelper*)getDatabaseHelper:(NSString*) instanceName; -- (BOOL)createTables; -- (BOOL)dropTables; -- (BOOL)upgrade:(int) oldVersion newVersion:(int) newVersion; -- (BOOL)resetDB:(BOOL) deleteDB; -- (BOOL)deleteDB; - -- (BOOL)addEvent:(NSString*) event; -- (BOOL)addIdentify:(NSString*) identify; -- (NSMutableArray*)getEvents:(long long) upToId limit:(long long) limit; -- (NSMutableArray*)getIdentifys:(long long) upToId limit:(long long) limit; -- (int)getEventCount; -- (int)getIdentifyCount; -- (int)getTotalEventCount; -- (BOOL)removeEvents:(long long) maxId; -- (BOOL)removeIdentifys:(long long) maxIdentifyId; -- (BOOL)removeEvent:(long long) eventId; -- (BOOL)removeIdentify:(long long) identifyId; -- (long long)getNthEventId:(long long) n; -- (long long)getNthIdentifyId:(long long) n; - -- (BOOL)insertOrReplaceKeyValue:(NSString*) key value:(NSString*) value; -- (BOOL)insertOrReplaceKeyLongValue:(NSString*) key value:(NSNumber*) value; -- (NSString*)getValue:(NSString*) key; -- (NSNumber*)getLongValue:(NSString*) key; - -@end diff --git a/Carthage/Build/iOS/Amplitude.framework/Headers/AMPDeviceInfo.h b/Carthage/Build/iOS/Amplitude.framework/Headers/AMPDeviceInfo.h deleted file mode 100644 index f6cf0b6e9a..0000000000 --- a/Carthage/Build/iOS/Amplitude.framework/Headers/AMPDeviceInfo.h +++ /dev/null @@ -1,20 +0,0 @@ -// -// AMPDeviceInfo.h - -@interface AMPDeviceInfo : NSObject - --(id) init; -@property (readonly) NSString *appVersion; -@property (readonly) NSString *osName; -@property (readonly) NSString *osVersion; -@property (readonly) NSString *manufacturer; -@property (readonly) NSString *model; -@property (readonly) NSString *carrier; -@property (readonly) NSString *country; -@property (readonly) NSString *language; -@property (readonly) NSString *advertiserID; -@property (readonly) NSString *vendorID; - --(NSString*) generateUUID; - -@end \ No newline at end of file diff --git a/Carthage/Build/iOS/Amplitude.framework/Headers/AMPIdentify.h b/Carthage/Build/iOS/Amplitude.framework/Headers/AMPIdentify.h deleted file mode 100644 index d9a6c16fb9..0000000000 --- a/Carthage/Build/iOS/Amplitude.framework/Headers/AMPIdentify.h +++ /dev/null @@ -1,138 +0,0 @@ -// -// AMPIdentify.h -// Amplitude -// -// Created by Daniel Jih on 10/5/15. -// Copyright © 2015 Amplitude. All rights reserved. -// - -/** - `AMPIdentify` objects are a wrapper for user property operations, which get passed to the `identify` method to send to Amplitude servers. - - **Note:** if a user property is used in multiple operations on the same Identify object, only the first operation will be saved, and the rest will be ignored. - - Each method adds a user property operation to the Identify object, and returns the same Identify object, allowing you to chain multiple method calls together. - - Here is an example of how to use `AMPIdentify` to send user property operations: - - AMPIdentify *identify = [[AMPIdentify identify] add:@"karma" value:[NSNumber numberWithInt:1]]; - [[identify set:@"colors" value:@[@"rose", @"gold"]] append:@"ab-tests" value:@"campaign_a"]; - [[Amplitude instance] identify:identify]; - - See [User Properties and User Property Operations](https://github.com/amplitude/amplitude-ios#user-properties-and-user-property-operations) - */ -@interface AMPIdentify : NSObject - -@property (nonatomic, strong, readonly) NSMutableDictionary *userPropertyOperations; - -/**----------------------------------------------------------------------------- - * @name Creating an AMPIdentify Object - * ----------------------------------------------------------------------------- - */ - -/** - Creates a nwe [AMPIdentify](#) object. - - @returns a new [AMPIdentify](#) object. - */ -+ (instancetype)identify; - -/**----------------------------------------------------------------------------- - * @name User Property Operations via Identify API - * ----------------------------------------------------------------------------- - */ - -/** - Increment a user property by a given value (can also be negative to decrement). - - If the user property does not have a value set yet, it will be initialized to 0 before being incremented. - - @param property The user property key - - @param value The amount by which to increment the user property. - - @returns the same [AMPIdentify](#) object, allowing you to chain multiple method calls together. - - @see [User Properties and User Property Operations](https://github.com/amplitude/amplitude-ios#user-properties-and-user-property-operations) - */ -- (AMPIdentify*)add:(NSString*) property value:(NSObject*) value; - -/** - Append a value or values to a user property. - - If the user property does not have a value set yet, it will be initialized to an empty list before the new values are appended. If the user property has an existing value and it is not a list, the existing value will be converted into a list with the new values appended. - - @param property The user property key - - @param value A value or values to append. - - @returns the same [AMPIdentify](#) object, allowing you to chain multiple method calls together. - - @see [User Properties and User Property Operations](https://github.com/amplitude/amplitude-ios#user-properties-and-user-property-operations) - */ -- (AMPIdentify*)append:(NSString*) property value:(NSObject*) value; - -/* - Internal method for clearing user properties. - - **Note:** $clearAll needs to be sent on its own Identify object. If there are already other operations, then don't add $clearAll. If $clearAll already in an Identify object, don't allow other operations to be added. - */ -- (AMPIdentify*)clearAll; - -/** - Prepend a value or values to a user property. Prepend means inserting the value or values at the front of a list. - - If the user property does not have a value set yet, it will be initialized to an empty list before the new values are prepended. If the user property has an existing value and it is not a list, the existing value will be converted into a list with the new values prepended. - - @param property The user property key - - @param value A value or values to prepend. - - @returns the same [AMPIdentify](#) object, allowing you to chain multiple method calls together. - - @see [User Properties and User Property Operations](https://github.com/amplitude/amplitude-ios#user-properties-and-user-property-operations) - */ -- (AMPIdentify*)prepend:(NSString*) property value:(NSObject*) value; - -/** - Sets the value of a given user property. If the value already exists, it will be overwritten with the new value. - - @param property The user property key - - @param value A value or values to set. - - @returns the same [AMPIdentify](#) object, allowing you to chain multiple method calls together. - - @see [User Properties and User Property Operations](https://github.com/amplitude/amplitude-ios#user-properties-and-user-property-operations) - */ -- (AMPIdentify*)set:(NSString*) property value:(NSObject*) value; - - -/** - Sets the value of a given user property only once. Subsequent `setOnce` operations on that user property will be ignored; however, that user property can still be modified through any of the other operations. - - This is useful for capturing properties such as initial_signup_date, initial_referrer, etc. - - @param property The user property key - - @param value A value or values to set once. - - @returns the same [AMPIdentify](#) object, allowing you to chain multiple method calls together. - - @see [User Properties and User Property Operations](https://github.com/amplitude/amplitude-ios#user-properties-and-user-property-operations) - */ -- (AMPIdentify*)setOnce:(NSString*) property value:(NSObject*) value; - - -/** - Unset and remove user property. This user property will no longer show up in that user's profile. - - @param property The user property key to unset. - - @returns the same [AMPIdentify](#) object, allowing you to chain multiple method calls together. - - @see [User Properties and User Property Operations](https://github.com/amplitude/amplitude-ios#user-properties-and-user-property-operations) - */ -- (AMPIdentify*)unset:(NSString*) property; - -@end diff --git a/Carthage/Build/iOS/Amplitude.framework/Headers/AMPLocationManagerDelegate.h b/Carthage/Build/iOS/Amplitude.framework/Headers/AMPLocationManagerDelegate.h deleted file mode 100644 index 314ff9a053..0000000000 --- a/Carthage/Build/iOS/Amplitude.framework/Headers/AMPLocationManagerDelegate.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// AMPLocationManagerDelegate.h - -#import -#import - -@interface AMPLocationManagerDelegate : NSObject - -- (void)locationManager:(CLLocationManager*) manager didFailWithError:(NSError*) error; - -- (void)locationManager:(CLLocationManager*) manager didUpdateToLocation:(CLLocation*) newLocation fromLocation:(CLLocation*) oldLocation; - -- (void)locationManager:(CLLocationManager*) manager didChangeAuthorizationStatus:(CLAuthorizationStatus) status; - -@end diff --git a/Carthage/Build/iOS/Amplitude.framework/Headers/AMPRevenue.h b/Carthage/Build/iOS/Amplitude.framework/Headers/AMPRevenue.h deleted file mode 100644 index 7b4b1bdaa5..0000000000 --- a/Carthage/Build/iOS/Amplitude.framework/Headers/AMPRevenue.h +++ /dev/null @@ -1,168 +0,0 @@ -// -// AMPRevenue.h -// Amplitude -// -// Created by Daniel Jih on 04/18/16. -// Copyright © 2016 Amplitude. All rights reserved. -// - -/** - `AMPRevenue` objects are a wrapper for revenue data, which get passed to the `logRevenueV2` method to send to Amplitude servers. - - **Note:** price is a required field. If quantity is not specified, then defaults to 1. - - **Note:** Revenue amount is calculated as price * quantity. - - Each method updates a revenue property in the Revenue object, and returns the same Revenue object, allowing you to chain multiple method calls together. - - Here is an example of how to use `AMPRevenue` to send revenue data: - - AMPRevenue *revenue = [[[AMPRevenue revenue] setProductIdentifier:@"productIdentifier"] setQuantity:3]; - [revenue setPrice:[NSNumber numberWithDouble:3.99]]; - [[Amplitude instance] logRevenueV2:revenue]; - - See [Tracking Revenue](https://github.com/amplitude/Amplitude-iOS#tracking-revenue) for more information about logging Revenue. - */ - -@interface AMPRevenue : NSObject - -/**----------------------------------------------------------------------------- - * @name Required Revenue Fields - * ----------------------------------------------------------------------------- - */ - -/** - The product identifier for the transaction (optional). - */ -@property (nonatomic, strong, readonly) NSString *productId; - -/** - The price of product(s) in the transaction. - - @warning: required field - */ -@property (nonatomic, strong, readonly) NSNumber *price; - -/**----------------------------------------------------------------------------- - * @name Optional Revenue Fields - * ----------------------------------------------------------------------------- - */ - -/** - The quantity of product(s) purchased in the transaction. - - @warning: defaults to 1 - */ -@property (nonatomic, readonly) NSInteger quantity; - -/** - The revenue type for the transaction (optional). - */ -@property (nonatomic, strong, readonly) NSString *revenueType; - -/** - The receipt data for the transaction. Required if you want to verify the revenue event. - - @see [Revenue Validation](https://github.com/amplitude/amplitude-ios#revenue-verification) - */ -@property (nonatomic, strong, readonly) NSData *receipt; - -/** - Event properties for the revenue event. - - @see [Setting Event Properties](https://github.com/amplitude/amplitude-ios#setting-event-properties) - */ -@property (nonatomic, strong, readonly) NSDictionary *properties; - -/**----------------------------------------------------------------------------- - * @name Creating an AMPRevenue Object - * ----------------------------------------------------------------------------- - */ - -/** - Creates a new [AMPRevenue](#) object. - - @returns a new [AMPRevenue](#) object. - */ -+ (instancetype)revenue; - -/* - private internal method to verify that all required revenue fields are set - */ -- (BOOL) isValidRevenue; - -/**----------------------------------------------------------------------------- - * @name Setter Methods for Revenue Fields - * ----------------------------------------------------------------------------- - */ - -/** - Set a value for the product identifier. - - @param productIdentifier The value for the product identifier. Empty strings are ignored. - - @returns the same [AMPRevenue](#) object, allowing you to chain multiple method calls together. - */ -- (AMPRevenue*)setProductIdentifier:(NSString*) productIdentifier; - -/** - Set a value for the quantity. - - **Note** revenue amount is calculated as price * quantity. - - @param quantity Integer value for the quantity. Defaults to 1 if not specified. - - @returns the same [AMPRevenue](#) object, allowing you to chain multiple method calls together. - */ -- (AMPRevenue*)setQuantity:(NSInteger) quantity; - - -/** - Set a value for the price. - - **Note** revenue amount is calculated as price * quantity. - - @param price The value for the price. - - @returns the same [AMPRevenue](#) object, allowing you to chain multiple method calls together. - */ -- (AMPRevenue*)setPrice:(NSNumber*) price; - - -/** - Set a value for the revenueType (for example purchase, cost, tax, refund, etc). - - @param revenueType String value for the revenue type. - - @returns the same [AMPRevenue](#) object, allowing you to chain multiple method calls together. - */ -- (AMPRevenue*)setRevenueType:(NSString*) revenueType; - - -/** - Add the receipt data for the transaction. Reequired if you want to verify this revenue event. - - @param receipt The receipt data from the App Store. - - @returns the same [AMPRevenue](#) object, allowing you to chain multiple method calls together. - - @see [Revenue Validation](https://github.com/amplitude/amplitude-ios#revenue-verification) - @see [Validating Receipts with the App Store](https://developer.apple.com/library/ios/releasenotes/General/ValidateAppStoreReceipt/Chapters/ValidateRemotely.html#//apple_ref/doc/uid/TP40010573-CH104-SW1) - */ -- (AMPRevenue*)setReceipt:(NSData*) receipt; - -/** - Set event properties for the revenue event. - - @param eventProperties An `NSDictionary` of event properties to set for the revenue event. - - @returns the same [AMPRevenue](#) object, allowing you to chain multiple method calls together. - - @see [Setting Event Properties](https://github.com/amplitude/amplitude-ios#setting-event-properties) - */ -- (AMPRevenue*)setEventProperties:(NSDictionary*) eventProperties; - - -- (NSDictionary*)toNSDictionary; - -@end diff --git a/Carthage/Build/iOS/Amplitude.framework/Headers/AMPURLConnection.h b/Carthage/Build/iOS/Amplitude.framework/Headers/AMPURLConnection.h deleted file mode 100644 index 3980a8f016..0000000000 --- a/Carthage/Build/iOS/Amplitude.framework/Headers/AMPURLConnection.h +++ /dev/null @@ -1,18 +0,0 @@ -#if AMPLITUDE_SSL_PINNING -// -// AMPURLConnection.h -// Amplitude -// -// Created by Allan on 3/13/15. -// Copyright (c) 2015 Amplitude. All rights reserved. -// - -#import -#import "ISPPinnedNSURLConnectionDelegate.h" - -@interface AMPURLConnection : ISPPinnedNSURLConnectionDelegate - -+ (void)sendAsynchronousRequest:(NSURLRequest *)request queue:(NSOperationQueue *)queue completionHandler:(void (^)(NSURLResponse *response, NSData *data, NSError *connectionError))handler; - -@end -#endif \ No newline at end of file diff --git a/Carthage/Build/iOS/Amplitude.framework/Headers/AMPUtils.h b/Carthage/Build/iOS/Amplitude.framework/Headers/AMPUtils.h deleted file mode 100644 index a80d8ff900..0000000000 --- a/Carthage/Build/iOS/Amplitude.framework/Headers/AMPUtils.h +++ /dev/null @@ -1,16 +0,0 @@ -// -// AMPUtils.h -// Pods -// -// Created by Daniel Jih on 10/4/15. -// -// - -@interface AMPUtils : NSObject - -+ (NSString*)generateUUID; -+ (id) makeJSONSerializable:(id) obj; -+ (BOOL) isEmptyString:(NSString*) str; -+ (NSDictionary*) validateGroups:(NSDictionary*) obj; - -@end diff --git a/Carthage/Build/iOS/Amplitude.framework/Headers/Amplitude+SSLPinning.h b/Carthage/Build/iOS/Amplitude.framework/Headers/Amplitude+SSLPinning.h deleted file mode 100644 index 05d4dd90d1..0000000000 --- a/Carthage/Build/iOS/Amplitude.framework/Headers/Amplitude+SSLPinning.h +++ /dev/null @@ -1,17 +0,0 @@ -#ifdef AMPLITUDE_SSL_PINNING -// -// Amplitude+SSLPinning -// Amplitude -// -// Created by Allan on 3/11/15. -// Copyright (c) 2015 Amplitude. All rights reserved. -// - -#import - -@interface Amplitude (SSLPinning) - -@property (nonatomic, assign) BOOL sslPinningEnabled; - -@end -#endif \ No newline at end of file diff --git a/Carthage/Build/iOS/Amplitude.framework/Headers/Amplitude.h b/Carthage/Build/iOS/Amplitude.framework/Headers/Amplitude.h deleted file mode 100644 index 5dff5a6d8c..0000000000 --- a/Carthage/Build/iOS/Amplitude.framework/Headers/Amplitude.h +++ /dev/null @@ -1,589 +0,0 @@ -// -// Amplitude.h - -#import -#import "AMPIdentify.h" -#import "AMPRevenue.h" - - -/** - Amplitude iOS SDK. - - Use the Amplitude SDK to track events in your application. - - Setup: - - 1. In every file that uses analytics, import Amplitude.h at the top `#import "Amplitude.h"` - 2. Be sure to initialize the API in your didFinishLaunchingWithOptions delegate `[[Amplitude instance] initializeApiKey:@"YOUR_API_KEY_HERE"];` - 3. Track an event anywhere in the app `[[Amplitude instance] logEvent:@"EVENT_IDENTIFIER_HERE"];` - 4. You can attach additional data to any event by passing a NSDictionary object: - - NSMutableDictionary *eventProperties = [NSMutableDictionary dictionary]; - [eventProperties setValue:@"VALUE_GOES_HERE" forKey:@"KEY_GOES_HERE"]; - [[Amplitude instance] logEvent:@"Compute Hash" withEventProperties:eventProperties]; - - **Note:** you should call SDK methods on an Amplitude instance, for example logging events with the default instance: `[[Amplitude instance] logEvent:@"testEvent"];` - - **Note:** the SDK supports tracking data to multiple Amplitude apps, via separate named instances. For example: `[[Amplitude instanceWithName:@"app1"] logEvent:@"testEvent"];` See [Tracking Events to Multiple Apps](https://github.com/amplitude/amplitude-ios#tracking-events-to-multiple-amplitude-apps). - - For more details on the setup and usage, be sure to checkout the [README](https://github.com/amplitude/Amplitude-iOS#amplitude-ios-sdk) - */ -@interface Amplitude : NSObject - -#pragma mark - Properties - - /**----------------------------------------------------------------------------- - * @name Instance Properties - * ----------------------------------------------------------------------------- - */ - - /** - API key for your Amplitude App. - */ -@property (nonatomic, strong, readonly) NSString *apiKey; - -/** - Identifier for the current user. - */ -@property (nonatomic, strong, readonly) NSString *userId; - -/** - Identifier for the current device. - */ -@property (nonatomic, strong, readonly) NSString *deviceId; - -/** - Name of the SDK instance (ex: no name for default instance, or custom name for a named instance) - */ -@property (nonatomic, strong, readonly) NSString *instanceName; -@property (nonatomic ,strong, readonly) NSString *propertyListPath; - -/** - Whether or to opt the current user out of tracking. If true then this blocks the logging of any events and properties, and blocks the sending of events to Amplitude servers. - */ -@property (nonatomic, assign) BOOL optOut; - - -/**----------------------------------------------------------------------------- - * @name Configurable SDK thresholds and parameters - * ----------------------------------------------------------------------------- - */ - -/** - The maximum number of events that can be stored locally before forcing an upload. The default is 30 events. - */ -@property (nonatomic, assign) int eventUploadThreshold; - -/** - The maximum number of events that can be uploaded in a single request. The default is 100 events. - */ -@property (nonatomic, assign) int eventUploadMaxBatchSize; - -/** - The maximum number of events that can be stored lcoally. The default is 1000 events. - */ -@property (nonatomic, assign) int eventMaxCount; - -/** - The amount of time after an event is logged that events will be batched before being uploaded to the server. The default is 30 seconds. - */ -@property (nonatomic, assign) int eventUploadPeriodSeconds; - -/** - When a user closes and reopens the app within minTimeBetweenSessionsMillis milliseconds, the reopen is considered part of the same session and the session continues. Otherwise, a new session is created. The default is 15 minutes. - */ -@property (nonatomic, assign) long minTimeBetweenSessionsMillis; - -/** - Whether to automatically log start and end session events corresponding to the start and end of a user's session. - */ -@property (nonatomic, assign) BOOL trackingSessionEvents; - - -#pragma mark - Methods - -/**----------------------------------------------------------------------------- - * @name Fetching Amplitude SDK instance - * ----------------------------------------------------------------------------- - */ - -/** - This fetches the default SDK instance. Recommended if you are only logging events to a single app. - - @returns the default Amplitude SDK instance - */ -+ (Amplitude *)instance; - -/** - This fetches a named SDK instance. Use this if logging events to multiple Amplitude apps. - - @param instanceName the name of the SDK instance to fetch. - - @returns the Amplitude SDK instance corresponding to `instanceName` - - @see [Tracking Events to Multiple Amplitude Apps](https://github.com/amplitude/amplitude-ios#tracking-events-to-multiple-amplitude-apps) - */ -+ (Amplitude *)instanceWithName:(NSString*) instanceName; - -/**----------------------------------------------------------------------------- - * @name Initialize the Amplitude SDK with your Amplitude API Key - * ----------------------------------------------------------------------------- - */ - -/** - Initializes the Amplitude instance with your Amplitude API key - - We recommend you first initialize your class within your "didFinishLaunchingWithOptions" method inside your app delegate. - - **Note:** this is required before you can log any events. - - @param apiKey Your Amplitude key obtained from your dashboard at https://amplitude.com/settings - */ -- (void)initializeApiKey:(NSString*) apiKey; - -/** - Initializes the Amplitude instance with your Amplitude API key and sets a user identifier for the current user. - - We recommend you first initialize your class within your "didFinishLaunchingWithOptions" method inside your app delegate. - - **Note:** this is required before you can log any events. - - @param apiKey Your Amplitude key obtained from your dashboard at https://amplitude.com/settings - - @param userId If your app has its own login system that you want to track users with, you can set the userId. - -*/ -- (void)initializeApiKey:(NSString*) apiKey userId:(NSString*) userId; - - -/**----------------------------------------------------------------------------- - * @name Logging Events - * ----------------------------------------------------------------------------- - */ - -/** - Tracks an event. Events are saved locally. - - Uploads are batched to occur every 30 events or every 30 seconds (whichever comes first), as well as on app close. - - @param eventType The name of the event you wish to track. - - @see [Tracking Events](https://github.com/amplitude/amplitude-ios#tracking-events) - */ -- (void)logEvent:(NSString*) eventType; - -/** - Tracks an event. Events are saved locally. - - Uploads are batched to occur every 30 events or every 30 seconds (whichever comes first), as well as on app close. - - @param eventType The name of the event you wish to track. - @param eventProperties You can attach additional data to any event by passing a NSDictionary object with property: value pairs. - - @see [Tracking Events](https://github.com/amplitude/amplitude-ios#tracking-events) - */ -- (void)logEvent:(NSString*) eventType withEventProperties:(NSDictionary*) eventProperties; - -/** - Tracks an event. Events are saved locally. - - Uploads are batched to occur every 30 events or every 30 seconds (whichever comes first), as well as on app close. - - @param eventType The name of the event you wish to track. - @param eventProperties You can attach additional data to any event by passing a NSDictionary object with property: value pairs. - @param outOfSession If YES, will track the event as out of session. Useful for push notification events. - - @see [Tracking Events](https://github.com/amplitude/amplitude-ios#tracking-events) - @see [Tracking Sessions](https://github.com/amplitude/Amplitude-iOS#tracking-sessions) - */ -- (void)logEvent:(NSString*) eventType withEventProperties:(NSDictionary*) eventProperties outOfSession:(BOOL) outOfSession; - -/** - Tracks an event. Events are saved locally. - - Uploads are batched to occur every 30 events or every 30 seconds (whichever comes first), as well as on app close. - - @param eventType The name of the event you wish to track. - @param eventProperties You can attach additional data to any event by passing a NSDictionary object with property: value pairs. - @param groups You can specify event-level groups for this user by passing a NSDictionary object with groupType: groupName pairs. Note the keys need to be strings, and the values can either be strings or an array of strings. - - @see [Tracking Events](https://github.com/amplitude/amplitude-ios#tracking-events) - - @see [Setting Groups](https://github.com/amplitude/Amplitude-iOS#setting-groups) - */ -- (void)logEvent:(NSString*) eventType withEventProperties:(NSDictionary*) eventProperties withGroups:(NSDictionary*) groups; - -/** - Tracks an event. Events are saved locally. - - Uploads are batched to occur every 30 events or every 30 seconds (whichever comes first), as well as on app close. - - @param eventType The name of the event you wish to track. - @param eventProperties You can attach additional data to any event by passing a NSDictionary object with property: value pairs. - @param groups You can specify event-level groups for this user by passing a NSDictionary object with groupType: groupName pairs. Note the keys need to be strings, and the values can either be strings or an array of strings. - @param outOfSession If YES, will track the event as out of session. Useful for push notification events. - - @see [Tracking Events](https://github.com/amplitude/amplitude-ios#tracking-events) - - @see [Setting Groups](https://github.com/amplitude/Amplitude-iOS#setting-groups) - - @see [Tracking Sessions](https://github.com/amplitude/Amplitude-iOS#tracking-sessions) - */ -- (void)logEvent:(NSString*) eventType withEventProperties:(NSDictionary*) eventProperties withGroups:(NSDictionary*) groups outOfSession:(BOOL) outOfSession; - -/** - Tracks an event. Events are saved locally. - - Uploads are batched to occur every 30 events or every 30 seconds (whichever comes first), as well as on app close. - - @param eventType The name of the event you wish to track. - @param eventProperties You can attach additional data to any event by passing a NSDictionary object with property: value pairs. - @param groups You can specify event-level groups for this user by passing a NSDictionary object with groupType: groupName pairs. Note the keys need to be strings, and the values can either be strings or an array of strings. - @param longLongtimestamp You can specify a custom timestamp by passing the milliseconds since epoch UTC time as a long long. - @param outOfSession If YES, will track the event as out of session. Useful for push notification events. - - @see [Tracking Events](https://github.com/amplitude/amplitude-ios#tracking-events) - - @see [Setting Groups](https://github.com/amplitude/Amplitude-iOS#setting-groups) - - @see [Tracking Sessions](https://github.com/amplitude/Amplitude-iOS#tracking-sessions) - */ -- (void)logEvent:(NSString*) eventType withEventProperties:(NSDictionary*) eventProperties withGroups:(NSDictionary*) groups withLongLongTimestamp:(long long) timestamp outOfSession:(BOOL)outOfSession; - -/** - Tracks an event. Events are saved locally. - - Uploads are batched to occur every 30 events or every 30 seconds (whichever comes first), as well as on app close. - - @param eventType The name of the event you wish to track. - @param eventProperties You can attach additional data to any event by passing a NSDictionary object with property: value pairs. - @param groups You can specify event-level groups for this user by passing a NSDictionary object with groupType: groupName pairs. Note the keys need to be strings, and the values can either be strings or an array of strings. - @param timestamp You can specify a custom timestamp by passing an NSNumber representing the milliseconds since epoch UTC time. We recommend using [NSNumber numberWithLongLong:milliseconds] to create the value. If nil is passed in, then the event will be timestamped with the current time. - @param outOfSession If YES, will track the event as out of session. Useful for push notification events. - - @see [Tracking Events](https://github.com/amplitude/amplitude-ios#tracking-events) - - @see [Setting Groups](https://github.com/amplitude/Amplitude-iOS#setting-groups) - - @see [Tracking Sessions](https://github.com/amplitude/Amplitude-iOS#tracking-sessions) - */ -- (void)logEvent:(NSString*) eventType withEventProperties:(NSDictionary*) eventProperties withGroups:(NSDictionary*) groups withTimestamp:(NSNumber*) timestamp outOfSession:(BOOL)outOfSession; - -/**----------------------------------------------------------------------------- - * @name Logging Revenue - * ----------------------------------------------------------------------------- - */ - -/** - **Note: this is deprecated** - please use `logRevenueV2` and `AMPRevenue` - - Tracks revenue. - - To track revenue from a user, call [[Amplitude instance] logRevenue:[NSNumber numberWithDouble:3.99]] each time the user generates revenue. logRevenue: takes in an NSNumber with the dollar amount of the sale as the only argument. This allows us to automatically display data relevant to revenue on the Amplitude website, including average revenue per daily active user (ARPDAU), 7, 30, and 90 day revenue, lifetime value (LTV) estimates, and revenue by advertising campaign cohort and daily/weekly/monthly cohorts. - - @param amount The amount of revenue to track, e.g. "3.99". - - @see [LogRevenue Backwards Compatability](https://github.com/amplitude/Amplitude-iOS#backwards-compatibility) - */ -- (void)logRevenue:(NSNumber*) amount; - -/** - **Note: this is deprecated** - please use `logRevenueV2` and `AMPRevenue` - - Tracks revenue. This allows us to automatically display data relevant to revenue on the Amplitude website, including average revenue per daily active user (ARPDAU), 7, 30, and 90 day revenue, lifetime value (LTV) estimates, and revenue by advertising campaign cohort and daily/weekly/monthly cohorts. - - @param productidentifier The identifier for the product in the transaction, e.g. "com.amplitude.productId" - @param quantity The number of products in the transaction. Revenue amount is calculated as quantity * price - @param price The price of the products in the transaction. Revenue amount is calculated as quantity * price - - @see [LogRevenueV2](https://github.com/amplitude/Amplitude-iOS#tracking-revenue) - @see [LogRevenue Backwards Compatability](https://github.com/amplitude/Amplitude-iOS#backwards-compatibility) - */ -- (void)logRevenue:(NSString*) productIdentifier quantity:(NSInteger) quantity price:(NSNumber*) price; - -/** - **Note: this is deprecated** - please use `logRevenueV2` and `AMPRevenue` - - Tracks revenue. This allows us to automatically display data relevant to revenue on the Amplitude website, including average revenue per daily active user (ARPDAU), 7, 30, and 90 day revenue, lifetime value (LTV) estimates, and revenue by advertising campaign cohort and daily/weekly/monthly cohorts. - - For validating revenue, use [[Amplitude instance] logRevenue:@"com.company.app.productId" quantity:1 price:[NSNumber numberWithDouble:3.99] receipt:transactionReceipt] - - @param productidentifier The identifier for the product in the transaction, e.g. "com.amplitude.productId" - @param quantity The number of products in the transaction. Revenue amount is calculated as quantity * price - @param price The price of the products in the transaction. Revenue amount is calculated as quantity * price - @param receipt The receipt data from the App Store. Required if you want to verify this revenue event. - - @see [LogRevenueV2](https://github.com/amplitude/Amplitude-iOS#tracking-revenue) - @see [LogRevenue Backwards Compatability](https://github.com/amplitude/Amplitude-iOS#backwards-compatibility) - @see [Revenue Verification](https://github.com/amplitude/Amplitude-iOS#revenue-verification) - */ -- (void)logRevenue:(NSString*) productIdentifier quantity:(NSInteger) quantity price:(NSNumber*) price receipt:(NSData*) receipt; - -/** - Tracks revenue - API v2. This uses the `AMPRevenue` object to store transaction properties such as quantity, price, and revenue type. This is the recommended method for tracking revenue in Amplitude. - - For validating revenue, make sure the receipt data is set on the AMPRevenue object. - - To track revenue from a user, create an AMPRevenue object each time the user generates revenue, and set the revenue properties (productIdentifier, price, quantity). logRevenuev2: takes in an AMPRevenue object. This allows us to automatically display data relevant to revenue on the Amplitude website, including average revenue per daily active user (ARPDAU), 7, 30, and 90 day revenue, lifetime value (LTV) estimates, and revenue by advertising campaign cohort and daily/weekly/monthly cohorts. - - @param AMPRevenue object revenue object contains all revenue information - - @see [Tracking Revenue](https://github.com/amplitude/Amplitude-iOS#tracking-revenue) - */ -- (void)logRevenueV2:(AMPRevenue*) revenue; - -/**----------------------------------------------------------------------------- - * @name User Properties and User Property Operations - * ----------------------------------------------------------------------------- - */ - -/** - Update user properties using operations provided via Identify API. - - To update user properties, first create an AMPIdentify object. For example if you wanted to set a user's gender, and then increment their karma count by 1, you would do: - - AMPIdentify *identify = [[[AMPIdentify identify] set:@"gender" value:@"male"] add:@"karma" value:[NSNumber numberWithInt:1]]; - - Then you would pass this AMPIdentify object to the identify function to send to the server: - - [[Amplitude instance] identify:identify]; - - @param identify An AMPIdentify object with the intended user property operations - - @see [User Properties and User Property Operations](https://github.com/amplitude/Amplitude-iOS#user-properties-and-user-property-operations) - - */ - -- (void)identify:(AMPIdentify *)identify; - -/** - Update user properties using operations provided via Identify API. If outOfSession is `YES` then the identify event is logged with a session id of -1 and does not trigger any session-handling logic. - - To update user properties, first create an AMPIdentify object. For example if you wanted to set a user's gender, and then increment their karma count by 1, you would do: - - AMPIdentify *identify = [[[AMPIdentify identify] set:@"gender" value:@"male"] add:@"karma" value:[NSNumber numberWithInt:1]]; - - Then you would pass this AMPIdentify object to the identify function to send to the server: - - [[Amplitude instance] identify:identify outOfSession:YES]; - - @param identify An AMPIdentify object with the intended user property operations - @param outOfSession Whether to log identify event out of session - - @see [User Properties and User Property Operations](https://github.com/amplitude/Amplitude-iOS#user-properties-and-user-property-operations) - - */ - -- (void)identify:(AMPIdentify *)identify outOfSession:(BOOL) outOfSession; - -/** - - Adds properties that are tracked on the user level. - - **Note:** Property keys must be NSString objects and values must be serializable. - - @param userProperties An NSDictionary containing any additional data to be tracked. - - @see [Setting Multiple Properties with setUserProperties](https://github.com/amplitude/Amplitude-iOS#setting-multiple-properties-with-setuserproperties) - */ -- (void)setUserProperties:(NSDictionary*) userProperties; - -/** - - **NOTE: this method is deprecated** - use `setUserProperties` instead. In earlier versions of the SDK, replace = YES replaced the in-memory userProperties dictionary with the input; however, now userProperties are no longer stored in memory, so the flag does nothing. - - Adds properties that are tracked on the user level. - - **Note:** Property keys must be NSString objects and values must be serializable. - - @param userProperties An NSDictionary containing any additional data to be tracked. - @param replace This is deprecated. In earlier versions of this SDK, this replaced the in-memory userProperties dictionary with the input, but now userProperties are no longer stored in memory. - - @see [Setting Multiple Properties with setUserProperties](https://github.com/amplitude/Amplitude-iOS#setting-multiple-properties-with-setuserproperties) - */ -- (void)setUserProperties:(NSDictionary*) userProperties replace:(BOOL) replace; - -/** - Clears all properties that are tracked on the user level. - - **Note: the result is irreversible!** - - @see [Clearing user properties](https://github.com/amplitude/Amplitude-iOS#clearing-user-properties-with-clearuserproperties) - */ - -- (void)clearUserProperties; - -/** - Adds a user to a group or groups. You need to specify a groupType and groupName(s). - - For example you can group people by their organization. In that case groupType is "orgId", and groupName would be the actual ID(s). groupName can be a string or an array of strings to indicate a user in multiple groups. - - You can also call setGroup multiple times with different groupTypes to track multiple types of groups (up to 5 per app). - - **Note:** this will also set groupType: groupName as a user property. - - @param groupType You need to specify a group type (for example "orgId"). - - @param groupName The value for the group name, can be a string or an array of strings, (for example for groupType orgId, the groupName would be the actual id number, like 15). - - @see [Setting Groups](https://github.com/amplitude/Amplitude-iOS#setting-groups) - */ - -- (void)setGroup:(NSString*) groupType groupName:(NSObject*) groupName; - -/**----------------------------------------------------------------------------- - * @name Setting User and Device Identifiers - * ----------------------------------------------------------------------------- - */ - -/** - Sets the userId. - - @param userId If your app has its own login system that you want to track users with, you can set the userId. - - @see [Setting Custom UserIds](https://github.com/amplitude/Amplitude-iOS#setting-custom-user-ids) - */ -- (void)setUserId:(NSString*) userId; - -/** - Sets the deviceId. - - **NOTE: not recommended unless you know what you are doing** - - @param deviceId If your app has its own system for tracking devices, you can set the deviceId. - - @see [Setting Custom Device Ids](https://github.com/amplitude/Amplitude-iOS#custom-device-ids) - */ -- (void)setDeviceId:(NSString*) deviceId; - -/**----------------------------------------------------------------------------- - * @name Configuring the SDK instance - * ----------------------------------------------------------------------------- - */ - -/** - Enables tracking opt out. - - If the user wants to opt out of all tracking, use this method to enable opt out for them. Once opt out is enabled, no events will be saved locally or sent to the server. Calling this method again with enabled set to NO will turn tracking back on for the user. - - @param enabled Whether tracking opt out should be enabled or disabled. - */ -- (void)setOptOut:(BOOL)enabled; - -/** - Disables sending logged events to Amplitude servers. - - If you want to stop logged events from being sent to Amplitude severs, use this method to set the client to offline. Once offline is enabled, logged events will not be sent to the server until offline is disabled. Calling this method again with offline set to NO will allow events to be sent to server and the client will attempt to send events that have been queued while offline. - - @param offline Whether logged events should be sent to Amplitude servers. - */ -- (void)setOffline:(BOOL)offline; - -/** - Enables location tracking. - - If the user has granted your app location permissions, the SDK will also grab the location of the user. Amplitude will never prompt the user for location permissions itself, this must be done by your app. - - **Note:** the user's location is only fetched once per session. Use `updateLocation` to force the SDK to fetch the user's latest location. - */ -- (void)enableLocationListening; - -/** - Disables location tracking. If you want location tracking disabled on startup of the app, call disableLocationListening before you call initializeApiKey. - */ -- (void)disableLocationListening; - -/** - Forces the SDK to update with the user's last known location if possible. - - If you want to manually force the SDK to update with the user's last known location, call updateLocation. - */ -- (void)updateLocation; - -/** - Uses advertisingIdentifier instead of identifierForVendor as the device ID - - Apple prohibits the use of advertisingIdentifier if your app does not have advertising. Useful for tying together data from advertising campaigns to anlaytics data. - - **NOTE:** Must be called before initializeApiKey: is called to function. - */ -- (void)useAdvertisingIdForDeviceId; - -/**----------------------------------------------------------------------------- - * @name Other Methods - * ----------------------------------------------------------------------------- - */ - -/** - Prints the number of events in the queue. - - Debugging method to find out how many events are being stored locally on the device. - */ -- (void)printEventsCount; - -/** - Fetches the deviceId, a unique identifier shared between multiple users using the same app on the same device. - - @returns the deviceId. - */ -- (NSString*)getDeviceId; - -/** - Fetches the current sessionId, an identifier used by Amplitude to group together events tracked during the same session. - - @returns the current session id - - @see [Tracking Sessions](https://github.com/amplitude/Amplitude-iOS#tracking-sessions) - */ -- (long long)getSessionId; - -/** - Manually forces the instance to immediately upload all unsent events. - - Events are saved locally. Uploads are batched to occur every 30 events and every 30 seconds, as well as on app close. Use this method to force the class to immediately upload all queued events. - */ -- (void)uploadEvents; - - -#pragma mark - Deprecated methods - -- (void)initializeApiKey:(NSString*) apiKey userId:(NSString*) userId startSession:(BOOL)startSession __attribute((deprecated())); - -- (void)startSession __attribute((deprecated())); - -+ (void)initializeApiKey:(NSString*) apiKey __attribute((deprecated())); - -+ (void)initializeApiKey:(NSString*) apiKey userId:(NSString*) userId __attribute((deprecated())); - -+ (void)logEvent:(NSString*) eventType __attribute((deprecated())); - -+ (void)logEvent:(NSString*) eventType withEventProperties:(NSDictionary*) eventProperties __attribute((deprecated())); - -+ (void)logRevenue:(NSNumber*) amount __attribute((deprecated())); - -+ (void)logRevenue:(NSString*) productIdentifier quantity:(NSInteger) quantity price:(NSNumber*) price __attribute((deprecated())) __attribute((deprecated())); - -+ (void)logRevenue:(NSString*) productIdentifier quantity:(NSInteger) quantity price:(NSNumber*) price receipt:(NSData*) receipt __attribute((deprecated())); - -+ (void)uploadEvents __attribute((deprecated())); - -+ (void)setUserProperties:(NSDictionary*) userProperties __attribute((deprecated())); - -+ (void)setUserId:(NSString*) userId __attribute((deprecated())); - -+ (void)enableLocationListening __attribute((deprecated())); - -+ (void)disableLocationListening __attribute((deprecated())); - -+ (void)useAdvertisingIdForDeviceId __attribute((deprecated())); - -+ (void)printEventsCount __attribute((deprecated())); - -+ (NSString*)getDeviceId __attribute((deprecated())); -@end - -#pragma mark - constants - -extern NSString *const kAMPSessionStartEvent; -extern NSString *const kAMPSessionEndEvent; -extern NSString *const kAMPRevenueEvent; diff --git a/Carthage/Build/iOS/Amplitude.framework/Headers/AmplitudeFramework.h b/Carthage/Build/iOS/Amplitude.framework/Headers/AmplitudeFramework.h deleted file mode 100644 index 196fa3f2d5..0000000000 --- a/Carthage/Build/iOS/Amplitude.framework/Headers/AmplitudeFramework.h +++ /dev/null @@ -1,24 +0,0 @@ -#import - -//! Project version number for Amplitude. -FOUNDATION_EXPORT double AmplitudeVersionNumber; - -//! Project version string for Amplitude. -FOUNDATION_EXPORT const unsigned char AmplitudeVersionString[]; - -// In this header, you should import all the public headers of your framework using statements like #import - -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import diff --git a/Carthage/Build/iOS/Amplitude.framework/Headers/ISPCertificatePinning.h b/Carthage/Build/iOS/Amplitude.framework/Headers/ISPCertificatePinning.h deleted file mode 100644 index d7435bdb7f..0000000000 --- a/Carthage/Build/iOS/Amplitude.framework/Headers/ISPCertificatePinning.h +++ /dev/null @@ -1,66 +0,0 @@ -#if AMPLITUDE_SSL_PINNING -// -// ISPCertificatePinning.h -// SSLCertificatePinning v3 -// -// Created by Alban Diquet on 1/14/14. -// Copyright (c) 2014 iSEC Partners. All rights reserved. -// - -#import - -/** This class implements certificate pinning utility functions. - - First, the certificates and domains to pin should be loaded using - setupSSLPinsUsingDictionnary:. This method will store them in - "~/Library/SSLPins.plist". - - Then, the verifyPinnedCertificateForTrust:andDomain: method can be - used to validate that at least one the certificates pinned to a - specific domain is in the server's certificate chain when connecting to - it. This method should be used for example in the - connection:willSendRequestForAuthenticationChallenge: method of the - NSURLConnectionDelegate object that is used to perform the connection. - - Alternatively, the ISPPinnedNSURLSessionDelegate or - ISPPinnedNSURLConnectionDelegate classes can be directly used - to create a delegate class performing certificate pinning. - - */ - -@interface ISPCertificatePinning : NSObject - - -/** - Certificate pinning loading method - - This method takes a dictionary with domain names as keys and arrays of DER- - encoded certificates as values, and stores them in a pre-defined location on - the filesystem. The ability to specify multiple certificates for a single - domain is useful when transitioning from an expiring certificate to a new one. - - @param certificates a dictionnary with domain names as keys and arrays of DER-encoded certificates as values - @return BOOL successfully loaded the public keys and domains - - */ -+ (BOOL)setupSSLPinsUsingDictionnary:(NSDictionary*)domainsAndCertificates; - - -/** - Certificate pinning validation method - - This method accesses the certificates previously loaded using the - setupSSLPinsUsingDictionnary: method and inspects the trust object's - certificate chain in order to find at least one certificate pinned to the - given domain. SecTrustEvaluate() should always be called before this method to - ensure that the certificate chain is valid. - - @param trust the trust object whose certificate chain must contain the certificate previously pinned to the given domain - @param domain the domain we're trying to connect to - @return BOOL found the domain's pinned certificate in the trust object's certificate chain - - */ -+ (BOOL)verifyPinnedCertificateForTrust:(SecTrustRef)trust andDomain:(NSString*)domain; - -@end -#endif \ No newline at end of file diff --git a/Carthage/Build/iOS/Amplitude.framework/Headers/ISPPinnedNSURLConnectionDelegate.h b/Carthage/Build/iOS/Amplitude.framework/Headers/ISPPinnedNSURLConnectionDelegate.h deleted file mode 100644 index a80efa43e3..0000000000 --- a/Carthage/Build/iOS/Amplitude.framework/Headers/ISPPinnedNSURLConnectionDelegate.h +++ /dev/null @@ -1,26 +0,0 @@ -#if AMPLITUDE_SSL_PINNING -// -// ISPPinnedNSURLConnectionDelegate.h -// SSLCertificatePinning -// -// Created by Alban Diquet on 1/14/14. -// Copyright (c) 2014 iSEC Partners. All rights reserved. -// - -#import - -/** Convenience class to automatically perform certificate pinning for NSURLConnection. - - ISPPinnedNSURLConnectionDelegate is designed to be subclassed in order to - implement an NSURLConnectionDelegate class. The - connection:willSendRequestForAuthenticationChallenge: method it implements - will automatically validate that at least one the certificates pinned to the domain the - connection is accessing is part of the server's certificate chain. - - */ -@interface ISPPinnedNSURLConnectionDelegate : NSObject - -- (void)connection:(NSURLConnection *)connection willSendRequestForAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge; - -@end -#endif \ No newline at end of file diff --git a/Carthage/Build/iOS/Amplitude.framework/Headers/ISPPinnedNSURLSessionDelegate.h b/Carthage/Build/iOS/Amplitude.framework/Headers/ISPPinnedNSURLSessionDelegate.h deleted file mode 100644 index cc4071aa4c..0000000000 --- a/Carthage/Build/iOS/Amplitude.framework/Headers/ISPPinnedNSURLSessionDelegate.h +++ /dev/null @@ -1,26 +0,0 @@ -#if AMPLITUDE_SSL_PINNING -// -// ISPPinnedNSURLSessionDelegate.h -// SSLCertificatePinning -// -// Created by Alban Diquet on 1/14/14. -// Copyright (c) 2014 iSEC Partners. All rights reserved. -// - -#import - -/** Convenience class to automatically perform certificate pinning for NSURLSession. - - ISPPinnedNSURLSessionDelegate is designed to be subclassed in order to - implement an NSURLSession class. The - URLSession:didReceiveChallenge:completionHandler: method it implements - will automatically validate that at least one the certificates pinned to the domain the - connection is accessing is part of the server's certificate chain. - - */ -@interface ISPPinnedNSURLSessionDelegate : NSObject - -- (void)URLSession:(NSURLSession *)session didReceiveChallenge:(NSURLAuthenticationChallenge *)challenge completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition disposition, NSURLCredential *credential))completionHandler; - -@end -#endif \ No newline at end of file diff --git a/Carthage/Build/iOS/Amplitude.framework/Info.plist b/Carthage/Build/iOS/Amplitude.framework/Info.plist deleted file mode 100644 index c4760a8015..0000000000 Binary files a/Carthage/Build/iOS/Amplitude.framework/Info.plist and /dev/null differ diff --git a/Carthage/Build/iOS/Amplitude.framework/Modules/module.modulemap b/Carthage/Build/iOS/Amplitude.framework/Modules/module.modulemap deleted file mode 100644 index 3a871d1523..0000000000 --- a/Carthage/Build/iOS/Amplitude.framework/Modules/module.modulemap +++ /dev/null @@ -1,6 +0,0 @@ -framework module Amplitude { - umbrella header "AmplitudeFramework.h" - - export * - module * { export * } -} \ No newline at end of file diff --git a/Carthage/Build/iOS/CarbKit.framework/CarbKit b/Carthage/Build/iOS/CarbKit.framework/CarbKit deleted file mode 100755 index afbe631ccd..0000000000 Binary files a/Carthage/Build/iOS/CarbKit.framework/CarbKit and /dev/null differ diff --git a/Carthage/Build/iOS/CarbKit.framework/CarbKit.storyboardc/CarbEntryEditViewController.nib b/Carthage/Build/iOS/CarbKit.framework/CarbKit.storyboardc/CarbEntryEditViewController.nib deleted file mode 100644 index d193e066f7..0000000000 Binary files a/Carthage/Build/iOS/CarbKit.framework/CarbKit.storyboardc/CarbEntryEditViewController.nib and /dev/null differ diff --git a/Carthage/Build/iOS/CarbKit.framework/CarbKit.storyboardc/Info.plist b/Carthage/Build/iOS/CarbKit.framework/CarbKit.storyboardc/Info.plist deleted file mode 100644 index 5811714e41..0000000000 Binary files a/Carthage/Build/iOS/CarbKit.framework/CarbKit.storyboardc/Info.plist and /dev/null differ diff --git a/Carthage/Build/iOS/CarbKit.framework/CarbKit.storyboardc/LyL-9U-twn-view-9Ci-XW-6nA.nib b/Carthage/Build/iOS/CarbKit.framework/CarbKit.storyboardc/LyL-9U-twn-view-9Ci-XW-6nA.nib deleted file mode 100644 index 1799d25298..0000000000 Binary files a/Carthage/Build/iOS/CarbKit.framework/CarbKit.storyboardc/LyL-9U-twn-view-9Ci-XW-6nA.nib and /dev/null differ diff --git a/Carthage/Build/iOS/CarbKit.framework/CarbKit.storyboardc/UINavigationController-wgu-gT-TgV.nib b/Carthage/Build/iOS/CarbKit.framework/CarbKit.storyboardc/UINavigationController-wgu-gT-TgV.nib deleted file mode 100644 index 8e9f4e566a..0000000000 Binary files a/Carthage/Build/iOS/CarbKit.framework/CarbKit.storyboardc/UINavigationController-wgu-gT-TgV.nib and /dev/null differ diff --git a/Carthage/Build/iOS/CarbKit.framework/CarbKit.storyboardc/UITableViewController-rUL-yg-cFX.nib b/Carthage/Build/iOS/CarbKit.framework/CarbKit.storyboardc/UITableViewController-rUL-yg-cFX.nib deleted file mode 100644 index f4dacb0772..0000000000 Binary files a/Carthage/Build/iOS/CarbKit.framework/CarbKit.storyboardc/UITableViewController-rUL-yg-cFX.nib and /dev/null differ diff --git a/Carthage/Build/iOS/CarbKit.framework/CarbKit.storyboardc/rUL-yg-cFX-view-b1s-8o-0Wp.nib b/Carthage/Build/iOS/CarbKit.framework/CarbKit.storyboardc/rUL-yg-cFX-view-b1s-8o-0Wp.nib deleted file mode 100644 index 17636671f7..0000000000 Binary files a/Carthage/Build/iOS/CarbKit.framework/CarbKit.storyboardc/rUL-yg-cFX-view-b1s-8o-0Wp.nib and /dev/null differ diff --git a/Carthage/Build/iOS/CarbKit.framework/Headers/CarbKit-Swift.h b/Carthage/Build/iOS/CarbKit.framework/Headers/CarbKit-Swift.h deleted file mode 100644 index 13185c08e9..0000000000 --- a/Carthage/Build/iOS/CarbKit.framework/Headers/CarbKit-Swift.h +++ /dev/null @@ -1,181 +0,0 @@ -// Generated by Apple Swift version 3.0.1 (swiftlang-800.0.58.6 clang-800.0.42.1) -#pragma clang diagnostic push - -#if defined(__has_include) && __has_include() -# include -#endif - -#pragma clang diagnostic ignored "-Wauto-import" -#include -#include -#include -#include - -#if !defined(SWIFT_TYPEDEFS) -# define SWIFT_TYPEDEFS 1 -# if defined(__has_include) && __has_include() -# include -# elif !defined(__cplusplus) || __cplusplus < 201103L -typedef uint_least16_t char16_t; -typedef uint_least32_t char32_t; -# endif -typedef float swift_float2 __attribute__((__ext_vector_type__(2))); -typedef float swift_float3 __attribute__((__ext_vector_type__(3))); -typedef float swift_float4 __attribute__((__ext_vector_type__(4))); -typedef double swift_double2 __attribute__((__ext_vector_type__(2))); -typedef double swift_double3 __attribute__((__ext_vector_type__(3))); -typedef double swift_double4 __attribute__((__ext_vector_type__(4))); -typedef int swift_int2 __attribute__((__ext_vector_type__(2))); -typedef int swift_int3 __attribute__((__ext_vector_type__(3))); -typedef int swift_int4 __attribute__((__ext_vector_type__(4))); -typedef unsigned int swift_uint2 __attribute__((__ext_vector_type__(2))); -typedef unsigned int swift_uint3 __attribute__((__ext_vector_type__(3))); -typedef unsigned int swift_uint4 __attribute__((__ext_vector_type__(4))); -#endif - -#if !defined(SWIFT_PASTE) -# define SWIFT_PASTE_HELPER(x, y) x##y -# define SWIFT_PASTE(x, y) SWIFT_PASTE_HELPER(x, y) -#endif -#if !defined(SWIFT_METATYPE) -# define SWIFT_METATYPE(X) Class -#endif -#if !defined(SWIFT_CLASS_PROPERTY) -# if __has_feature(objc_class_property) -# define SWIFT_CLASS_PROPERTY(...) __VA_ARGS__ -# else -# define SWIFT_CLASS_PROPERTY(...) -# endif -#endif - -#if defined(__has_attribute) && __has_attribute(objc_runtime_name) -# define SWIFT_RUNTIME_NAME(X) __attribute__((objc_runtime_name(X))) -#else -# define SWIFT_RUNTIME_NAME(X) -#endif -#if defined(__has_attribute) && __has_attribute(swift_name) -# define SWIFT_COMPILE_NAME(X) __attribute__((swift_name(X))) -#else -# define SWIFT_COMPILE_NAME(X) -#endif -#if defined(__has_attribute) && __has_attribute(objc_method_family) -# define SWIFT_METHOD_FAMILY(X) __attribute__((objc_method_family(X))) -#else -# define SWIFT_METHOD_FAMILY(X) -#endif -#if defined(__has_attribute) && __has_attribute(noescape) -# define SWIFT_NOESCAPE __attribute__((noescape)) -#else -# define SWIFT_NOESCAPE -#endif -#if !defined(SWIFT_CLASS_EXTRA) -# define SWIFT_CLASS_EXTRA -#endif -#if !defined(SWIFT_PROTOCOL_EXTRA) -# define SWIFT_PROTOCOL_EXTRA -#endif -#if !defined(SWIFT_ENUM_EXTRA) -# define SWIFT_ENUM_EXTRA -#endif -#if !defined(SWIFT_CLASS) -# if defined(__has_attribute) && __has_attribute(objc_subclassing_restricted) -# define SWIFT_CLASS(SWIFT_NAME) SWIFT_RUNTIME_NAME(SWIFT_NAME) __attribute__((objc_subclassing_restricted)) SWIFT_CLASS_EXTRA -# define SWIFT_CLASS_NAMED(SWIFT_NAME) __attribute__((objc_subclassing_restricted)) SWIFT_COMPILE_NAME(SWIFT_NAME) SWIFT_CLASS_EXTRA -# else -# define SWIFT_CLASS(SWIFT_NAME) SWIFT_RUNTIME_NAME(SWIFT_NAME) SWIFT_CLASS_EXTRA -# define SWIFT_CLASS_NAMED(SWIFT_NAME) SWIFT_COMPILE_NAME(SWIFT_NAME) SWIFT_CLASS_EXTRA -# endif -#endif - -#if !defined(SWIFT_PROTOCOL) -# define SWIFT_PROTOCOL(SWIFT_NAME) SWIFT_RUNTIME_NAME(SWIFT_NAME) SWIFT_PROTOCOL_EXTRA -# define SWIFT_PROTOCOL_NAMED(SWIFT_NAME) SWIFT_COMPILE_NAME(SWIFT_NAME) SWIFT_PROTOCOL_EXTRA -#endif - -#if !defined(SWIFT_EXTENSION) -# define SWIFT_EXTENSION(M) SWIFT_PASTE(M##_Swift_, __LINE__) -#endif - -#if !defined(OBJC_DESIGNATED_INITIALIZER) -# if defined(__has_attribute) && __has_attribute(objc_designated_initializer) -# define OBJC_DESIGNATED_INITIALIZER __attribute__((objc_designated_initializer)) -# else -# define OBJC_DESIGNATED_INITIALIZER -# endif -#endif -#if !defined(SWIFT_ENUM) -# define SWIFT_ENUM(_type, _name) enum _name : _type _name; enum SWIFT_ENUM_EXTRA _name : _type -# if defined(__has_feature) && __has_feature(generalized_swift_name) -# define SWIFT_ENUM_NAMED(_type, _name, SWIFT_NAME) enum _name : _type _name SWIFT_COMPILE_NAME(SWIFT_NAME); enum SWIFT_COMPILE_NAME(SWIFT_NAME) SWIFT_ENUM_EXTRA _name : _type -# else -# define SWIFT_ENUM_NAMED(_type, _name, SWIFT_NAME) SWIFT_ENUM(_type, _name) -# endif -#endif -#if !defined(SWIFT_UNAVAILABLE) -# define SWIFT_UNAVAILABLE __attribute__((unavailable)) -#endif -#if defined(__has_feature) && __has_feature(modules) -@import UIKit; -@import Foundation; -@import HealthKit; -#endif - -#pragma clang diagnostic ignored "-Wproperty-attribute-mismatch" -#pragma clang diagnostic ignored "-Wduplicate-method-arg" -@class HKUnit; -@class UITableView; -@class UITableViewCell; -@class UIStoryboardSegue; -@class NSBundle; -@class NSCoder; - -SWIFT_CLASS("_TtC7CarbKit27CarbEntryEditViewController") -@interface CarbEntryEditViewController : UITableViewController -@property (nonatomic, strong) HKUnit * _Nonnull preferredUnit; -- (void)viewDidLoad; -- (NSInteger)numberOfSectionsInTableView:(UITableView * _Nonnull)tableView; -- (NSInteger)tableView:(UITableView * _Nonnull)tableView numberOfRowsInSection:(NSInteger)section; -- (UITableViewCell * _Nonnull)tableView:(UITableView * _Nonnull)tableView cellForRowAtIndexPath:(NSIndexPath * _Nonnull)indexPath; -- (NSIndexPath * _Nullable)tableView:(UITableView * _Nonnull)tableView willSelectRowAtIndexPath:(NSIndexPath * _Nonnull)indexPath; -- (void)tableView:(UITableView * _Nonnull)tableView didSelectRowAtIndexPath:(NSIndexPath * _Nonnull)indexPath; -- (void)prepareForSegue:(UIStoryboardSegue * _Nonnull)segue sender:(id _Nullable)sender; -- (nonnull instancetype)initWithStyle:(UITableViewStyle)style OBJC_DESIGNATED_INITIALIZER; -- (nonnull instancetype)initWithNibName:(NSString * _Nullable)nibNameOrNil bundle:(NSBundle * _Nullable)nibBundleOrNil OBJC_DESIGNATED_INITIALIZER; -- (nullable instancetype)initWithCoder:(NSCoder * _Nonnull)aDecoder OBJC_DESIGNATED_INITIALIZER; -@end - - -SWIFT_CLASS("_TtC7CarbKit28CarbEntryTableViewController") -@interface CarbEntryTableViewController : UITableViewController -- (void)viewDidLoad; -- (void)viewWillAppear:(BOOL)animated; -- (void)viewDidAppear:(BOOL)animated; -- (void)viewWillDisappear:(BOOL)animated; -- (NSInteger)numberOfSectionsInTableView:(UITableView * _Nonnull)tableView; -- (NSInteger)tableView:(UITableView * _Nonnull)tableView numberOfRowsInSection:(NSInteger)section; -- (UITableViewCell * _Nonnull)tableView:(UITableView * _Nonnull)tableView cellForRowAtIndexPath:(NSIndexPath * _Nonnull)indexPath; -- (BOOL)tableView:(UITableView * _Nonnull)tableView canEditRowAtIndexPath:(NSIndexPath * _Nonnull)indexPath; -- (void)tableView:(UITableView * _Nonnull)tableView commitEditingStyle:(UITableViewCellEditingStyle)editingStyle forRowAtIndexPath:(NSIndexPath * _Nonnull)indexPath; -- (NSIndexPath * _Nullable)tableView:(UITableView * _Nonnull)tableView willSelectRowAtIndexPath:(NSIndexPath * _Nonnull)indexPath; -- (void)prepareForSegue:(UIStoryboardSegue * _Nonnull)segue sender:(id _Nullable)sender; -- (nonnull instancetype)initWithStyle:(UITableViewStyle)style OBJC_DESIGNATED_INITIALIZER; -- (nonnull instancetype)initWithNibName:(NSString * _Nullable)nibNameOrNil bundle:(NSBundle * _Nullable)nibBundleOrNil OBJC_DESIGNATED_INITIALIZER; -- (nullable instancetype)initWithCoder:(NSCoder * _Nonnull)aDecoder OBJC_DESIGNATED_INITIALIZER; -@end - - -@interface HKQuantitySample (SWIFT_EXTENSION(CarbKit)) -@property (nonatomic, readonly, copy) NSString * _Nullable foodType; -@property (nonatomic, readonly) BOOL createdByCurrentApp; -@property (nonatomic, readonly, copy) NSString * _Nullable externalId; -@end - - -@interface UITableViewCell (SWIFT_EXTENSION(CarbKit)) -@end - - -@interface NSUserDefaults (SWIFT_EXTENSION(CarbKit)) -@end - -#pragma clang diagnostic pop diff --git a/Carthage/Build/iOS/CarbKit.framework/Headers/CarbKit.h b/Carthage/Build/iOS/CarbKit.framework/Headers/CarbKit.h deleted file mode 100644 index 912c55ffc2..0000000000 --- a/Carthage/Build/iOS/CarbKit.framework/Headers/CarbKit.h +++ /dev/null @@ -1,19 +0,0 @@ -// -// CarbKit.h -// CarbKit -// -// Created by Nathan Racklyeft on 2/15/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -#import - -//! Project version number for CarbKit. -FOUNDATION_EXPORT double CarbKitVersionNumber; - -//! Project version string for CarbKit. -FOUNDATION_EXPORT const unsigned char CarbKitVersionString[]; - -// In this header, you should import all the public headers of your framework using statements like #import - - diff --git a/Carthage/Build/iOS/CarbKit.framework/Info.plist b/Carthage/Build/iOS/CarbKit.framework/Info.plist deleted file mode 100644 index 21e10580a3..0000000000 Binary files a/Carthage/Build/iOS/CarbKit.framework/Info.plist and /dev/null differ diff --git a/Carthage/Build/iOS/CarbKit.framework/Modules/CarbKit.swiftmodule/arm.swiftdoc b/Carthage/Build/iOS/CarbKit.framework/Modules/CarbKit.swiftmodule/arm.swiftdoc deleted file mode 100644 index 21401712a0..0000000000 Binary files a/Carthage/Build/iOS/CarbKit.framework/Modules/CarbKit.swiftmodule/arm.swiftdoc and /dev/null differ diff --git a/Carthage/Build/iOS/CarbKit.framework/Modules/CarbKit.swiftmodule/arm.swiftmodule b/Carthage/Build/iOS/CarbKit.framework/Modules/CarbKit.swiftmodule/arm.swiftmodule deleted file mode 100644 index 39b48ebe86..0000000000 Binary files a/Carthage/Build/iOS/CarbKit.framework/Modules/CarbKit.swiftmodule/arm.swiftmodule and /dev/null differ diff --git a/Carthage/Build/iOS/CarbKit.framework/Modules/CarbKit.swiftmodule/arm64.swiftdoc b/Carthage/Build/iOS/CarbKit.framework/Modules/CarbKit.swiftmodule/arm64.swiftdoc deleted file mode 100644 index d59ec549f8..0000000000 Binary files a/Carthage/Build/iOS/CarbKit.framework/Modules/CarbKit.swiftmodule/arm64.swiftdoc and /dev/null differ diff --git a/Carthage/Build/iOS/CarbKit.framework/Modules/CarbKit.swiftmodule/arm64.swiftmodule b/Carthage/Build/iOS/CarbKit.framework/Modules/CarbKit.swiftmodule/arm64.swiftmodule deleted file mode 100644 index 350ee163f8..0000000000 Binary files a/Carthage/Build/iOS/CarbKit.framework/Modules/CarbKit.swiftmodule/arm64.swiftmodule and /dev/null differ diff --git a/Carthage/Build/iOS/CarbKit.framework/Modules/CarbKit.swiftmodule/i386.swiftdoc b/Carthage/Build/iOS/CarbKit.framework/Modules/CarbKit.swiftmodule/i386.swiftdoc deleted file mode 100644 index e660e46fa6..0000000000 Binary files a/Carthage/Build/iOS/CarbKit.framework/Modules/CarbKit.swiftmodule/i386.swiftdoc and /dev/null differ diff --git a/Carthage/Build/iOS/CarbKit.framework/Modules/CarbKit.swiftmodule/i386.swiftmodule b/Carthage/Build/iOS/CarbKit.framework/Modules/CarbKit.swiftmodule/i386.swiftmodule deleted file mode 100644 index 6169fc28c9..0000000000 Binary files a/Carthage/Build/iOS/CarbKit.framework/Modules/CarbKit.swiftmodule/i386.swiftmodule and /dev/null differ diff --git a/Carthage/Build/iOS/CarbKit.framework/Modules/CarbKit.swiftmodule/x86_64.swiftdoc b/Carthage/Build/iOS/CarbKit.framework/Modules/CarbKit.swiftmodule/x86_64.swiftdoc deleted file mode 100644 index 0950c077a3..0000000000 Binary files a/Carthage/Build/iOS/CarbKit.framework/Modules/CarbKit.swiftmodule/x86_64.swiftdoc and /dev/null differ diff --git a/Carthage/Build/iOS/CarbKit.framework/Modules/CarbKit.swiftmodule/x86_64.swiftmodule b/Carthage/Build/iOS/CarbKit.framework/Modules/CarbKit.swiftmodule/x86_64.swiftmodule deleted file mode 100644 index 79567112f2..0000000000 Binary files a/Carthage/Build/iOS/CarbKit.framework/Modules/CarbKit.swiftmodule/x86_64.swiftmodule and /dev/null differ diff --git a/Carthage/Build/iOS/CarbKit.framework/Modules/module.modulemap b/Carthage/Build/iOS/CarbKit.framework/Modules/module.modulemap deleted file mode 100644 index 975f13df9f..0000000000 --- a/Carthage/Build/iOS/CarbKit.framework/Modules/module.modulemap +++ /dev/null @@ -1,10 +0,0 @@ -framework module CarbKit { - umbrella header "CarbKit.h" - - export * - module * { export * } -} - -module CarbKit.Swift { - header "CarbKit-Swift.h" -} diff --git a/Carthage/Build/iOS/Crypto.framework/Crypto b/Carthage/Build/iOS/Crypto.framework/Crypto deleted file mode 100755 index f319d084c8..0000000000 Binary files a/Carthage/Build/iOS/Crypto.framework/Crypto and /dev/null differ diff --git a/Carthage/Build/iOS/Crypto.framework/Headers/Crypto.h b/Carthage/Build/iOS/Crypto.framework/Headers/Crypto.h deleted file mode 100644 index abf4334f5d..0000000000 --- a/Carthage/Build/iOS/Crypto.framework/Headers/Crypto.h +++ /dev/null @@ -1,24 +0,0 @@ -// -// Crypto.h -// Crypto -// -// Created by Nate Racklyeft on 9/13/16. -// Copyright © 2016 Pete Schwamb. All rights reserved. -// - -#import - -//! Project version number for Crypto. -FOUNDATION_EXPORT double CryptoVersionNumber; - -//! Project version string for Crypto. -FOUNDATION_EXPORT const unsigned char CryptoVersionString[]; - -// In this header, you should import all the public headers of your framework using statements like #import - - -@interface NSString (Crypto) - -@property (nonatomic, nonnull, readonly) NSString *sha1; - -@end diff --git a/Carthage/Build/iOS/Crypto.framework/Info.plist b/Carthage/Build/iOS/Crypto.framework/Info.plist deleted file mode 100644 index 0d4b8f3f2c..0000000000 Binary files a/Carthage/Build/iOS/Crypto.framework/Info.plist and /dev/null differ diff --git a/Carthage/Build/iOS/Crypto.framework/Modules/module.modulemap b/Carthage/Build/iOS/Crypto.framework/Modules/module.modulemap deleted file mode 100644 index 9d53a1b38e..0000000000 --- a/Carthage/Build/iOS/Crypto.framework/Modules/module.modulemap +++ /dev/null @@ -1,6 +0,0 @@ -framework module Crypto { - umbrella header "Crypto.h" - - export * - module * { export * } -} diff --git a/Carthage/Build/iOS/G4ShareSpy.framework/G4ShareSpy b/Carthage/Build/iOS/G4ShareSpy.framework/G4ShareSpy deleted file mode 100755 index 44315d31c0..0000000000 Binary files a/Carthage/Build/iOS/G4ShareSpy.framework/G4ShareSpy and /dev/null differ diff --git a/Carthage/Build/iOS/G4ShareSpy.framework/Headers/G4ShareSpy-Swift.h b/Carthage/Build/iOS/G4ShareSpy.framework/Headers/G4ShareSpy-Swift.h deleted file mode 100644 index edb321dbfb..0000000000 --- a/Carthage/Build/iOS/G4ShareSpy.framework/Headers/G4ShareSpy-Swift.h +++ /dev/null @@ -1,122 +0,0 @@ -// Generated by Apple Swift version 3.0.1 (swiftlang-800.0.58.6 clang-800.0.42.1) -#pragma clang diagnostic push - -#if defined(__has_include) && __has_include() -# include -#endif - -#pragma clang diagnostic ignored "-Wauto-import" -#include -#include -#include -#include - -#if !defined(SWIFT_TYPEDEFS) -# define SWIFT_TYPEDEFS 1 -# if defined(__has_include) && __has_include() -# include -# elif !defined(__cplusplus) || __cplusplus < 201103L -typedef uint_least16_t char16_t; -typedef uint_least32_t char32_t; -# endif -typedef float swift_float2 __attribute__((__ext_vector_type__(2))); -typedef float swift_float3 __attribute__((__ext_vector_type__(3))); -typedef float swift_float4 __attribute__((__ext_vector_type__(4))); -typedef double swift_double2 __attribute__((__ext_vector_type__(2))); -typedef double swift_double3 __attribute__((__ext_vector_type__(3))); -typedef double swift_double4 __attribute__((__ext_vector_type__(4))); -typedef int swift_int2 __attribute__((__ext_vector_type__(2))); -typedef int swift_int3 __attribute__((__ext_vector_type__(3))); -typedef int swift_int4 __attribute__((__ext_vector_type__(4))); -typedef unsigned int swift_uint2 __attribute__((__ext_vector_type__(2))); -typedef unsigned int swift_uint3 __attribute__((__ext_vector_type__(3))); -typedef unsigned int swift_uint4 __attribute__((__ext_vector_type__(4))); -#endif - -#if !defined(SWIFT_PASTE) -# define SWIFT_PASTE_HELPER(x, y) x##y -# define SWIFT_PASTE(x, y) SWIFT_PASTE_HELPER(x, y) -#endif -#if !defined(SWIFT_METATYPE) -# define SWIFT_METATYPE(X) Class -#endif -#if !defined(SWIFT_CLASS_PROPERTY) -# if __has_feature(objc_class_property) -# define SWIFT_CLASS_PROPERTY(...) __VA_ARGS__ -# else -# define SWIFT_CLASS_PROPERTY(...) -# endif -#endif - -#if defined(__has_attribute) && __has_attribute(objc_runtime_name) -# define SWIFT_RUNTIME_NAME(X) __attribute__((objc_runtime_name(X))) -#else -# define SWIFT_RUNTIME_NAME(X) -#endif -#if defined(__has_attribute) && __has_attribute(swift_name) -# define SWIFT_COMPILE_NAME(X) __attribute__((swift_name(X))) -#else -# define SWIFT_COMPILE_NAME(X) -#endif -#if defined(__has_attribute) && __has_attribute(objc_method_family) -# define SWIFT_METHOD_FAMILY(X) __attribute__((objc_method_family(X))) -#else -# define SWIFT_METHOD_FAMILY(X) -#endif -#if defined(__has_attribute) && __has_attribute(noescape) -# define SWIFT_NOESCAPE __attribute__((noescape)) -#else -# define SWIFT_NOESCAPE -#endif -#if !defined(SWIFT_CLASS_EXTRA) -# define SWIFT_CLASS_EXTRA -#endif -#if !defined(SWIFT_PROTOCOL_EXTRA) -# define SWIFT_PROTOCOL_EXTRA -#endif -#if !defined(SWIFT_ENUM_EXTRA) -# define SWIFT_ENUM_EXTRA -#endif -#if !defined(SWIFT_CLASS) -# if defined(__has_attribute) && __has_attribute(objc_subclassing_restricted) -# define SWIFT_CLASS(SWIFT_NAME) SWIFT_RUNTIME_NAME(SWIFT_NAME) __attribute__((objc_subclassing_restricted)) SWIFT_CLASS_EXTRA -# define SWIFT_CLASS_NAMED(SWIFT_NAME) __attribute__((objc_subclassing_restricted)) SWIFT_COMPILE_NAME(SWIFT_NAME) SWIFT_CLASS_EXTRA -# else -# define SWIFT_CLASS(SWIFT_NAME) SWIFT_RUNTIME_NAME(SWIFT_NAME) SWIFT_CLASS_EXTRA -# define SWIFT_CLASS_NAMED(SWIFT_NAME) SWIFT_COMPILE_NAME(SWIFT_NAME) SWIFT_CLASS_EXTRA -# endif -#endif - -#if !defined(SWIFT_PROTOCOL) -# define SWIFT_PROTOCOL(SWIFT_NAME) SWIFT_RUNTIME_NAME(SWIFT_NAME) SWIFT_PROTOCOL_EXTRA -# define SWIFT_PROTOCOL_NAMED(SWIFT_NAME) SWIFT_COMPILE_NAME(SWIFT_NAME) SWIFT_PROTOCOL_EXTRA -#endif - -#if !defined(SWIFT_EXTENSION) -# define SWIFT_EXTENSION(M) SWIFT_PASTE(M##_Swift_, __LINE__) -#endif - -#if !defined(OBJC_DESIGNATED_INITIALIZER) -# if defined(__has_attribute) && __has_attribute(objc_designated_initializer) -# define OBJC_DESIGNATED_INITIALIZER __attribute__((objc_designated_initializer)) -# else -# define OBJC_DESIGNATED_INITIALIZER -# endif -#endif -#if !defined(SWIFT_ENUM) -# define SWIFT_ENUM(_type, _name) enum _name : _type _name; enum SWIFT_ENUM_EXTRA _name : _type -# if defined(__has_feature) && __has_feature(generalized_swift_name) -# define SWIFT_ENUM_NAMED(_type, _name, SWIFT_NAME) enum _name : _type _name SWIFT_COMPILE_NAME(SWIFT_NAME); enum SWIFT_COMPILE_NAME(SWIFT_NAME) SWIFT_ENUM_EXTRA _name : _type -# else -# define SWIFT_ENUM_NAMED(_type, _name, SWIFT_NAME) SWIFT_ENUM(_type, _name) -# endif -#endif -#if !defined(SWIFT_UNAVAILABLE) -# define SWIFT_UNAVAILABLE __attribute__((unavailable)) -#endif -#if defined(__has_feature) && __has_feature(modules) -#endif - -#pragma clang diagnostic ignored "-Wproperty-attribute-mismatch" -#pragma clang diagnostic ignored "-Wduplicate-method-arg" -#pragma clang diagnostic pop diff --git a/Carthage/Build/iOS/G4ShareSpy.framework/Headers/G4ShareSpy.h b/Carthage/Build/iOS/G4ShareSpy.framework/Headers/G4ShareSpy.h deleted file mode 100644 index 3ef4c98e96..0000000000 --- a/Carthage/Build/iOS/G4ShareSpy.framework/Headers/G4ShareSpy.h +++ /dev/null @@ -1,19 +0,0 @@ -// -// G4ShareSpy.h -// G4ShareSpy -// -// Created by Mark on 7/21/16. -// Copyright © 2016 Mark Wilson. All rights reserved. -// - -#import - -//! Project version number for G4ShareSpy. -FOUNDATION_EXPORT double G4ShareSpyVersionNumber; - -//! Project version string for G4ShareSpy. -FOUNDATION_EXPORT const unsigned char G4ShareSpyVersionString[]; - -// In this header, you should import all the public headers of your framework using statements like #import - - diff --git a/Carthage/Build/iOS/G4ShareSpy.framework/Info.plist b/Carthage/Build/iOS/G4ShareSpy.framework/Info.plist deleted file mode 100644 index e79e6fb14b..0000000000 Binary files a/Carthage/Build/iOS/G4ShareSpy.framework/Info.plist and /dev/null differ diff --git a/Carthage/Build/iOS/G4ShareSpy.framework/Modules/G4ShareSpy.swiftmodule/arm.swiftdoc b/Carthage/Build/iOS/G4ShareSpy.framework/Modules/G4ShareSpy.swiftmodule/arm.swiftdoc deleted file mode 100644 index 32c0c5e861..0000000000 Binary files a/Carthage/Build/iOS/G4ShareSpy.framework/Modules/G4ShareSpy.swiftmodule/arm.swiftdoc and /dev/null differ diff --git a/Carthage/Build/iOS/G4ShareSpy.framework/Modules/G4ShareSpy.swiftmodule/arm.swiftmodule b/Carthage/Build/iOS/G4ShareSpy.framework/Modules/G4ShareSpy.swiftmodule/arm.swiftmodule deleted file mode 100644 index 4154f8e746..0000000000 Binary files a/Carthage/Build/iOS/G4ShareSpy.framework/Modules/G4ShareSpy.swiftmodule/arm.swiftmodule and /dev/null differ diff --git a/Carthage/Build/iOS/G4ShareSpy.framework/Modules/G4ShareSpy.swiftmodule/arm64.swiftdoc b/Carthage/Build/iOS/G4ShareSpy.framework/Modules/G4ShareSpy.swiftmodule/arm64.swiftdoc deleted file mode 100644 index 0eea818fcd..0000000000 Binary files a/Carthage/Build/iOS/G4ShareSpy.framework/Modules/G4ShareSpy.swiftmodule/arm64.swiftdoc and /dev/null differ diff --git a/Carthage/Build/iOS/G4ShareSpy.framework/Modules/G4ShareSpy.swiftmodule/arm64.swiftmodule b/Carthage/Build/iOS/G4ShareSpy.framework/Modules/G4ShareSpy.swiftmodule/arm64.swiftmodule deleted file mode 100644 index 94554a2f3e..0000000000 Binary files a/Carthage/Build/iOS/G4ShareSpy.framework/Modules/G4ShareSpy.swiftmodule/arm64.swiftmodule and /dev/null differ diff --git a/Carthage/Build/iOS/G4ShareSpy.framework/Modules/G4ShareSpy.swiftmodule/i386.swiftdoc b/Carthage/Build/iOS/G4ShareSpy.framework/Modules/G4ShareSpy.swiftmodule/i386.swiftdoc deleted file mode 100644 index 1cc1e13ecd..0000000000 Binary files a/Carthage/Build/iOS/G4ShareSpy.framework/Modules/G4ShareSpy.swiftmodule/i386.swiftdoc and /dev/null differ diff --git a/Carthage/Build/iOS/G4ShareSpy.framework/Modules/G4ShareSpy.swiftmodule/i386.swiftmodule b/Carthage/Build/iOS/G4ShareSpy.framework/Modules/G4ShareSpy.swiftmodule/i386.swiftmodule deleted file mode 100644 index 05624b314e..0000000000 Binary files a/Carthage/Build/iOS/G4ShareSpy.framework/Modules/G4ShareSpy.swiftmodule/i386.swiftmodule and /dev/null differ diff --git a/Carthage/Build/iOS/G4ShareSpy.framework/Modules/G4ShareSpy.swiftmodule/x86_64.swiftdoc b/Carthage/Build/iOS/G4ShareSpy.framework/Modules/G4ShareSpy.swiftmodule/x86_64.swiftdoc deleted file mode 100644 index 2032a22004..0000000000 Binary files a/Carthage/Build/iOS/G4ShareSpy.framework/Modules/G4ShareSpy.swiftmodule/x86_64.swiftdoc and /dev/null differ diff --git a/Carthage/Build/iOS/G4ShareSpy.framework/Modules/G4ShareSpy.swiftmodule/x86_64.swiftmodule b/Carthage/Build/iOS/G4ShareSpy.framework/Modules/G4ShareSpy.swiftmodule/x86_64.swiftmodule deleted file mode 100644 index f4095c522e..0000000000 Binary files a/Carthage/Build/iOS/G4ShareSpy.framework/Modules/G4ShareSpy.swiftmodule/x86_64.swiftmodule and /dev/null differ diff --git a/Carthage/Build/iOS/G4ShareSpy.framework/Modules/module.modulemap b/Carthage/Build/iOS/G4ShareSpy.framework/Modules/module.modulemap deleted file mode 100644 index be22cea6a2..0000000000 --- a/Carthage/Build/iOS/G4ShareSpy.framework/Modules/module.modulemap +++ /dev/null @@ -1,10 +0,0 @@ -framework module G4ShareSpy { - umbrella header "G4ShareSpy.h" - - export * - module * { export * } -} - -module G4ShareSpy.Swift { - header "G4ShareSpy-Swift.h" -} diff --git a/Carthage/Build/iOS/GlucoseKit.framework/GlucoseKit b/Carthage/Build/iOS/GlucoseKit.framework/GlucoseKit deleted file mode 100755 index 9c6148173d..0000000000 Binary files a/Carthage/Build/iOS/GlucoseKit.framework/GlucoseKit and /dev/null differ diff --git a/Carthage/Build/iOS/GlucoseKit.framework/Headers/GlucoseKit-Swift.h b/Carthage/Build/iOS/GlucoseKit.framework/Headers/GlucoseKit-Swift.h deleted file mode 100644 index 3dd820d614..0000000000 --- a/Carthage/Build/iOS/GlucoseKit.framework/Headers/GlucoseKit-Swift.h +++ /dev/null @@ -1,127 +0,0 @@ -// Generated by Apple Swift version 3.0.1 (swiftlang-800.0.58.6 clang-800.0.42.1) -#pragma clang diagnostic push - -#if defined(__has_include) && __has_include() -# include -#endif - -#pragma clang diagnostic ignored "-Wauto-import" -#include -#include -#include -#include - -#if !defined(SWIFT_TYPEDEFS) -# define SWIFT_TYPEDEFS 1 -# if defined(__has_include) && __has_include() -# include -# elif !defined(__cplusplus) || __cplusplus < 201103L -typedef uint_least16_t char16_t; -typedef uint_least32_t char32_t; -# endif -typedef float swift_float2 __attribute__((__ext_vector_type__(2))); -typedef float swift_float3 __attribute__((__ext_vector_type__(3))); -typedef float swift_float4 __attribute__((__ext_vector_type__(4))); -typedef double swift_double2 __attribute__((__ext_vector_type__(2))); -typedef double swift_double3 __attribute__((__ext_vector_type__(3))); -typedef double swift_double4 __attribute__((__ext_vector_type__(4))); -typedef int swift_int2 __attribute__((__ext_vector_type__(2))); -typedef int swift_int3 __attribute__((__ext_vector_type__(3))); -typedef int swift_int4 __attribute__((__ext_vector_type__(4))); -typedef unsigned int swift_uint2 __attribute__((__ext_vector_type__(2))); -typedef unsigned int swift_uint3 __attribute__((__ext_vector_type__(3))); -typedef unsigned int swift_uint4 __attribute__((__ext_vector_type__(4))); -#endif - -#if !defined(SWIFT_PASTE) -# define SWIFT_PASTE_HELPER(x, y) x##y -# define SWIFT_PASTE(x, y) SWIFT_PASTE_HELPER(x, y) -#endif -#if !defined(SWIFT_METATYPE) -# define SWIFT_METATYPE(X) Class -#endif -#if !defined(SWIFT_CLASS_PROPERTY) -# if __has_feature(objc_class_property) -# define SWIFT_CLASS_PROPERTY(...) __VA_ARGS__ -# else -# define SWIFT_CLASS_PROPERTY(...) -# endif -#endif - -#if defined(__has_attribute) && __has_attribute(objc_runtime_name) -# define SWIFT_RUNTIME_NAME(X) __attribute__((objc_runtime_name(X))) -#else -# define SWIFT_RUNTIME_NAME(X) -#endif -#if defined(__has_attribute) && __has_attribute(swift_name) -# define SWIFT_COMPILE_NAME(X) __attribute__((swift_name(X))) -#else -# define SWIFT_COMPILE_NAME(X) -#endif -#if defined(__has_attribute) && __has_attribute(objc_method_family) -# define SWIFT_METHOD_FAMILY(X) __attribute__((objc_method_family(X))) -#else -# define SWIFT_METHOD_FAMILY(X) -#endif -#if defined(__has_attribute) && __has_attribute(noescape) -# define SWIFT_NOESCAPE __attribute__((noescape)) -#else -# define SWIFT_NOESCAPE -#endif -#if !defined(SWIFT_CLASS_EXTRA) -# define SWIFT_CLASS_EXTRA -#endif -#if !defined(SWIFT_PROTOCOL_EXTRA) -# define SWIFT_PROTOCOL_EXTRA -#endif -#if !defined(SWIFT_ENUM_EXTRA) -# define SWIFT_ENUM_EXTRA -#endif -#if !defined(SWIFT_CLASS) -# if defined(__has_attribute) && __has_attribute(objc_subclassing_restricted) -# define SWIFT_CLASS(SWIFT_NAME) SWIFT_RUNTIME_NAME(SWIFT_NAME) __attribute__((objc_subclassing_restricted)) SWIFT_CLASS_EXTRA -# define SWIFT_CLASS_NAMED(SWIFT_NAME) __attribute__((objc_subclassing_restricted)) SWIFT_COMPILE_NAME(SWIFT_NAME) SWIFT_CLASS_EXTRA -# else -# define SWIFT_CLASS(SWIFT_NAME) SWIFT_RUNTIME_NAME(SWIFT_NAME) SWIFT_CLASS_EXTRA -# define SWIFT_CLASS_NAMED(SWIFT_NAME) SWIFT_COMPILE_NAME(SWIFT_NAME) SWIFT_CLASS_EXTRA -# endif -#endif - -#if !defined(SWIFT_PROTOCOL) -# define SWIFT_PROTOCOL(SWIFT_NAME) SWIFT_RUNTIME_NAME(SWIFT_NAME) SWIFT_PROTOCOL_EXTRA -# define SWIFT_PROTOCOL_NAMED(SWIFT_NAME) SWIFT_COMPILE_NAME(SWIFT_NAME) SWIFT_PROTOCOL_EXTRA -#endif - -#if !defined(SWIFT_EXTENSION) -# define SWIFT_EXTENSION(M) SWIFT_PASTE(M##_Swift_, __LINE__) -#endif - -#if !defined(OBJC_DESIGNATED_INITIALIZER) -# if defined(__has_attribute) && __has_attribute(objc_designated_initializer) -# define OBJC_DESIGNATED_INITIALIZER __attribute__((objc_designated_initializer)) -# else -# define OBJC_DESIGNATED_INITIALIZER -# endif -#endif -#if !defined(SWIFT_ENUM) -# define SWIFT_ENUM(_type, _name) enum _name : _type _name; enum SWIFT_ENUM_EXTRA _name : _type -# if defined(__has_feature) && __has_feature(generalized_swift_name) -# define SWIFT_ENUM_NAMED(_type, _name, SWIFT_NAME) enum _name : _type _name SWIFT_COMPILE_NAME(SWIFT_NAME); enum SWIFT_COMPILE_NAME(SWIFT_NAME) SWIFT_ENUM_EXTRA _name : _type -# else -# define SWIFT_ENUM_NAMED(_type, _name, SWIFT_NAME) SWIFT_ENUM(_type, _name) -# endif -#endif -#if !defined(SWIFT_UNAVAILABLE) -# define SWIFT_UNAVAILABLE __attribute__((unavailable)) -#endif -#if defined(__has_feature) && __has_feature(modules) -@import HealthKit; -#endif - -#pragma clang diagnostic ignored "-Wproperty-attribute-mismatch" -#pragma clang diagnostic ignored "-Wduplicate-method-arg" - -@interface HKQuantitySample (SWIFT_EXTENSION(GlucoseKit)) -@end - -#pragma clang diagnostic pop diff --git a/Carthage/Build/iOS/GlucoseKit.framework/Headers/GlucoseKit.h b/Carthage/Build/iOS/GlucoseKit.framework/Headers/GlucoseKit.h deleted file mode 100644 index 78da548953..0000000000 --- a/Carthage/Build/iOS/GlucoseKit.framework/Headers/GlucoseKit.h +++ /dev/null @@ -1,19 +0,0 @@ -// -// GlucoseKit.h -// GlucoseKit -// -// Created by Nathan Racklyeft on 2/15/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -#import - -//! Project version number for GlucoseKit. -FOUNDATION_EXPORT double GlucoseKitVersionNumber; - -//! Project version string for GlucoseKit. -FOUNDATION_EXPORT const unsigned char GlucoseKitVersionString[]; - -// In this header, you should import all the public headers of your framework using statements like #import - - diff --git a/Carthage/Build/iOS/GlucoseKit.framework/Info.plist b/Carthage/Build/iOS/GlucoseKit.framework/Info.plist deleted file mode 100644 index 83496b92f8..0000000000 Binary files a/Carthage/Build/iOS/GlucoseKit.framework/Info.plist and /dev/null differ diff --git a/Carthage/Build/iOS/GlucoseKit.framework/Modules/GlucoseKit.swiftmodule/arm.swiftdoc b/Carthage/Build/iOS/GlucoseKit.framework/Modules/GlucoseKit.swiftmodule/arm.swiftdoc deleted file mode 100644 index 9338b822b9..0000000000 Binary files a/Carthage/Build/iOS/GlucoseKit.framework/Modules/GlucoseKit.swiftmodule/arm.swiftdoc and /dev/null differ diff --git a/Carthage/Build/iOS/GlucoseKit.framework/Modules/GlucoseKit.swiftmodule/arm.swiftmodule b/Carthage/Build/iOS/GlucoseKit.framework/Modules/GlucoseKit.swiftmodule/arm.swiftmodule deleted file mode 100644 index dbaf3bc6e6..0000000000 Binary files a/Carthage/Build/iOS/GlucoseKit.framework/Modules/GlucoseKit.swiftmodule/arm.swiftmodule and /dev/null differ diff --git a/Carthage/Build/iOS/GlucoseKit.framework/Modules/GlucoseKit.swiftmodule/arm64.swiftdoc b/Carthage/Build/iOS/GlucoseKit.framework/Modules/GlucoseKit.swiftmodule/arm64.swiftdoc deleted file mode 100644 index c0875002db..0000000000 Binary files a/Carthage/Build/iOS/GlucoseKit.framework/Modules/GlucoseKit.swiftmodule/arm64.swiftdoc and /dev/null differ diff --git a/Carthage/Build/iOS/GlucoseKit.framework/Modules/GlucoseKit.swiftmodule/arm64.swiftmodule b/Carthage/Build/iOS/GlucoseKit.framework/Modules/GlucoseKit.swiftmodule/arm64.swiftmodule deleted file mode 100644 index 7f492d450c..0000000000 Binary files a/Carthage/Build/iOS/GlucoseKit.framework/Modules/GlucoseKit.swiftmodule/arm64.swiftmodule and /dev/null differ diff --git a/Carthage/Build/iOS/GlucoseKit.framework/Modules/GlucoseKit.swiftmodule/i386.swiftdoc b/Carthage/Build/iOS/GlucoseKit.framework/Modules/GlucoseKit.swiftmodule/i386.swiftdoc deleted file mode 100644 index a4de46db7d..0000000000 Binary files a/Carthage/Build/iOS/GlucoseKit.framework/Modules/GlucoseKit.swiftmodule/i386.swiftdoc and /dev/null differ diff --git a/Carthage/Build/iOS/GlucoseKit.framework/Modules/GlucoseKit.swiftmodule/i386.swiftmodule b/Carthage/Build/iOS/GlucoseKit.framework/Modules/GlucoseKit.swiftmodule/i386.swiftmodule deleted file mode 100644 index 6daf0e9bd1..0000000000 Binary files a/Carthage/Build/iOS/GlucoseKit.framework/Modules/GlucoseKit.swiftmodule/i386.swiftmodule and /dev/null differ diff --git a/Carthage/Build/iOS/GlucoseKit.framework/Modules/GlucoseKit.swiftmodule/x86_64.swiftdoc b/Carthage/Build/iOS/GlucoseKit.framework/Modules/GlucoseKit.swiftmodule/x86_64.swiftdoc deleted file mode 100644 index 1562cb3d4e..0000000000 Binary files a/Carthage/Build/iOS/GlucoseKit.framework/Modules/GlucoseKit.swiftmodule/x86_64.swiftdoc and /dev/null differ diff --git a/Carthage/Build/iOS/GlucoseKit.framework/Modules/GlucoseKit.swiftmodule/x86_64.swiftmodule b/Carthage/Build/iOS/GlucoseKit.framework/Modules/GlucoseKit.swiftmodule/x86_64.swiftmodule deleted file mode 100644 index 8cb1b61cb2..0000000000 Binary files a/Carthage/Build/iOS/GlucoseKit.framework/Modules/GlucoseKit.swiftmodule/x86_64.swiftmodule and /dev/null differ diff --git a/Carthage/Build/iOS/GlucoseKit.framework/Modules/module.modulemap b/Carthage/Build/iOS/GlucoseKit.framework/Modules/module.modulemap deleted file mode 100644 index f8229630b3..0000000000 --- a/Carthage/Build/iOS/GlucoseKit.framework/Modules/module.modulemap +++ /dev/null @@ -1,10 +0,0 @@ -framework module GlucoseKit { - umbrella header "GlucoseKit.h" - - export * - module * { export * } -} - -module GlucoseKit.Swift { - header "GlucoseKit-Swift.h" -} diff --git a/Carthage/Build/iOS/InsulinKit.framework/Base.lproj/InsulinKit.storyboardc/Info.plist b/Carthage/Build/iOS/InsulinKit.framework/Base.lproj/InsulinKit.storyboardc/Info.plist deleted file mode 100644 index 67202dc4c9..0000000000 Binary files a/Carthage/Build/iOS/InsulinKit.framework/Base.lproj/InsulinKit.storyboardc/Info.plist and /dev/null differ diff --git a/Carthage/Build/iOS/InsulinKit.framework/Base.lproj/InsulinKit.storyboardc/UITableViewController-jGX-GA-nlH.nib b/Carthage/Build/iOS/InsulinKit.framework/Base.lproj/InsulinKit.storyboardc/UITableViewController-jGX-GA-nlH.nib deleted file mode 100644 index c33b19b9b7..0000000000 Binary files a/Carthage/Build/iOS/InsulinKit.framework/Base.lproj/InsulinKit.storyboardc/UITableViewController-jGX-GA-nlH.nib and /dev/null differ diff --git a/Carthage/Build/iOS/InsulinKit.framework/Base.lproj/InsulinKit.storyboardc/jGX-GA-nlH-view-ccM-3y-LQM.nib b/Carthage/Build/iOS/InsulinKit.framework/Base.lproj/InsulinKit.storyboardc/jGX-GA-nlH-view-ccM-3y-LQM.nib deleted file mode 100644 index 79b475636e..0000000000 Binary files a/Carthage/Build/iOS/InsulinKit.framework/Base.lproj/InsulinKit.storyboardc/jGX-GA-nlH-view-ccM-3y-LQM.nib and /dev/null differ diff --git a/Carthage/Build/iOS/InsulinKit.framework/Headers/InsulinKit-Swift.h b/Carthage/Build/iOS/InsulinKit.framework/Headers/InsulinKit-Swift.h deleted file mode 100644 index 328deddb2a..0000000000 --- a/Carthage/Build/iOS/InsulinKit.framework/Headers/InsulinKit-Swift.h +++ /dev/null @@ -1,147 +0,0 @@ -// Generated by Apple Swift version 3.0.1 (swiftlang-800.0.58.6 clang-800.0.42.1) -#pragma clang diagnostic push - -#if defined(__has_include) && __has_include() -# include -#endif - -#pragma clang diagnostic ignored "-Wauto-import" -#include -#include -#include -#include - -#if !defined(SWIFT_TYPEDEFS) -# define SWIFT_TYPEDEFS 1 -# if defined(__has_include) && __has_include() -# include -# elif !defined(__cplusplus) || __cplusplus < 201103L -typedef uint_least16_t char16_t; -typedef uint_least32_t char32_t; -# endif -typedef float swift_float2 __attribute__((__ext_vector_type__(2))); -typedef float swift_float3 __attribute__((__ext_vector_type__(3))); -typedef float swift_float4 __attribute__((__ext_vector_type__(4))); -typedef double swift_double2 __attribute__((__ext_vector_type__(2))); -typedef double swift_double3 __attribute__((__ext_vector_type__(3))); -typedef double swift_double4 __attribute__((__ext_vector_type__(4))); -typedef int swift_int2 __attribute__((__ext_vector_type__(2))); -typedef int swift_int3 __attribute__((__ext_vector_type__(3))); -typedef int swift_int4 __attribute__((__ext_vector_type__(4))); -typedef unsigned int swift_uint2 __attribute__((__ext_vector_type__(2))); -typedef unsigned int swift_uint3 __attribute__((__ext_vector_type__(3))); -typedef unsigned int swift_uint4 __attribute__((__ext_vector_type__(4))); -#endif - -#if !defined(SWIFT_PASTE) -# define SWIFT_PASTE_HELPER(x, y) x##y -# define SWIFT_PASTE(x, y) SWIFT_PASTE_HELPER(x, y) -#endif -#if !defined(SWIFT_METATYPE) -# define SWIFT_METATYPE(X) Class -#endif -#if !defined(SWIFT_CLASS_PROPERTY) -# if __has_feature(objc_class_property) -# define SWIFT_CLASS_PROPERTY(...) __VA_ARGS__ -# else -# define SWIFT_CLASS_PROPERTY(...) -# endif -#endif - -#if defined(__has_attribute) && __has_attribute(objc_runtime_name) -# define SWIFT_RUNTIME_NAME(X) __attribute__((objc_runtime_name(X))) -#else -# define SWIFT_RUNTIME_NAME(X) -#endif -#if defined(__has_attribute) && __has_attribute(swift_name) -# define SWIFT_COMPILE_NAME(X) __attribute__((swift_name(X))) -#else -# define SWIFT_COMPILE_NAME(X) -#endif -#if defined(__has_attribute) && __has_attribute(objc_method_family) -# define SWIFT_METHOD_FAMILY(X) __attribute__((objc_method_family(X))) -#else -# define SWIFT_METHOD_FAMILY(X) -#endif -#if defined(__has_attribute) && __has_attribute(noescape) -# define SWIFT_NOESCAPE __attribute__((noescape)) -#else -# define SWIFT_NOESCAPE -#endif -#if !defined(SWIFT_CLASS_EXTRA) -# define SWIFT_CLASS_EXTRA -#endif -#if !defined(SWIFT_PROTOCOL_EXTRA) -# define SWIFT_PROTOCOL_EXTRA -#endif -#if !defined(SWIFT_ENUM_EXTRA) -# define SWIFT_ENUM_EXTRA -#endif -#if !defined(SWIFT_CLASS) -# if defined(__has_attribute) && __has_attribute(objc_subclassing_restricted) -# define SWIFT_CLASS(SWIFT_NAME) SWIFT_RUNTIME_NAME(SWIFT_NAME) __attribute__((objc_subclassing_restricted)) SWIFT_CLASS_EXTRA -# define SWIFT_CLASS_NAMED(SWIFT_NAME) __attribute__((objc_subclassing_restricted)) SWIFT_COMPILE_NAME(SWIFT_NAME) SWIFT_CLASS_EXTRA -# else -# define SWIFT_CLASS(SWIFT_NAME) SWIFT_RUNTIME_NAME(SWIFT_NAME) SWIFT_CLASS_EXTRA -# define SWIFT_CLASS_NAMED(SWIFT_NAME) SWIFT_COMPILE_NAME(SWIFT_NAME) SWIFT_CLASS_EXTRA -# endif -#endif - -#if !defined(SWIFT_PROTOCOL) -# define SWIFT_PROTOCOL(SWIFT_NAME) SWIFT_RUNTIME_NAME(SWIFT_NAME) SWIFT_PROTOCOL_EXTRA -# define SWIFT_PROTOCOL_NAMED(SWIFT_NAME) SWIFT_COMPILE_NAME(SWIFT_NAME) SWIFT_PROTOCOL_EXTRA -#endif - -#if !defined(SWIFT_EXTENSION) -# define SWIFT_EXTENSION(M) SWIFT_PASTE(M##_Swift_, __LINE__) -#endif - -#if !defined(OBJC_DESIGNATED_INITIALIZER) -# if defined(__has_attribute) && __has_attribute(objc_designated_initializer) -# define OBJC_DESIGNATED_INITIALIZER __attribute__((objc_designated_initializer)) -# else -# define OBJC_DESIGNATED_INITIALIZER -# endif -#endif -#if !defined(SWIFT_ENUM) -# define SWIFT_ENUM(_type, _name) enum _name : _type _name; enum SWIFT_ENUM_EXTRA _name : _type -# if defined(__has_feature) && __has_feature(generalized_swift_name) -# define SWIFT_ENUM_NAMED(_type, _name, SWIFT_NAME) enum _name : _type _name SWIFT_COMPILE_NAME(SWIFT_NAME); enum SWIFT_COMPILE_NAME(SWIFT_NAME) SWIFT_ENUM_EXTRA _name : _type -# else -# define SWIFT_ENUM_NAMED(_type, _name, SWIFT_NAME) SWIFT_ENUM(_type, _name) -# endif -#endif -#if !defined(SWIFT_UNAVAILABLE) -# define SWIFT_UNAVAILABLE __attribute__((unavailable)) -#endif -#if defined(__has_feature) && __has_feature(modules) -@import UIKit; -@import Foundation; -#endif - -#pragma clang diagnostic ignored "-Wproperty-attribute-mismatch" -#pragma clang diagnostic ignored "-Wduplicate-method-arg" -@class UITableView; -@class UITableViewCell; -@class NSBundle; -@class NSCoder; - -SWIFT_CLASS("_TtC10InsulinKit34InsulinDeliveryTableViewController") -@interface InsulinDeliveryTableViewController : UITableViewController -- (void)viewDidLoad; -- (void)viewWillAppear:(BOOL)animated; -- (void)viewDidAppear:(BOOL)animated; -- (void)viewWillDisappear:(BOOL)animated; -- (void)viewDidDisappear:(BOOL)animated; -- (NSInteger)numberOfSectionsInTableView:(UITableView * _Nonnull)tableView; -- (NSInteger)tableView:(UITableView * _Nonnull)tableView numberOfRowsInSection:(NSInteger)section; -- (UITableViewCell * _Nonnull)tableView:(UITableView * _Nonnull)tableView cellForRowAtIndexPath:(NSIndexPath * _Nonnull)indexPath; -- (BOOL)tableView:(UITableView * _Nonnull)tableView canEditRowAtIndexPath:(NSIndexPath * _Nonnull)indexPath; -- (void)tableView:(UITableView * _Nonnull)tableView commitEditingStyle:(UITableViewCellEditingStyle)editingStyle forRowAtIndexPath:(NSIndexPath * _Nonnull)indexPath; -- (void)tableView:(UITableView * _Nonnull)tableView didSelectRowAtIndexPath:(NSIndexPath * _Nonnull)indexPath; -- (nonnull instancetype)initWithStyle:(UITableViewStyle)style OBJC_DESIGNATED_INITIALIZER; -- (nonnull instancetype)initWithNibName:(NSString * _Nullable)nibNameOrNil bundle:(NSBundle * _Nullable)nibBundleOrNil OBJC_DESIGNATED_INITIALIZER; -- (nullable instancetype)initWithCoder:(NSCoder * _Nonnull)aDecoder OBJC_DESIGNATED_INITIALIZER; -@end - -#pragma clang diagnostic pop diff --git a/Carthage/Build/iOS/InsulinKit.framework/Headers/InsulinKit.h b/Carthage/Build/iOS/InsulinKit.framework/Headers/InsulinKit.h deleted file mode 100644 index 6ed55d515b..0000000000 --- a/Carthage/Build/iOS/InsulinKit.framework/Headers/InsulinKit.h +++ /dev/null @@ -1,19 +0,0 @@ -// -// InsulinKit.h -// InsulinKit -// -// Created by Nathan Racklyeft on 2/15/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -#import - -//! Project version number for InsulinKit. -FOUNDATION_EXPORT double InsulinKitVersionNumber; - -//! Project version string for InsulinKit. -FOUNDATION_EXPORT const unsigned char InsulinKitVersionString[]; - -// In this header, you should import all the public headers of your framework using statements like #import - - diff --git a/Carthage/Build/iOS/InsulinKit.framework/Info.plist b/Carthage/Build/iOS/InsulinKit.framework/Info.plist deleted file mode 100644 index 07d56f9afc..0000000000 Binary files a/Carthage/Build/iOS/InsulinKit.framework/Info.plist and /dev/null differ diff --git a/Carthage/Build/iOS/InsulinKit.framework/InsulinKit b/Carthage/Build/iOS/InsulinKit.framework/InsulinKit deleted file mode 100755 index 0ab7460e72..0000000000 Binary files a/Carthage/Build/iOS/InsulinKit.framework/InsulinKit and /dev/null differ diff --git a/Carthage/Build/iOS/InsulinKit.framework/Model.momd/Model.mom b/Carthage/Build/iOS/InsulinKit.framework/Model.momd/Model.mom deleted file mode 100644 index 5b1a010ac7..0000000000 Binary files a/Carthage/Build/iOS/InsulinKit.framework/Model.momd/Model.mom and /dev/null differ diff --git a/Carthage/Build/iOS/InsulinKit.framework/Model.momd/VersionInfo.plist b/Carthage/Build/iOS/InsulinKit.framework/Model.momd/VersionInfo.plist deleted file mode 100644 index 6326325ef0..0000000000 Binary files a/Carthage/Build/iOS/InsulinKit.framework/Model.momd/VersionInfo.plist and /dev/null differ diff --git a/Carthage/Build/iOS/InsulinKit.framework/Modules/InsulinKit.swiftmodule/arm.swiftdoc b/Carthage/Build/iOS/InsulinKit.framework/Modules/InsulinKit.swiftmodule/arm.swiftdoc deleted file mode 100644 index 4ccb1e0113..0000000000 Binary files a/Carthage/Build/iOS/InsulinKit.framework/Modules/InsulinKit.swiftmodule/arm.swiftdoc and /dev/null differ diff --git a/Carthage/Build/iOS/InsulinKit.framework/Modules/InsulinKit.swiftmodule/arm.swiftmodule b/Carthage/Build/iOS/InsulinKit.framework/Modules/InsulinKit.swiftmodule/arm.swiftmodule deleted file mode 100644 index 0c93064e3f..0000000000 Binary files a/Carthage/Build/iOS/InsulinKit.framework/Modules/InsulinKit.swiftmodule/arm.swiftmodule and /dev/null differ diff --git a/Carthage/Build/iOS/InsulinKit.framework/Modules/InsulinKit.swiftmodule/arm64.swiftdoc b/Carthage/Build/iOS/InsulinKit.framework/Modules/InsulinKit.swiftmodule/arm64.swiftdoc deleted file mode 100644 index 545e8fb744..0000000000 Binary files a/Carthage/Build/iOS/InsulinKit.framework/Modules/InsulinKit.swiftmodule/arm64.swiftdoc and /dev/null differ diff --git a/Carthage/Build/iOS/InsulinKit.framework/Modules/InsulinKit.swiftmodule/arm64.swiftmodule b/Carthage/Build/iOS/InsulinKit.framework/Modules/InsulinKit.swiftmodule/arm64.swiftmodule deleted file mode 100644 index 1ff1e1a649..0000000000 Binary files a/Carthage/Build/iOS/InsulinKit.framework/Modules/InsulinKit.swiftmodule/arm64.swiftmodule and /dev/null differ diff --git a/Carthage/Build/iOS/InsulinKit.framework/Modules/InsulinKit.swiftmodule/i386.swiftdoc b/Carthage/Build/iOS/InsulinKit.framework/Modules/InsulinKit.swiftmodule/i386.swiftdoc deleted file mode 100644 index df6160b1e0..0000000000 Binary files a/Carthage/Build/iOS/InsulinKit.framework/Modules/InsulinKit.swiftmodule/i386.swiftdoc and /dev/null differ diff --git a/Carthage/Build/iOS/InsulinKit.framework/Modules/InsulinKit.swiftmodule/i386.swiftmodule b/Carthage/Build/iOS/InsulinKit.framework/Modules/InsulinKit.swiftmodule/i386.swiftmodule deleted file mode 100644 index b5cfcf1f56..0000000000 Binary files a/Carthage/Build/iOS/InsulinKit.framework/Modules/InsulinKit.swiftmodule/i386.swiftmodule and /dev/null differ diff --git a/Carthage/Build/iOS/InsulinKit.framework/Modules/InsulinKit.swiftmodule/x86_64.swiftdoc b/Carthage/Build/iOS/InsulinKit.framework/Modules/InsulinKit.swiftmodule/x86_64.swiftdoc deleted file mode 100644 index 24e2f11fca..0000000000 Binary files a/Carthage/Build/iOS/InsulinKit.framework/Modules/InsulinKit.swiftmodule/x86_64.swiftdoc and /dev/null differ diff --git a/Carthage/Build/iOS/InsulinKit.framework/Modules/InsulinKit.swiftmodule/x86_64.swiftmodule b/Carthage/Build/iOS/InsulinKit.framework/Modules/InsulinKit.swiftmodule/x86_64.swiftmodule deleted file mode 100644 index f61c0b10e7..0000000000 Binary files a/Carthage/Build/iOS/InsulinKit.framework/Modules/InsulinKit.swiftmodule/x86_64.swiftmodule and /dev/null differ diff --git a/Carthage/Build/iOS/InsulinKit.framework/Modules/module.modulemap b/Carthage/Build/iOS/InsulinKit.framework/Modules/module.modulemap deleted file mode 100644 index 94340c2075..0000000000 --- a/Carthage/Build/iOS/InsulinKit.framework/Modules/module.modulemap +++ /dev/null @@ -1,10 +0,0 @@ -framework module InsulinKit { - umbrella header "InsulinKit.h" - - export * - module * { export * } -} - -module InsulinKit.Swift { - header "InsulinKit-Swift.h" -} diff --git a/Carthage/Build/iOS/LoopKit.framework/Assets.car b/Carthage/Build/iOS/LoopKit.framework/Assets.car deleted file mode 100644 index 082f5e49ab..0000000000 Binary files a/Carthage/Build/iOS/LoopKit.framework/Assets.car and /dev/null differ diff --git a/Carthage/Build/iOS/LoopKit.framework/GlucoseRangeOverrideTableViewCell.nib b/Carthage/Build/iOS/LoopKit.framework/GlucoseRangeOverrideTableViewCell.nib deleted file mode 100644 index 0bfca4bc15..0000000000 Binary files a/Carthage/Build/iOS/LoopKit.framework/GlucoseRangeOverrideTableViewCell.nib and /dev/null differ diff --git a/Carthage/Build/iOS/LoopKit.framework/GlucoseRangeTableViewCell.nib b/Carthage/Build/iOS/LoopKit.framework/GlucoseRangeTableViewCell.nib deleted file mode 100644 index 1f9a29f99c..0000000000 Binary files a/Carthage/Build/iOS/LoopKit.framework/GlucoseRangeTableViewCell.nib and /dev/null differ diff --git a/Carthage/Build/iOS/LoopKit.framework/Headers/LoopKit-Swift.h b/Carthage/Build/iOS/LoopKit.framework/Headers/LoopKit-Swift.h deleted file mode 100644 index 37793a365c..0000000000 --- a/Carthage/Build/iOS/LoopKit.framework/Headers/LoopKit-Swift.h +++ /dev/null @@ -1,312 +0,0 @@ -// Generated by Apple Swift version 3.0.1 (swiftlang-800.0.58.6 clang-800.0.42.1) -#pragma clang diagnostic push - -#if defined(__has_include) && __has_include() -# include -#endif - -#pragma clang diagnostic ignored "-Wauto-import" -#include -#include -#include -#include - -#if !defined(SWIFT_TYPEDEFS) -# define SWIFT_TYPEDEFS 1 -# if defined(__has_include) && __has_include() -# include -# elif !defined(__cplusplus) || __cplusplus < 201103L -typedef uint_least16_t char16_t; -typedef uint_least32_t char32_t; -# endif -typedef float swift_float2 __attribute__((__ext_vector_type__(2))); -typedef float swift_float3 __attribute__((__ext_vector_type__(3))); -typedef float swift_float4 __attribute__((__ext_vector_type__(4))); -typedef double swift_double2 __attribute__((__ext_vector_type__(2))); -typedef double swift_double3 __attribute__((__ext_vector_type__(3))); -typedef double swift_double4 __attribute__((__ext_vector_type__(4))); -typedef int swift_int2 __attribute__((__ext_vector_type__(2))); -typedef int swift_int3 __attribute__((__ext_vector_type__(3))); -typedef int swift_int4 __attribute__((__ext_vector_type__(4))); -typedef unsigned int swift_uint2 __attribute__((__ext_vector_type__(2))); -typedef unsigned int swift_uint3 __attribute__((__ext_vector_type__(3))); -typedef unsigned int swift_uint4 __attribute__((__ext_vector_type__(4))); -#endif - -#if !defined(SWIFT_PASTE) -# define SWIFT_PASTE_HELPER(x, y) x##y -# define SWIFT_PASTE(x, y) SWIFT_PASTE_HELPER(x, y) -#endif -#if !defined(SWIFT_METATYPE) -# define SWIFT_METATYPE(X) Class -#endif -#if !defined(SWIFT_CLASS_PROPERTY) -# if __has_feature(objc_class_property) -# define SWIFT_CLASS_PROPERTY(...) __VA_ARGS__ -# else -# define SWIFT_CLASS_PROPERTY(...) -# endif -#endif - -#if defined(__has_attribute) && __has_attribute(objc_runtime_name) -# define SWIFT_RUNTIME_NAME(X) __attribute__((objc_runtime_name(X))) -#else -# define SWIFT_RUNTIME_NAME(X) -#endif -#if defined(__has_attribute) && __has_attribute(swift_name) -# define SWIFT_COMPILE_NAME(X) __attribute__((swift_name(X))) -#else -# define SWIFT_COMPILE_NAME(X) -#endif -#if defined(__has_attribute) && __has_attribute(objc_method_family) -# define SWIFT_METHOD_FAMILY(X) __attribute__((objc_method_family(X))) -#else -# define SWIFT_METHOD_FAMILY(X) -#endif -#if defined(__has_attribute) && __has_attribute(noescape) -# define SWIFT_NOESCAPE __attribute__((noescape)) -#else -# define SWIFT_NOESCAPE -#endif -#if !defined(SWIFT_CLASS_EXTRA) -# define SWIFT_CLASS_EXTRA -#endif -#if !defined(SWIFT_PROTOCOL_EXTRA) -# define SWIFT_PROTOCOL_EXTRA -#endif -#if !defined(SWIFT_ENUM_EXTRA) -# define SWIFT_ENUM_EXTRA -#endif -#if !defined(SWIFT_CLASS) -# if defined(__has_attribute) && __has_attribute(objc_subclassing_restricted) -# define SWIFT_CLASS(SWIFT_NAME) SWIFT_RUNTIME_NAME(SWIFT_NAME) __attribute__((objc_subclassing_restricted)) SWIFT_CLASS_EXTRA -# define SWIFT_CLASS_NAMED(SWIFT_NAME) __attribute__((objc_subclassing_restricted)) SWIFT_COMPILE_NAME(SWIFT_NAME) SWIFT_CLASS_EXTRA -# else -# define SWIFT_CLASS(SWIFT_NAME) SWIFT_RUNTIME_NAME(SWIFT_NAME) SWIFT_CLASS_EXTRA -# define SWIFT_CLASS_NAMED(SWIFT_NAME) SWIFT_COMPILE_NAME(SWIFT_NAME) SWIFT_CLASS_EXTRA -# endif -#endif - -#if !defined(SWIFT_PROTOCOL) -# define SWIFT_PROTOCOL(SWIFT_NAME) SWIFT_RUNTIME_NAME(SWIFT_NAME) SWIFT_PROTOCOL_EXTRA -# define SWIFT_PROTOCOL_NAMED(SWIFT_NAME) SWIFT_COMPILE_NAME(SWIFT_NAME) SWIFT_PROTOCOL_EXTRA -#endif - -#if !defined(SWIFT_EXTENSION) -# define SWIFT_EXTENSION(M) SWIFT_PASTE(M##_Swift_, __LINE__) -#endif - -#if !defined(OBJC_DESIGNATED_INITIALIZER) -# if defined(__has_attribute) && __has_attribute(objc_designated_initializer) -# define OBJC_DESIGNATED_INITIALIZER __attribute__((objc_designated_initializer)) -# else -# define OBJC_DESIGNATED_INITIALIZER -# endif -#endif -#if !defined(SWIFT_ENUM) -# define SWIFT_ENUM(_type, _name) enum _name : _type _name; enum SWIFT_ENUM_EXTRA _name : _type -# if defined(__has_feature) && __has_feature(generalized_swift_name) -# define SWIFT_ENUM_NAMED(_type, _name, SWIFT_NAME) enum _name : _type _name SWIFT_COMPILE_NAME(SWIFT_NAME); enum SWIFT_COMPILE_NAME(SWIFT_NAME) SWIFT_ENUM_EXTRA _name : _type -# else -# define SWIFT_ENUM_NAMED(_type, _name, SWIFT_NAME) SWIFT_ENUM(_type, _name) -# endif -#endif -#if !defined(SWIFT_UNAVAILABLE) -# define SWIFT_UNAVAILABLE __attribute__((unavailable)) -#endif -#if defined(__has_feature) && __has_feature(modules) -@import UIKit; -@import Foundation; -@import CoreGraphics; -@import HealthKit; -#endif - -#pragma clang diagnostic ignored "-Wproperty-attribute-mismatch" -#pragma clang diagnostic ignored "-Wduplicate-method-arg" -@class NSCoder; -@class NSBundle; - -SWIFT_CLASS("_TtC7LoopKit29CommandResponseViewController") -@interface CommandResponseViewController : UIViewController -- (nonnull instancetype)initWithCommand:(NSString * _Nonnull (^ _Nonnull)(void (^ _Nonnull)(NSString * _Nonnull)))command OBJC_DESIGNATED_INITIALIZER; -- (nullable instancetype)initWithCoder:(NSCoder * _Nonnull)aDecoder OBJC_DESIGNATED_INITIALIZER; -- (void)loadView; -- (void)viewDidLoad; -- (nonnull instancetype)initWithNibName:(NSString * _Nullable)nibNameOrNil bundle:(NSBundle * _Nullable)nibBundleOrNil SWIFT_UNAVAILABLE; -@end - -@class UIActivityViewController; - -@interface CommandResponseViewController (SWIFT_EXTENSION(LoopKit)) -- (id _Nonnull)activityViewControllerPlaceholderItem:(UIActivityViewController * _Nonnull)activityViewController; -- (id _Nullable)activityViewController:(UIActivityViewController * _Nonnull)activityViewController itemForActivityType:(UIActivityType _Nonnull)activityType; -- (NSString * _Nonnull)activityViewController:(UIActivityViewController * _Nonnull)activityViewController subjectForActivityType:(UIActivityType _Nullable)activityType; -@end - -@class UIBarButtonItem; -@class UITableView; -@class UITableViewCell; - -SWIFT_CLASS("_TtC7LoopKit37DailyValueScheduleTableViewController") -@interface DailyValueScheduleTableViewController : UITableViewController -- (nonnull instancetype)init OBJC_DESIGNATED_INITIALIZER; -- (nullable instancetype)initWithCoder:(NSCoder * _Nonnull)aDecoder OBJC_DESIGNATED_INITIALIZER; -- (void)viewDidLoad; -- (void)setEditing:(BOOL)editing animated:(BOOL)animated; -- (void)viewWillDisappear:(BOOL)animated; -- (UIBarButtonItem * _Nonnull)insertButtonItem; -@property (nonatomic, copy) NSTimeZone * _Nonnull timeZone; -@property (nonatomic, copy) NSString * _Nonnull unitDisplayString; -- (NSInteger)numberOfSectionsInTableView:(UITableView * _Nonnull)tableView; -- (NSInteger)tableView:(UITableView * _Nonnull)tableView numberOfRowsInSection:(NSInteger)section; -- (BOOL)tableView:(UITableView * _Nonnull)tableView canEditRowAtIndexPath:(NSIndexPath * _Nonnull)indexPath; -- (BOOL)tableView:(UITableView * _Nonnull)tableView canMoveRowAtIndexPath:(NSIndexPath * _Nonnull)indexPath; -- (UITableViewCell * _Nonnull)tableView:(UITableView * _Nonnull)tableView cellForRowAtIndexPath:(NSIndexPath * _Nonnull)indexPath; -- (void)tableView:(UITableView * _Nonnull)tableView commitEditingStyle:(UITableViewCellEditingStyle)editingStyle forRowAtIndexPath:(NSIndexPath * _Nonnull)indexPath; -- (CGFloat)tableView:(UITableView * _Nonnull)tableView heightForRowAtIndexPath:(NSIndexPath * _Nonnull)indexPath; -- (BOOL)tableView:(UITableView * _Nonnull)tableView shouldHighlightRowAtIndexPath:(NSIndexPath * _Nonnull)indexPath; -- (NSIndexPath * _Nullable)tableView:(UITableView * _Nonnull)tableView willSelectRowAtIndexPath:(NSIndexPath * _Nonnull)indexPath; -- (void)tableView:(UITableView * _Nonnull)tableView didDeselectRowAtIndexPath:(NSIndexPath * _Nonnull)indexPath; -- (void)tableView:(UITableView * _Nonnull)tableView didSelectRowAtIndexPath:(NSIndexPath * _Nonnull)indexPath; -- (NSIndexPath * _Nonnull)tableView:(UITableView * _Nonnull)tableView targetIndexPathForMoveFromRowAtIndexPath:(NSIndexPath * _Nonnull)sourceIndexPath toProposedIndexPath:(NSIndexPath * _Nonnull)proposedDestinationIndexPath; -- (nonnull instancetype)initWithStyle:(UITableViewStyle)style SWIFT_UNAVAILABLE; -- (nonnull instancetype)initWithNibName:(NSString * _Nullable)nibNameOrNil bundle:(NSBundle * _Nullable)nibBundleOrNil SWIFT_UNAVAILABLE; -@end - - -SWIFT_CLASS("_TtC7LoopKit38SingleValueScheduleTableViewController") -@interface SingleValueScheduleTableViewController : DailyValueScheduleTableViewController -- (void)viewDidLoad; -- (NSInteger)tableView:(UITableView * _Nonnull)tableView numberOfRowsInSection:(NSInteger)section; -- (UITableViewCell * _Nonnull)tableView:(UITableView * _Nonnull)tableView cellForRowAtIndexPath:(NSIndexPath * _Nonnull)indexPath; -- (void)tableView:(UITableView * _Nonnull)tableView commitEditingStyle:(UITableViewCellEditingStyle)editingStyle forRowAtIndexPath:(NSIndexPath * _Nonnull)indexPath; -- (void)tableView:(UITableView * _Nonnull)tableView moveRowAtIndexPath:(NSIndexPath * _Nonnull)sourceIndexPath toIndexPath:(NSIndexPath * _Nonnull)destinationIndexPath; -- (NSIndexPath * _Nonnull)tableView:(UITableView * _Nonnull)tableView targetIndexPathForMoveFromRowAtIndexPath:(NSIndexPath * _Nonnull)sourceIndexPath toProposedIndexPath:(NSIndexPath * _Nonnull)proposedDestinationIndexPath; -- (nonnull instancetype)init OBJC_DESIGNATED_INITIALIZER; -- (nullable instancetype)initWithCoder:(NSCoder * _Nonnull)aDecoder OBJC_DESIGNATED_INITIALIZER; -@end - -@class HKUnit; - -SWIFT_CLASS("_TtC7LoopKit40DailyQuantityScheduleTableViewController") -@interface DailyQuantityScheduleTableViewController : SingleValueScheduleTableViewController -@property (nonatomic, strong) HKUnit * _Nonnull unit; -- (nonnull instancetype)init OBJC_DESIGNATED_INITIALIZER; -- (nullable instancetype)initWithCoder:(NSCoder * _Nonnull)aDecoder OBJC_DESIGNATED_INITIALIZER; -@end - - - -SWIFT_CLASS("_TtC7LoopKit39GlucoseRangeScheduleTableViewController") -@interface GlucoseRangeScheduleTableViewController : DailyValueScheduleTableViewController -@property (nonatomic, strong) HKUnit * _Nonnull unit; -- (void)viewDidLoad; -- (NSInteger)numberOfSectionsInTableView:(UITableView * _Nonnull)tableView; -- (NSInteger)tableView:(UITableView * _Nonnull)tableView numberOfRowsInSection:(NSInteger)section; -- (UITableViewCell * _Nonnull)tableView:(UITableView * _Nonnull)tableView cellForRowAtIndexPath:(NSIndexPath * _Nonnull)indexPath; -- (void)tableView:(UITableView * _Nonnull)tableView commitEditingStyle:(UITableViewCellEditingStyle)editingStyle forRowAtIndexPath:(NSIndexPath * _Nonnull)indexPath; -- (void)tableView:(UITableView * _Nonnull)tableView moveRowAtIndexPath:(NSIndexPath * _Nonnull)sourceIndexPath toIndexPath:(NSIndexPath * _Nonnull)destinationIndexPath; -- (BOOL)tableView:(UITableView * _Nonnull)tableView canEditRowAtIndexPath:(NSIndexPath * _Nonnull)indexPath; -- (BOOL)tableView:(UITableView * _Nonnull)tableView canMoveRowAtIndexPath:(NSIndexPath * _Nonnull)indexPath; -- (NSString * _Nullable)tableView:(UITableView * _Nonnull)tableView titleForHeaderInSection:(NSInteger)section; -- (nonnull instancetype)init OBJC_DESIGNATED_INITIALIZER; -- (nullable instancetype)initWithCoder:(NSCoder * _Nonnull)aDecoder OBJC_DESIGNATED_INITIALIZER; -@end - - -@interface GlucoseRangeScheduleTableViewController (SWIFT_EXTENSION(LoopKit)) -@end - - -@interface HKQuantity (SWIFT_EXTENSION(LoopKit)) -@end - - -@interface HKQuantitySample (SWIFT_EXTENSION(LoopKit)) -@end - - -@interface HKQuantitySample (SWIFT_EXTENSION(LoopKit)) -@end - - -@interface HKUnit (SWIFT_EXTENSION(LoopKit)) -+ (HKUnit * _Nonnull)milligramsPerDeciliterUnit; -+ (HKUnit * _Nonnull)millimolesPerLiterUnit; -/** - A formatting helper for determining the preferred decimal style for a given unit -*/ -@property (nonatomic, readonly) NSInteger preferredMinimumFractionDigits; -/** - A presentation helper for the localized unit string -*/ -@property (nonatomic, readonly, copy) NSString * _Nonnull glucoseUnitDisplayString; -@end - - -@class UITextField; - -SWIFT_CLASS("_TtC7LoopKit28TextFieldTableViewController") -@interface TextFieldTableViewController : UITableViewController -@property (nonatomic, copy) NSIndexPath * _Nullable indexPath; -@property (nonatomic, copy) NSString * _Nullable placeholder; -@property (nonatomic, copy) NSString * _Nullable unit; -@property (nonatomic, copy) NSString * _Nullable value; -@property (nonatomic, copy) NSString * _Nullable contextHelp; -@property (nonatomic) UIKeyboardType keyboardType; -- (nonnull instancetype)init; -- (void)viewDidLoad; -- (void)viewDidAppear:(BOOL)animated; -- (NSInteger)tableView:(UITableView * _Nonnull)tableView numberOfRowsInSection:(NSInteger)section; -- (UITableViewCell * _Nonnull)tableView:(UITableView * _Nonnull)tableView cellForRowAtIndexPath:(NSIndexPath * _Nonnull)indexPath; -- (NSString * _Nullable)tableView:(UITableView * _Nonnull)tableView titleForFooterInSection:(NSInteger)section; -- (BOOL)textFieldShouldEndEditing:(UITextField * _Nonnull)textField; -- (BOOL)textFieldShouldReturn:(UITextField * _Nonnull)textField; -- (nonnull instancetype)initWithStyle:(UITableViewStyle)style OBJC_DESIGNATED_INITIALIZER; -- (nonnull instancetype)initWithNibName:(NSString * _Nullable)nibNameOrNil bundle:(NSBundle * _Nullable)nibBundleOrNil OBJC_DESIGNATED_INITIALIZER; -- (nullable instancetype)initWithCoder:(NSCoder * _Nonnull)aDecoder OBJC_DESIGNATED_INITIALIZER; -@end - - -@interface UITableViewCell (SWIFT_EXTENSION(LoopKit)) -@end - - -@interface UIViewController (SWIFT_EXTENSION(LoopKit)) -/** - Convenience method to present an alert controller on the active view controller - \param title The title of the alert - - \param message The message of the alert - - \param animated Whether to animate the alert - - \param completion An optional closure to execute after the presentation finishes - -*/ -- (void)presentAlertControllerWithTitle:(NSString * _Nullable)title message:(NSString * _Nonnull)message animated:(BOOL)animated completion:(void (^ _Nullable)(void))completion; -/** - Convenience method to display an error object in an alert controller - \param error The error to display - - \param animated Whether to animate the alert - - \param completion An optional closure to execute after the presentation finishes - -*/ -- (void)presentAlertControllerWith:(NSError * _Nonnull)error animated:(BOOL)animated completion:(void (^ _Nullable)(void))completion; -/** - Convenience method to present a view controller on the active view controller. - If the receiver is not in a window, or already has a presented view controller, this method will - attempt to find the most appropriate view controller for presenting. - \param viewControllerToPresent The view controller to display over the view controller’s content - - \param animated Whether to animate the presentation - - \param completion An optional closure to execute after the presentation finishes - -*/ -- (void)presentViewControllerOnActiveViewController:(UIViewController * _Nonnull)viewControllerToPresent animated:(BOOL)animated completion:(void (^ _Nullable)(void))completion; -@end - -#pragma clang diagnostic pop diff --git a/Carthage/Build/iOS/LoopKit.framework/Headers/LoopKit.h b/Carthage/Build/iOS/LoopKit.framework/Headers/LoopKit.h deleted file mode 100644 index 16ad5bea32..0000000000 --- a/Carthage/Build/iOS/LoopKit.framework/Headers/LoopKit.h +++ /dev/null @@ -1,19 +0,0 @@ -// -// LoopKit.h -// LoopKit -// -// Created by Nathan Racklyeft on 1/18/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -#import - -//! Project version number for LoopKit. -FOUNDATION_EXPORT double LoopKitVersionNumber; - -//! Project version string for LoopKit. -FOUNDATION_EXPORT const unsigned char LoopKitVersionString[]; - -// In this header, you should import all the public headers of your framework using statements like #import - - diff --git a/Carthage/Build/iOS/LoopKit.framework/Info.plist b/Carthage/Build/iOS/LoopKit.framework/Info.plist deleted file mode 100644 index a6af3642fa..0000000000 Binary files a/Carthage/Build/iOS/LoopKit.framework/Info.plist and /dev/null differ diff --git a/Carthage/Build/iOS/LoopKit.framework/LoopKit b/Carthage/Build/iOS/LoopKit.framework/LoopKit deleted file mode 100755 index 8f18ee305d..0000000000 Binary files a/Carthage/Build/iOS/LoopKit.framework/LoopKit and /dev/null differ diff --git a/Carthage/Build/iOS/LoopKit.framework/Modules/LoopKit.swiftmodule/arm.swiftdoc b/Carthage/Build/iOS/LoopKit.framework/Modules/LoopKit.swiftmodule/arm.swiftdoc deleted file mode 100644 index 80cdc0fac3..0000000000 Binary files a/Carthage/Build/iOS/LoopKit.framework/Modules/LoopKit.swiftmodule/arm.swiftdoc and /dev/null differ diff --git a/Carthage/Build/iOS/LoopKit.framework/Modules/LoopKit.swiftmodule/arm.swiftmodule b/Carthage/Build/iOS/LoopKit.framework/Modules/LoopKit.swiftmodule/arm.swiftmodule deleted file mode 100644 index ca3383e9de..0000000000 Binary files a/Carthage/Build/iOS/LoopKit.framework/Modules/LoopKit.swiftmodule/arm.swiftmodule and /dev/null differ diff --git a/Carthage/Build/iOS/LoopKit.framework/Modules/LoopKit.swiftmodule/arm64.swiftdoc b/Carthage/Build/iOS/LoopKit.framework/Modules/LoopKit.swiftmodule/arm64.swiftdoc deleted file mode 100644 index 434785f1ec..0000000000 Binary files a/Carthage/Build/iOS/LoopKit.framework/Modules/LoopKit.swiftmodule/arm64.swiftdoc and /dev/null differ diff --git a/Carthage/Build/iOS/LoopKit.framework/Modules/LoopKit.swiftmodule/arm64.swiftmodule b/Carthage/Build/iOS/LoopKit.framework/Modules/LoopKit.swiftmodule/arm64.swiftmodule deleted file mode 100644 index 5e1b1582b8..0000000000 Binary files a/Carthage/Build/iOS/LoopKit.framework/Modules/LoopKit.swiftmodule/arm64.swiftmodule and /dev/null differ diff --git a/Carthage/Build/iOS/LoopKit.framework/Modules/LoopKit.swiftmodule/i386.swiftdoc b/Carthage/Build/iOS/LoopKit.framework/Modules/LoopKit.swiftmodule/i386.swiftdoc deleted file mode 100644 index d2c1441853..0000000000 Binary files a/Carthage/Build/iOS/LoopKit.framework/Modules/LoopKit.swiftmodule/i386.swiftdoc and /dev/null differ diff --git a/Carthage/Build/iOS/LoopKit.framework/Modules/LoopKit.swiftmodule/i386.swiftmodule b/Carthage/Build/iOS/LoopKit.framework/Modules/LoopKit.swiftmodule/i386.swiftmodule deleted file mode 100644 index ace1d01ec6..0000000000 Binary files a/Carthage/Build/iOS/LoopKit.framework/Modules/LoopKit.swiftmodule/i386.swiftmodule and /dev/null differ diff --git a/Carthage/Build/iOS/LoopKit.framework/Modules/LoopKit.swiftmodule/x86_64.swiftdoc b/Carthage/Build/iOS/LoopKit.framework/Modules/LoopKit.swiftmodule/x86_64.swiftdoc deleted file mode 100644 index 2ba7e7fd5b..0000000000 Binary files a/Carthage/Build/iOS/LoopKit.framework/Modules/LoopKit.swiftmodule/x86_64.swiftdoc and /dev/null differ diff --git a/Carthage/Build/iOS/LoopKit.framework/Modules/LoopKit.swiftmodule/x86_64.swiftmodule b/Carthage/Build/iOS/LoopKit.framework/Modules/LoopKit.swiftmodule/x86_64.swiftmodule deleted file mode 100644 index 3afbbcb4c4..0000000000 Binary files a/Carthage/Build/iOS/LoopKit.framework/Modules/LoopKit.swiftmodule/x86_64.swiftmodule and /dev/null differ diff --git a/Carthage/Build/iOS/LoopKit.framework/Modules/module.modulemap b/Carthage/Build/iOS/LoopKit.framework/Modules/module.modulemap deleted file mode 100644 index d5c960cf54..0000000000 --- a/Carthage/Build/iOS/LoopKit.framework/Modules/module.modulemap +++ /dev/null @@ -1,10 +0,0 @@ -framework module LoopKit { - umbrella header "LoopKit.h" - - export * - module * { export * } -} - -module LoopKit.Swift { - header "LoopKit-Swift.h" -} diff --git a/Carthage/Build/iOS/LoopKit.framework/RepeatingScheduleValueTableViewCell.nib b/Carthage/Build/iOS/LoopKit.framework/RepeatingScheduleValueTableViewCell.nib deleted file mode 100644 index c617671e42..0000000000 Binary files a/Carthage/Build/iOS/LoopKit.framework/RepeatingScheduleValueTableViewCell.nib and /dev/null differ diff --git a/Carthage/Build/iOS/LoopKit.framework/TextFieldTableViewCell.nib b/Carthage/Build/iOS/LoopKit.framework/TextFieldTableViewCell.nib deleted file mode 100644 index a456b14a28..0000000000 Binary files a/Carthage/Build/iOS/LoopKit.framework/TextFieldTableViewCell.nib and /dev/null differ diff --git a/Carthage/Build/iOS/MinimedKit.framework/Headers/MinimedKit-Swift.h b/Carthage/Build/iOS/MinimedKit.framework/Headers/MinimedKit-Swift.h deleted file mode 100644 index f6e51dd3a9..0000000000 --- a/Carthage/Build/iOS/MinimedKit.framework/Headers/MinimedKit-Swift.h +++ /dev/null @@ -1,127 +0,0 @@ -// Generated by Apple Swift version 3.0.1 (swiftlang-800.0.58.6 clang-800.0.42.1) -#pragma clang diagnostic push - -#if defined(__has_include) && __has_include() -# include -#endif - -#pragma clang diagnostic ignored "-Wauto-import" -#include -#include -#include -#include - -#if !defined(SWIFT_TYPEDEFS) -# define SWIFT_TYPEDEFS 1 -# if defined(__has_include) && __has_include() -# include -# elif !defined(__cplusplus) || __cplusplus < 201103L -typedef uint_least16_t char16_t; -typedef uint_least32_t char32_t; -# endif -typedef float swift_float2 __attribute__((__ext_vector_type__(2))); -typedef float swift_float3 __attribute__((__ext_vector_type__(3))); -typedef float swift_float4 __attribute__((__ext_vector_type__(4))); -typedef double swift_double2 __attribute__((__ext_vector_type__(2))); -typedef double swift_double3 __attribute__((__ext_vector_type__(3))); -typedef double swift_double4 __attribute__((__ext_vector_type__(4))); -typedef int swift_int2 __attribute__((__ext_vector_type__(2))); -typedef int swift_int3 __attribute__((__ext_vector_type__(3))); -typedef int swift_int4 __attribute__((__ext_vector_type__(4))); -typedef unsigned int swift_uint2 __attribute__((__ext_vector_type__(2))); -typedef unsigned int swift_uint3 __attribute__((__ext_vector_type__(3))); -typedef unsigned int swift_uint4 __attribute__((__ext_vector_type__(4))); -#endif - -#if !defined(SWIFT_PASTE) -# define SWIFT_PASTE_HELPER(x, y) x##y -# define SWIFT_PASTE(x, y) SWIFT_PASTE_HELPER(x, y) -#endif -#if !defined(SWIFT_METATYPE) -# define SWIFT_METATYPE(X) Class -#endif -#if !defined(SWIFT_CLASS_PROPERTY) -# if __has_feature(objc_class_property) -# define SWIFT_CLASS_PROPERTY(...) __VA_ARGS__ -# else -# define SWIFT_CLASS_PROPERTY(...) -# endif -#endif - -#if defined(__has_attribute) && __has_attribute(objc_runtime_name) -# define SWIFT_RUNTIME_NAME(X) __attribute__((objc_runtime_name(X))) -#else -# define SWIFT_RUNTIME_NAME(X) -#endif -#if defined(__has_attribute) && __has_attribute(swift_name) -# define SWIFT_COMPILE_NAME(X) __attribute__((swift_name(X))) -#else -# define SWIFT_COMPILE_NAME(X) -#endif -#if defined(__has_attribute) && __has_attribute(objc_method_family) -# define SWIFT_METHOD_FAMILY(X) __attribute__((objc_method_family(X))) -#else -# define SWIFT_METHOD_FAMILY(X) -#endif -#if defined(__has_attribute) && __has_attribute(noescape) -# define SWIFT_NOESCAPE __attribute__((noescape)) -#else -# define SWIFT_NOESCAPE -#endif -#if !defined(SWIFT_CLASS_EXTRA) -# define SWIFT_CLASS_EXTRA -#endif -#if !defined(SWIFT_PROTOCOL_EXTRA) -# define SWIFT_PROTOCOL_EXTRA -#endif -#if !defined(SWIFT_ENUM_EXTRA) -# define SWIFT_ENUM_EXTRA -#endif -#if !defined(SWIFT_CLASS) -# if defined(__has_attribute) && __has_attribute(objc_subclassing_restricted) -# define SWIFT_CLASS(SWIFT_NAME) SWIFT_RUNTIME_NAME(SWIFT_NAME) __attribute__((objc_subclassing_restricted)) SWIFT_CLASS_EXTRA -# define SWIFT_CLASS_NAMED(SWIFT_NAME) __attribute__((objc_subclassing_restricted)) SWIFT_COMPILE_NAME(SWIFT_NAME) SWIFT_CLASS_EXTRA -# else -# define SWIFT_CLASS(SWIFT_NAME) SWIFT_RUNTIME_NAME(SWIFT_NAME) SWIFT_CLASS_EXTRA -# define SWIFT_CLASS_NAMED(SWIFT_NAME) SWIFT_COMPILE_NAME(SWIFT_NAME) SWIFT_CLASS_EXTRA -# endif -#endif - -#if !defined(SWIFT_PROTOCOL) -# define SWIFT_PROTOCOL(SWIFT_NAME) SWIFT_RUNTIME_NAME(SWIFT_NAME) SWIFT_PROTOCOL_EXTRA -# define SWIFT_PROTOCOL_NAMED(SWIFT_NAME) SWIFT_COMPILE_NAME(SWIFT_NAME) SWIFT_PROTOCOL_EXTRA -#endif - -#if !defined(SWIFT_EXTENSION) -# define SWIFT_EXTENSION(M) SWIFT_PASTE(M##_Swift_, __LINE__) -#endif - -#if !defined(OBJC_DESIGNATED_INITIALIZER) -# if defined(__has_attribute) && __has_attribute(objc_designated_initializer) -# define OBJC_DESIGNATED_INITIALIZER __attribute__((objc_designated_initializer)) -# else -# define OBJC_DESIGNATED_INITIALIZER -# endif -#endif -#if !defined(SWIFT_ENUM) -# define SWIFT_ENUM(_type, _name) enum _name : _type _name; enum SWIFT_ENUM_EXTRA _name : _type -# if defined(__has_feature) && __has_feature(generalized_swift_name) -# define SWIFT_ENUM_NAMED(_type, _name, SWIFT_NAME) enum _name : _type _name SWIFT_COMPILE_NAME(SWIFT_NAME); enum SWIFT_COMPILE_NAME(SWIFT_NAME) SWIFT_ENUM_EXTRA _name : _type -# else -# define SWIFT_ENUM_NAMED(_type, _name, SWIFT_NAME) SWIFT_ENUM(_type, _name) -# endif -#endif -#if !defined(SWIFT_UNAVAILABLE) -# define SWIFT_UNAVAILABLE __attribute__((unavailable)) -#endif -#if defined(__has_feature) && __has_feature(modules) -@import Foundation; -#endif - -#pragma clang diagnostic ignored "-Wproperty-attribute-mismatch" -#pragma clang diagnostic ignored "-Wduplicate-method-arg" - -@interface NSDateFormatter (SWIFT_EXTENSION(MinimedKit)) -@end - -#pragma clang diagnostic pop diff --git a/Carthage/Build/iOS/MinimedKit.framework/Headers/MinimedKit.h b/Carthage/Build/iOS/MinimedKit.framework/Headers/MinimedKit.h deleted file mode 100644 index b4f1d1cc90..0000000000 --- a/Carthage/Build/iOS/MinimedKit.framework/Headers/MinimedKit.h +++ /dev/null @@ -1,19 +0,0 @@ -// -// MinimedKit.h -// MinimedKit -// -// Created by Pete Schwamb on 2/27/16. -// Copyright © 2016 Pete Schwamb. All rights reserved. -// - -#import - -//! Project version number for MinimedKit. -FOUNDATION_EXPORT double MinimedKitVersionNumber; - -//! Project version string for MinimedKit. -FOUNDATION_EXPORT const unsigned char MinimedKitVersionString[]; - -// In this header, you should import all the public headers of your framework using statements like #import - - diff --git a/Carthage/Build/iOS/MinimedKit.framework/Info.plist b/Carthage/Build/iOS/MinimedKit.framework/Info.plist deleted file mode 100644 index 19ba481d1d..0000000000 Binary files a/Carthage/Build/iOS/MinimedKit.framework/Info.plist and /dev/null differ diff --git a/Carthage/Build/iOS/MinimedKit.framework/MinimedKit b/Carthage/Build/iOS/MinimedKit.framework/MinimedKit deleted file mode 100755 index 151de0bd46..0000000000 Binary files a/Carthage/Build/iOS/MinimedKit.framework/MinimedKit and /dev/null differ diff --git a/Carthage/Build/iOS/MinimedKit.framework/Modules/MinimedKit.swiftmodule/arm.swiftdoc b/Carthage/Build/iOS/MinimedKit.framework/Modules/MinimedKit.swiftmodule/arm.swiftdoc deleted file mode 100644 index c3c8072875..0000000000 Binary files a/Carthage/Build/iOS/MinimedKit.framework/Modules/MinimedKit.swiftmodule/arm.swiftdoc and /dev/null differ diff --git a/Carthage/Build/iOS/MinimedKit.framework/Modules/MinimedKit.swiftmodule/arm.swiftmodule b/Carthage/Build/iOS/MinimedKit.framework/Modules/MinimedKit.swiftmodule/arm.swiftmodule deleted file mode 100644 index 26ce1a102b..0000000000 Binary files a/Carthage/Build/iOS/MinimedKit.framework/Modules/MinimedKit.swiftmodule/arm.swiftmodule and /dev/null differ diff --git a/Carthage/Build/iOS/MinimedKit.framework/Modules/MinimedKit.swiftmodule/arm64.swiftdoc b/Carthage/Build/iOS/MinimedKit.framework/Modules/MinimedKit.swiftmodule/arm64.swiftdoc deleted file mode 100644 index c0d66fa8f2..0000000000 Binary files a/Carthage/Build/iOS/MinimedKit.framework/Modules/MinimedKit.swiftmodule/arm64.swiftdoc and /dev/null differ diff --git a/Carthage/Build/iOS/MinimedKit.framework/Modules/MinimedKit.swiftmodule/arm64.swiftmodule b/Carthage/Build/iOS/MinimedKit.framework/Modules/MinimedKit.swiftmodule/arm64.swiftmodule deleted file mode 100644 index b14a3646e1..0000000000 Binary files a/Carthage/Build/iOS/MinimedKit.framework/Modules/MinimedKit.swiftmodule/arm64.swiftmodule and /dev/null differ diff --git a/Carthage/Build/iOS/MinimedKit.framework/Modules/MinimedKit.swiftmodule/i386.swiftdoc b/Carthage/Build/iOS/MinimedKit.framework/Modules/MinimedKit.swiftmodule/i386.swiftdoc deleted file mode 100644 index 49d9845b9a..0000000000 Binary files a/Carthage/Build/iOS/MinimedKit.framework/Modules/MinimedKit.swiftmodule/i386.swiftdoc and /dev/null differ diff --git a/Carthage/Build/iOS/MinimedKit.framework/Modules/MinimedKit.swiftmodule/i386.swiftmodule b/Carthage/Build/iOS/MinimedKit.framework/Modules/MinimedKit.swiftmodule/i386.swiftmodule deleted file mode 100644 index baca9c635b..0000000000 Binary files a/Carthage/Build/iOS/MinimedKit.framework/Modules/MinimedKit.swiftmodule/i386.swiftmodule and /dev/null differ diff --git a/Carthage/Build/iOS/MinimedKit.framework/Modules/MinimedKit.swiftmodule/x86_64.swiftdoc b/Carthage/Build/iOS/MinimedKit.framework/Modules/MinimedKit.swiftmodule/x86_64.swiftdoc deleted file mode 100644 index ae54bb7209..0000000000 Binary files a/Carthage/Build/iOS/MinimedKit.framework/Modules/MinimedKit.swiftmodule/x86_64.swiftdoc and /dev/null differ diff --git a/Carthage/Build/iOS/MinimedKit.framework/Modules/MinimedKit.swiftmodule/x86_64.swiftmodule b/Carthage/Build/iOS/MinimedKit.framework/Modules/MinimedKit.swiftmodule/x86_64.swiftmodule deleted file mode 100644 index 1dc7ce5950..0000000000 Binary files a/Carthage/Build/iOS/MinimedKit.framework/Modules/MinimedKit.swiftmodule/x86_64.swiftmodule and /dev/null differ diff --git a/Carthage/Build/iOS/MinimedKit.framework/Modules/module.modulemap b/Carthage/Build/iOS/MinimedKit.framework/Modules/module.modulemap deleted file mode 100644 index ed1a2e0ddc..0000000000 --- a/Carthage/Build/iOS/MinimedKit.framework/Modules/module.modulemap +++ /dev/null @@ -1,10 +0,0 @@ -framework module MinimedKit { - umbrella header "MinimedKit.h" - - export * - module * { export * } -} - -module MinimedKit.Swift { - header "MinimedKit-Swift.h" -} diff --git a/Carthage/Build/iOS/NightscoutUploadKit.framework/Headers/NightscoutUploadKit-Swift.h b/Carthage/Build/iOS/NightscoutUploadKit.framework/Headers/NightscoutUploadKit-Swift.h deleted file mode 100644 index 370a1ca7bb..0000000000 --- a/Carthage/Build/iOS/NightscoutUploadKit.framework/Headers/NightscoutUploadKit-Swift.h +++ /dev/null @@ -1,136 +0,0 @@ -// Generated by Apple Swift version 3.0.1 (swiftlang-800.0.58.6 clang-800.0.42.1) -#pragma clang diagnostic push - -#if defined(__has_include) && __has_include() -# include -#endif - -#pragma clang diagnostic ignored "-Wauto-import" -#include -#include -#include -#include - -#if !defined(SWIFT_TYPEDEFS) -# define SWIFT_TYPEDEFS 1 -# if defined(__has_include) && __has_include() -# include -# elif !defined(__cplusplus) || __cplusplus < 201103L -typedef uint_least16_t char16_t; -typedef uint_least32_t char32_t; -# endif -typedef float swift_float2 __attribute__((__ext_vector_type__(2))); -typedef float swift_float3 __attribute__((__ext_vector_type__(3))); -typedef float swift_float4 __attribute__((__ext_vector_type__(4))); -typedef double swift_double2 __attribute__((__ext_vector_type__(2))); -typedef double swift_double3 __attribute__((__ext_vector_type__(3))); -typedef double swift_double4 __attribute__((__ext_vector_type__(4))); -typedef int swift_int2 __attribute__((__ext_vector_type__(2))); -typedef int swift_int3 __attribute__((__ext_vector_type__(3))); -typedef int swift_int4 __attribute__((__ext_vector_type__(4))); -typedef unsigned int swift_uint2 __attribute__((__ext_vector_type__(2))); -typedef unsigned int swift_uint3 __attribute__((__ext_vector_type__(3))); -typedef unsigned int swift_uint4 __attribute__((__ext_vector_type__(4))); -#endif - -#if !defined(SWIFT_PASTE) -# define SWIFT_PASTE_HELPER(x, y) x##y -# define SWIFT_PASTE(x, y) SWIFT_PASTE_HELPER(x, y) -#endif -#if !defined(SWIFT_METATYPE) -# define SWIFT_METATYPE(X) Class -#endif -#if !defined(SWIFT_CLASS_PROPERTY) -# if __has_feature(objc_class_property) -# define SWIFT_CLASS_PROPERTY(...) __VA_ARGS__ -# else -# define SWIFT_CLASS_PROPERTY(...) -# endif -#endif - -#if defined(__has_attribute) && __has_attribute(objc_runtime_name) -# define SWIFT_RUNTIME_NAME(X) __attribute__((objc_runtime_name(X))) -#else -# define SWIFT_RUNTIME_NAME(X) -#endif -#if defined(__has_attribute) && __has_attribute(swift_name) -# define SWIFT_COMPILE_NAME(X) __attribute__((swift_name(X))) -#else -# define SWIFT_COMPILE_NAME(X) -#endif -#if defined(__has_attribute) && __has_attribute(objc_method_family) -# define SWIFT_METHOD_FAMILY(X) __attribute__((objc_method_family(X))) -#else -# define SWIFT_METHOD_FAMILY(X) -#endif -#if defined(__has_attribute) && __has_attribute(noescape) -# define SWIFT_NOESCAPE __attribute__((noescape)) -#else -# define SWIFT_NOESCAPE -#endif -#if !defined(SWIFT_CLASS_EXTRA) -# define SWIFT_CLASS_EXTRA -#endif -#if !defined(SWIFT_PROTOCOL_EXTRA) -# define SWIFT_PROTOCOL_EXTRA -#endif -#if !defined(SWIFT_ENUM_EXTRA) -# define SWIFT_ENUM_EXTRA -#endif -#if !defined(SWIFT_CLASS) -# if defined(__has_attribute) && __has_attribute(objc_subclassing_restricted) -# define SWIFT_CLASS(SWIFT_NAME) SWIFT_RUNTIME_NAME(SWIFT_NAME) __attribute__((objc_subclassing_restricted)) SWIFT_CLASS_EXTRA -# define SWIFT_CLASS_NAMED(SWIFT_NAME) __attribute__((objc_subclassing_restricted)) SWIFT_COMPILE_NAME(SWIFT_NAME) SWIFT_CLASS_EXTRA -# else -# define SWIFT_CLASS(SWIFT_NAME) SWIFT_RUNTIME_NAME(SWIFT_NAME) SWIFT_CLASS_EXTRA -# define SWIFT_CLASS_NAMED(SWIFT_NAME) SWIFT_COMPILE_NAME(SWIFT_NAME) SWIFT_CLASS_EXTRA -# endif -#endif - -#if !defined(SWIFT_PROTOCOL) -# define SWIFT_PROTOCOL(SWIFT_NAME) SWIFT_RUNTIME_NAME(SWIFT_NAME) SWIFT_PROTOCOL_EXTRA -# define SWIFT_PROTOCOL_NAMED(SWIFT_NAME) SWIFT_COMPILE_NAME(SWIFT_NAME) SWIFT_PROTOCOL_EXTRA -#endif - -#if !defined(SWIFT_EXTENSION) -# define SWIFT_EXTENSION(M) SWIFT_PASTE(M##_Swift_, __LINE__) -#endif - -#if !defined(OBJC_DESIGNATED_INITIALIZER) -# if defined(__has_attribute) && __has_attribute(objc_designated_initializer) -# define OBJC_DESIGNATED_INITIALIZER __attribute__((objc_designated_initializer)) -# else -# define OBJC_DESIGNATED_INITIALIZER -# endif -#endif -#if !defined(SWIFT_ENUM) -# define SWIFT_ENUM(_type, _name) enum _name : _type _name; enum SWIFT_ENUM_EXTRA _name : _type -# if defined(__has_feature) && __has_feature(generalized_swift_name) -# define SWIFT_ENUM_NAMED(_type, _name, SWIFT_NAME) enum _name : _type _name SWIFT_COMPILE_NAME(SWIFT_NAME); enum SWIFT_COMPILE_NAME(SWIFT_NAME) SWIFT_ENUM_EXTRA _name : _type -# else -# define SWIFT_ENUM_NAMED(_type, _name, SWIFT_NAME) SWIFT_ENUM(_type, _name) -# endif -#endif -#if !defined(SWIFT_UNAVAILABLE) -# define SWIFT_UNAVAILABLE __attribute__((unavailable)) -#endif -#if defined(__has_feature) && __has_feature(modules) -@import Foundation; -@import HealthKit; -#endif - -#pragma clang diagnostic ignored "-Wproperty-attribute-mismatch" -#pragma clang diagnostic ignored "-Wduplicate-method-arg" - -@interface NSDateFormatter (SWIFT_EXTENSION(NightscoutUploadKit)) -@end - - -@interface HKUnit (SWIFT_EXTENSION(NightscoutUploadKit)) -@end - - -@interface NSUserDefaults (SWIFT_EXTENSION(NightscoutUploadKit)) -@end - -#pragma clang diagnostic pop diff --git a/Carthage/Build/iOS/NightscoutUploadKit.framework/Headers/NightscoutUploadKit.h b/Carthage/Build/iOS/NightscoutUploadKit.framework/Headers/NightscoutUploadKit.h deleted file mode 100644 index f5a66044aa..0000000000 --- a/Carthage/Build/iOS/NightscoutUploadKit.framework/Headers/NightscoutUploadKit.h +++ /dev/null @@ -1,17 +0,0 @@ -// -// NightscoutUploadKit.h -// NightscoutUploadKit -// -// Created by Pete Schwamb on 4/26/16. -// Copyright © 2016 Pete Schwamb. All rights reserved. -// - -#import - -//! Project version number for NightscoutUploadKit. -FOUNDATION_EXPORT double NightscoutUploadKitVersionNumber; - -//! Project version string for NightscoutUploadKit. -FOUNDATION_EXPORT const unsigned char NightscoutUploadKitVersionString[]; - -// In this header, you should import all the public headers of your framework using statements like #import diff --git a/Carthage/Build/iOS/NightscoutUploadKit.framework/Info.plist b/Carthage/Build/iOS/NightscoutUploadKit.framework/Info.plist deleted file mode 100644 index 5dada430a2..0000000000 Binary files a/Carthage/Build/iOS/NightscoutUploadKit.framework/Info.plist and /dev/null differ diff --git a/Carthage/Build/iOS/NightscoutUploadKit.framework/Modules/NightscoutUploadKit.swiftmodule/arm.swiftdoc b/Carthage/Build/iOS/NightscoutUploadKit.framework/Modules/NightscoutUploadKit.swiftmodule/arm.swiftdoc deleted file mode 100644 index 74634d265a..0000000000 Binary files a/Carthage/Build/iOS/NightscoutUploadKit.framework/Modules/NightscoutUploadKit.swiftmodule/arm.swiftdoc and /dev/null differ diff --git a/Carthage/Build/iOS/NightscoutUploadKit.framework/Modules/NightscoutUploadKit.swiftmodule/arm.swiftmodule b/Carthage/Build/iOS/NightscoutUploadKit.framework/Modules/NightscoutUploadKit.swiftmodule/arm.swiftmodule deleted file mode 100644 index 46f360bf86..0000000000 Binary files a/Carthage/Build/iOS/NightscoutUploadKit.framework/Modules/NightscoutUploadKit.swiftmodule/arm.swiftmodule and /dev/null differ diff --git a/Carthage/Build/iOS/NightscoutUploadKit.framework/Modules/NightscoutUploadKit.swiftmodule/arm64.swiftdoc b/Carthage/Build/iOS/NightscoutUploadKit.framework/Modules/NightscoutUploadKit.swiftmodule/arm64.swiftdoc deleted file mode 100644 index 40299c8557..0000000000 Binary files a/Carthage/Build/iOS/NightscoutUploadKit.framework/Modules/NightscoutUploadKit.swiftmodule/arm64.swiftdoc and /dev/null differ diff --git a/Carthage/Build/iOS/NightscoutUploadKit.framework/Modules/NightscoutUploadKit.swiftmodule/arm64.swiftmodule b/Carthage/Build/iOS/NightscoutUploadKit.framework/Modules/NightscoutUploadKit.swiftmodule/arm64.swiftmodule deleted file mode 100644 index 795cf43257..0000000000 Binary files a/Carthage/Build/iOS/NightscoutUploadKit.framework/Modules/NightscoutUploadKit.swiftmodule/arm64.swiftmodule and /dev/null differ diff --git a/Carthage/Build/iOS/NightscoutUploadKit.framework/Modules/NightscoutUploadKit.swiftmodule/i386.swiftdoc b/Carthage/Build/iOS/NightscoutUploadKit.framework/Modules/NightscoutUploadKit.swiftmodule/i386.swiftdoc deleted file mode 100644 index 64cf2ec702..0000000000 Binary files a/Carthage/Build/iOS/NightscoutUploadKit.framework/Modules/NightscoutUploadKit.swiftmodule/i386.swiftdoc and /dev/null differ diff --git a/Carthage/Build/iOS/NightscoutUploadKit.framework/Modules/NightscoutUploadKit.swiftmodule/i386.swiftmodule b/Carthage/Build/iOS/NightscoutUploadKit.framework/Modules/NightscoutUploadKit.swiftmodule/i386.swiftmodule deleted file mode 100644 index 7306848d5b..0000000000 Binary files a/Carthage/Build/iOS/NightscoutUploadKit.framework/Modules/NightscoutUploadKit.swiftmodule/i386.swiftmodule and /dev/null differ diff --git a/Carthage/Build/iOS/NightscoutUploadKit.framework/Modules/NightscoutUploadKit.swiftmodule/x86_64.swiftdoc b/Carthage/Build/iOS/NightscoutUploadKit.framework/Modules/NightscoutUploadKit.swiftmodule/x86_64.swiftdoc deleted file mode 100644 index f56cef176b..0000000000 Binary files a/Carthage/Build/iOS/NightscoutUploadKit.framework/Modules/NightscoutUploadKit.swiftmodule/x86_64.swiftdoc and /dev/null differ diff --git a/Carthage/Build/iOS/NightscoutUploadKit.framework/Modules/NightscoutUploadKit.swiftmodule/x86_64.swiftmodule b/Carthage/Build/iOS/NightscoutUploadKit.framework/Modules/NightscoutUploadKit.swiftmodule/x86_64.swiftmodule deleted file mode 100644 index c48b8a61fc..0000000000 Binary files a/Carthage/Build/iOS/NightscoutUploadKit.framework/Modules/NightscoutUploadKit.swiftmodule/x86_64.swiftmodule and /dev/null differ diff --git a/Carthage/Build/iOS/NightscoutUploadKit.framework/Modules/module.modulemap b/Carthage/Build/iOS/NightscoutUploadKit.framework/Modules/module.modulemap deleted file mode 100644 index 5153dc9932..0000000000 --- a/Carthage/Build/iOS/NightscoutUploadKit.framework/Modules/module.modulemap +++ /dev/null @@ -1,10 +0,0 @@ -framework module NightscoutUploadKit { - umbrella header "NightscoutUploadKit.h" - - export * - module * { export * } -} - -module NightscoutUploadKit.Swift { - header "NightscoutUploadKit-Swift.h" -} diff --git a/Carthage/Build/iOS/NightscoutUploadKit.framework/NightscoutUploadKit b/Carthage/Build/iOS/NightscoutUploadKit.framework/NightscoutUploadKit deleted file mode 100755 index e9fb5ff933..0000000000 Binary files a/Carthage/Build/iOS/NightscoutUploadKit.framework/NightscoutUploadKit and /dev/null differ diff --git a/Carthage/Build/iOS/RileyLinkBLEKit.framework/Headers/CmdBase.h b/Carthage/Build/iOS/RileyLinkBLEKit.framework/Headers/CmdBase.h deleted file mode 100644 index fd81d13117..0000000000 --- a/Carthage/Build/iOS/RileyLinkBLEKit.framework/Headers/CmdBase.h +++ /dev/null @@ -1,25 +0,0 @@ -// -// BaseCmd.h -// RileyLink -// -// Created by Pete Schwamb on 12/26/15. -// Copyright © 2015 Pete Schwamb. All rights reserved. -// - -@import Foundation; - -#define RILEYLINK_CMD_GET_STATE 1 -#define RILEYLINK_CMD_GET_VERSION 2 -#define RILEYLINK_CMD_GET_PACKET 3 -#define RILEYLINK_CMD_SEND_PACKET 4 -#define RILEYLINK_CMD_SEND_AND_LISTEN 5 -#define RILEYLINK_CMD_UPDATE_REGISTER 6 -#define RILEYLINK_CMD_RESET 7 - -@interface CmdBase : NSObject - -@property (NS_NONATOMIC_IOSONLY, readonly, copy) NSData *data; - -@property (nonatomic, strong) NSData *response; - -@end diff --git a/Carthage/Build/iOS/RileyLinkBLEKit.framework/Headers/GetPacketCmd.h b/Carthage/Build/iOS/RileyLinkBLEKit.framework/Headers/GetPacketCmd.h deleted file mode 100644 index 284efa8c46..0000000000 --- a/Carthage/Build/iOS/RileyLinkBLEKit.framework/Headers/GetPacketCmd.h +++ /dev/null @@ -1,18 +0,0 @@ -// -// GetPacketCmd.h -// RileyLink -// -// Created by Pete Schwamb on 1/2/16. -// Copyright © 2016 Pete Schwamb. All rights reserved. -// - -@import Foundation; -#import "ReceivingPacketCmd.h" - - -@interface GetPacketCmd : ReceivingPacketCmd - -@property (nonatomic, assign) uint8_t listenChannel; -@property (nonatomic, assign) uint16_t timeoutMS; - -@end diff --git a/Carthage/Build/iOS/RileyLinkBLEKit.framework/Headers/RFPacket.h b/Carthage/Build/iOS/RileyLinkBLEKit.framework/Headers/RFPacket.h deleted file mode 100644 index 2300bca7df..0000000000 --- a/Carthage/Build/iOS/RileyLinkBLEKit.framework/Headers/RFPacket.h +++ /dev/null @@ -1,24 +0,0 @@ -// -// RFPacket.h -// RileyLink -// -// Created by Pete Schwamb on 2/28/16. -// Copyright © 2016 Pete Schwamb. All rights reserved. -// - -@import Foundation; - -@interface RFPacket : NSObject - -- (nonnull instancetype)initWithData:(nonnull NSData*)data NS_DESIGNATED_INITIALIZER; -- (nullable instancetype)initWithRFSPYResponse:(nonnull NSData*)data NS_DESIGNATED_INITIALIZER; - -- (nonnull NSData*)encodedData; - -@property (nonatomic, nullable, strong) NSData *data; -@property (nonatomic, nullable, strong) NSDate *capturedAt; -@property (nonatomic, assign) int rssi; -@property (nonatomic, assign) int packetNumber; - - -@end diff --git a/Carthage/Build/iOS/RileyLinkBLEKit.framework/Headers/ReceivingPacketCmd.h b/Carthage/Build/iOS/RileyLinkBLEKit.framework/Headers/ReceivingPacketCmd.h deleted file mode 100644 index 57e8648a3f..0000000000 --- a/Carthage/Build/iOS/RileyLinkBLEKit.framework/Headers/ReceivingPacketCmd.h +++ /dev/null @@ -1,19 +0,0 @@ -// -// ReceivingPacketCmd.h -// RileyLink -// -// Created by Pete Schwamb on 3/3/16. -// Copyright © 2016 Pete Schwamb. All rights reserved. -// - -@import Foundation; -#import "CmdBase.h" -#import "RFPacket.h" - -@interface ReceivingPacketCmd : CmdBase - -@property (nonatomic, strong) RFPacket *receivedPacket; -@property (nonatomic, readonly) BOOL didReceiveResponse; -@property (nonatomic, readonly) NSData *rawReceivedData; - -@end diff --git a/Carthage/Build/iOS/RileyLinkBLEKit.framework/Headers/RileyLinkBLEDevice.h b/Carthage/Build/iOS/RileyLinkBLEKit.framework/Headers/RileyLinkBLEDevice.h deleted file mode 100644 index a21b9471f6..0000000000 --- a/Carthage/Build/iOS/RileyLinkBLEKit.framework/Headers/RileyLinkBLEDevice.h +++ /dev/null @@ -1,102 +0,0 @@ -// -// RileyLinkBLE.h -// RileyLink -// -// Created by Pete Schwamb on 7/28/15. -// Copyright (c) 2015 Pete Schwamb. All rights reserved. -// - -@import Foundation; -@import CoreBluetooth; -#import "CmdBase.h" - -typedef NS_ENUM(NSUInteger, RileyLinkState) { - RileyLinkStateConnecting, - RileyLinkStateConnected, - RileyLinkStateDisconnected -}; - -typedef NS_ENUM(NSUInteger, SubgRfspyError) { - SubgRfspyErrorRxTimeout = 0xaa, - SubgRfspyErrorCmdInterrupted = 0xbb, - SubgRfspyErrorZeroData = 0xcc -}; - -typedef NS_ENUM(NSUInteger, SubgRfspyVersionState) { - SubgRfspyVersionStateUnknown = 0, - SubgRfspyVersionStateUpToDate, - SubgRfspyVersionStateOutOfDate, - SubgRfspyVersionStateInvalid -}; - - -#define ERROR_RX_TIMEOUT 0xaa -#define ERROR_CMD_INTERRUPTED 0xbb -#define ERROR_ZERO_DATA 0xcc - -#define RILEYLINK_FREQ_XTAL 24000000 - -#define CC111X_REG_FREQ2 0x09 -#define CC111X_REG_FREQ1 0x0A -#define CC111X_REG_FREQ0 0x0B -#define CC111X_REG_MDMCFG4 0x0C -#define CC111X_REG_MDMCFG3 0x0D -#define CC111X_REG_MDMCFG2 0x0E -#define CC111X_REG_MDMCFG1 0x0F -#define CC111X_REG_MDMCFG0 0x10 -#define CC111X_REG_DEVIATN 0x11 -#define CC111X_REG_AGCCTRL2 0x17 -#define CC111X_REG_AGCCTRL1 0x18 -#define CC111X_REG_AGCCTRL0 0x19 -#define CC111X_REG_FREND1 0x1A -#define CC111X_REG_FREND0 0x1B - - -@interface RileyLinkCmdSession : NSObject -/** - Runs a command synchronously. I.E. this method will not return until the command - finishes, or times out. Returns NO if the command timed out. The command's response - is set if the command did not time out. - */ -- (BOOL) doCmd:(nonnull CmdBase*)cmd withTimeoutMs:(NSInteger)timeoutMS; -@end - -@interface RileyLinkBLEDevice : NSObject - -@property (nonatomic, nullable, readonly) NSString * name; -@property (nonatomic, nullable, retain) NSNumber * RSSI; -@property (nonatomic, nonnull, readonly) NSString * peripheralId; -@property (nonatomic, nonnull, readonly, retain) CBPeripheral * peripheral; - -@property (nonatomic, readonly) RileyLinkState state; - -@property (nonatomic, readonly, copy, nonnull) NSString * deviceURI; - -@property (nonatomic, readonly, nullable) NSString *firmwareVersion; - -@property (nonatomic, readonly) SubgRfspyVersionState firmwareState; - -@property (nonatomic, readonly, nullable) NSString *bleFirmwareVersion; - -@property (nonatomic, readonly, nullable) NSDate *lastIdle; - -@property (nonatomic) BOOL timerTickEnabled; - -/** - Initializes the device with a specified peripheral - - @param peripheral The peripheral to represent - - @return A newly-initialized device - */ -- (nonnull instancetype)initWithPeripheral:(nonnull CBPeripheral *)peripheral NS_DESIGNATED_INITIALIZER; - -- (void) connectionStateDidChange:(nullable NSError *)error; - -- (void) runSessionWithName:(nonnull NSString*)name usingBlock:(void (^ _Nonnull)(RileyLinkCmdSession* _Nonnull))proc; -- (void) setCustomName:(nonnull NSString*)customName; -- (void) enableIdleListeningOnChannel:(uint8_t)channel; -- (void) disableIdleListening; -- (void) assertIdleListening; - -@end diff --git a/Carthage/Build/iOS/RileyLinkBLEKit.framework/Headers/RileyLinkBLEKit.h b/Carthage/Build/iOS/RileyLinkBLEKit.framework/Headers/RileyLinkBLEKit.h deleted file mode 100644 index 64ce668ea4..0000000000 --- a/Carthage/Build/iOS/RileyLinkBLEKit.framework/Headers/RileyLinkBLEKit.h +++ /dev/null @@ -1,23 +0,0 @@ -// -// RileyLinkBLEKit.h -// RileyLinkBLEKit -// -// Created by Nathan Racklyeft on 4/8/16. -// Copyright © 2016 Pete Schwamb. All rights reserved. -// - -#import - -//! Project version number for RileyLinkBLEKit. -FOUNDATION_EXPORT double RileyLinkBLEKitVersionNumber; - -//! Project version string for RileyLinkBLEKit. -FOUNDATION_EXPORT const unsigned char RileyLinkBLEKitVersionString[]; - -#import -#import -#import -#import -#import -#import -#import diff --git a/Carthage/Build/iOS/RileyLinkBLEKit.framework/Headers/RileyLinkBLEManager.h b/Carthage/Build/iOS/RileyLinkBLEKit.framework/Headers/RileyLinkBLEManager.h deleted file mode 100644 index e7d2d4601c..0000000000 --- a/Carthage/Build/iOS/RileyLinkBLEKit.framework/Headers/RileyLinkBLEManager.h +++ /dev/null @@ -1,54 +0,0 @@ -// -// RileyLink.h -// RileyLink -// -// Created by Pete Schwamb on 8/5/14. -// Copyright (c) 2014 Pete Schwamb. All rights reserved. -// - -@import CoreBluetooth; -@import Foundation; - -#define RILEYLINK_EVENT_LIST_UPDATED @"RILEYLINK_EVENT_LIST_UPDATED" -#define RILEYLINK_EVENT_PACKET_RECEIVED @"RILEYLINK_EVENT_PACKET_RECEIVED" -#define RILEYLINK_EVENT_DEVICE_ADDED @"RILEYLINK_EVENT_DEVICE_ADDED" -#define RILEYLINK_EVENT_DEVICE_CONNECTED @"RILEYLINK_EVENT_DEVICE_CONNECTED" -#define RILEYLINK_EVENT_DEVICE_DISCONNECTED @"RILEYLINK_EVENT_DEVICE_DISCONNECTED" -#define RILEYLINK_EVENT_DEVICE_ATTRS_DISCOVERED @"RILEYLINK_EVENT_DEVICE_ATTRS_DISCOVERED" -#define RILEYLINK_EVENT_DEVICE_READY @"RILEYLINK_EVENT_DEVICE_READY" -#define RILEYLINK_EVENT_DEVICE_TIMER_TICK @"RILEYLINK_EVENT_DEVICE_TIMER_TICK" -#define RILEYLINK_EVENT_RSSI_CHANGED @"RILEYLINK_EVENT_RSSI_CHANGED" -#define RILEYLINK_EVENT_NAME_CHANGED @"RILEYLINK_EVENT_NAME_CHANGED" - -#define RILEYLINK_SERVICE_UUID @"0235733b-99c5-4197-b856-69219c2a3845" -#define RILEYLINK_DATA_UUID @"c842e849-5028-42e2-867c-016adada9155" -#define RILEYLINK_RESPONSE_COUNT_UUID @"6e6c7910-b89e-43a5-a0fe-50c5e2b81f4a" -#define RILEYLINK_CUSTOM_NAME_UUID @"d93b2af0-1e28-11e4-8c21-0800200c9a66" -#define RILEYLINK_TIMER_TICK_UUID @"6e6c7910-b89e-43a5-78af-50c5e2b86f7e" -#define RILEYLINK_FIRMWARE_VERSION_UUID @"30d99dc9-7c91-4295-a051-0a104d238cf2" - - -@interface RileyLinkBLEManager : NSObject - -@property (nonatomic, nonnull, readonly, copy) NSArray *rileyLinkList; - -- (void)connectPeripheral:(nonnull CBPeripheral *)peripheral; -- (void)disconnectPeripheral:(nonnull CBPeripheral *)peripheral; - -+ (nonnull instancetype)sharedManager; - -@property (nonatomic, nonnull, strong) NSSet *autoConnectIds; -@property (nonatomic, getter=isScanningEnabled) BOOL scanningEnabled; - -/** - Converts an array of UUID strings to CBUUID objects, excluding those represented in an array of CBAttribute objects. - - @param UUIDStrings An array of UUID string representations to filter - @param attributes An array of CBAttribute objects to exclude - - @return An array of CBUUID objects - */ -+ (nonnull NSArray *)UUIDsFromUUIDStrings:(nonnull NSArray *)UUIDStrings excludingAttributes:(nullable NSArray *)attributes; - -@end - diff --git a/Carthage/Build/iOS/RileyLinkBLEKit.framework/Headers/SendAndListenCmd.h b/Carthage/Build/iOS/RileyLinkBLEKit.framework/Headers/SendAndListenCmd.h deleted file mode 100644 index 1a9263f8a5..0000000000 --- a/Carthage/Build/iOS/RileyLinkBLEKit.framework/Headers/SendAndListenCmd.h +++ /dev/null @@ -1,23 +0,0 @@ -// -// SendDataCmd.h -// RileyLink -// -// Created by Pete Schwamb on 8/9/15. -// Copyright (c) 2015 Pete Schwamb. All rights reserved. -// - -@import Foundation; -#import "ReceivingPacketCmd.h" -#import "RFPacket.h" - -@interface SendAndListenCmd : ReceivingPacketCmd - -@property (nonatomic, strong) RFPacket *packet; -@property (nonatomic, assign) uint8_t sendChannel; // In general, 0 = meter, cgm. 2 = pump -@property (nonatomic, assign) uint8_t repeatCount; // 0 = no repeat, i.e. only one packet. 1 repeat = 2 packets sent total. -@property (nonatomic, assign) uint8_t msBetweenPackets; -@property (nonatomic, assign) uint8_t listenChannel; -@property (nonatomic, assign) uint16_t timeoutMS; -@property (nonatomic, assign) uint8_t retryCount; - -@end diff --git a/Carthage/Build/iOS/RileyLinkBLEKit.framework/Headers/SendPacketCmd.h b/Carthage/Build/iOS/RileyLinkBLEKit.framework/Headers/SendPacketCmd.h deleted file mode 100644 index c5d50e8303..0000000000 --- a/Carthage/Build/iOS/RileyLinkBLEKit.framework/Headers/SendPacketCmd.h +++ /dev/null @@ -1,20 +0,0 @@ -// -// SendPacketCmd.h -// RileyLink -// -// Created by Pete Schwamb on 12/27/15. -// Copyright © 2015 Pete Schwamb. All rights reserved. -// - -@import Foundation; -#import "CmdBase.h" -#import "RFPacket.h" - -@interface SendPacketCmd : CmdBase - -@property (nonatomic, strong) RFPacket *packet; -@property (nonatomic, assign) uint8_t sendChannel; // In general, 0 = meter, cgm. 2 = pump -@property (nonatomic, assign) uint8_t repeatCount; // 0 = no repeat, i.e. only one packet. 1 repeat = 2 packets sent total. -@property (nonatomic, assign) uint8_t msBetweenPackets; - -@end diff --git a/Carthage/Build/iOS/RileyLinkBLEKit.framework/Headers/UpdateRegisterCmd.h b/Carthage/Build/iOS/RileyLinkBLEKit.framework/Headers/UpdateRegisterCmd.h deleted file mode 100644 index 61289a45e2..0000000000 --- a/Carthage/Build/iOS/RileyLinkBLEKit.framework/Headers/UpdateRegisterCmd.h +++ /dev/null @@ -1,16 +0,0 @@ -// -// UpdateRegisterCmd.h -// RileyLink -// -// Created by Pete Schwamb on 1/25/16. -// Copyright © 2016 Pete Schwamb. All rights reserved. -// - -#import "CmdBase.h" - -@interface UpdateRegisterCmd : CmdBase - -@property (nonatomic, assign) uint8_t addr; -@property (nonatomic, assign) uint8_t value; - -@end diff --git a/Carthage/Build/iOS/RileyLinkBLEKit.framework/Info.plist b/Carthage/Build/iOS/RileyLinkBLEKit.framework/Info.plist deleted file mode 100644 index 6b463c848a..0000000000 Binary files a/Carthage/Build/iOS/RileyLinkBLEKit.framework/Info.plist and /dev/null differ diff --git a/Carthage/Build/iOS/RileyLinkBLEKit.framework/Modules/module.modulemap b/Carthage/Build/iOS/RileyLinkBLEKit.framework/Modules/module.modulemap deleted file mode 100644 index 3e72917a43..0000000000 --- a/Carthage/Build/iOS/RileyLinkBLEKit.framework/Modules/module.modulemap +++ /dev/null @@ -1,6 +0,0 @@ -framework module RileyLinkBLEKit { - umbrella header "RileyLinkBLEKit.h" - - export * - module * { export * } -} diff --git a/Carthage/Build/iOS/RileyLinkBLEKit.framework/RileyLinkBLEKit b/Carthage/Build/iOS/RileyLinkBLEKit.framework/RileyLinkBLEKit deleted file mode 100755 index 27f8cf9793..0000000000 Binary files a/Carthage/Build/iOS/RileyLinkBLEKit.framework/RileyLinkBLEKit and /dev/null differ diff --git a/Carthage/Build/iOS/RileyLinkKit.framework/Headers/RileyLinkKit-Swift.h b/Carthage/Build/iOS/RileyLinkKit.framework/Headers/RileyLinkKit-Swift.h deleted file mode 100644 index e46ac85acc..0000000000 --- a/Carthage/Build/iOS/RileyLinkKit.framework/Headers/RileyLinkKit-Swift.h +++ /dev/null @@ -1,161 +0,0 @@ -// Generated by Apple Swift version 3.0.1 (swiftlang-800.0.58.6 clang-800.0.42.1) -#pragma clang diagnostic push - -#if defined(__has_include) && __has_include() -# include -#endif - -#pragma clang diagnostic ignored "-Wauto-import" -#include -#include -#include -#include - -#if !defined(SWIFT_TYPEDEFS) -# define SWIFT_TYPEDEFS 1 -# if defined(__has_include) && __has_include() -# include -# elif !defined(__cplusplus) || __cplusplus < 201103L -typedef uint_least16_t char16_t; -typedef uint_least32_t char32_t; -# endif -typedef float swift_float2 __attribute__((__ext_vector_type__(2))); -typedef float swift_float3 __attribute__((__ext_vector_type__(3))); -typedef float swift_float4 __attribute__((__ext_vector_type__(4))); -typedef double swift_double2 __attribute__((__ext_vector_type__(2))); -typedef double swift_double3 __attribute__((__ext_vector_type__(3))); -typedef double swift_double4 __attribute__((__ext_vector_type__(4))); -typedef int swift_int2 __attribute__((__ext_vector_type__(2))); -typedef int swift_int3 __attribute__((__ext_vector_type__(3))); -typedef int swift_int4 __attribute__((__ext_vector_type__(4))); -typedef unsigned int swift_uint2 __attribute__((__ext_vector_type__(2))); -typedef unsigned int swift_uint3 __attribute__((__ext_vector_type__(3))); -typedef unsigned int swift_uint4 __attribute__((__ext_vector_type__(4))); -#endif - -#if !defined(SWIFT_PASTE) -# define SWIFT_PASTE_HELPER(x, y) x##y -# define SWIFT_PASTE(x, y) SWIFT_PASTE_HELPER(x, y) -#endif -#if !defined(SWIFT_METATYPE) -# define SWIFT_METATYPE(X) Class -#endif -#if !defined(SWIFT_CLASS_PROPERTY) -# if __has_feature(objc_class_property) -# define SWIFT_CLASS_PROPERTY(...) __VA_ARGS__ -# else -# define SWIFT_CLASS_PROPERTY(...) -# endif -#endif - -#if defined(__has_attribute) && __has_attribute(objc_runtime_name) -# define SWIFT_RUNTIME_NAME(X) __attribute__((objc_runtime_name(X))) -#else -# define SWIFT_RUNTIME_NAME(X) -#endif -#if defined(__has_attribute) && __has_attribute(swift_name) -# define SWIFT_COMPILE_NAME(X) __attribute__((swift_name(X))) -#else -# define SWIFT_COMPILE_NAME(X) -#endif -#if defined(__has_attribute) && __has_attribute(objc_method_family) -# define SWIFT_METHOD_FAMILY(X) __attribute__((objc_method_family(X))) -#else -# define SWIFT_METHOD_FAMILY(X) -#endif -#if defined(__has_attribute) && __has_attribute(noescape) -# define SWIFT_NOESCAPE __attribute__((noescape)) -#else -# define SWIFT_NOESCAPE -#endif -#if !defined(SWIFT_CLASS_EXTRA) -# define SWIFT_CLASS_EXTRA -#endif -#if !defined(SWIFT_PROTOCOL_EXTRA) -# define SWIFT_PROTOCOL_EXTRA -#endif -#if !defined(SWIFT_ENUM_EXTRA) -# define SWIFT_ENUM_EXTRA -#endif -#if !defined(SWIFT_CLASS) -# if defined(__has_attribute) && __has_attribute(objc_subclassing_restricted) -# define SWIFT_CLASS(SWIFT_NAME) SWIFT_RUNTIME_NAME(SWIFT_NAME) __attribute__((objc_subclassing_restricted)) SWIFT_CLASS_EXTRA -# define SWIFT_CLASS_NAMED(SWIFT_NAME) __attribute__((objc_subclassing_restricted)) SWIFT_COMPILE_NAME(SWIFT_NAME) SWIFT_CLASS_EXTRA -# else -# define SWIFT_CLASS(SWIFT_NAME) SWIFT_RUNTIME_NAME(SWIFT_NAME) SWIFT_CLASS_EXTRA -# define SWIFT_CLASS_NAMED(SWIFT_NAME) SWIFT_COMPILE_NAME(SWIFT_NAME) SWIFT_CLASS_EXTRA -# endif -#endif - -#if !defined(SWIFT_PROTOCOL) -# define SWIFT_PROTOCOL(SWIFT_NAME) SWIFT_RUNTIME_NAME(SWIFT_NAME) SWIFT_PROTOCOL_EXTRA -# define SWIFT_PROTOCOL_NAMED(SWIFT_NAME) SWIFT_COMPILE_NAME(SWIFT_NAME) SWIFT_PROTOCOL_EXTRA -#endif - -#if !defined(SWIFT_EXTENSION) -# define SWIFT_EXTENSION(M) SWIFT_PASTE(M##_Swift_, __LINE__) -#endif - -#if !defined(OBJC_DESIGNATED_INITIALIZER) -# if defined(__has_attribute) && __has_attribute(objc_designated_initializer) -# define OBJC_DESIGNATED_INITIALIZER __attribute__((objc_designated_initializer)) -# else -# define OBJC_DESIGNATED_INITIALIZER -# endif -#endif -#if !defined(SWIFT_ENUM) -# define SWIFT_ENUM(_type, _name) enum _name : _type _name; enum SWIFT_ENUM_EXTRA _name : _type -# if defined(__has_feature) && __has_feature(generalized_swift_name) -# define SWIFT_ENUM_NAMED(_type, _name, SWIFT_NAME) enum _name : _type _name SWIFT_COMPILE_NAME(SWIFT_NAME); enum SWIFT_COMPILE_NAME(SWIFT_NAME) SWIFT_ENUM_EXTRA _name : _type -# else -# define SWIFT_ENUM_NAMED(_type, _name, SWIFT_NAME) SWIFT_ENUM(_type, _name) -# endif -#endif -#if !defined(SWIFT_UNAVAILABLE) -# define SWIFT_UNAVAILABLE __attribute__((unavailable)) -#endif -#if defined(__has_feature) && __has_feature(modules) -@import UIKit; -@import Foundation; -#endif - -#pragma clang diagnostic ignored "-Wproperty-attribute-mismatch" -#pragma clang diagnostic ignored "-Wduplicate-method-arg" -@class UINib; -@class UISwitch; -@class NSCoder; - -SWIFT_CLASS("_TtC12RileyLinkKit28RileyLinkDeviceTableViewCell") -@interface RileyLinkDeviceTableViewCell : UITableViewCell -@property (nonatomic, weak) IBOutlet UISwitch * _Null_unspecified connectSwitch; -+ (UINib * _Nonnull)nib; -- (void)prepareForReuse; -- (nonnull instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString * _Nullable)reuseIdentifier OBJC_DESIGNATED_INITIALIZER; -- (nullable instancetype)initWithCoder:(NSCoder * _Nonnull)aDecoder OBJC_DESIGNATED_INITIALIZER; -@end - -@class UITableView; -@class NSBundle; - -SWIFT_CLASS("_TtC12RileyLinkKit34RileyLinkDeviceTableViewController") -@interface RileyLinkDeviceTableViewController : UITableViewController -- (nonnull instancetype)init SWIFT_UNAVAILABLE; -- (void)viewDidLoad; -- (void)viewWillAppear:(BOOL)animated; -- (void)viewWillDisappear:(BOOL)animated; -- (NSInteger)numberOfSectionsInTableView:(UITableView * _Nonnull)tableView; -- (NSInteger)tableView:(UITableView * _Nonnull)tableView numberOfRowsInSection:(NSInteger)section; -- (UITableViewCell * _Nonnull)tableView:(UITableView * _Nonnull)tableView cellForRowAtIndexPath:(NSIndexPath * _Nonnull)indexPath; -- (NSString * _Nullable)tableView:(UITableView * _Nonnull)tableView titleForHeaderInSection:(NSInteger)section; -- (BOOL)tableView:(UITableView * _Nonnull)tableView shouldHighlightRowAtIndexPath:(NSIndexPath * _Nonnull)indexPath; -- (void)tableView:(UITableView * _Nonnull)tableView didSelectRowAtIndexPath:(NSIndexPath * _Nonnull)indexPath; -- (nonnull instancetype)initWithStyle:(UITableViewStyle)style OBJC_DESIGNATED_INITIALIZER; -- (nonnull instancetype)initWithNibName:(NSString * _Nullable)nibNameOrNil bundle:(NSBundle * _Nullable)nibBundleOrNil OBJC_DESIGNATED_INITIALIZER; -- (nullable instancetype)initWithCoder:(NSCoder * _Nonnull)aDecoder OBJC_DESIGNATED_INITIALIZER; -@end - - -@interface UITableViewCell (SWIFT_EXTENSION(RileyLinkKit)) -@end - -#pragma clang diagnostic pop diff --git a/Carthage/Build/iOS/RileyLinkKit.framework/Headers/RileyLinkKit.h b/Carthage/Build/iOS/RileyLinkKit.framework/Headers/RileyLinkKit.h deleted file mode 100644 index 472a86e7fc..0000000000 --- a/Carthage/Build/iOS/RileyLinkKit.framework/Headers/RileyLinkKit.h +++ /dev/null @@ -1,17 +0,0 @@ -// -// RileyLinkKit.h -// RileyLinkKit -// -// Created by Nathan Racklyeft on 4/9/16. -// Copyright © 2016 Pete Schwamb. All rights reserved. -// - -#import - -//! Project version number for RileyLinkKit. -FOUNDATION_EXPORT double RileyLinkKitVersionNumber; - -//! Project version string for RileyLinkKit. -FOUNDATION_EXPORT const unsigned char RileyLinkKitVersionString[]; - -// In this header, you should import all the public headers of your framework using statements like #import diff --git a/Carthage/Build/iOS/RileyLinkKit.framework/Info.plist b/Carthage/Build/iOS/RileyLinkKit.framework/Info.plist deleted file mode 100644 index c696b6370d..0000000000 Binary files a/Carthage/Build/iOS/RileyLinkKit.framework/Info.plist and /dev/null differ diff --git a/Carthage/Build/iOS/RileyLinkKit.framework/Modules/RileyLinkKit.swiftmodule/arm.swiftdoc b/Carthage/Build/iOS/RileyLinkKit.framework/Modules/RileyLinkKit.swiftmodule/arm.swiftdoc deleted file mode 100644 index dff4963744..0000000000 Binary files a/Carthage/Build/iOS/RileyLinkKit.framework/Modules/RileyLinkKit.swiftmodule/arm.swiftdoc and /dev/null differ diff --git a/Carthage/Build/iOS/RileyLinkKit.framework/Modules/RileyLinkKit.swiftmodule/arm.swiftmodule b/Carthage/Build/iOS/RileyLinkKit.framework/Modules/RileyLinkKit.swiftmodule/arm.swiftmodule deleted file mode 100644 index 5d19d8ebae..0000000000 Binary files a/Carthage/Build/iOS/RileyLinkKit.framework/Modules/RileyLinkKit.swiftmodule/arm.swiftmodule and /dev/null differ diff --git a/Carthage/Build/iOS/RileyLinkKit.framework/Modules/RileyLinkKit.swiftmodule/arm64.swiftdoc b/Carthage/Build/iOS/RileyLinkKit.framework/Modules/RileyLinkKit.swiftmodule/arm64.swiftdoc deleted file mode 100644 index fb265fdc50..0000000000 Binary files a/Carthage/Build/iOS/RileyLinkKit.framework/Modules/RileyLinkKit.swiftmodule/arm64.swiftdoc and /dev/null differ diff --git a/Carthage/Build/iOS/RileyLinkKit.framework/Modules/RileyLinkKit.swiftmodule/arm64.swiftmodule b/Carthage/Build/iOS/RileyLinkKit.framework/Modules/RileyLinkKit.swiftmodule/arm64.swiftmodule deleted file mode 100644 index 401a39ed78..0000000000 Binary files a/Carthage/Build/iOS/RileyLinkKit.framework/Modules/RileyLinkKit.swiftmodule/arm64.swiftmodule and /dev/null differ diff --git a/Carthage/Build/iOS/RileyLinkKit.framework/Modules/RileyLinkKit.swiftmodule/i386.swiftdoc b/Carthage/Build/iOS/RileyLinkKit.framework/Modules/RileyLinkKit.swiftmodule/i386.swiftdoc deleted file mode 100644 index fbbc405426..0000000000 Binary files a/Carthage/Build/iOS/RileyLinkKit.framework/Modules/RileyLinkKit.swiftmodule/i386.swiftdoc and /dev/null differ diff --git a/Carthage/Build/iOS/RileyLinkKit.framework/Modules/RileyLinkKit.swiftmodule/i386.swiftmodule b/Carthage/Build/iOS/RileyLinkKit.framework/Modules/RileyLinkKit.swiftmodule/i386.swiftmodule deleted file mode 100644 index e3d55c1a5a..0000000000 Binary files a/Carthage/Build/iOS/RileyLinkKit.framework/Modules/RileyLinkKit.swiftmodule/i386.swiftmodule and /dev/null differ diff --git a/Carthage/Build/iOS/RileyLinkKit.framework/Modules/RileyLinkKit.swiftmodule/x86_64.swiftdoc b/Carthage/Build/iOS/RileyLinkKit.framework/Modules/RileyLinkKit.swiftmodule/x86_64.swiftdoc deleted file mode 100644 index ca3d7821da..0000000000 Binary files a/Carthage/Build/iOS/RileyLinkKit.framework/Modules/RileyLinkKit.swiftmodule/x86_64.swiftdoc and /dev/null differ diff --git a/Carthage/Build/iOS/RileyLinkKit.framework/Modules/RileyLinkKit.swiftmodule/x86_64.swiftmodule b/Carthage/Build/iOS/RileyLinkKit.framework/Modules/RileyLinkKit.swiftmodule/x86_64.swiftmodule deleted file mode 100644 index f7dac05344..0000000000 Binary files a/Carthage/Build/iOS/RileyLinkKit.framework/Modules/RileyLinkKit.swiftmodule/x86_64.swiftmodule and /dev/null differ diff --git a/Carthage/Build/iOS/RileyLinkKit.framework/Modules/module.modulemap b/Carthage/Build/iOS/RileyLinkKit.framework/Modules/module.modulemap deleted file mode 100644 index 1bb40d9f5c..0000000000 --- a/Carthage/Build/iOS/RileyLinkKit.framework/Modules/module.modulemap +++ /dev/null @@ -1,10 +0,0 @@ -framework module RileyLinkKit { - umbrella header "RileyLinkKit.h" - - export * - module * { export * } -} - -module RileyLinkKit.Swift { - header "RileyLinkKit-Swift.h" -} diff --git a/Carthage/Build/iOS/RileyLinkKit.framework/RileyLinkDeviceTableViewCell.nib b/Carthage/Build/iOS/RileyLinkKit.framework/RileyLinkDeviceTableViewCell.nib deleted file mode 100644 index c235fc993e..0000000000 Binary files a/Carthage/Build/iOS/RileyLinkKit.framework/RileyLinkDeviceTableViewCell.nib and /dev/null differ diff --git a/Carthage/Build/iOS/RileyLinkKit.framework/RileyLinkKit b/Carthage/Build/iOS/RileyLinkKit.framework/RileyLinkKit deleted file mode 100755 index cc7985fc92..0000000000 Binary files a/Carthage/Build/iOS/RileyLinkKit.framework/RileyLinkKit and /dev/null differ diff --git a/Carthage/Build/iOS/RileyLinkKit.framework/TextFieldTableViewCell.nib b/Carthage/Build/iOS/RileyLinkKit.framework/TextFieldTableViewCell.nib deleted file mode 100644 index 63040633ee..0000000000 Binary files a/Carthage/Build/iOS/RileyLinkKit.framework/TextFieldTableViewCell.nib and /dev/null differ diff --git a/Carthage/Build/iOS/ShareClient.framework/Headers/ShareClient-Swift.h b/Carthage/Build/iOS/ShareClient.framework/Headers/ShareClient-Swift.h deleted file mode 100644 index edb321dbfb..0000000000 --- a/Carthage/Build/iOS/ShareClient.framework/Headers/ShareClient-Swift.h +++ /dev/null @@ -1,122 +0,0 @@ -// Generated by Apple Swift version 3.0.1 (swiftlang-800.0.58.6 clang-800.0.42.1) -#pragma clang diagnostic push - -#if defined(__has_include) && __has_include() -# include -#endif - -#pragma clang diagnostic ignored "-Wauto-import" -#include -#include -#include -#include - -#if !defined(SWIFT_TYPEDEFS) -# define SWIFT_TYPEDEFS 1 -# if defined(__has_include) && __has_include() -# include -# elif !defined(__cplusplus) || __cplusplus < 201103L -typedef uint_least16_t char16_t; -typedef uint_least32_t char32_t; -# endif -typedef float swift_float2 __attribute__((__ext_vector_type__(2))); -typedef float swift_float3 __attribute__((__ext_vector_type__(3))); -typedef float swift_float4 __attribute__((__ext_vector_type__(4))); -typedef double swift_double2 __attribute__((__ext_vector_type__(2))); -typedef double swift_double3 __attribute__((__ext_vector_type__(3))); -typedef double swift_double4 __attribute__((__ext_vector_type__(4))); -typedef int swift_int2 __attribute__((__ext_vector_type__(2))); -typedef int swift_int3 __attribute__((__ext_vector_type__(3))); -typedef int swift_int4 __attribute__((__ext_vector_type__(4))); -typedef unsigned int swift_uint2 __attribute__((__ext_vector_type__(2))); -typedef unsigned int swift_uint3 __attribute__((__ext_vector_type__(3))); -typedef unsigned int swift_uint4 __attribute__((__ext_vector_type__(4))); -#endif - -#if !defined(SWIFT_PASTE) -# define SWIFT_PASTE_HELPER(x, y) x##y -# define SWIFT_PASTE(x, y) SWIFT_PASTE_HELPER(x, y) -#endif -#if !defined(SWIFT_METATYPE) -# define SWIFT_METATYPE(X) Class -#endif -#if !defined(SWIFT_CLASS_PROPERTY) -# if __has_feature(objc_class_property) -# define SWIFT_CLASS_PROPERTY(...) __VA_ARGS__ -# else -# define SWIFT_CLASS_PROPERTY(...) -# endif -#endif - -#if defined(__has_attribute) && __has_attribute(objc_runtime_name) -# define SWIFT_RUNTIME_NAME(X) __attribute__((objc_runtime_name(X))) -#else -# define SWIFT_RUNTIME_NAME(X) -#endif -#if defined(__has_attribute) && __has_attribute(swift_name) -# define SWIFT_COMPILE_NAME(X) __attribute__((swift_name(X))) -#else -# define SWIFT_COMPILE_NAME(X) -#endif -#if defined(__has_attribute) && __has_attribute(objc_method_family) -# define SWIFT_METHOD_FAMILY(X) __attribute__((objc_method_family(X))) -#else -# define SWIFT_METHOD_FAMILY(X) -#endif -#if defined(__has_attribute) && __has_attribute(noescape) -# define SWIFT_NOESCAPE __attribute__((noescape)) -#else -# define SWIFT_NOESCAPE -#endif -#if !defined(SWIFT_CLASS_EXTRA) -# define SWIFT_CLASS_EXTRA -#endif -#if !defined(SWIFT_PROTOCOL_EXTRA) -# define SWIFT_PROTOCOL_EXTRA -#endif -#if !defined(SWIFT_ENUM_EXTRA) -# define SWIFT_ENUM_EXTRA -#endif -#if !defined(SWIFT_CLASS) -# if defined(__has_attribute) && __has_attribute(objc_subclassing_restricted) -# define SWIFT_CLASS(SWIFT_NAME) SWIFT_RUNTIME_NAME(SWIFT_NAME) __attribute__((objc_subclassing_restricted)) SWIFT_CLASS_EXTRA -# define SWIFT_CLASS_NAMED(SWIFT_NAME) __attribute__((objc_subclassing_restricted)) SWIFT_COMPILE_NAME(SWIFT_NAME) SWIFT_CLASS_EXTRA -# else -# define SWIFT_CLASS(SWIFT_NAME) SWIFT_RUNTIME_NAME(SWIFT_NAME) SWIFT_CLASS_EXTRA -# define SWIFT_CLASS_NAMED(SWIFT_NAME) SWIFT_COMPILE_NAME(SWIFT_NAME) SWIFT_CLASS_EXTRA -# endif -#endif - -#if !defined(SWIFT_PROTOCOL) -# define SWIFT_PROTOCOL(SWIFT_NAME) SWIFT_RUNTIME_NAME(SWIFT_NAME) SWIFT_PROTOCOL_EXTRA -# define SWIFT_PROTOCOL_NAMED(SWIFT_NAME) SWIFT_COMPILE_NAME(SWIFT_NAME) SWIFT_PROTOCOL_EXTRA -#endif - -#if !defined(SWIFT_EXTENSION) -# define SWIFT_EXTENSION(M) SWIFT_PASTE(M##_Swift_, __LINE__) -#endif - -#if !defined(OBJC_DESIGNATED_INITIALIZER) -# if defined(__has_attribute) && __has_attribute(objc_designated_initializer) -# define OBJC_DESIGNATED_INITIALIZER __attribute__((objc_designated_initializer)) -# else -# define OBJC_DESIGNATED_INITIALIZER -# endif -#endif -#if !defined(SWIFT_ENUM) -# define SWIFT_ENUM(_type, _name) enum _name : _type _name; enum SWIFT_ENUM_EXTRA _name : _type -# if defined(__has_feature) && __has_feature(generalized_swift_name) -# define SWIFT_ENUM_NAMED(_type, _name, SWIFT_NAME) enum _name : _type _name SWIFT_COMPILE_NAME(SWIFT_NAME); enum SWIFT_COMPILE_NAME(SWIFT_NAME) SWIFT_ENUM_EXTRA _name : _type -# else -# define SWIFT_ENUM_NAMED(_type, _name, SWIFT_NAME) SWIFT_ENUM(_type, _name) -# endif -#endif -#if !defined(SWIFT_UNAVAILABLE) -# define SWIFT_UNAVAILABLE __attribute__((unavailable)) -#endif -#if defined(__has_feature) && __has_feature(modules) -#endif - -#pragma clang diagnostic ignored "-Wproperty-attribute-mismatch" -#pragma clang diagnostic ignored "-Wduplicate-method-arg" -#pragma clang diagnostic pop diff --git a/Carthage/Build/iOS/ShareClient.framework/Headers/ShareClient.h b/Carthage/Build/iOS/ShareClient.framework/Headers/ShareClient.h deleted file mode 100644 index 2d67034b08..0000000000 --- a/Carthage/Build/iOS/ShareClient.framework/Headers/ShareClient.h +++ /dev/null @@ -1,19 +0,0 @@ -// -// ShareClient.h -// ShareClient -// -// Created by Nathan Racklyeft on 5/8/16. -// Copyright © 2016 Mark Wilson. All rights reserved. -// - -#import - -//! Project version number for ShareClient. -FOUNDATION_EXPORT double ShareClientVersionNumber; - -//! Project version string for ShareClient. -FOUNDATION_EXPORT const unsigned char ShareClientVersionString[]; - -// In this header, you should import all the public headers of your framework using statements like #import - - diff --git a/Carthage/Build/iOS/ShareClient.framework/Info.plist b/Carthage/Build/iOS/ShareClient.framework/Info.plist deleted file mode 100644 index cfb5f8820b..0000000000 Binary files a/Carthage/Build/iOS/ShareClient.framework/Info.plist and /dev/null differ diff --git a/Carthage/Build/iOS/ShareClient.framework/Modules/ShareClient.swiftmodule/arm.swiftdoc b/Carthage/Build/iOS/ShareClient.framework/Modules/ShareClient.swiftmodule/arm.swiftdoc deleted file mode 100644 index 32c0c5e861..0000000000 Binary files a/Carthage/Build/iOS/ShareClient.framework/Modules/ShareClient.swiftmodule/arm.swiftdoc and /dev/null differ diff --git a/Carthage/Build/iOS/ShareClient.framework/Modules/ShareClient.swiftmodule/arm.swiftmodule b/Carthage/Build/iOS/ShareClient.framework/Modules/ShareClient.swiftmodule/arm.swiftmodule deleted file mode 100644 index b07af66f3e..0000000000 Binary files a/Carthage/Build/iOS/ShareClient.framework/Modules/ShareClient.swiftmodule/arm.swiftmodule and /dev/null differ diff --git a/Carthage/Build/iOS/ShareClient.framework/Modules/ShareClient.swiftmodule/arm64.swiftdoc b/Carthage/Build/iOS/ShareClient.framework/Modules/ShareClient.swiftmodule/arm64.swiftdoc deleted file mode 100644 index 0eea818fcd..0000000000 Binary files a/Carthage/Build/iOS/ShareClient.framework/Modules/ShareClient.swiftmodule/arm64.swiftdoc and /dev/null differ diff --git a/Carthage/Build/iOS/ShareClient.framework/Modules/ShareClient.swiftmodule/arm64.swiftmodule b/Carthage/Build/iOS/ShareClient.framework/Modules/ShareClient.swiftmodule/arm64.swiftmodule deleted file mode 100644 index 23b77dff24..0000000000 Binary files a/Carthage/Build/iOS/ShareClient.framework/Modules/ShareClient.swiftmodule/arm64.swiftmodule and /dev/null differ diff --git a/Carthage/Build/iOS/ShareClient.framework/Modules/ShareClient.swiftmodule/i386.swiftdoc b/Carthage/Build/iOS/ShareClient.framework/Modules/ShareClient.swiftmodule/i386.swiftdoc deleted file mode 100644 index 1cc1e13ecd..0000000000 Binary files a/Carthage/Build/iOS/ShareClient.framework/Modules/ShareClient.swiftmodule/i386.swiftdoc and /dev/null differ diff --git a/Carthage/Build/iOS/ShareClient.framework/Modules/ShareClient.swiftmodule/i386.swiftmodule b/Carthage/Build/iOS/ShareClient.framework/Modules/ShareClient.swiftmodule/i386.swiftmodule deleted file mode 100644 index 903f88398a..0000000000 Binary files a/Carthage/Build/iOS/ShareClient.framework/Modules/ShareClient.swiftmodule/i386.swiftmodule and /dev/null differ diff --git a/Carthage/Build/iOS/ShareClient.framework/Modules/ShareClient.swiftmodule/x86_64.swiftdoc b/Carthage/Build/iOS/ShareClient.framework/Modules/ShareClient.swiftmodule/x86_64.swiftdoc deleted file mode 100644 index 2032a22004..0000000000 Binary files a/Carthage/Build/iOS/ShareClient.framework/Modules/ShareClient.swiftmodule/x86_64.swiftdoc and /dev/null differ diff --git a/Carthage/Build/iOS/ShareClient.framework/Modules/ShareClient.swiftmodule/x86_64.swiftmodule b/Carthage/Build/iOS/ShareClient.framework/Modules/ShareClient.swiftmodule/x86_64.swiftmodule deleted file mode 100644 index b563fc3824..0000000000 Binary files a/Carthage/Build/iOS/ShareClient.framework/Modules/ShareClient.swiftmodule/x86_64.swiftmodule and /dev/null differ diff --git a/Carthage/Build/iOS/ShareClient.framework/Modules/module.modulemap b/Carthage/Build/iOS/ShareClient.framework/Modules/module.modulemap deleted file mode 100644 index bb522d7b49..0000000000 --- a/Carthage/Build/iOS/ShareClient.framework/Modules/module.modulemap +++ /dev/null @@ -1,10 +0,0 @@ -framework module ShareClient { - umbrella header "ShareClient.h" - - export * - module * { export * } -} - -module ShareClient.Swift { - header "ShareClient-Swift.h" -} diff --git a/Carthage/Build/iOS/ShareClient.framework/ShareClient b/Carthage/Build/iOS/ShareClient.framework/ShareClient deleted file mode 100755 index d36f061f7e..0000000000 Binary files a/Carthage/Build/iOS/ShareClient.framework/ShareClient and /dev/null differ diff --git a/Carthage/Build/iOS/SwiftCharts.framework/Headers/SwiftCharts-Swift.h b/Carthage/Build/iOS/SwiftCharts.framework/Headers/SwiftCharts-Swift.h deleted file mode 100644 index 5baac790f2..0000000000 --- a/Carthage/Build/iOS/SwiftCharts.framework/Headers/SwiftCharts-Swift.h +++ /dev/null @@ -1,267 +0,0 @@ -// Generated by Apple Swift version 3.0.1 (swiftlang-800.0.58.6 clang-800.0.42.1) -#pragma clang diagnostic push - -#if defined(__has_include) && __has_include() -# include -#endif - -#pragma clang diagnostic ignored "-Wauto-import" -#include -#include -#include -#include - -#if !defined(SWIFT_TYPEDEFS) -# define SWIFT_TYPEDEFS 1 -# if defined(__has_include) && __has_include() -# include -# elif !defined(__cplusplus) || __cplusplus < 201103L -typedef uint_least16_t char16_t; -typedef uint_least32_t char32_t; -# endif -typedef float swift_float2 __attribute__((__ext_vector_type__(2))); -typedef float swift_float3 __attribute__((__ext_vector_type__(3))); -typedef float swift_float4 __attribute__((__ext_vector_type__(4))); -typedef double swift_double2 __attribute__((__ext_vector_type__(2))); -typedef double swift_double3 __attribute__((__ext_vector_type__(3))); -typedef double swift_double4 __attribute__((__ext_vector_type__(4))); -typedef int swift_int2 __attribute__((__ext_vector_type__(2))); -typedef int swift_int3 __attribute__((__ext_vector_type__(3))); -typedef int swift_int4 __attribute__((__ext_vector_type__(4))); -typedef unsigned int swift_uint2 __attribute__((__ext_vector_type__(2))); -typedef unsigned int swift_uint3 __attribute__((__ext_vector_type__(3))); -typedef unsigned int swift_uint4 __attribute__((__ext_vector_type__(4))); -#endif - -#if !defined(SWIFT_PASTE) -# define SWIFT_PASTE_HELPER(x, y) x##y -# define SWIFT_PASTE(x, y) SWIFT_PASTE_HELPER(x, y) -#endif -#if !defined(SWIFT_METATYPE) -# define SWIFT_METATYPE(X) Class -#endif -#if !defined(SWIFT_CLASS_PROPERTY) -# if __has_feature(objc_class_property) -# define SWIFT_CLASS_PROPERTY(...) __VA_ARGS__ -# else -# define SWIFT_CLASS_PROPERTY(...) -# endif -#endif - -#if defined(__has_attribute) && __has_attribute(objc_runtime_name) -# define SWIFT_RUNTIME_NAME(X) __attribute__((objc_runtime_name(X))) -#else -# define SWIFT_RUNTIME_NAME(X) -#endif -#if defined(__has_attribute) && __has_attribute(swift_name) -# define SWIFT_COMPILE_NAME(X) __attribute__((swift_name(X))) -#else -# define SWIFT_COMPILE_NAME(X) -#endif -#if defined(__has_attribute) && __has_attribute(objc_method_family) -# define SWIFT_METHOD_FAMILY(X) __attribute__((objc_method_family(X))) -#else -# define SWIFT_METHOD_FAMILY(X) -#endif -#if defined(__has_attribute) && __has_attribute(noescape) -# define SWIFT_NOESCAPE __attribute__((noescape)) -#else -# define SWIFT_NOESCAPE -#endif -#if !defined(SWIFT_CLASS_EXTRA) -# define SWIFT_CLASS_EXTRA -#endif -#if !defined(SWIFT_PROTOCOL_EXTRA) -# define SWIFT_PROTOCOL_EXTRA -#endif -#if !defined(SWIFT_ENUM_EXTRA) -# define SWIFT_ENUM_EXTRA -#endif -#if !defined(SWIFT_CLASS) -# if defined(__has_attribute) && __has_attribute(objc_subclassing_restricted) -# define SWIFT_CLASS(SWIFT_NAME) SWIFT_RUNTIME_NAME(SWIFT_NAME) __attribute__((objc_subclassing_restricted)) SWIFT_CLASS_EXTRA -# define SWIFT_CLASS_NAMED(SWIFT_NAME) __attribute__((objc_subclassing_restricted)) SWIFT_COMPILE_NAME(SWIFT_NAME) SWIFT_CLASS_EXTRA -# else -# define SWIFT_CLASS(SWIFT_NAME) SWIFT_RUNTIME_NAME(SWIFT_NAME) SWIFT_CLASS_EXTRA -# define SWIFT_CLASS_NAMED(SWIFT_NAME) SWIFT_COMPILE_NAME(SWIFT_NAME) SWIFT_CLASS_EXTRA -# endif -#endif - -#if !defined(SWIFT_PROTOCOL) -# define SWIFT_PROTOCOL(SWIFT_NAME) SWIFT_RUNTIME_NAME(SWIFT_NAME) SWIFT_PROTOCOL_EXTRA -# define SWIFT_PROTOCOL_NAMED(SWIFT_NAME) SWIFT_COMPILE_NAME(SWIFT_NAME) SWIFT_PROTOCOL_EXTRA -#endif - -#if !defined(SWIFT_EXTENSION) -# define SWIFT_EXTENSION(M) SWIFT_PASTE(M##_Swift_, __LINE__) -#endif - -#if !defined(OBJC_DESIGNATED_INITIALIZER) -# if defined(__has_attribute) && __has_attribute(objc_designated_initializer) -# define OBJC_DESIGNATED_INITIALIZER __attribute__((objc_designated_initializer)) -# else -# define OBJC_DESIGNATED_INITIALIZER -# endif -#endif -#if !defined(SWIFT_ENUM) -# define SWIFT_ENUM(_type, _name) enum _name : _type _name; enum SWIFT_ENUM_EXTRA _name : _type -# if defined(__has_feature) && __has_feature(generalized_swift_name) -# define SWIFT_ENUM_NAMED(_type, _name, SWIFT_NAME) enum _name : _type _name SWIFT_COMPILE_NAME(SWIFT_NAME); enum SWIFT_COMPILE_NAME(SWIFT_NAME) SWIFT_ENUM_EXTRA _name : _type -# else -# define SWIFT_ENUM_NAMED(_type, _name, SWIFT_NAME) SWIFT_ENUM(_type, _name) -# endif -#endif -#if !defined(SWIFT_UNAVAILABLE) -# define SWIFT_UNAVAILABLE __attribute__((unavailable)) -#endif -#if defined(__has_feature) && __has_feature(modules) -@import UIKit; -@import CoreGraphics; -#endif - -#pragma clang diagnostic ignored "-Wproperty-attribute-mismatch" -#pragma clang diagnostic ignored "-Wduplicate-method-arg" -@class UIColor; -@class NSCoder; - -SWIFT_CLASS("_TtC11SwiftCharts14ChartAreasView") -@interface ChartAreasView : UIView -- (nonnull instancetype)initWithPoints:(NSArray * _Nonnull)points frame:(CGRect)frame color:(UIColor * _Nonnull)color animDuration:(float)animDuration animDelay:(float)animDelay OBJC_DESIGNATED_INITIALIZER; -- (nonnull instancetype)initWithCoder:(NSCoder * _Nonnull)aDecoder OBJC_DESIGNATED_INITIALIZER; -- (nonnull instancetype)initWithFrame:(CGRect)frame SWIFT_UNAVAILABLE; -@end - - -SWIFT_CLASS("_TtC11SwiftCharts9ChartView") -@interface ChartView : UIView -- (nonnull instancetype)initWithFrame:(CGRect)frame OBJC_DESIGNATED_INITIALIZER; -- (nullable instancetype)initWithCoder:(NSCoder * _Nonnull)aDecoder OBJC_DESIGNATED_INITIALIZER; -@end - - -/** - A UIView subclass for drawing charts -*/ -SWIFT_CLASS("_TtC11SwiftCharts13ChartBaseView") -@interface ChartBaseView : ChartView -- (void)drawRect:(CGRect)rect; -- (nonnull instancetype)initWithFrame:(CGRect)frame OBJC_DESIGNATED_INITIALIZER; -- (nullable instancetype)initWithCoder:(NSCoder * _Nonnull)aDecoder OBJC_DESIGNATED_INITIALIZER; -@end - - -SWIFT_CLASS("_TtC11SwiftCharts20ChartCandleStickView") -@interface ChartCandleStickView : UIView -- (nonnull instancetype)initWithLineX:(CGFloat)lineX width:(CGFloat)width top:(CGFloat)top bottom:(CGFloat)bottom innerRectTop:(CGFloat)innerRectTop innerRectBottom:(CGFloat)innerRectBottom fillColor:(UIColor * _Nonnull)fillColor strokeColor:(UIColor * _Nonnull)strokeColor strokeWidth:(CGFloat)strokeWidth OBJC_DESIGNATED_INITIALIZER; -- (nonnull instancetype)initWithCoder:(NSCoder * _Nonnull)aDecoder OBJC_DESIGNATED_INITIALIZER; -- (void)drawRect:(CGRect)rect; -- (nonnull instancetype)initWithFrame:(CGRect)frame SWIFT_UNAVAILABLE; -@end - - -SWIFT_CLASS("_TtC11SwiftCharts14ChartLinesView") -@interface ChartLinesView : UIView -- (nonnull instancetype)initWithCoder:(NSCoder * _Nonnull)aDecoder OBJC_DESIGNATED_INITIALIZER; -- (nonnull instancetype)initWithFrame:(CGRect)frame SWIFT_UNAVAILABLE; -@end - -@class UITouch; -@class UIEvent; - -SWIFT_CLASS("_TtC11SwiftCharts21ChartPointEllipseView") -@interface ChartPointEllipseView : UIView -@property (nonatomic, strong) UIColor * _Nonnull fillColor; -@property (nonatomic, strong) UIColor * _Nullable borderColor; -@property (nonatomic) float animDelay; -@property (nonatomic) float animDuration; -@property (nonatomic) BOOL animateSize; -@property (nonatomic) BOOL animateAlpha; -@property (nonatomic) CGFloat animDamping; -@property (nonatomic) CGFloat animInitSpringVelocity; -@property (nonatomic, copy) void (^ _Nullable touchHandler)(void); -- (nonnull instancetype)initWithCenter:(CGPoint)center diameter:(CGFloat)diameter; -- (nonnull instancetype)initWithCenter:(CGPoint)center width:(CGFloat)width height:(CGFloat)height OBJC_DESIGNATED_INITIALIZER; -- (nonnull instancetype)initWithCoder:(NSCoder * _Nonnull)aDecoder OBJC_DESIGNATED_INITIALIZER; -- (void)didMoveToSuperview; -- (void)drawRect:(CGRect)rect; -- (void)touchesEnded:(NSSet * _Nonnull)touches withEvent:(UIEvent * _Nullable)event; -- (nonnull instancetype)initWithFrame:(CGRect)frame SWIFT_UNAVAILABLE; -@end - - -SWIFT_CLASS("_TtC11SwiftCharts23ChartPointTargetingView") -@interface ChartPointTargetingView : UIView -- (nonnull instancetype)initWithCoder:(NSCoder * _Nonnull)aDecoder OBJC_DESIGNATED_INITIALIZER; -- (void)didMoveToSuperview; -- (nonnull instancetype)initWithFrame:(CGRect)frame SWIFT_UNAVAILABLE; -@end - - -SWIFT_CLASS("_TtC11SwiftCharts24ChartPointTextCircleView") -@interface ChartPointTextCircleView : UILabel -@property (nonatomic, copy) void (^ _Nullable viewTapped)(ChartPointTextCircleView * _Nonnull); -@property (nonatomic) BOOL selected; -- (void)didMoveToSuperview; -- (nonnull instancetype)initWithCoder:(NSCoder * _Nonnull)aDecoder OBJC_DESIGNATED_INITIALIZER; -- (void)touchesEnded:(NSSet * _Nonnull)touches withEvent:(UIEvent * _Nullable)event; -- (nonnull instancetype)initWithFrame:(CGRect)frame SWIFT_UNAVAILABLE; -@end - - -SWIFT_CLASS("_TtC11SwiftCharts17ChartPointViewBar") -@interface ChartPointViewBar : UIView -- (nonnull instancetype)initWithP1:(CGPoint)p1 p2:(CGPoint)p2 width:(CGFloat)width bgColor:(UIColor * _Nullable)bgColor animDuration:(float)animDuration OBJC_DESIGNATED_INITIALIZER; -- (nonnull instancetype)initWithCoder:(NSCoder * _Nonnull)aDecoder OBJC_DESIGNATED_INITIALIZER; -- (void)didMoveToSuperview; -- (nonnull instancetype)initWithFrame:(CGRect)frame SWIFT_UNAVAILABLE; -@end - - -SWIFT_CLASS("_TtC11SwiftCharts24ChartPointViewBarGreyOut") -@interface ChartPointViewBarGreyOut : ChartPointViewBar -- (nonnull instancetype)initWithCoder:(NSCoder * _Nonnull)aDecoder OBJC_DESIGNATED_INITIALIZER; -- (void)didMoveToSuperview; -- (nonnull instancetype)initWithP1:(CGPoint)p1 p2:(CGPoint)p2 width:(CGFloat)width bgColor:(UIColor * _Nullable)bgColor animDuration:(float)animDuration SWIFT_UNAVAILABLE; -@end - - -SWIFT_CLASS("_TtC11SwiftCharts24ChartPointViewBarStacked") -@interface ChartPointViewBarStacked : ChartPointViewBar -- (nonnull instancetype)initWithCoder:(NSCoder * _Nonnull)aDecoder OBJC_DESIGNATED_INITIALIZER; -- (void)drawRect:(CGRect)rect; -- (nonnull instancetype)initWithP1:(CGPoint)p1 p2:(CGPoint)p2 width:(CGFloat)width bgColor:(UIColor * _Nullable)bgColor animDuration:(float)animDuration SWIFT_UNAVAILABLE; -@end - - - -SWIFT_CLASS("_TtC11SwiftCharts13HandlingLabel") -@interface HandlingLabel : UILabel -@property (nonatomic, copy) void (^ _Nullable movedToSuperViewHandler)(void); -@property (nonatomic, copy) void (^ _Nullable touchHandler)(void); -- (void)didMoveToSuperview; -- (void)touchesEnded:(NSSet * _Nonnull)touches withEvent:(UIEvent * _Nullable)event; -- (nonnull instancetype)initWithFrame:(CGRect)frame OBJC_DESIGNATED_INITIALIZER; -- (nullable instancetype)initWithCoder:(NSCoder * _Nonnull)aDecoder OBJC_DESIGNATED_INITIALIZER; -@end - - -SWIFT_CLASS("_TtC11SwiftCharts12HandlingView") -@interface HandlingView : UIView -@property (nonatomic, copy) void (^ _Nullable movedToSuperViewHandler)(void); -@property (nonatomic, copy) void (^ _Nullable touchHandler)(void); -- (void)didMoveToSuperview; -- (void)touchesEnded:(NSSet * _Nonnull)touches withEvent:(UIEvent * _Nullable)event; -- (nonnull instancetype)initWithFrame:(CGRect)frame OBJC_DESIGNATED_INITIALIZER; -- (nullable instancetype)initWithCoder:(NSCoder * _Nonnull)aDecoder OBJC_DESIGNATED_INITIALIZER; -@end - - -SWIFT_CLASS("_TtC11SwiftCharts10InfoBubble") -@interface InfoBubble : UIView -- (nonnull instancetype)initWithFrame:(CGRect)frame arrowWidth:(CGFloat)arrowWidth arrowHeight:(CGFloat)arrowHeight bgColor:(UIColor * _Nonnull)bgColor arrowX:(CGFloat)arrowX OBJC_DESIGNATED_INITIALIZER; -- (nonnull instancetype)initWithCoder:(NSCoder * _Nonnull)aDecoder OBJC_DESIGNATED_INITIALIZER; -- (void)drawRect:(CGRect)rect; -- (nonnull instancetype)initWithFrame:(CGRect)frame SWIFT_UNAVAILABLE; -@end - -#pragma clang diagnostic pop diff --git a/Carthage/Build/iOS/SwiftCharts.framework/Headers/SwiftCharts.h b/Carthage/Build/iOS/SwiftCharts.framework/Headers/SwiftCharts.h deleted file mode 100644 index 4cfb01e91c..0000000000 --- a/Carthage/Build/iOS/SwiftCharts.framework/Headers/SwiftCharts.h +++ /dev/null @@ -1,19 +0,0 @@ -// -// SwiftCharts.h -// SwiftCharts -// -// Created by Pierre-Marc Airoldi on 2015-08-23. -// Copyright (c) 2015 Pierre-Marc Airoldi. All rights reserved. -// - -#import - -//! Project version number for SwiftCharts. -FOUNDATION_EXPORT double SwiftChartsVersionNumber; - -//! Project version string for SwiftCharts. -FOUNDATION_EXPORT const unsigned char SwiftChartsVersionString[]; - -// In this header, you should import all the public headers of your framework using statements like #import - - diff --git a/Carthage/Build/iOS/SwiftCharts.framework/Info.plist b/Carthage/Build/iOS/SwiftCharts.framework/Info.plist deleted file mode 100644 index 816090ea91..0000000000 Binary files a/Carthage/Build/iOS/SwiftCharts.framework/Info.plist and /dev/null differ diff --git a/Carthage/Build/iOS/SwiftCharts.framework/Modules/SwiftCharts.swiftmodule/arm.swiftdoc b/Carthage/Build/iOS/SwiftCharts.framework/Modules/SwiftCharts.swiftmodule/arm.swiftdoc deleted file mode 100644 index 7e796a4bee..0000000000 Binary files a/Carthage/Build/iOS/SwiftCharts.framework/Modules/SwiftCharts.swiftmodule/arm.swiftdoc and /dev/null differ diff --git a/Carthage/Build/iOS/SwiftCharts.framework/Modules/SwiftCharts.swiftmodule/arm.swiftmodule b/Carthage/Build/iOS/SwiftCharts.framework/Modules/SwiftCharts.swiftmodule/arm.swiftmodule deleted file mode 100644 index c1b0d4bb7f..0000000000 Binary files a/Carthage/Build/iOS/SwiftCharts.framework/Modules/SwiftCharts.swiftmodule/arm.swiftmodule and /dev/null differ diff --git a/Carthage/Build/iOS/SwiftCharts.framework/Modules/SwiftCharts.swiftmodule/arm64.swiftdoc b/Carthage/Build/iOS/SwiftCharts.framework/Modules/SwiftCharts.swiftmodule/arm64.swiftdoc deleted file mode 100644 index 890b5032f3..0000000000 Binary files a/Carthage/Build/iOS/SwiftCharts.framework/Modules/SwiftCharts.swiftmodule/arm64.swiftdoc and /dev/null differ diff --git a/Carthage/Build/iOS/SwiftCharts.framework/Modules/SwiftCharts.swiftmodule/arm64.swiftmodule b/Carthage/Build/iOS/SwiftCharts.framework/Modules/SwiftCharts.swiftmodule/arm64.swiftmodule deleted file mode 100644 index 6ed11328f2..0000000000 Binary files a/Carthage/Build/iOS/SwiftCharts.framework/Modules/SwiftCharts.swiftmodule/arm64.swiftmodule and /dev/null differ diff --git a/Carthage/Build/iOS/SwiftCharts.framework/Modules/SwiftCharts.swiftmodule/i386.swiftdoc b/Carthage/Build/iOS/SwiftCharts.framework/Modules/SwiftCharts.swiftmodule/i386.swiftdoc deleted file mode 100644 index ea2c52c369..0000000000 Binary files a/Carthage/Build/iOS/SwiftCharts.framework/Modules/SwiftCharts.swiftmodule/i386.swiftdoc and /dev/null differ diff --git a/Carthage/Build/iOS/SwiftCharts.framework/Modules/SwiftCharts.swiftmodule/i386.swiftmodule b/Carthage/Build/iOS/SwiftCharts.framework/Modules/SwiftCharts.swiftmodule/i386.swiftmodule deleted file mode 100644 index 44ff729d5e..0000000000 Binary files a/Carthage/Build/iOS/SwiftCharts.framework/Modules/SwiftCharts.swiftmodule/i386.swiftmodule and /dev/null differ diff --git a/Carthage/Build/iOS/SwiftCharts.framework/Modules/SwiftCharts.swiftmodule/x86_64.swiftdoc b/Carthage/Build/iOS/SwiftCharts.framework/Modules/SwiftCharts.swiftmodule/x86_64.swiftdoc deleted file mode 100644 index 0316fa76ee..0000000000 Binary files a/Carthage/Build/iOS/SwiftCharts.framework/Modules/SwiftCharts.swiftmodule/x86_64.swiftdoc and /dev/null differ diff --git a/Carthage/Build/iOS/SwiftCharts.framework/Modules/SwiftCharts.swiftmodule/x86_64.swiftmodule b/Carthage/Build/iOS/SwiftCharts.framework/Modules/SwiftCharts.swiftmodule/x86_64.swiftmodule deleted file mode 100644 index acd8714c06..0000000000 Binary files a/Carthage/Build/iOS/SwiftCharts.framework/Modules/SwiftCharts.swiftmodule/x86_64.swiftmodule and /dev/null differ diff --git a/Carthage/Build/iOS/SwiftCharts.framework/Modules/module.modulemap b/Carthage/Build/iOS/SwiftCharts.framework/Modules/module.modulemap deleted file mode 100644 index bd82053cad..0000000000 --- a/Carthage/Build/iOS/SwiftCharts.framework/Modules/module.modulemap +++ /dev/null @@ -1,10 +0,0 @@ -framework module SwiftCharts { - umbrella header "SwiftCharts.h" - - export * - module * { export * } -} - -module SwiftCharts.Swift { - header "SwiftCharts-Swift.h" -} diff --git a/Carthage/Build/iOS/SwiftCharts.framework/SwiftCharts b/Carthage/Build/iOS/SwiftCharts.framework/SwiftCharts deleted file mode 100755 index 19414e0cad..0000000000 Binary files a/Carthage/Build/iOS/SwiftCharts.framework/SwiftCharts and /dev/null differ diff --git a/Carthage/Build/iOS/xDripG5.framework/Headers/AESCrypt.h b/Carthage/Build/iOS/xDripG5.framework/Headers/AESCrypt.h deleted file mode 100644 index 357420d607..0000000000 --- a/Carthage/Build/iOS/xDripG5.framework/Headers/AESCrypt.h +++ /dev/null @@ -1,19 +0,0 @@ -// -// AESCrypt.h -// xDripG5 -// -// Created by Nate Racklyeft on 6/17/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -#import - -@interface AESCrypt : NSObject - -NS_ASSUME_NONNULL_BEGIN - -+ (nullable NSData *)encryptData:(NSData *)data usingKey:(NSData *)key error:(NSError **)error; - -NS_ASSUME_NONNULL_END - -@end diff --git a/Carthage/Build/iOS/xDripG5.framework/Headers/xDripG5-Swift.h b/Carthage/Build/iOS/xDripG5.framework/Headers/xDripG5-Swift.h deleted file mode 100644 index cb44e665fa..0000000000 --- a/Carthage/Build/iOS/xDripG5.framework/Headers/xDripG5-Swift.h +++ /dev/null @@ -1,127 +0,0 @@ -// Generated by Apple Swift version 3.0.1 (swiftlang-800.0.58.6 clang-800.0.42.1) -#pragma clang diagnostic push - -#if defined(__has_include) && __has_include() -# include -#endif - -#pragma clang diagnostic ignored "-Wauto-import" -#include -#include -#include -#include - -#if !defined(SWIFT_TYPEDEFS) -# define SWIFT_TYPEDEFS 1 -# if defined(__has_include) && __has_include() -# include -# elif !defined(__cplusplus) || __cplusplus < 201103L -typedef uint_least16_t char16_t; -typedef uint_least32_t char32_t; -# endif -typedef float swift_float2 __attribute__((__ext_vector_type__(2))); -typedef float swift_float3 __attribute__((__ext_vector_type__(3))); -typedef float swift_float4 __attribute__((__ext_vector_type__(4))); -typedef double swift_double2 __attribute__((__ext_vector_type__(2))); -typedef double swift_double3 __attribute__((__ext_vector_type__(3))); -typedef double swift_double4 __attribute__((__ext_vector_type__(4))); -typedef int swift_int2 __attribute__((__ext_vector_type__(2))); -typedef int swift_int3 __attribute__((__ext_vector_type__(3))); -typedef int swift_int4 __attribute__((__ext_vector_type__(4))); -typedef unsigned int swift_uint2 __attribute__((__ext_vector_type__(2))); -typedef unsigned int swift_uint3 __attribute__((__ext_vector_type__(3))); -typedef unsigned int swift_uint4 __attribute__((__ext_vector_type__(4))); -#endif - -#if !defined(SWIFT_PASTE) -# define SWIFT_PASTE_HELPER(x, y) x##y -# define SWIFT_PASTE(x, y) SWIFT_PASTE_HELPER(x, y) -#endif -#if !defined(SWIFT_METATYPE) -# define SWIFT_METATYPE(X) Class -#endif -#if !defined(SWIFT_CLASS_PROPERTY) -# if __has_feature(objc_class_property) -# define SWIFT_CLASS_PROPERTY(...) __VA_ARGS__ -# else -# define SWIFT_CLASS_PROPERTY(...) -# endif -#endif - -#if defined(__has_attribute) && __has_attribute(objc_runtime_name) -# define SWIFT_RUNTIME_NAME(X) __attribute__((objc_runtime_name(X))) -#else -# define SWIFT_RUNTIME_NAME(X) -#endif -#if defined(__has_attribute) && __has_attribute(swift_name) -# define SWIFT_COMPILE_NAME(X) __attribute__((swift_name(X))) -#else -# define SWIFT_COMPILE_NAME(X) -#endif -#if defined(__has_attribute) && __has_attribute(objc_method_family) -# define SWIFT_METHOD_FAMILY(X) __attribute__((objc_method_family(X))) -#else -# define SWIFT_METHOD_FAMILY(X) -#endif -#if defined(__has_attribute) && __has_attribute(noescape) -# define SWIFT_NOESCAPE __attribute__((noescape)) -#else -# define SWIFT_NOESCAPE -#endif -#if !defined(SWIFT_CLASS_EXTRA) -# define SWIFT_CLASS_EXTRA -#endif -#if !defined(SWIFT_PROTOCOL_EXTRA) -# define SWIFT_PROTOCOL_EXTRA -#endif -#if !defined(SWIFT_ENUM_EXTRA) -# define SWIFT_ENUM_EXTRA -#endif -#if !defined(SWIFT_CLASS) -# if defined(__has_attribute) && __has_attribute(objc_subclassing_restricted) -# define SWIFT_CLASS(SWIFT_NAME) SWIFT_RUNTIME_NAME(SWIFT_NAME) __attribute__((objc_subclassing_restricted)) SWIFT_CLASS_EXTRA -# define SWIFT_CLASS_NAMED(SWIFT_NAME) __attribute__((objc_subclassing_restricted)) SWIFT_COMPILE_NAME(SWIFT_NAME) SWIFT_CLASS_EXTRA -# else -# define SWIFT_CLASS(SWIFT_NAME) SWIFT_RUNTIME_NAME(SWIFT_NAME) SWIFT_CLASS_EXTRA -# define SWIFT_CLASS_NAMED(SWIFT_NAME) SWIFT_COMPILE_NAME(SWIFT_NAME) SWIFT_CLASS_EXTRA -# endif -#endif - -#if !defined(SWIFT_PROTOCOL) -# define SWIFT_PROTOCOL(SWIFT_NAME) SWIFT_RUNTIME_NAME(SWIFT_NAME) SWIFT_PROTOCOL_EXTRA -# define SWIFT_PROTOCOL_NAMED(SWIFT_NAME) SWIFT_COMPILE_NAME(SWIFT_NAME) SWIFT_PROTOCOL_EXTRA -#endif - -#if !defined(SWIFT_EXTENSION) -# define SWIFT_EXTENSION(M) SWIFT_PASTE(M##_Swift_, __LINE__) -#endif - -#if !defined(OBJC_DESIGNATED_INITIALIZER) -# if defined(__has_attribute) && __has_attribute(objc_designated_initializer) -# define OBJC_DESIGNATED_INITIALIZER __attribute__((objc_designated_initializer)) -# else -# define OBJC_DESIGNATED_INITIALIZER -# endif -#endif -#if !defined(SWIFT_ENUM) -# define SWIFT_ENUM(_type, _name) enum _name : _type _name; enum SWIFT_ENUM_EXTRA _name : _type -# if defined(__has_feature) && __has_feature(generalized_swift_name) -# define SWIFT_ENUM_NAMED(_type, _name, SWIFT_NAME) enum _name : _type _name SWIFT_COMPILE_NAME(SWIFT_NAME); enum SWIFT_COMPILE_NAME(SWIFT_NAME) SWIFT_ENUM_EXTRA _name : _type -# else -# define SWIFT_ENUM_NAMED(_type, _name, SWIFT_NAME) SWIFT_ENUM(_type, _name) -# endif -#endif -#if !defined(SWIFT_UNAVAILABLE) -# define SWIFT_UNAVAILABLE __attribute__((unavailable)) -#endif -#if defined(__has_feature) && __has_feature(modules) -@import HealthKit; -#endif - -#pragma clang diagnostic ignored "-Wproperty-attribute-mismatch" -#pragma clang diagnostic ignored "-Wduplicate-method-arg" - -@interface HKUnit (SWIFT_EXTENSION(xDripG5)) -@end - -#pragma clang diagnostic pop diff --git a/Carthage/Build/iOS/xDripG5.framework/Headers/xDripG5.h b/Carthage/Build/iOS/xDripG5.framework/Headers/xDripG5.h deleted file mode 100644 index 3b949160bf..0000000000 --- a/Carthage/Build/iOS/xDripG5.framework/Headers/xDripG5.h +++ /dev/null @@ -1,17 +0,0 @@ -// -// xDripG5.h -// xDripG5 -// -// Created by Nathan Racklyeft on 12/30/15. -// Copyright © 2015 Nathan Racklyeft. All rights reserved. -// - -#import -#import - -//! Project version number for xDripG5. -FOUNDATION_EXPORT double xDripG5VersionNumber; - -//! Project version string for xDripG5. -FOUNDATION_EXPORT const unsigned char xDripG5VersionString[]; - diff --git a/Carthage/Build/iOS/xDripG5.framework/Info.plist b/Carthage/Build/iOS/xDripG5.framework/Info.plist deleted file mode 100644 index c509bba059..0000000000 Binary files a/Carthage/Build/iOS/xDripG5.framework/Info.plist and /dev/null differ diff --git a/Carthage/Build/iOS/xDripG5.framework/Modules/module.modulemap b/Carthage/Build/iOS/xDripG5.framework/Modules/module.modulemap deleted file mode 100644 index 67b9c75e9f..0000000000 --- a/Carthage/Build/iOS/xDripG5.framework/Modules/module.modulemap +++ /dev/null @@ -1,10 +0,0 @@ -framework module xDripG5 { - umbrella header "xDripG5.h" - - export * - module * { export * } -} - -module xDripG5.Swift { - header "xDripG5-Swift.h" -} diff --git a/Carthage/Build/iOS/xDripG5.framework/Modules/xDripG5.swiftmodule/arm.swiftdoc b/Carthage/Build/iOS/xDripG5.framework/Modules/xDripG5.swiftmodule/arm.swiftdoc deleted file mode 100644 index 3c5d71ac22..0000000000 Binary files a/Carthage/Build/iOS/xDripG5.framework/Modules/xDripG5.swiftmodule/arm.swiftdoc and /dev/null differ diff --git a/Carthage/Build/iOS/xDripG5.framework/Modules/xDripG5.swiftmodule/arm.swiftmodule b/Carthage/Build/iOS/xDripG5.framework/Modules/xDripG5.swiftmodule/arm.swiftmodule deleted file mode 100644 index f1a09da8a7..0000000000 Binary files a/Carthage/Build/iOS/xDripG5.framework/Modules/xDripG5.swiftmodule/arm.swiftmodule and /dev/null differ diff --git a/Carthage/Build/iOS/xDripG5.framework/Modules/xDripG5.swiftmodule/arm64.swiftdoc b/Carthage/Build/iOS/xDripG5.framework/Modules/xDripG5.swiftmodule/arm64.swiftdoc deleted file mode 100644 index 59d04b3f9c..0000000000 Binary files a/Carthage/Build/iOS/xDripG5.framework/Modules/xDripG5.swiftmodule/arm64.swiftdoc and /dev/null differ diff --git a/Carthage/Build/iOS/xDripG5.framework/Modules/xDripG5.swiftmodule/arm64.swiftmodule b/Carthage/Build/iOS/xDripG5.framework/Modules/xDripG5.swiftmodule/arm64.swiftmodule deleted file mode 100644 index f995615b43..0000000000 Binary files a/Carthage/Build/iOS/xDripG5.framework/Modules/xDripG5.swiftmodule/arm64.swiftmodule and /dev/null differ diff --git a/Carthage/Build/iOS/xDripG5.framework/Modules/xDripG5.swiftmodule/i386.swiftdoc b/Carthage/Build/iOS/xDripG5.framework/Modules/xDripG5.swiftmodule/i386.swiftdoc deleted file mode 100644 index cdd5a6e17b..0000000000 Binary files a/Carthage/Build/iOS/xDripG5.framework/Modules/xDripG5.swiftmodule/i386.swiftdoc and /dev/null differ diff --git a/Carthage/Build/iOS/xDripG5.framework/Modules/xDripG5.swiftmodule/i386.swiftmodule b/Carthage/Build/iOS/xDripG5.framework/Modules/xDripG5.swiftmodule/i386.swiftmodule deleted file mode 100644 index f4c0b29577..0000000000 Binary files a/Carthage/Build/iOS/xDripG5.framework/Modules/xDripG5.swiftmodule/i386.swiftmodule and /dev/null differ diff --git a/Carthage/Build/iOS/xDripG5.framework/Modules/xDripG5.swiftmodule/x86_64.swiftdoc b/Carthage/Build/iOS/xDripG5.framework/Modules/xDripG5.swiftmodule/x86_64.swiftdoc deleted file mode 100644 index b1aad0dc01..0000000000 Binary files a/Carthage/Build/iOS/xDripG5.framework/Modules/xDripG5.swiftmodule/x86_64.swiftdoc and /dev/null differ diff --git a/Carthage/Build/iOS/xDripG5.framework/Modules/xDripG5.swiftmodule/x86_64.swiftmodule b/Carthage/Build/iOS/xDripG5.framework/Modules/xDripG5.swiftmodule/x86_64.swiftmodule deleted file mode 100644 index bf0596e8ba..0000000000 Binary files a/Carthage/Build/iOS/xDripG5.framework/Modules/xDripG5.swiftmodule/x86_64.swiftmodule and /dev/null differ diff --git a/Carthage/Build/iOS/xDripG5.framework/xDripG5 b/Carthage/Build/iOS/xDripG5.framework/xDripG5 deleted file mode 100755 index f67fb14aa6..0000000000 Binary files a/Carthage/Build/iOS/xDripG5.framework/xDripG5 and /dev/null differ diff --git a/Common/Base.lproj/Intents.intentdefinition b/Common/Base.lproj/Intents.intentdefinition new file mode 100644 index 0000000000..dcaa89bcd3 --- /dev/null +++ b/Common/Base.lproj/Intents.intentdefinition @@ -0,0 +1,198 @@ + + + + + INEnums + + INIntentDefinitionModelVersion + 1.2 + INIntentDefinitionNamespace + wqYaJi + INIntentDefinitionSystemVersion + 19G2021 + INIntentDefinitionToolsBuildVersion + 12B45b + INIntentDefinitionToolsVersion + 12.2 + INIntents + + + INIntentCategory + create + INIntentDescription + Add a carb entry to Loop + INIntentDescriptionID + yc02Yq + INIntentName + NewCarbEntry + INIntentParameterCombinations + + + + INIntentParameterCombinationIsPrimary + + INIntentParameterCombinationSupportsBackgroundExecution + + INIntentParameterCombinationTitle + Add Carb Entry + INIntentParameterCombinationTitleID + OcNxIj + + + INIntentResponse + + INIntentResponseCodes + + + INIntentResponseCodeName + success + INIntentResponseCodeSuccess + + + + INIntentResponseCodeName + failure + + + + INIntentTitle + Add Carb Entry + INIntentTitleID + 80eo5o + INIntentType + Custom + INIntentVerb + Add + + + INIntentCategory + generic + INIntentConfigurable + + INIntentDescription + Enable an override preset in Loop + INIntentDescriptionID + ZZ3mtM + INIntentLastParameterTag + 1 + INIntentManagedParameterCombinations + + overrideName + + INIntentParameterCombinationSupportsBackgroundExecution + + INIntentParameterCombinationUpdatesLinked + + + + INIntentName + EnableOverridePreset + INIntentParameterCombinations + + overrideName + + INIntentParameterCombinationSubtitle + Enable preset in Loop + INIntentParameterCombinationSubtitleID + XNNmtH + INIntentParameterCombinationSupportsBackgroundExecution + + INIntentParameterCombinationTitle + Enable '${overrideName}' Override Preset + INIntentParameterCombinationTitleID + oLQSsJ + + + INIntentParameters + + + INIntentParameterConfigurable + + INIntentParameterCustomDisambiguation + + INIntentParameterDisplayName + Override Name + INIntentParameterDisplayNameID + lYMuWV + INIntentParameterDisplayPriority + 1 + INIntentParameterMetadata + + INIntentParameterMetadataCapitalization + None + INIntentParameterMetadataDefaultValueID + sfi0XQ + + INIntentParameterName + overrideName + INIntentParameterPromptDialogs + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogFormatString + Override Selection + INIntentParameterPromptDialogFormatStringID + yBzwCL + INIntentParameterPromptDialogType + Configuration + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogFormatString + What's the name of the override you'd like to set? + INIntentParameterPromptDialogFormatStringID + nDKAmn + INIntentParameterPromptDialogType + Primary + + + INIntentParameterSupportsDynamicEnumeration + + INIntentParameterSupportsResolution + + INIntentParameterTag + 1 + INIntentParameterType + String + + + INIntentResponse + + INIntentResponseCodes + + + INIntentResponseCodeFormatString + I've set the preset + INIntentResponseCodeFormatStringID + 9KhaIS + INIntentResponseCodeName + success + INIntentResponseCodeSuccess + + + + INIntentResponseCodeFormatString + I wasn't able to set the preset. + INIntentResponseCodeFormatStringID + b085BW + INIntentResponseCodeName + failure + + + + INIntentTitle + Enable Override Preset + INIntentTitleID + I4OZy8 + INIntentType + Custom + INIntentVerb + Do + + + INTypes + + + diff --git a/Common/Extensions/Double.swift b/Common/Extensions/Double.swift new file mode 100644 index 0000000000..217e5666cb --- /dev/null +++ b/Common/Extensions/Double.swift @@ -0,0 +1,35 @@ +// +// Double.swift +// Loop +// +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +import Foundation + + +extension FloatingPoint { + func floored(to increment: Self) -> Self { + if increment == 0 { + return self + } + + return floor(self / increment) * increment + } + + func ceiled(to increment: Self) -> Self { + if increment == 0 { + return self + } + + return ceil(self / increment) * increment + } +} + +infix operator =~ : ComparisonPrecedence + +extension Double { + static func =~ (lhs: Double, rhs: Double) -> Bool { + return fabs(lhs - rhs) < Double.ulpOfOne + } +} diff --git a/Common/Extensions/GlucoseRangeSchedule.swift b/Common/Extensions/GlucoseRangeSchedule.swift new file mode 100644 index 0000000000..9b092d6ad3 --- /dev/null +++ b/Common/Extensions/GlucoseRangeSchedule.swift @@ -0,0 +1,19 @@ +// +// GlucoseRangeSchedule.swift +// Loop +// +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +import LoopKit +import HealthKit + + +extension GlucoseRangeSchedule { + func minQuantity(at date: Date) -> HKQuantity { + return HKQuantity(unit: unit, doubleValue: value(at: date).minValue) + } + func maxQuantity(at date: Date) -> HKQuantity { + return HKQuantity(unit: unit, doubleValue: value(at: date).maxValue) + } +} diff --git a/Common/Extensions/HKUnit.swift b/Common/Extensions/HKUnit.swift index a4b7df21f6..36f7576d80 100644 --- a/Common/Extensions/HKUnit.swift +++ b/Common/Extensions/HKUnit.swift @@ -7,6 +7,7 @@ // import HealthKit +import LoopCore // Code in this extension is duplicated from: // https://github.com/LoopKit/LoopKit/blob/master/LoopKit/HKUnit.swift @@ -14,24 +15,33 @@ import HealthKit extension HKUnit { // A formatting helper for determining the preferred decimal style for a given unit var preferredFractionDigits: Int { - if self.unitString == "mg/dL" { + if self == .milligramsPerDeciliter { return 0 } else { return 1 } } - - static func millimolesPerLiterUnit() -> HKUnit { - return HKUnit.moleUnit(with: .milli, molarMass: HKUnitMolarMassBloodGlucose).unitDivided(by: HKUnit.liter()) - } - - // A glucose-centric presentation helper for the localized unit string - var glucoseUnitDisplayString: String { - if self == HKUnit.millimolesPerLiterUnit() { - return NSLocalizedString("mmol/L", comment: "The unit display string for millimoles of glucose per liter") + + var localizedShortUnitString: String { + if self == HKUnit.millimolesPerLiter { + return NSLocalizedString("mmol/L", comment: "The short unit display string for millimoles of glucose per liter") + } else if self == .milligramsPerDeciliter { + return NSLocalizedString("mg/dL", comment: "The short unit display string for milligrams of glucose per decilter") + } else if self == .internationalUnit() { + return NSLocalizedString("U", comment: "The short unit display string for international units of insulin") + } else if self == .gram() { + return NSLocalizedString("g", comment: "The short unit display string for grams") } else { return String(describing: self) } } + /// The smallest value expected to be visible on a chart + var chartableIncrement: Double { + if self == .milligramsPerDeciliter { + return 1 + } else { + return 1 / 25 + } + } } diff --git a/Common/Extensions/IdentifiableClass.swift b/Common/Extensions/IdentifiableClass.swift deleted file mode 100644 index a350a1fde5..0000000000 --- a/Common/Extensions/IdentifiableClass.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// IdentifiableClass.swift -// Naterade -// -// Created by Nathan Racklyeft on 5/22/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import Foundation - - -protocol IdentifiableClass: class { - static var className: String { get } -} - - -extension IdentifiableClass { - static var className: String { - return NSStringFromClass(self).components(separatedBy: ".").last! - } -} diff --git a/Common/Extensions/NSBundle.swift b/Common/Extensions/NSBundle.swift index e9202624d6..57b7d6ad88 100644 --- a/Common/Extensions/NSBundle.swift +++ b/Common/Extensions/NSBundle.swift @@ -8,18 +8,25 @@ import Foundation - extension Bundle { + var fullVersionString: String { + return "\(shortVersionString) (\(version))" + } + var shortVersionString: String { return object(forInfoDictionaryKey: "CFBundleShortVersionString") as! String } + var version: String { + return object(forInfoDictionaryKey: "CFBundleVersion") as! String + } + var bundleDisplayName: String { return object(forInfoDictionaryKey: "CFBundleDisplayName") as! String } var localizedNameAndVersion: String { - return String(format: NSLocalizedString("%1$@ v%2$@", comment: "The format string for the app name and version number. (1: bundle name)(2: bundle version)"), bundleDisplayName, shortVersionString) + return String(format: NSLocalizedString("%1$@ v%2$@", comment: "The format string for the app name and version number. (1: bundle name)(2: bundle version)"), bundleDisplayName, fullVersionString) } private var mainAppBundleIdentifier: String? { @@ -29,6 +36,14 @@ extension Bundle { var appGroupSuiteName: String { return object(forInfoDictionaryKey: "AppGroupIdentifier") as! String } + + var appStoreURL: String? { + return object(forInfoDictionaryKey: "AppStoreURL") as? String + } + + var isAppExtension: Bool { + return bundleURL.pathExtension == "appex" + } var mainAppUrl: URL? { if let mainAppBundleIdentifier = mainAppBundleIdentifier { @@ -37,4 +52,13 @@ extension Bundle { return nil } } + + var localCacheDuration: TimeInterval { + guard let localCacheDurationDaysString = object(forInfoDictionaryKey: "LoopLocalCacheDurationDays") as? String, + let localCacheDurationDays = Double(localCacheDurationDaysString) else { + return .days(1) + } + return .days(localCacheDurationDays) + } } + diff --git a/Common/Extensions/NSTimeInterval.swift b/Common/Extensions/NSTimeInterval.swift index 400a5e01cb..3b7cee33f7 100644 --- a/Common/Extensions/NSTimeInterval.swift +++ b/Common/Extensions/NSTimeInterval.swift @@ -10,6 +10,22 @@ import Foundation extension TimeInterval { + static func seconds(_ seconds: Double) -> TimeInterval { + return seconds + } + + static func minutes(_ minutes: Double) -> TimeInterval { + return TimeInterval(minutes: minutes) + } + + static func hours(_ hours: Double) -> TimeInterval { + return TimeInterval(hours: hours) + } + + static func days(_ days: Double) -> TimeInterval { + return TimeInterval(days: days) + } + init(minutes: Double) { self.init(minutes * 60) } @@ -18,6 +34,10 @@ extension TimeInterval { self.init(minutes: hours * 60) } + init(days: Double) { + self.init(hours: days * 24) + } + var minutes: Double { return self / 60.0 } @@ -25,4 +45,9 @@ extension TimeInterval { var hours: Double { return minutes / 60.0 } + + var days: Double { + return hours / 24.0 + } + } diff --git a/Common/Extensions/NSUserActivity.swift b/Common/Extensions/NSUserActivity.swift new file mode 100644 index 0000000000..a434fe0be6 --- /dev/null +++ b/Common/Extensions/NSUserActivity.swift @@ -0,0 +1,33 @@ +// +// NSUserActivity.swift +// Loop +// +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +import Foundation + + +extension NSUserActivity { + /// Activity of viewing the current status of the Loop + static let viewLoopStatusActivityType = "ViewLoopStatus" + + class func forViewLoopStatus() -> NSUserActivity { + return NSUserActivity(activityType: viewLoopStatusActivityType) + } + + static let didAddCarbEntryOnWatchActivityType = "com.loopkit.Loop.AddCarbEntryOnWatch" + + class func forDidAddCarbEntryOnWatch() -> NSUserActivity { + let activity = NSUserActivity(activityType: didAddCarbEntryOnWatchActivityType) + activity.isEligibleForSearch = true + activity.isEligibleForHandoff = false + activity.isEligibleForPublicIndexing = false + if #available(watchOSApplicationExtension 5.0, *) { + activity.isEligibleForPrediction = true + } + activity.requiredUserInfoKeys = [] + activity.title = NSLocalizedString("Add Carb Entry", comment: "Title of the user activity for adding carbs") + return activity + } +} diff --git a/Common/Extensions/NewCarbEntryIntent+Loop.swift b/Common/Extensions/NewCarbEntryIntent+Loop.swift new file mode 100644 index 0000000000..2fe09940d7 --- /dev/null +++ b/Common/Extensions/NewCarbEntryIntent+Loop.swift @@ -0,0 +1,12 @@ +// +// NewCarbEntryIntent+Loop.swift +// Loop +// +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopCore + +@available(watchOSApplicationExtension 5.0, *) +extension NewCarbEntryIntent: IdentifiableClass { } diff --git a/Common/Extensions/NibLoadable.swift b/Common/Extensions/NibLoadable.swift deleted file mode 100644 index 4e762b2c69..0000000000 --- a/Common/Extensions/NibLoadable.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// NibLoadable.swift -// Loop -// -// Created by Nate Racklyeft on 7/2/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import UIKit - - -protocol NibLoadable: IdentifiableClass { - static func nib() -> UINib -} - - -extension NibLoadable { - static func nib() -> UINib { - return UINib(nibName: className, bundle: Bundle(for: self)) - } -} diff --git a/Common/Extensions/NumberFormatter.swift b/Common/Extensions/NumberFormatter.swift new file mode 100644 index 0000000000..51f411ae7d --- /dev/null +++ b/Common/Extensions/NumberFormatter.swift @@ -0,0 +1,58 @@ +// +// NSNumberFormatter.swift +// Loop +// +// Created by Nate Racklyeft on 9/5/16. +// Copyright © 2016 Nathan Racklyeft. All rights reserved. +// + +import Foundation +import HealthKit + + +extension NumberFormatter { + static func glucoseFormatter(for unit: HKUnit) -> NumberFormatter { + let numberFormatter = NumberFormatter() + + numberFormatter.numberStyle = .decimal + numberFormatter.minimumFractionDigits = unit.preferredFractionDigits + numberFormatter.maximumFractionDigits = unit.preferredFractionDigits + return numberFormatter + } + + func string(from number: Double) -> String? { + return string(from: NSNumber(value: number)) + } + + func string(from quantity: HKQuantity, unit: HKUnit) -> String? { + return string(from: quantity.doubleValue(for: unit), unit: unit) + } + + func string(from number: Double, unit: HKUnit) -> String? { + return string(from: number, unit: unit.localizedShortUnitString) + } + + func string(from number: Double, unit: String) -> String? { + guard let stringValue = string(from: number) else { + return nil + } + + return String( + format: NSLocalizedString( + "QUANTITY_VALUE_AND_UNIT", + value: "%1$@ %2$@", + comment: "Format string for combining localized numeric value and unit. (1: numeric value)(2: unit)" + ), + stringValue, + unit + ) + } + + func decibleString(from decibles: Int?) -> String? { + if let decibles = decibles { + return string(from: Double(decibles), unit: NSLocalizedString("dB", comment: "The short unit display string for decibles")) + } else { + return nil + } + } +} diff --git a/Common/Extensions/OSLog.swift b/Common/Extensions/OSLog.swift new file mode 100644 index 0000000000..61899e9310 --- /dev/null +++ b/Common/Extensions/OSLog.swift @@ -0,0 +1,56 @@ +// +// OSLog.swift +// Loop +// +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +import os.log + + +extension Logger { + init(category: String) { + self.init(subsystem: "com.loopkit.Loop", category: category) + } +} + +extension OSLog { + convenience init(category: String) { + self.init(subsystem: "com.loopkit.Loop", category: category) + } + + func debug(_ message: StaticString, _ args: CVarArg...) { + log(message, type: .debug, args) + } + + func info(_ message: StaticString, _ args: CVarArg...) { + log(message, type: .info, args) + } + + func `default`(_ message: StaticString, _ args: CVarArg...) { + log(message, type: .default, args) + } + + func error(_ message: StaticString, _ args: CVarArg...) { + log(message, type: .error, args) + } + + private func log(_ message: StaticString, type: OSLogType, _ args: [CVarArg]) { + switch args.count { + case 0: + os_log(message, log: self, type: type) + case 1: + os_log(message, log: self, type: type, args[0]) + case 2: + os_log(message, log: self, type: type, args[0], args[1]) + case 3: + os_log(message, log: self, type: type, args[0], args[1], args[2]) + case 4: + os_log(message, log: self, type: type, args[0], args[1], args[2], args[3]) + case 5: + os_log(message, log: self, type: type, args[0], args[1], args[2], args[3], args[4]) + default: + os_log(message, log: self, type: type, args) + } + } +} diff --git a/Common/Extensions/SampleValue.swift b/Common/Extensions/SampleValue.swift new file mode 100644 index 0000000000..39dcb16e9c --- /dev/null +++ b/Common/Extensions/SampleValue.swift @@ -0,0 +1,38 @@ +// +// SampleValue.swift +// Loop +// +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +import HealthKit +import LoopKit + + +extension Collection where Element == SampleValue { + /// O(n) + var quantityRange: ClosedRange? { + var lowest: HKQuantity? + var highest: HKQuantity? + + for sample in self { + if let l = lowest { + lowest = Swift.min(l, sample.quantity) + } else { + lowest = sample.quantity + } + + if let h = highest { + highest = Swift.max(h, sample.quantity) + } else { + highest = sample.quantity + } + } + + guard let l = lowest, let h = highest else { + return nil + } + + return l...h + } +} diff --git a/Common/Extensions/TextFieldTableViewCell.swift b/Common/Extensions/TextFieldTableViewCell.swift new file mode 100644 index 0000000000..82c5867cc3 --- /dev/null +++ b/Common/Extensions/TextFieldTableViewCell.swift @@ -0,0 +1,12 @@ +// +// TextFieldTableViewCell.swift +// Loop +// +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +import LoopKitUI +import LoopUI + + +extension TextFieldTableViewCell: NibLoadable { } diff --git a/Common/Extensions/UIColor+HIG.swift b/Common/Extensions/UIColor+HIG.swift new file mode 100644 index 0000000000..79512dcf4c --- /dev/null +++ b/Common/Extensions/UIColor+HIG.swift @@ -0,0 +1,20 @@ +// +// UIColor+HIG.swift +// Naterade +// +// Created by Nathan Racklyeft on 1/23/16. +// Copyright © 2016 Nathan Racklyeft. All rights reserved. +// + +import UIKit + + +extension UIColor { + // MARK: - HIG colors + // See: https://developer.apple.com/ios/human-interface-guidelines/visual-design/color/ + + // HIG Green has changed for iOS 13. This is the legacy color. + static func HIGGreenColor() -> UIColor { + return UIColor(red: 76 / 255, green: 217 / 255, blue: 100 / 255, alpha: 1) + } +} diff --git a/Common/Extensions/UserDefaults+LoopIntents.swift b/Common/Extensions/UserDefaults+LoopIntents.swift new file mode 100644 index 0000000000..07082d0615 --- /dev/null +++ b/Common/Extensions/UserDefaults+LoopIntents.swift @@ -0,0 +1,42 @@ +// +// UserDefaults+LoopIntents.swift +// Loop Intent Extension +// +// Created by Anna Quinlan on 10/17/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import Foundation + +extension UserDefaults { + + private enum Key: String { + case IntentExtensionContext = "com.loopkit.Loop.IntentExtensionContext" + // This key needs to be EXACTLY the same string as the objc dynamic var for the KVO to work correctly + case IntentExtensionOverrideToSet = "intentExtensionOverrideToSet" + } + + // Information for the extension from Loop + var intentExtensionInfo: IntentExtensionInfo? { + get { + if let rawValue = dictionary(forKey: Key.IntentExtensionContext.rawValue) { + return IntentExtensionInfo(rawValue: rawValue) + } else { + return nil + } + } + set { + set(newValue?.rawValue, forKey: Key.IntentExtensionContext.rawValue) + } + } + + @objc dynamic var intentExtensionOverrideToSet: String? { + get { + return object(forKey: Key.IntentExtensionOverrideToSet.rawValue) as? String + } + set { + set(newValue, forKey: Key.IntentExtensionOverrideToSet.rawValue) + } + } +} + diff --git a/Common/FeatureFlags.swift b/Common/FeatureFlags.swift new file mode 100644 index 0000000000..0c6440b564 --- /dev/null +++ b/Common/FeatureFlags.swift @@ -0,0 +1,325 @@ +// +// FeatureFlags.swift +// Loop +// +// Created by Michael Pangburn on 5/19/19. +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import Foundation + +let FeatureFlags = FeatureFlagConfiguration() + +struct FeatureFlagConfiguration: Decodable { + let automaticBolusEnabled: Bool + let cgmManagerCategorizeManualGlucoseRangeEnabled: Bool + let criticalAlertsEnabled: Bool + let entryDeletionEnabled: Bool + let fiaspInsulinModelEnabled: Bool + let lyumjevInsulinModelEnabled: Bool + let afrezzaInsulinModelEnabled: Bool + let includeServicesInSettingsEnabled: Bool + let manualDoseEntryEnabled: Bool + let insulinDeliveryReservoirViewEnabled: Bool + let mockTherapySettingsEnabled: Bool + let nonlinearCarbModelEnabled: Bool + let observeHealthKitCarbSamplesFromOtherApps: Bool + let observeHealthKitDoseSamplesFromOtherApps: Bool + let observeHealthKitGlucoseSamplesFromOtherApps: Bool + let remoteCommandsEnabled: Bool + let predictedGlucoseChartClampEnabled: Bool + let scenariosEnabled: Bool + let sensitivityOverridesEnabled: Bool + let showEventualBloodGlucoseOnWatchEnabled: Bool + let simulatedCoreDataEnabled: Bool + let siriEnabled: Bool + let simpleBolusCalculatorEnabled: Bool + let usePositiveMomentumAndRCForManualBoluses: Bool + let adultChildInsulinModelSelectionEnabled: Bool + let profileExpirationSettingsViewEnabled: Bool + let missedMealNotifications: Bool + let allowAlgorithmExperiments: Bool + + + fileprivate init() { + // Swift compiler config is inverse, since the default state is enabled. + #if AUTOMATIC_BOLUS_DISABLED + self.automaticBolusEnabled = false + #else + self.automaticBolusEnabled = true + #endif + + #if CGM_MANAGER_CATEGORIZE_GLUCOSE_RANGE_ENABLED + self.cgmManagerCategorizeManualGlucoseRangeEnabled = true + #else + self.cgmManagerCategorizeManualGlucoseRangeEnabled = false + #endif + + #if CRITICAL_ALERTS_ENABLED + self.criticalAlertsEnabled = true + #else + self.criticalAlertsEnabled = false + #endif + + // Swift compiler config is inverse, since the default state is enabled. + #if ENTRY_DELETION_DISABLED + self.entryDeletionEnabled = false + #else + self.entryDeletionEnabled = true + #endif + + // Swift compiler config is inverse, since the default state is enabled. + #if FEATURE_OVERRIDES_DISABLED + self.sensitivityOverridesEnabled = false + #else + self.sensitivityOverridesEnabled = true + #endif + + // Swift compiler config is inverse, since the default state is enabled. + #if FIASP_INSULIN_MODEL_DISABLED + self.fiaspInsulinModelEnabled = false + #else + self.fiaspInsulinModelEnabled = true + #endif + + // Swift compiler config is inverse, since the default state is enabled. + #if LYUMJEV_INSULIN_MODEL_DISABLED + self.lyumjevInsulinModelEnabled = false + #else + self.lyumjevInsulinModelEnabled = true + #endif + + // Swift compiler config is inverse, since the default state is enabled. + #if AFREZZA_INSULIN_MODEL_DISABLED + self.afrezzaInsulinModelEnabled = false + #else + self.afrezzaInsulinModelEnabled = true + #endif + + // Swift compiler config is inverse, since the default state is enabled. + #if INCLUDE_SERVICES_IN_SETTINGS_DISABLED + self.includeServicesInSettingsEnabled = false + #else + self.includeServicesInSettingsEnabled = true + #endif + + // Swift compiler config is inverse, since the default state is enabled. + #if MANUAL_DOSE_ENTRY_DISABLED + self.manualDoseEntryEnabled = false + #else + self.manualDoseEntryEnabled = true + #endif + + // Swift compiler config is inverse, since the default state is enabled. + #if INSULIN_DELIVERY_RESERVOIR_VIEW_DISABLED + self.insulinDeliveryReservoirViewEnabled = false + #else + self.insulinDeliveryReservoirViewEnabled = true + #endif + + #if MOCK_THERAPY_SETTINGS_ENABLED + self.mockTherapySettingsEnabled = true + #else + self.mockTherapySettingsEnabled = false + #endif + + // Swift compiler config is inverse, since the default state is enabled. + #if NONLINEAR_CARB_MODEL_DISABLED + self.nonlinearCarbModelEnabled = false + #else + self.nonlinearCarbModelEnabled = true + #endif + + #if OBSERVE_HEALTH_KIT_CARB_SAMPLES_FROM_OTHER_APPS_ENABLED + self.observeHealthKitCarbSamplesFromOtherApps = true + #else + self.observeHealthKitCarbSamplesFromOtherApps = false + #endif + + // Swift compiler config is inverse, since the default state is enabled. + #if OBSERVE_HEALTH_KIT_SAMPLES_FROM_OTHER_APPS_DISABLED + self.observeHealthKitDoseSamplesFromOtherApps = false + self.observeHealthKitGlucoseSamplesFromOtherApps = false + #else + self.observeHealthKitDoseSamplesFromOtherApps = true + self.observeHealthKitGlucoseSamplesFromOtherApps = true + #endif + + // Swift compiler config is inverse, since the default state is enabled. + #if OBSERVE_HEALTH_KIT_DOSE_SAMPLES_FROM_OTHER_APPS_DISABLED + self.observeHealthKitDoseSamplesFromOtherApps = false + #endif + + // Swift compiler config is inverse, since the default state is enabled. + #if OBSERVE_HEALTH_KIT_GLUCOSE_SAMPLES_FROM_OTHER_APPS_DISABLED + self.observeHealthKitGlucoseSamplesFromOtherApps = false + #endif + + #if PREDICTED_GLUCOSE_CHART_CLAMP_ENABLED + self.predictedGlucoseChartClampEnabled = true + #else + self.predictedGlucoseChartClampEnabled = false + #endif + + // Swift compiler config is inverse, since the default state is enabled. + #if REMOTE_COMMANDS_DISABLED || REMOTE_OVERRIDES_DISABLED //REMOTE_OVERRIDES_DISABLED: backwards compatibility of Loop 3 & prior + self.remoteCommandsEnabled = false + #else + self.remoteCommandsEnabled = true + #endif + + #if SCENARIOS_ENABLED + self.scenariosEnabled = true + #else + self.scenariosEnabled = false + #endif + + // Swift compiler config is inverse, since the default state is enabled. + #if SHOW_EVENTUAL_BLOOD_GLUCOSE_ON_WATCH_DISABLED + self.showEventualBloodGlucoseOnWatchEnabled = false + #else + self.showEventualBloodGlucoseOnWatchEnabled = true + #endif + + #if SIMULATED_CORE_DATA_ENABLED + self.simulatedCoreDataEnabled = true + #else + self.simulatedCoreDataEnabled = false + #endif + + // Swift compiler config is inverse, since the default state is enabled. + #if SIRI_DISABLED + self.siriEnabled = false + #else + self.siriEnabled = true + #endif + + #if SIMPLE_BOLUS_CALCULATOR_ENABLED + self.simpleBolusCalculatorEnabled = true + #else + self.simpleBolusCalculatorEnabled = false + #endif + + // Swift compiler config is inverse, since the default state is enabled. + #if DISABLE_POSITIVE_MOMENTUM_AND_RC_FOR_MANUAL_BOLUSES + self.usePositiveMomentumAndRCForManualBoluses = false + #else + self.usePositiveMomentumAndRCForManualBoluses = true + #endif + + #if ADULT_CHILD_INSULIN_MODEL_SELECTION_ENABLED + self.adultChildInsulinModelSelectionEnabled = true + #else + self.adultChildInsulinModelSelectionEnabled = false + #endif + + // ProfileExpirationSettingsView is inverse, since the default state is enabled. + #if PROFILE_EXPIRATION_SETTINGS_VIEW_DISABLED + self.profileExpirationSettingsViewEnabled = false + #else + self.profileExpirationSettingsViewEnabled = true + #endif + + // Missed meal notifications compiler flag is inverse, since the default state is enabled. + #if MISSED_MEAL_NOTIFICATIONS_DISABLED + self.missedMealNotifications = false + #else + self.missedMealNotifications = true + #endif + + #if ALLOW_ALGORITHM_EXPERIMENTS + self.allowAlgorithmExperiments = true + #else + self.allowAlgorithmExperiments = false + #endif + } +} + + +extension FeatureFlagConfiguration : CustomDebugStringConvertible { + var debugDescription: String { + return [ + "* cgmManagerCategorizeManualGlucoseRangeEnabled: \(cgmManagerCategorizeManualGlucoseRangeEnabled)", + "* criticalAlertsEnabled: \(criticalAlertsEnabled)", + "* entryDeletionEnabled: \(entryDeletionEnabled)", + "* fiaspInsulinModelEnabled: \(fiaspInsulinModelEnabled)", + "* lyumjevInsulinModelEnabled: \(lyumjevInsulinModelEnabled)", + "* afrezzaInsulinModelEnabled: \(afrezzaInsulinModelEnabled)", + "* includeServicesInSettingsEnabled: \(includeServicesInSettingsEnabled)", + "* mockTherapySettingsEnabled: \(mockTherapySettingsEnabled)", + "* nonlinearCarbModelEnabled: \(nonlinearCarbModelEnabled)", + "* observeHealthKitCarbSamplesFromOtherApps: \(observeHealthKitCarbSamplesFromOtherApps)", + "* observeHealthKitDoseSamplesFromOtherApps: \(observeHealthKitDoseSamplesFromOtherApps)", + "* observeHealthKitGlucoseSamplesFromOtherApps: \(observeHealthKitGlucoseSamplesFromOtherApps)", + "* predictedGlucoseChartClampEnabled: \(predictedGlucoseChartClampEnabled)", + "* remoteCommandsEnabled: \(remoteCommandsEnabled)", + "* scenariosEnabled: \(scenariosEnabled)", + "* sensitivityOverridesEnabled: \(sensitivityOverridesEnabled)", + "* showEventualBloodGlucoseOnWatchEnabled: \(showEventualBloodGlucoseOnWatchEnabled)", + "* simulatedCoreDataEnabled: \(simulatedCoreDataEnabled)", + "* siriEnabled: \(siriEnabled)", + "* automaticBolusEnabled: \(automaticBolusEnabled)", + "* manualDoseEntryEnabled: \(manualDoseEntryEnabled)", + "* allowDebugFeatures: \(allowDebugFeatures)", + "* simpleBolusCalculatorEnabled: \(simpleBolusCalculatorEnabled)", + "* usePositiveMomentumAndRCForManualBoluses: \(usePositiveMomentumAndRCForManualBoluses)", + "* adultChildInsulinModelSelectionEnabled: \(adultChildInsulinModelSelectionEnabled)", + "* profileExpirationSettingsViewEnabled: \(profileExpirationSettingsViewEnabled)", + "* missedMealNotifications: \(missedMealNotifications)", + "* allowAlgorithmExperiments: \(allowAlgorithmExperiments)", + "* allowExperimentalFeatures: \(allowExperimentalFeatures)" + ].joined(separator: "\n") + } +} + +extension FeatureFlagConfiguration { + var allowDebugFeatures: Bool { + #if DEBUG_FEATURES_ENABLED + return true + #elseif DEBUG_FEATURES_ENABLED_CONDITIONALLY + if debugEnabled { + return true + } else { + if UserDefaults.appGroup?.allowDebugFeatures ?? false { + return true + } else { + return false + } + } + #else + return false + #endif + } + + var allowExperimentalFeatures: Bool { + #if EXPERIMENTAL_FEATURES_ENABLED + return true + #elseif EXPERIMENTAL_FEATURES_ENABLED_CONDITIONALLY + if debugEnabled { + return true + } else { + return UserDefaults.appGroup?.allowExperimentalFeatures ?? false + } + #else + return false + #endif + } + + var allowSimulators: Bool { + #if SIMULATORS_ENABLED + return true + #elseif SIMULATORS_ENABLED_CONDITIONALLY + if debugEnabled { + return true + } else { + if UserDefaults.appGroup?.allowSimulators ?? false { + return true + } else { + return false + } + } + #else + return false + #endif + } +} diff --git a/Common/Models/BolusSuggestionUserInfo.swift b/Common/Models/BolusSuggestionUserInfo.swift deleted file mode 100644 index 0bc8119f1b..0000000000 --- a/Common/Models/BolusSuggestionUserInfo.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// BolusSuggestionUserInfo.swift -// Naterade -// -// Created by Nathan Racklyeft on 3/20/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import Foundation - - -final class BolusSuggestionUserInfo: RawRepresentable { - let recommendedBolus: Double - let maxBolus: Double? - - init(recommendedBolus: Double, maxBolus: Double? = nil) { - self.recommendedBolus = recommendedBolus - self.maxBolus = maxBolus - } - - // MARK: - RawRepresentable - typealias RawValue = [String: Any] - - static let version = 1 - static let name = "BolusSuggestionUserInfo" - - required init?(rawValue: RawValue) { - guard rawValue["v"] as? Int == type(of: self).version && rawValue["name"] as? String == BolusSuggestionUserInfo.name, - let recommendedBolus = rawValue["br"] as? Double else - { - return nil - } - - self.recommendedBolus = recommendedBolus - self.maxBolus = rawValue["mb"] as? Double - } - - var rawValue: RawValue { - var raw: RawValue = [ - "v": type(of: self).version, - "name": BolusSuggestionUserInfo.name, - "br": recommendedBolus - ] - - if let maxBolus = maxBolus { - raw["mb"] = maxBolus - } - - return raw - } -} diff --git a/Common/Models/BuildDetails.swift b/Common/Models/BuildDetails.swift new file mode 100644 index 0000000000..4a1a1894fc --- /dev/null +++ b/Common/Models/BuildDetails.swift @@ -0,0 +1,69 @@ +// +// BuildDetails.swift +// Loop +// +// Created by Pete Schwamb on 6/13/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation + +class BuildDetails { + + static var `default` = BuildDetails() + + let dict: [String: Any] + + init() { + guard let url = Bundle.main.url(forResource: "BuildDetails", withExtension: ".plist"), + let data = try? Data(contentsOf: url), + let parsed = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] else + { + dict = [:] + return + } + dict = parsed + } + + var buildDateString: String? { + return dict["com-loopkit-Loop-build-date"] as? String + } + + var xcodeVersion: String? { + return dict["com-loopkit-Loop-xcode-version"] as? String + } + + var gitRevision: String? { + return dict["com-loopkit-Loop-git-revision"] as? String + } + + var gitBranch: String? { + return dict["com-loopkit-Loop-git-branch"] as? String + } + + var sourceRoot: String? { + return dict["com-loopkit-Loop-srcroot"] as? String + } + + var profileExpiration: Date? { + return dict["com-loopkit-Loop-profile-expiration"] as? Date + } + + var profileExpirationString: String { + if let profileExpiration = profileExpiration { + return "\(profileExpiration)" + } else { + return "N/A" + } + } + + // These strings are only configured if it is a workspace build + var workspaceGitRevision: String? { + return dict["com-loopkit-LoopWorkspace-git-revision"] as? String + } + + var workspaceGitBranch: String? { + return dict["com-loopkit-LoopWorkspace-git-branch"] as? String + } +} + diff --git a/Common/Models/CarbAbsorptionTime.swift b/Common/Models/CarbAbsorptionTime.swift new file mode 100644 index 0000000000..b26eb3dc8e --- /dev/null +++ b/Common/Models/CarbAbsorptionTime.swift @@ -0,0 +1,24 @@ +// +// CarbAbsorptionTime.swift +// WatchApp Extension +// +// Created by Michael Pangburn on 4/1/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +enum CarbAbsorptionTime: Int, CaseIterable { + case fast = 0 + case medium + case slow + + var emoji: String { + switch self { + case .fast: + return "🍭" + case .medium: + return "🌮" + case .slow: + return "🍕" + } + } +} diff --git a/Common/Models/CarbBackfillRequestUserInfo.swift b/Common/Models/CarbBackfillRequestUserInfo.swift new file mode 100644 index 0000000000..83fce9c335 --- /dev/null +++ b/Common/Models/CarbBackfillRequestUserInfo.swift @@ -0,0 +1,40 @@ +// +// CarbBackfillRequestUserInfo.swift +// Loop +// +// Created by Darin Krauss on 8/14/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import Foundation + +struct CarbBackfillRequestUserInfo { + let version = 1 + let startDate: Date +} + +extension CarbBackfillRequestUserInfo: RawRepresentable { + typealias RawValue = [String: Any] + + static let name = "CarbBackfillRequestUserInfo" + + init?(rawValue: RawValue) { + guard + rawValue["v"] as? Int == version, + rawValue["name"] as? String == CarbBackfillRequestUserInfo.name, + let startDate = rawValue["sd"] as? Date + else { + return nil + } + + self.startDate = startDate + } + + var rawValue: RawValue { + return [ + "v": version, + "name": CarbBackfillRequestUserInfo.name, + "sd": startDate + ] + } +} diff --git a/Common/Models/CarbEntryUserInfo.swift b/Common/Models/CarbEntryUserInfo.swift deleted file mode 100644 index 4b1a6285a4..0000000000 --- a/Common/Models/CarbEntryUserInfo.swift +++ /dev/null @@ -1,90 +0,0 @@ -// -// CarbEntryUserInfo.swift -// Naterade -// -// Created by Nathan Racklyeft on 1/23/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import Foundation - -enum AbsorptionTimeType { - case fast - case medium - case slow -} - - -struct CarbEntryUserInfo { - let value: Double - let absorptionTimeType: AbsorptionTimeType - let startDate: Date - - init(value: Double, absorptionTimeType: AbsorptionTimeType, startDate: Date) { - self.value = value - self.absorptionTimeType = absorptionTimeType - self.startDate = startDate - } -} - - -extension AbsorptionTimeType: RawRepresentable { - typealias RawValue = Int - - init?(rawValue: RawValue) { - switch rawValue { - case 0: - self = .fast - case 1: - self = .medium - case 2: - self = .slow - default: - return nil - } - } - - var rawValue: RawValue { - switch self { - case .fast: - return 0 - case .medium: - return 1 - case .slow: - return 2 - } - } -} - - -extension CarbEntryUserInfo: RawRepresentable { - typealias RawValue = [String: Any] - - static let version = 1 - static let name = "CarbEntryUserInfo" - - init?(rawValue: RawValue) { - guard rawValue["v"] as? Int == type(of: self).version && rawValue["name"] as? String == CarbEntryUserInfo.name, - let value = rawValue["cv"] as? Double, - let absorptionTimeRaw = rawValue["ca"] as? Int, - let absorptionTime = AbsorptionTimeType(rawValue: absorptionTimeRaw), - let startDate = rawValue["sd"] as? Date else - { - return nil - } - - self.value = value - self.startDate = startDate - self.absorptionTimeType = absorptionTime - } - - var rawValue: RawValue { - return [ - "v": type(of: self).version, - "name": CarbEntryUserInfo.name, - "cv": value, - "ca": absorptionTimeType.rawValue, - "sd": startDate - ] - } -} diff --git a/Common/Models/GlucoseBackfillRequestUserInfo.swift b/Common/Models/GlucoseBackfillRequestUserInfo.swift new file mode 100644 index 0000000000..899a93434b --- /dev/null +++ b/Common/Models/GlucoseBackfillRequestUserInfo.swift @@ -0,0 +1,40 @@ +// +// GlucoseBackfillRequestUserInfo.swift +// Loop +// +// Created by Bharat Mediratta on 6/21/18. +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +import Foundation + +struct GlucoseBackfillRequestUserInfo { + let version = 1 + let startDate: Date +} + +extension GlucoseBackfillRequestUserInfo: RawRepresentable { + typealias RawValue = [String: Any] + + static let name = "GlucoseBackfillRequestUserInfo" + + init?(rawValue: RawValue) { + guard + rawValue["v"] as? Int == version, + rawValue["name"] as? String == GlucoseBackfillRequestUserInfo.name, + let startDate = rawValue["sd"] as? Date + else { + return nil + } + + self.startDate = startDate + } + + var rawValue: RawValue { + return [ + "v": version, + "name": GlucoseBackfillRequestUserInfo.name, + "sd": startDate + ] + } +} diff --git a/Common/Models/GlucoseTrend.swift b/Common/Models/GlucoseTrend.swift deleted file mode 100644 index 99b2999708..0000000000 --- a/Common/Models/GlucoseTrend.swift +++ /dev/null @@ -1,58 +0,0 @@ -// -// GlucoseTrend.swift -// Loop -// -// Created by Nate Racklyeft on 8/2/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import Foundation - - -public enum GlucoseTrend: Int { - case upUpUp = 1 - case upUp = 2 - case up = 3 - case flat = 4 - case down = 5 - case downDown = 6 - case downDownDown = 7 - - var symbol: String { - switch self { - case .upUpUp: - return "⇈" - case .upUp: - return "↑" - case .up: - return "↗︎" - case .flat: - return "→" - case .down: - return "↘︎" - case .downDown: - return "↓" - case .downDownDown: - return "⇊" - } - } - - var localizedDescription: String { - switch self { - case .upUpUp: - return NSLocalizedString("Rising very fast", comment: "Glucose trend up-up-up") - case .upUp: - return NSLocalizedString("Rising fast", comment: "Glucose trend up-up") - case .up: - return NSLocalizedString("Rising", comment: "Glucose trend up") - case .flat: - return NSLocalizedString("Flat", comment: "Glucose trend flat") - case .down: - return NSLocalizedString("Falling", comment: "Glucose trend down") - case .downDown: - return NSLocalizedString("Falling fast", comment: "Glucose trend down-down") - case .downDownDown: - return NSLocalizedString("Falling very fast", comment: "Glucose trend down-down-down") - } - } -} diff --git a/Common/Models/IntentExtensionInfo.swift b/Common/Models/IntentExtensionInfo.swift new file mode 100644 index 0000000000..653fbe9afc --- /dev/null +++ b/Common/Models/IntentExtensionInfo.swift @@ -0,0 +1,33 @@ +// +// IntentExtensionInfo.swift +// Loop Intent Extension +// +// Created by Anna Quinlan on 10/17/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import Foundation + +struct IntentExtensionInfo: RawRepresentable { + typealias RawValue = [String: Any] + + var overridePresetNames: [String]? + + init() { } + + init(rawValue: RawValue) { + overridePresetNames = rawValue["overridePresetNames"] as? [String] + } + + init(overridePresetNames: [String]?) { + self.overridePresetNames = overridePresetNames + } + + var rawValue: RawValue { + var raw: RawValue = [:] + + raw["overridePresetNames"] = overridePresetNames + + return raw + } +} diff --git a/Common/Models/LoopSettingsUserInfo.swift b/Common/Models/LoopSettingsUserInfo.swift new file mode 100644 index 0000000000..a6123825d8 --- /dev/null +++ b/Common/Models/LoopSettingsUserInfo.swift @@ -0,0 +1,41 @@ +// +// LoopSettingsUserInfo.swift +// Loop +// +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +import LoopCore + + +struct LoopSettingsUserInfo { + let settings: LoopSettings +} + + +extension LoopSettingsUserInfo: RawRepresentable { + typealias RawValue = [String: Any] + + static let name = "LoopSettingsUserInfo" + static let version = 1 + + init?(rawValue: RawValue) { + guard rawValue["v"] as? Int == LoopSettingsUserInfo.version, + rawValue["name"] as? String == LoopSettingsUserInfo.name, + let settingsRaw = rawValue["s"] as? LoopSettings.RawValue, + let settings = LoopSettings(rawValue: settingsRaw) + else { + return nil + } + + self.settings = settings + } + + var rawValue: RawValue { + return [ + "v": LoopSettingsUserInfo.version, + "name": LoopSettingsUserInfo.name, + "s": settings.rawValue + ] + } +} diff --git a/Common/Models/PumpManager.swift b/Common/Models/PumpManager.swift new file mode 100644 index 0000000000..5ec574366c --- /dev/null +++ b/Common/Models/PumpManager.swift @@ -0,0 +1,38 @@ +// +// PumpManager.swift +// Loop +// +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit +import LoopKitUI +import MockKit +import MockKitUI + +let staticPumpManagersByIdentifier: [String: PumpManagerUI.Type] = [ + MockPumpManager.pluginIdentifier : MockPumpManager.self +] + +var availableStaticPumpManagers: [PumpManagerDescriptor] { + if FeatureFlags.allowSimulators { + return [ + PumpManagerDescriptor(identifier: MockPumpManager.pluginIdentifier, localizedTitle: MockPumpManager.localizedTitle) + ] + } else { + return [] + } +} + +extension PumpManager { + + typealias RawValue = [String: Any] + + var rawValue: RawValue { + return [ + "managerIdentifier": self.pluginIdentifier, + "state": self.rawState + ] + } +} diff --git a/Common/Models/PumpManagerUI.swift b/Common/Models/PumpManagerUI.swift new file mode 100644 index 0000000000..e9250d4939 --- /dev/null +++ b/Common/Models/PumpManagerUI.swift @@ -0,0 +1,32 @@ +// +// PumpManagerUI.swift +// Loop +// +// Created by Pete Schwamb on 10/18/18. +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit +import LoopKitUI + +typealias PumpManagerHUDViewRawValue = [String: Any] + +func PumpManagerHUDViewFromRawValue(_ rawValue: PumpManagerHUDViewRawValue, pluginManager: PluginManager) -> BaseHUDView? { + guard + let identifier = rawValue["managerIdentifier"] as? String, + let rawState = rawValue["hudProviderView"] as? HUDProvider.HUDViewRawState, + let manager = pluginManager.getPumpManagerTypeByIdentifier(identifier) ?? staticPumpManagersByIdentifier[identifier] else + { + return nil + } + + return manager.createHUDView(rawValue: rawState) +} + +func PumpManagerHUDViewRawValueFromHUDProvider(_ hudProvider: HUDProvider) -> PumpManagerHUDViewRawValue { + return [ + "managerIdentifier": hudProvider.managerIdentifier, + "hudProviderView": hudProvider.hudViewRawState + ] +} diff --git a/Common/Models/SetBolusUserInfo.swift b/Common/Models/SetBolusUserInfo.swift index 25525b1ac2..a12f7c2ef2 100644 --- a/Common/Models/SetBolusUserInfo.swift +++ b/Common/Models/SetBolusUserInfo.swift @@ -7,16 +7,15 @@ // import Foundation +import LoopKit struct SetBolusUserInfo { let value: Double let startDate: Date - - init(value: Double, startDate: Date) { - self.value = value - self.startDate = startDate - } + let contextDate: Date? + let carbEntry: NewCarbEntry? + let activationType: BolusActivationType } @@ -28,23 +27,34 @@ extension SetBolusUserInfo: RawRepresentable { init?(rawValue: RawValue) { guard rawValue["v"] as? Int == type(of: self).version && - rawValue["name"] as? String == SetBolusUserInfo.name, - let value = rawValue["bv"] as? Double, - let startDate = rawValue["sd"] as? Date else - { + rawValue["name"] as? String == SetBolusUserInfo.name, + let value = rawValue["bv"] as? Double, + let startDate = rawValue["sd"] as? Date, + let rawActivationType = rawValue["at"] as? BolusActivationType.RawValue, + let activationType = BolusActivationType(rawValue: rawActivationType) + else { return nil } self.value = value self.startDate = startDate + self.contextDate = rawValue["cd"] as? Date + self.carbEntry = (rawValue["ce"] as? NewCarbEntry.RawValue).flatMap(NewCarbEntry.init(rawValue:)) + self.activationType = activationType } var rawValue: RawValue { - return [ + var raw: RawValue = [ "v": type(of: self).version, "name": SetBolusUserInfo.name, "bv": value, "sd": startDate ] + + raw["cd"] = contextDate + raw["ce"] = carbEntry?.rawValue + raw["at"] = activationType.rawValue + + return raw } } diff --git a/Common/Models/StatusExtensionContext.swift b/Common/Models/StatusExtensionContext.swift index be37aa25d4..bee1f32894 100644 --- a/Common/Models/StatusExtensionContext.swift +++ b/Common/Models/StatusExtensionContext.swift @@ -9,152 +9,380 @@ import Foundation import HealthKit -import LoopUI +import LoopKit +import LoopKitUI -struct ReservoirContext { - let startDate: Date - let unitVolume: Double - let capacity: Int -} - -struct LoopContext { - let dosingEnabled: Bool - let lastCompleted: Date? -} struct NetBasalContext { let rate: Double let percentage: Double - let startDate: Date + let start: Date + let end: Date? } -struct SensorDisplayableContext: SensorDisplayable { +struct GlucoseDisplayableContext: GlucoseDisplayable { let isStateValid: Bool let stateDescription: String let trendType: GlucoseTrend? + let trendRate: HKQuantity? let isLocal: Bool + let glucoseRangeCategory: GlucoseRangeCategory? } -struct GlucoseContext { - let quantity: Double +struct GlucoseContext: GlucoseValue { + let value: Double + let unit: HKUnit let startDate: Date - let sensor: SensorDisplayable? + + var quantity: HKQuantity { + return HKQuantity(unit: unit, doubleValue: value) + } } -final class StatusExtensionContext: RawRepresentable { - typealias RawValue = [String: Any] - private let version = 1 - - var preferredUnitString: String? - var latestGlucose: GlucoseContext? - var reservoir: ReservoirContext? - var loop: LoopContext? - var netBasal: NetBasalContext? - var batteryPercentage: Double? - var eventualGlucose: Double? +struct PredictedGlucoseContext { + let values: [Double] + let unit: HKUnit + let startDate: Date + let interval: TimeInterval + + var samples: [GlucoseContext] { + var result: [GlucoseContext] = [] + for (i, v) in values.enumerated() { + result.append(GlucoseContext(value: v, unit: unit, startDate: startDate.addingTimeInterval(Double(i) * interval))) + } + return result + } +} + +struct DeviceStatusHighlightContext: DeviceStatusHighlight { + var localizedMessage: String + var imageName: String + var state: DeviceStatusHighlightState - init() { } + init(localizedMessage: String, + imageName: String, + state: DeviceStatusHighlightState) + { + self.localizedMessage = localizedMessage + self.imageName = imageName + self.state = state + } - required init?(rawValue: RawValue) { - let raw = rawValue - - if let preferredString = raw["preferredUnitString"] as? String, - let latestValue = raw["latestGlucose_value"] as? Double, - let startDate = raw["latestGlucose_startDate"] as? Date { - - var sensor: SensorDisplayableContext? = nil - if let state = raw["latestGlucose_sensor_isStateValid"] as? Bool, - let desc = raw["latestGlucose_sensor_stateDescription"] as? String, - let local = raw["latestGlucose_sensor_isLocal"] as? Bool { - - var glucoseTrend: GlucoseTrend? - if let trendType = raw["latestGlucose_sensor_trendType"] as? Int { - glucoseTrend = GlucoseTrend(rawValue: trendType) - } - - sensor = SensorDisplayableContext( - isStateValid: state, - stateDescription: desc, - trendType: glucoseTrend, - isLocal: local) - } - - preferredUnitString = preferredString - latestGlucose = GlucoseContext( - quantity: latestValue, - startDate: startDate, - sensor: sensor) + init?(from deviceStatusHighlight: DeviceStatusHighlight?) { + guard let deviceStatusHighlight = deviceStatusHighlight else { + return nil } - batteryPercentage = raw["batteryPercentage"] as? Double + self.init(localizedMessage: deviceStatusHighlight.localizedMessage, + imageName: deviceStatusHighlight.imageName, + state: deviceStatusHighlight.state) + } +} + +struct DeviceLifecycleProgressContext: DeviceLifecycleProgress { + var percentComplete: Double + var progressState: DeviceLifecycleProgressState + + init(percentComplete: Double, + progressState: DeviceLifecycleProgressState) + { + self.percentComplete = percentComplete + self.progressState = progressState + } + + init?(from deviceLifecycleProgress: DeviceLifecycleProgress?) { + guard let deviceLifecycleProgress = deviceLifecycleProgress else { + return nil + } - if let startDate = raw["reservoir_startDate"] as? Date, - let unitVolume = raw["reservoir_unitVolume"] as? Double, - let capacity = raw["reservoir_capacity"] as? Int { - reservoir = ReservoirContext(startDate: startDate, unitVolume: unitVolume, capacity: capacity) + self.init(percentComplete: deviceLifecycleProgress.percentComplete, + progressState: deviceLifecycleProgress.progressState) + } +} + +extension NetBasalContext: RawRepresentable { + typealias RawValue = [String: Any] + + init?(rawValue: RawValue) { + guard + let rate = rawValue["rate"] as? Double, + let percentage = rawValue["percentage"] as? Double, + let start = rawValue["start"] as? Date + else { + return nil } - if let dosingEnabled = raw["loop_dosingEnabled"] as? Bool, - let lastCompleted = raw["loop_lastCompleted"] as? Date { - loop = LoopContext(dosingEnabled: dosingEnabled, lastCompleted: lastCompleted) + self.rate = rate + self.percentage = percentage + self.start = start + self.end = rawValue["end"] as? Date + } + + var rawValue: RawValue { + var value: RawValue = [ + "rate": rate, + "percentage": percentage, + "start": start + ] + value["end"] = end + return value + } +} + +extension GlucoseDisplayableContext: RawRepresentable { + typealias RawValue = [String: Any] + + init(_ other: GlucoseDisplayable) { + isStateValid = other.isStateValid + stateDescription = other.stateDescription + isLocal = other.isLocal + trendType = other.trendType + trendRate = other.trendRate + glucoseRangeCategory = other.glucoseRangeCategory + } + + init?(rawValue: RawValue) { + guard + let isStateValid = rawValue["isStateValid"] as? Bool, + let stateDescription = rawValue["stateDescription"] as? String, + let isLocal = rawValue["isLocal"] as? Bool + else { + return nil } - - if let rate = raw["netBasal_rate"] as? Double, - let percentage = raw["netBasal_percentage"] as? Double, - let startDate = raw["netBasal_startDate"] as? Date { - netBasal = NetBasalContext(rate: rate, percentage: percentage, startDate: startDate) + + self.isStateValid = isStateValid + self.stateDescription = stateDescription + self.isLocal = isLocal + + if let rawValue = rawValue["trendType"] as? GlucoseTrend.RawValue { + trendType = GlucoseTrend(rawValue: rawValue) + } else { + trendType = nil } - eventualGlucose = raw["eventualGlucose"] as? Double + if let trendRateValue = rawValue["trendRateValue"] as? Double { + trendRate = HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: trendRateValue) + } else { + trendRate = nil + } + + if let glucoseRangeCategoryRawValue = rawValue["glucoseRangeCategory"] as? GlucoseRangeCategory.RawValue { + glucoseRangeCategory = GlucoseRangeCategory(rawValue: glucoseRangeCategoryRawValue) + } else { + glucoseRangeCategory = nil + } } var rawValue: RawValue { var raw: RawValue = [ - "version": version + "isStateValid": isStateValid, + "stateDescription": stateDescription, + "isLocal": isLocal ] + raw["trendType"] = trendType?.rawValue + if let trendRate = trendRate { + raw["trendRateValue"] = trendRate.doubleValue(for: HKUnit.milligramsPerDeciliterPerMinute) + } + raw["glucoseRangeCategory"] = glucoseRangeCategory?.rawValue - raw["preferredUnitString"] = preferredUnitString - - if preferredUnitString != nil, - let glucose = latestGlucose { - raw["latestGlucose_value"] = glucose.quantity - raw["latestGlucose_startDate"] = glucose.startDate + return raw + } +} + +extension PredictedGlucoseContext: RawRepresentable { + typealias RawValue = [String: Any] + + init?(rawValue: RawValue) { + guard + let values = rawValue["values"] as? [Double], + let unitString = rawValue["unit"] as? String, + let startDate = rawValue["startDate"] as? Date, + let interval = rawValue["interval"] as? TimeInterval + else { + return nil + } + + self.values = values + self.unit = HKUnit(from: unitString) + self.startDate = startDate + self.interval = interval + } + + var rawValue: RawValue { + return [ + "values": values, + "unit": unit.unitString, + "startDate": startDate, + "interval": interval + ] + } +} + +extension DeviceStatusHighlightContext: RawRepresentable { + typealias RawValue = [String: Any] + + init?(rawValue: RawValue) { + guard let localizedMessage = rawValue["localizedMessage"] as? String, + let imageName = rawValue["imageName"] as? String, + let rawState = rawValue["state"] as? DeviceStatusHighlightState.RawValue, + let state = DeviceStatusHighlightState(rawValue: rawState) else + { + return nil + } + + self.localizedMessage = localizedMessage + self.imageName = imageName + self.state = state + } + + var rawValue: RawValue { + return [ + "localizedMessage": localizedMessage, + "imageName": imageName, + "state": state.rawValue, + ] + } +} + +extension DeviceLifecycleProgressContext: RawRepresentable { + typealias RawValue = [String: Any] + + init?(rawValue: RawValue) { + guard let percentComplete = rawValue["percentComplete"] as? Double, + let rawProgressState = rawValue["progressState"] as? DeviceLifecycleProgressState.RawValue, + let progressState = DeviceLifecycleProgressState(rawValue: rawProgressState) else + { + return nil + } + + self.percentComplete = percentComplete + self.progressState = progressState + } + + var rawValue: RawValue { + return [ + "percentComplete": percentComplete, + "progressState": progressState.rawValue, + ] + } +} + +struct PumpManagerHUDViewContext: RawRepresentable { + typealias RawValue = [String: Any] + + let pumpManagerHUDViewRawValue: PumpManagerHUDViewRawValue + + init(pumpManagerHUDViewRawValue: PumpManagerHUDViewRawValue) { + self.pumpManagerHUDViewRawValue = pumpManagerHUDViewRawValue + } + + init?(rawValue: RawValue) { + if let pumpManagerHUDViewRawValue = rawValue["pumpManagerHUDViewRawValue"] as? PumpManagerHUDViewRawValue { + self.pumpManagerHUDViewRawValue = pumpManagerHUDViewRawValue + } else { + return nil + } + } + + var rawValue: RawValue { + return ["pumpManagerHUDViewRawValue": pumpManagerHUDViewRawValue] + } +} + +struct StatusExtensionContext: RawRepresentable { + typealias RawValue = [String: Any] + private let version = 5 + + var predictedGlucose: PredictedGlucoseContext? + var lastLoopCompleted: Date? + var createdAt: Date? + var isClosedLoop: Bool? + var preMealPresetAllowed: Bool? + var preMealPresetActive: Bool? + var customPresetActive: Bool? + var netBasal: NetBasalContext? + var batteryPercentage: Double? + var reservoirCapacity: Double? + var glucoseDisplay: GlucoseDisplayableContext? + var pumpManagerHUDViewContext: PumpManagerHUDViewContext? + var pumpStatusHighlightContext: DeviceStatusHighlightContext? + var pumpLifecycleProgressContext: DeviceLifecycleProgressContext? + var cgmStatusHighlightContext: DeviceStatusHighlightContext? + var cgmLifecycleProgressContext: DeviceLifecycleProgressContext? + var carbsOnBoard: Double? + + init() { } + + init?(rawValue: RawValue) { + guard let version = rawValue["version"] as? Int, version == self.version else { + return nil + } + + if let rawValue = rawValue["predictedGlucose"] as? PredictedGlucoseContext.RawValue { + predictedGlucose = PredictedGlucoseContext(rawValue: rawValue) } - if let sensor = latestGlucose?.sensor { - raw["latestGlucose_sensor_isStateValid"] = sensor.isStateValid - raw["latestGlucose_sensor_stateDescription"] = sensor.stateDescription - raw["latestGlucose_sensor_isLocal"] = sensor.isLocal - - if let trendType = sensor.trendType { - raw["latestGlucose_sensor_trendType"] = trendType.rawValue - } + if let rawValue = rawValue["netBasal"] as? NetBasalContext.RawValue { + netBasal = NetBasalContext(rawValue: rawValue) } - if let batteryPercentage = batteryPercentage { - raw["batteryPercentage"] = batteryPercentage + lastLoopCompleted = rawValue["lastLoopCompleted"] as? Date + createdAt = rawValue["createdAt"] as? Date + isClosedLoop = rawValue["isClosedLoop"] as? Bool + preMealPresetAllowed = rawValue["preMealPresetAllowed"] as? Bool + preMealPresetActive = rawValue["preMealPresetActive"] as? Bool + customPresetActive = rawValue["customPresetActive"] as? Bool + batteryPercentage = rawValue["batteryPercentage"] as? Double + reservoirCapacity = rawValue["reservoirCapacity"] as? Double + carbsOnBoard = rawValue["carbsOnBoard"] as? Double + + if let rawValue = rawValue["glucoseDisplay"] as? GlucoseDisplayableContext.RawValue { + glucoseDisplay = GlucoseDisplayableContext(rawValue: rawValue) + } + + if let rawPumpManagerHUDViewContext = rawValue["pumpManagerHUDViewContext"] as? PumpManagerHUDViewContext.RawValue { + pumpManagerHUDViewContext = PumpManagerHUDViewContext(rawValue: rawPumpManagerHUDViewContext) } - if let reservoir = reservoir { - raw["reservoir_startDate"] = reservoir.startDate - raw["reservoir_unitVolume"] = reservoir.unitVolume - raw["reservoir_capacity"] = reservoir.capacity + if let rawPumpStatusHighlightContext = rawValue["pumpStatusHighlightContext"] as? DeviceStatusHighlightContext.RawValue { + pumpStatusHighlightContext = DeviceStatusHighlightContext(rawValue: rawPumpStatusHighlightContext) } - if let loop = loop { - raw["loop_dosingEnabled"] = loop.dosingEnabled - raw["loop_lastCompleted"] = loop.lastCompleted + if let rawPumpLifecycleProgressContext = rawValue["pumpLifecycleProgressContext"] as? DeviceLifecycleProgressContext.RawValue { + pumpLifecycleProgressContext = DeviceLifecycleProgressContext(rawValue: rawPumpLifecycleProgressContext) } - if let netBasal = netBasal { - raw["netBasal_rate"] = netBasal.rate - raw["netBasal_percentage"] = netBasal.percentage - raw["netBasal_startDate"] = netBasal.startDate + if let rawCGMStatusHighlightContext = rawValue["cgmStatusHighlightContext"] as? DeviceStatusHighlightContext.RawValue { + cgmStatusHighlightContext = DeviceStatusHighlightContext(rawValue: rawCGMStatusHighlightContext) } - if let eventualGlucose = eventualGlucose { - raw["eventualGlucose"] = eventualGlucose + if let rawCGMLifecycleProgressContext = rawValue["cgmLifecycleProgressContext"] as? DeviceLifecycleProgressContext.RawValue { + cgmLifecycleProgressContext = DeviceLifecycleProgressContext(rawValue: rawCGMLifecycleProgressContext) } + } + + var rawValue: RawValue { + var raw: RawValue = [ + "version": version + ] + + raw["predictedGlucose"] = predictedGlucose?.rawValue + raw["lastLoopCompleted"] = lastLoopCompleted + raw["createdAt"] = createdAt + raw["isClosedLoop"] = isClosedLoop + raw["preMealPresetAllowed"] = preMealPresetAllowed + raw["preMealPresetActive"] = preMealPresetActive + raw["customPresetActive"] = customPresetActive + raw["netBasal"] = netBasal?.rawValue + raw["batteryPercentage"] = batteryPercentage + raw["reservoirCapacity"] = reservoirCapacity + raw["glucoseDisplay"] = glucoseDisplay?.rawValue + raw["pumpManagerHUDViewContext"] = pumpManagerHUDViewContext?.rawValue + raw["pumpStatusHighlightContext"] = pumpStatusHighlightContext?.rawValue + raw["pumpLifecycleProgressContext"] = pumpLifecycleProgressContext?.rawValue + raw["cgmStatusHighlightContext"] = cgmStatusHighlightContext?.rawValue + raw["cgmLifecycleProgressContext"] = cgmLifecycleProgressContext?.rawValue + raw["carbsOnBoard"] = carbsOnBoard return raw } diff --git a/Common/Models/SupportedBolusVolumesUserInfo.swift b/Common/Models/SupportedBolusVolumesUserInfo.swift new file mode 100644 index 0000000000..077540e534 --- /dev/null +++ b/Common/Models/SupportedBolusVolumesUserInfo.swift @@ -0,0 +1,44 @@ +// +// SupportedBolusVolumesUserInfo.swift +// Loop +// +// Created by Michael Pangburn on 6/30/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +struct SupportedBolusVolumesUserInfo { + var supportedBolusVolumes: [Double] +} + +extension SupportedBolusVolumesUserInfo: RawRepresentable { + typealias RawValue = [String: Any] + + private enum Key: String { + case version = "v" + case name = "name" + case supportedBolusVolumes = "sbv" + } + + static let name = "SupportedBolusVolumesUserInfo" + static let version = 1 + + init?(rawValue: RawValue) { + guard + rawValue[Key.version.rawValue] as? Int == Self.version, + rawValue[Key.name.rawValue] as? String == Self.name, + let supportedBolusVolumes = rawValue[Key.supportedBolusVolumes.rawValue] as? [Double] + else { + return nil + } + + self.init(supportedBolusVolumes: supportedBolusVolumes) + } + + var rawValue: RawValue { + [ + Key.version.rawValue: Self.version, + Key.name.rawValue: Self.name, + Key.supportedBolusVolumes.rawValue: supportedBolusVolumes + ] + } +} diff --git a/Common/Models/WatchContext.swift b/Common/Models/WatchContext.swift index e5e7af322f..3ce3adebf1 100644 --- a/Common/Models/WatchContext.swift +++ b/Common/Models/WatchContext.swift @@ -8,65 +8,81 @@ import Foundation import HealthKit +import LoopKit -final class WatchContext: NSObject, RawRepresentable { + +final class WatchContext: RawRepresentable { typealias RawValue = [String: Any] - private let version = 3 + private let version = 5 + + var creationDate = Date() - var preferredGlucoseUnit: HKUnit? - var maxBolus: Double? + var displayGlucoseUnit: HKUnit? var glucose: HKQuantity? - var glucoseTrendRawValue: Int? - var eventualGlucose: HKQuantity? + var glucoseCondition: GlucoseCondition? + var glucoseTrend: GlucoseTrend? + var glucoseTrendRate: HKQuantity? var glucoseDate: Date? + var glucoseIsDisplayOnly: Bool? + var glucoseWasUserEntered: Bool? + var glucoseSyncIdentifier: String? + + var predictedGlucose: WatchPredictedGlucose? + var eventualGlucose: HKQuantity? { + return predictedGlucose?.values.last?.quantity + } var loopLastRunDate: Date? var lastNetTempBasalDose: Double? var lastNetTempBasalDate: Date? var recommendedBolusDose: Double? - var bolusSuggestion: BolusSuggestionUserInfo? { - guard let recommended = recommendedBolusDose else { return nil } + var potentialCarbEntry: NewCarbEntry? - return BolusSuggestionUserInfo(recommendedBolus: recommended, maxBolus: maxBolus) - } - - var COB: Double? - var IOB: Double? + var cob: Double? + var iob: Double? var reservoir: Double? var reservoirPercentage: Double? var batteryPercentage: Double? - override init() { - super.init() - } + var cgmManagerState: CGMManager.RawStateValue? - required init?(rawValue: RawValue) { - super.init() + var isClosedLoop: Bool? + + init() {} - guard rawValue["v"] as? Int == version else { + required init?(rawValue: RawValue) { + guard rawValue["v"] as? Int == version, let creationDate = rawValue["cd"] as? Date else { return nil } - if let unitString = rawValue["gu"] as? String { - let unit = HKUnit(from: unitString) - preferredGlucoseUnit = unit - - if let glucoseValue = rawValue["gv"] as? Double { - glucose = HKQuantity(unit: unit, doubleValue: glucoseValue) - } + self.creationDate = creationDate + isClosedLoop = rawValue["cl"] as? Bool - if let glucoseValue = rawValue["egv"] as? Double { - eventualGlucose = HKQuantity(unit: unit, doubleValue: glucoseValue) - } + if let unitString = rawValue["gu"] as? String { + displayGlucoseUnit = HKUnit(from: unitString) + } + let unit = displayGlucoseUnit ?? .milligramsPerDeciliter + if let glucoseValue = rawValue["gv"] as? Double { + glucose = HKQuantity(unit: unit, doubleValue: glucoseValue) } - glucoseTrendRawValue = rawValue["gt"] as? Int + if let rawGlucoseCondition = rawValue["gc"] as? GlucoseCondition.RawValue { + glucoseCondition = GlucoseCondition(rawValue: rawGlucoseCondition) + } + if let rawGlucoseTrend = rawValue["gt"] as? GlucoseTrend.RawValue { + glucoseTrend = GlucoseTrend(rawValue: rawGlucoseTrend) + } + if let glucoseTrendRateValue = rawValue["gtrv"] as? Double { + glucoseTrendRate = HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: glucoseTrendRateValue) + } glucoseDate = rawValue["gd"] as? Date - - IOB = rawValue["iob"] as? Double + glucoseIsDisplayOnly = rawValue["gdo"] as? Bool + glucoseWasUserEntered = rawValue["gue"] as? Bool + glucoseSyncIdentifier = rawValue["gs"] as? String + iob = rawValue["iob"] as? Double reservoir = rawValue["r"] as? Double reservoirPercentage = rawValue["rp"] as? Double batteryPercentage = rawValue["bp"] as? Double @@ -75,35 +91,84 @@ final class WatchContext: NSObject, RawRepresentable { lastNetTempBasalDose = rawValue["ba"] as? Double lastNetTempBasalDate = rawValue["bad"] as? Date recommendedBolusDose = rawValue["rbo"] as? Double - COB = rawValue["cob"] as? Double - maxBolus = rawValue["mb"] as? Double + if let rawPotentialCarbEntry = rawValue["pce"] as? NewCarbEntry.RawValue { + potentialCarbEntry = NewCarbEntry(rawValue: rawPotentialCarbEntry) + } + cob = rawValue["cob"] as? Double + + cgmManagerState = rawValue["cgmManagerState"] as? CGMManager.RawStateValue + + if let rawValue = rawValue["pg"] as? WatchPredictedGlucose.RawValue { + predictedGlucose = WatchPredictedGlucose(rawValue: rawValue) + } } var rawValue: RawValue { var raw: [String: Any] = [ - "v": version + "v": version, + "cd": creationDate ] raw["ba"] = lastNetTempBasalDose raw["bad"] = lastNetTempBasalDate raw["bp"] = batteryPercentage - raw["cob"] = COB + raw["cl"] = isClosedLoop - if let unit = preferredGlucoseUnit { - raw["egv"] = eventualGlucose?.doubleValue(for: unit) - raw["gu"] = unit.unitString - raw["gv"] = glucose?.doubleValue(for: unit) - } + raw["cgmManagerState"] = cgmManagerState + + raw["cob"] = cob - raw["gt"] = glucoseTrendRawValue + let unit = displayGlucoseUnit ?? .milligramsPerDeciliter + raw["gu"] = displayGlucoseUnit?.unitString + raw["gv"] = glucose?.doubleValue(for: unit) + + raw["gc"] = glucoseCondition?.rawValue + raw["gt"] = glucoseTrend?.rawValue + if let glucoseTrendRate = glucoseTrendRate { + let unitPerMinute = unit.unitDivided(by: .minute()) + raw["gtru"] = unitPerMinute.unitString + raw["gtrv"] = glucoseTrendRate.doubleValue(for: unitPerMinute) + } raw["gd"] = glucoseDate - raw["iob"] = IOB + raw["gdo"] = glucoseIsDisplayOnly + raw["gue"] = glucoseWasUserEntered + raw["gs"] = glucoseSyncIdentifier + raw["iob"] = iob raw["ld"] = loopLastRunDate - raw["mb"] = maxBolus raw["r"] = reservoir raw["rbo"] = recommendedBolusDose + raw["pce"] = potentialCarbEntry?.rawValue raw["rp"] = reservoirPercentage + raw["pg"] = predictedGlucose?.rawValue + return raw } } + + +extension WatchContext { + func shouldReplace(_ other: WatchContext) -> Bool { + if let date = self.glucoseDate, let otherDate = other.glucoseDate { + return date >= otherDate + } else { + return true + } + } +} + +extension WatchContext { + var newGlucoseSample: NewGlucoseSample? { + if let quantity = glucose, let date = glucoseDate, let syncIdentifier = glucoseSyncIdentifier { + return NewGlucoseSample(date: date, + quantity: quantity, + condition: glucoseCondition, + trend: glucoseTrend, + trendRate: glucoseTrendRate, + isDisplayOnly: glucoseIsDisplayOnly ?? false, + wasUserEntered: glucoseWasUserEntered ?? false, + syncIdentifier: syncIdentifier, syncVersion: 0) + } + return nil + } +} diff --git a/Common/Models/WatchContextRequestUserInfo.swift b/Common/Models/WatchContextRequestUserInfo.swift new file mode 100644 index 0000000000..6f7797ce9d --- /dev/null +++ b/Common/Models/WatchContextRequestUserInfo.swift @@ -0,0 +1,30 @@ +// +// WatchContextRequestUserInfo.swift +// Loop +// +// Created by Pete Schwamb on 2/6/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import Foundation + + +struct WatchContextRequestUserInfo { } + +extension WatchContextRequestUserInfo: RawRepresentable { + public typealias RawValue = [String: Any] + + public static let name = "WatchContextRequestUserInfo" + + public init?(rawValue: RawValue) { + guard rawValue["name"] as? String == WatchContextRequestUserInfo.name else { + return nil + } + } + + public var rawValue: RawValue { + return [ + "name": WatchContextRequestUserInfo.name, + ] + } +} diff --git a/Common/Models/WatchHistoricalCarbs.swift b/Common/Models/WatchHistoricalCarbs.swift new file mode 100644 index 0000000000..a0c22cd446 --- /dev/null +++ b/Common/Models/WatchHistoricalCarbs.swift @@ -0,0 +1,43 @@ +// +// WatchHistoricalCarbs.swift +// Loop +// +// Created by Darin Krauss on 8/14/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit + +struct WatchHistoricalCarbs { + let objects: [SyncCarbObject] +} + +extension WatchHistoricalCarbs: RawRepresentable { + typealias RawValue = [String: Any] + + init?(rawValue: RawValue) { + guard let rawObjects = rawValue["o"] as? Data, + let objects = try? Self.decoder.decode([SyncCarbObject].self, from: rawObjects) else { + return nil + } + self.objects = objects + } + + var rawValue: RawValue { + guard let rawObjects = try? Self.encoder.encode(objects) else { + return [:] + } + return [ + "o": rawObjects + ] + } + + private static var encoder: PropertyListEncoder { + let encoder = PropertyListEncoder() + encoder.outputFormat = .binary + return encoder + } + + private static var decoder: PropertyListDecoder = PropertyListDecoder() +} diff --git a/Common/Models/WatchHistoricalGlucose.swift b/Common/Models/WatchHistoricalGlucose.swift new file mode 100644 index 0000000000..13fda34816 --- /dev/null +++ b/Common/Models/WatchHistoricalGlucose.swift @@ -0,0 +1,94 @@ +// +// WatchHistoricalGlucose.swift +// Loop +// +// Created by Bharat Mediratta on 6/22/18. +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +import Foundation +import HealthKit +import LoopKit + +struct WatchHistoricalGlucose { + let samples: [StoredGlucoseSample] +} + +extension WatchHistoricalGlucose: RawRepresentable { + typealias RawValue = [String: Any] + + init?(rawValue: RawValue) { + guard let rawSamples = rawValue["samples"] as? Data, + let flattened = try? Self.decoder.decode(Flattened.self, from: rawSamples) else { + return nil + } + self.samples = flattened.samples + } + + var rawValue: RawValue { + guard let rawSamples = try? Self.encoder.encode(Flattened(samples: samples)) else { + return [:] + } + return [ + "samples": rawSamples + ] + } + + private struct Flattened: Codable { + let uuids: [UUID?] + let provenanceIdentifiers: [String] + let syncIdentifiers: [String?] + let syncVersions: [Int?] + let startDates: [Date] + let quantities: [Double] + let conditions: [GlucoseCondition?] + let trends: [GlucoseTrend?] + let trendRates: [Double?] + let isDisplayOnlys: [Bool] + let wasUserEntereds: [Bool] + let devices: [Data?] + let healthKitEligibleDates: [Date?] + + init(samples: [StoredGlucoseSample]) { + self.uuids = samples.map { $0.uuid } + self.provenanceIdentifiers = samples.map { $0.provenanceIdentifier } + self.syncIdentifiers = samples.map { $0.syncIdentifier } + self.syncVersions = samples.map { $0.syncVersion } + self.startDates = samples.map { $0.startDate } + self.quantities = samples.map { $0.quantity.doubleValue(for: .milligramsPerDeciliter) } + self.conditions = samples.map { $0.condition } + self.trends = samples.map { $0.trend } + self.trendRates = samples.map { $0.trendRate.flatMap { $0.doubleValue(for: .milligramsPerDeciliterPerMinute) } } + self.isDisplayOnlys = samples.map { $0.isDisplayOnly } + self.wasUserEntereds = samples.map { $0.wasUserEntered } + self.devices = samples.map { try? WatchHistoricalGlucose.encoder.encode($0.device) } + self.healthKitEligibleDates = samples.map { $0.healthKitEligibleDate } + } + + var samples: [StoredGlucoseSample] { + return (0.. 1 else { + return nil + } + self.values = values + } +} + + +extension WatchPredictedGlucose: RawRepresentable { + typealias RawValue = [String: Any] + + var rawValue: RawValue { + + return [ + "v": values.map { Int16($0.quantity.doubleValue(for: .milligramsPerDeciliter)) }, + "d": values[0].startDate, + "i": values[1].startDate.timeIntervalSince(values[0].startDate) + ] + } + + init?(rawValue: RawValue) { + guard + let values = rawValue["v"] as? [Int16], + let firstDate = rawValue["d"] as? Date, + let interval = rawValue["i"] as? TimeInterval + else { + return nil + } + + self.values = values.enumerated().map { tuple in + PredictedGlucoseValue(startDate: firstDate + Double(tuple.0) * interval, + quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: Double(tuple.1))) + } + } +} diff --git a/Common/ar.lproj/Intents.strings b/Common/ar.lproj/Intents.strings new file mode 100644 index 0000000000..69202aa99c --- /dev/null +++ b/Common/ar.lproj/Intents.strings @@ -0,0 +1,36 @@ +/* (No Comment) */ +"9KhaIS" = "I've set the preset"; + +/* (No Comment) */ +"80eo5o" = "Add Carb Entry"; + +/* (No Comment) */ +"b085BW" = "I wasn't able to set the preset."; + +/* (No Comment) */ +"I4OZy8" = "Enable Override Preset"; + +/* (No Comment) */ +"lYMuWV" = "Override Name"; + +/* (No Comment) */ +"nDKAmn" = "What's the name of the override you'd like to set?"; + +/* (No Comment) */ +"OcNxIj" = "Add Carb Entry"; + +/* (No Comment) */ +"oLQSsJ" = "Enable '${overrideName}' Override Preset"; + +/* (No Comment) */ +"XNNmtH" = "Enable preset in Loop"; + +/* (No Comment) */ +"yBzwCL" = "Override Selection"; + +/* (No Comment) */ +"yc02Yq" = "Add a carb entry to Loop"; + +/* (No Comment) */ +"ZZ3mtM" = "Enable an override preset in Loop"; + diff --git a/Common/ar.lproj/Localizable.strings b/Common/ar.lproj/Localizable.strings new file mode 100644 index 0000000000..e0fb9dff1b --- /dev/null +++ b/Common/ar.lproj/Localizable.strings @@ -0,0 +1,24 @@ +/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ +"%1$@ v%2$@" = "%1$@ v%2$@"; + +/* Title of the user activity for adding carbs */ +"Add Carb Entry" = "Add Carb Entry"; + +/* The short unit display string for decibles */ +"dB" = "dB"; + +/* The short unit display string for grams */ +"g" = "g"; + +/* The short unit display string for milligrams of glucose per decilter */ +"mg/dL" = "mg/dL"; + +/* The short unit display string for millimoles of glucose per liter */ +"mmol/L" = "mmol/L"; + +/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ +"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; + +/* The short unit display string for international units of insulin */ +"U" = "U"; + diff --git a/Common/ce.lproj/Intents.strings b/Common/ce.lproj/Intents.strings new file mode 100644 index 0000000000..69202aa99c --- /dev/null +++ b/Common/ce.lproj/Intents.strings @@ -0,0 +1,36 @@ +/* (No Comment) */ +"9KhaIS" = "I've set the preset"; + +/* (No Comment) */ +"80eo5o" = "Add Carb Entry"; + +/* (No Comment) */ +"b085BW" = "I wasn't able to set the preset."; + +/* (No Comment) */ +"I4OZy8" = "Enable Override Preset"; + +/* (No Comment) */ +"lYMuWV" = "Override Name"; + +/* (No Comment) */ +"nDKAmn" = "What's the name of the override you'd like to set?"; + +/* (No Comment) */ +"OcNxIj" = "Add Carb Entry"; + +/* (No Comment) */ +"oLQSsJ" = "Enable '${overrideName}' Override Preset"; + +/* (No Comment) */ +"XNNmtH" = "Enable preset in Loop"; + +/* (No Comment) */ +"yBzwCL" = "Override Selection"; + +/* (No Comment) */ +"yc02Yq" = "Add a carb entry to Loop"; + +/* (No Comment) */ +"ZZ3mtM" = "Enable an override preset in Loop"; + diff --git a/Common/cs.lproj/Intents.strings b/Common/cs.lproj/Intents.strings new file mode 100644 index 0000000000..69202aa99c --- /dev/null +++ b/Common/cs.lproj/Intents.strings @@ -0,0 +1,36 @@ +/* (No Comment) */ +"9KhaIS" = "I've set the preset"; + +/* (No Comment) */ +"80eo5o" = "Add Carb Entry"; + +/* (No Comment) */ +"b085BW" = "I wasn't able to set the preset."; + +/* (No Comment) */ +"I4OZy8" = "Enable Override Preset"; + +/* (No Comment) */ +"lYMuWV" = "Override Name"; + +/* (No Comment) */ +"nDKAmn" = "What's the name of the override you'd like to set?"; + +/* (No Comment) */ +"OcNxIj" = "Add Carb Entry"; + +/* (No Comment) */ +"oLQSsJ" = "Enable '${overrideName}' Override Preset"; + +/* (No Comment) */ +"XNNmtH" = "Enable preset in Loop"; + +/* (No Comment) */ +"yBzwCL" = "Override Selection"; + +/* (No Comment) */ +"yc02Yq" = "Add a carb entry to Loop"; + +/* (No Comment) */ +"ZZ3mtM" = "Enable an override preset in Loop"; + diff --git a/Common/da.lproj/Intents.strings b/Common/da.lproj/Intents.strings new file mode 100644 index 0000000000..7fdba3c551 --- /dev/null +++ b/Common/da.lproj/Intents.strings @@ -0,0 +1,36 @@ +/* (No Comment) */ +"9KhaIS" = "Jeg har indstillet forudindstillingen"; + +/* (No Comment) */ +"80eo5o" = "Tilføj kulhydrater"; + +/* (No Comment) */ +"b085BW" = "Jeg var ikke i stand til at indstille forudindstillingen."; + +/* (No Comment) */ +"I4OZy8" = "Aktiver forudindstilling for Override"; + +/* (No Comment) */ +"lYMuWV" = "Override navn"; + +/* (No Comment) */ +"nDKAmn" = "Hvad er navnet på den Override, du vil angive?"; + +/* (No Comment) */ +"OcNxIj" = "Tilføj kulhydrater"; + +/* (No Comment) */ +"oLQSsJ" = "Aktiver \"${overrideName}\" Override forudindstilling"; + +/* (No Comment) */ +"XNNmtH" = "Aktiver forudindstilling i Loop"; + +/* (No Comment) */ +"yBzwCL" = "Override valg"; + +/* (No Comment) */ +"yc02Yq" = "Tilføj kulhydrater til Loop"; + +/* (No Comment) */ +"ZZ3mtM" = "Aktivere en forudindstilling for Override i Loop"; + diff --git a/Common/da.lproj/Localizable.strings b/Common/da.lproj/Localizable.strings new file mode 100644 index 0000000000..05492ad924 --- /dev/null +++ b/Common/da.lproj/Localizable.strings @@ -0,0 +1,57 @@ +/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ +"%1$@ v%2$@" = "%1$@ v%2$@"; + +/* Title of the user activity for adding carbs */ +"Add Carb Entry" = "Tilføj kulhydrater"; + +/* Lesson subtitle */ +"Computes the percentage of glucose measurements within a specified range" = "Beregner procentdelen af ​​blodsukkermålinger inden for et specificeret interval"; + +/* Title of the button to begin lesson execution */ +"Continue" = "Fortsæt"; + +/* The short unit display string for decibles */ +"dB" = "dB"; + +/* The short unit display string for grams */ +"g" = "g"; + +/* Placeholder for upper range entry */ +"Maximum" = "Maksimum"; + +/* The short unit display string for milligrams of glucose per decilter */ +"mg/dL" = "mg/dL"; + +/* Placeholder for lower range entry */ +"Minimum" = "Minimum"; + +/* The short unit display string for millimoles of glucose per liter */ +"mmol/L" = "mmol/L"; + +/* Lesson title */ +"Modal Day" = "Modal-dag"; + +/* Lesson result text for no data */ +"No data available" = "Ingen data tilgængelige"; + +/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ +"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; + +/* Section title for glucose range */ +"Range" = "Interval"; + +/* Title of config entry */ +"Start Date" = "Startdato"; + +/* Lesson title */ +"Time in Range" = "Tid inden for korrektionsområde"; + +/* The short unit display string for international units of insulin */ +"U" = "E"; + +/* Lesson subtitle */ +"Visualizes the most frequent glucose values by time of day" = "Visualiserer de hyppigste blodsukkerværdier fordelt på dagen"; + +/* Unit string for a count of calendar weeks */ +"Weeks" = "Uger"; + diff --git a/Common/de.lproj/Intents.strings b/Common/de.lproj/Intents.strings new file mode 100644 index 0000000000..94c68d7e0d --- /dev/null +++ b/Common/de.lproj/Intents.strings @@ -0,0 +1,36 @@ +/* (No Comment) */ +"9KhaIS" = "Voreinstellung gesetzt"; + +/* (No Comment) */ +"80eo5o" = "KH-Eintrag hinzufügen"; + +/* (No Comment) */ +"b085BW" = "Voreinstellung konnte nicht gesetzt werden."; + +/* (No Comment) */ +"I4OZy8" = "Aktiviere Voreinstellung"; + +/* (No Comment) */ +"lYMuWV" = "Voreinstellungs-Name"; + +/* (No Comment) */ +"nDKAmn" = "Welche Voreinstellung möchtest Du aktivieren?"; + +/* (No Comment) */ +"OcNxIj" = "KH hinzufügen"; + +/* (No Comment) */ +"oLQSsJ" = "Aktiviere '${overrideName}' Voreinstellung"; + +/* (No Comment) */ +"XNNmtH" = "Aktiviere Voreinstellung in Loop"; + +/* (No Comment) */ +"yBzwCL" = "Voreinstellungsauswahl"; + +/* (No Comment) */ +"yc02Yq" = "Füge einen KH-Eintrag zu Loop hinzu."; + +/* (No Comment) */ +"ZZ3mtM" = "Aktiviere eine Voreinstellung in Loop"; + diff --git a/Common/de.lproj/Localizable.strings b/Common/de.lproj/Localizable.strings new file mode 100644 index 0000000000..071ae71860 --- /dev/null +++ b/Common/de.lproj/Localizable.strings @@ -0,0 +1,57 @@ +/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ +"%1$@ v%2$@" = "%1$@ v%2$@"; + +/* Title of the user activity for adding carbs */ +"Add Carb Entry" = "KH-Eintrag hinzufügen"; + +/* Lesson subtitle */ +"Computes the percentage of glucose measurements within a specified range" = "Berechnet den Prozentsatz der Glukosemessungen innerhalb eines angegebenen Bereichs"; + +/* Title of the button to begin lesson execution */ +"Continue" = "Weiter"; + +/* The short unit display string for decibles */ +"dB" = "dB"; + +/* The short unit display string for grams */ +"g" = "g"; + +/* Placeholder for upper range entry */ +"Maximum" = "Maximum"; + +/* The short unit display string for milligrams of glucose per decilter */ +"mg/dL" = "mg/dL"; + +/* Placeholder for lower range entry */ +"Minimum" = "Minimum"; + +/* The short unit display string for millimoles of glucose per liter */ +"mmol/L" = "mmol/L"; + +/* Lesson title */ +"Modal Day" = "Modaler Tag"; + +/* Lesson result text for no data */ +"No data available" = "Keine Daten vorhanden"; + +/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ +"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; + +/* Section title for glucose range */ +"Range" = "Bereich"; + +/* Title of config entry */ +"Start Date" = "Start Datum"; + +/* Lesson title */ +"Time in Range" = "Zeit im Bereich"; + +/* The short unit display string for international units of insulin */ +"U" = "IE"; + +/* Lesson subtitle */ +"Visualizes the most frequent glucose values by time of day" = "Zeigt die häufigsten Glukosewerte nach Tageszeit"; + +/* Unit string for a count of calendar weeks */ +"Weeks" = "Wochen"; + diff --git a/Common/en.lproj/Intents.strings b/Common/en.lproj/Intents.strings new file mode 100644 index 0000000000..9cec9c4b65 --- /dev/null +++ b/Common/en.lproj/Intents.strings @@ -0,0 +1,18 @@ +/* INIntentTitle */ +"80eo5o" = "Add Carb Entry"; + +/* INIntentParameterCombinationTitle */ +"OcNxIj" = "Add Carb Entry"; + +/* INIntentDescription */ +"yc02Yq" = "Add a carb entry to Loop"; + +"9KhaIS" = "I've set the preset"; +"I4OZy8" = "Enable Override Preset"; +"XNNmtH" = "Enable preset in Loop"; +"ZZ3mtM" = "Enable an override preset in Loop"; +"b085BW" = "I wasn't able to set the preset."; +"lYMuWV" = "Override Name"; +"nDKAmn" = "What's the name of the override you'd like to set?"; +"oLQSsJ" = "Enable '${overrideName}' Override Preset"; +"yBzwCL" = "Override Selection"; diff --git a/Common/en.lproj/Localizable.strings b/Common/en.lproj/Localizable.strings new file mode 100644 index 0000000000..e0fb9dff1b --- /dev/null +++ b/Common/en.lproj/Localizable.strings @@ -0,0 +1,24 @@ +/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ +"%1$@ v%2$@" = "%1$@ v%2$@"; + +/* Title of the user activity for adding carbs */ +"Add Carb Entry" = "Add Carb Entry"; + +/* The short unit display string for decibles */ +"dB" = "dB"; + +/* The short unit display string for grams */ +"g" = "g"; + +/* The short unit display string for milligrams of glucose per decilter */ +"mg/dL" = "mg/dL"; + +/* The short unit display string for millimoles of glucose per liter */ +"mmol/L" = "mmol/L"; + +/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ +"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; + +/* The short unit display string for international units of insulin */ +"U" = "U"; + diff --git a/Common/es.lproj/Intents.strings b/Common/es.lproj/Intents.strings new file mode 100644 index 0000000000..fe46670365 --- /dev/null +++ b/Common/es.lproj/Intents.strings @@ -0,0 +1,36 @@ +/* (No Comment) */ +"9KhaIS" = "He establecido el ajuste"; + +/* (No Comment) */ +"80eo5o" = "Agregar Entrada de Carb"; + +/* (No Comment) */ +"b085BW" = "I wasn't able to set the preset."; + +/* (No Comment) */ +"I4OZy8" = "Enable Override Preset"; + +/* (No Comment) */ +"lYMuWV" = "Override Name"; + +/* (No Comment) */ +"nDKAmn" = "What's the name of the override you'd like to set?"; + +/* (No Comment) */ +"OcNxIj" = "Agregar Entrada de Carb"; + +/* (No Comment) */ +"oLQSsJ" = "Enable '${overrideName}' Override Preset"; + +/* (No Comment) */ +"XNNmtH" = "Enable preset in Loop"; + +/* (No Comment) */ +"yBzwCL" = "Override Selection"; + +/* (No Comment) */ +"yc02Yq" = "Agregar registro de carbs a Loop"; + +/* (No Comment) */ +"ZZ3mtM" = "Enable an override preset in Loop"; + diff --git a/Common/es.lproj/Localizable.strings b/Common/es.lproj/Localizable.strings new file mode 100644 index 0000000000..2517bb2aa1 --- /dev/null +++ b/Common/es.lproj/Localizable.strings @@ -0,0 +1,57 @@ +/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ +"%1$@ v%2$@" = "%1$@ v%2$@"; + +/* Title of the user activity for adding carbs */ +"Add Carb Entry" = "Agregar Registro de Carbs"; + +/* Lesson subtitle */ +"Computes the percentage of glucose measurements within a specified range" = "Calcula el porcentaje de medidas de glucosa entre una gama especificada"; + +/* Title of the button to begin lesson execution */ +"Continue" = "Continuar"; + +/* The short unit display string for decibles */ +"dB" = "dB"; + +/* The short unit display string for grams */ +"g" = "g"; + +/* Placeholder for upper range entry */ +"Maximum" = "Máximo"; + +/* The short unit display string for milligrams of glucose per decilter */ +"mg/dL" = "mg/dL"; + +/* Placeholder for lower range entry */ +"Minimum" = "Mínimo"; + +/* The short unit display string for millimoles of glucose per liter */ +"mmol/L" = "mmol/L"; + +/* Lesson title */ +"Modal Day" = "Día modal"; + +/* Lesson result text for no data */ +"No data available" = "Datos no disponibles"; + +/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ +"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; + +/* Section title for glucose range */ +"Range" = "Rango"; + +/* Title of config entry */ +"Start Date" = "Fecha de Inicio"; + +/* Lesson title */ +"Time in Range" = "Tiempo en Rango"; + +/* The short unit display string for international units of insulin */ +"U" = "U"; + +/* Lesson subtitle */ +"Visualizes the most frequent glucose values by time of day" = "Visualiza los valores de glucosa más frecuentes por hora del día"; + +/* Unit string for a count of calendar weeks */ +"Weeks" = "Semanas"; + diff --git a/Common/fi.lproj/Intents.strings b/Common/fi.lproj/Intents.strings new file mode 100644 index 0000000000..e4c15dd161 --- /dev/null +++ b/Common/fi.lproj/Intents.strings @@ -0,0 +1,36 @@ +/* (No Comment) */ +"9KhaIS" = "I've set the preset"; + +/* (No Comment) */ +"80eo5o" = "Lisää hiilihydraatteja"; + +/* (No Comment) */ +"b085BW" = "I wasn't able to set the preset."; + +/* (No Comment) */ +"I4OZy8" = "Enable Override Preset"; + +/* (No Comment) */ +"lYMuWV" = "Override Name"; + +/* (No Comment) */ +"nDKAmn" = "What's the name of the override you'd like to set?"; + +/* (No Comment) */ +"OcNxIj" = "Lisää hiilihydraatteja"; + +/* (No Comment) */ +"oLQSsJ" = "Enable '${overrideName}' Override Preset"; + +/* (No Comment) */ +"XNNmtH" = "Enable preset in Loop"; + +/* (No Comment) */ +"yBzwCL" = "Override Selection"; + +/* (No Comment) */ +"yc02Yq" = "Lisää hiilihydraatteja Loopiin"; + +/* (No Comment) */ +"ZZ3mtM" = "Enable an override preset in Loop"; + diff --git a/Common/fi.lproj/Localizable.strings b/Common/fi.lproj/Localizable.strings new file mode 100644 index 0000000000..4601e886dc --- /dev/null +++ b/Common/fi.lproj/Localizable.strings @@ -0,0 +1,57 @@ +/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ +"%1$@ v%2$@" = "%1$@ v%2$@"; + +/* Title of the user activity for adding carbs */ +"Add Carb Entry" = "Lisää hiilihydraatteja"; + +/* Lesson subtitle */ +"Computes the percentage of glucose measurements within a specified range" = "Laskee glukoosimittausten prosenttimäärän määritellyllä alueella"; + +/* Title of the button to begin lesson execution */ +"Continue" = "Jatka"; + +/* The short unit display string for decibles */ +"dB" = "dB"; + +/* The short unit display string for grams */ +"g" = "g"; + +/* Placeholder for upper range entry */ +"Maximum" = "Maksimi"; + +/* The short unit display string for milligrams of glucose per decilter */ +"mg/dL" = "mg/dL"; + +/* Placeholder for lower range entry */ +"Minimum" = "Minimi"; + +/* The short unit display string for millimoles of glucose per liter */ +"mmol/L" = "mmol/L"; + +/* Lesson title */ +"Modal Day" = "Tyypillinen päivä"; + +/* Lesson result text for no data */ +"No data available" = "Tietoja ei saatavilla"; + +/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ +"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; + +/* Section title for glucose range */ +"Range" = "Alue"; + +/* Title of config entry */ +"Start Date" = "Aloitusaika"; + +/* Lesson title */ +"Time in Range" = "Aika tavoitealueella"; + +/* The short unit display string for international units of insulin */ +"U" = "U"; + +/* Lesson subtitle */ +"Visualizes the most frequent glucose values by time of day" = "Näyttää yleisimmät glukoosiarvot vuorokaudenajan mukaan"; + +/* Unit string for a count of calendar weeks */ +"Weeks" = "Viikkoa"; + diff --git a/Common/fr.lproj/Intents.strings b/Common/fr.lproj/Intents.strings new file mode 100644 index 0000000000..3e42e5566d --- /dev/null +++ b/Common/fr.lproj/Intents.strings @@ -0,0 +1,36 @@ +/* (No Comment) */ +"9KhaIS" = "J'ai défini le préréglage"; + +/* (No Comment) */ +"80eo5o" = "Ajouter des glucides"; + +/* (No Comment) */ +"b085BW" = "Je n'ai pas pu définir le préréglage."; + +/* (No Comment) */ +"I4OZy8" = "Activer la surcharge temporaire"; + +/* (No Comment) */ +"lYMuWV" = "Nom Ajustement"; + +/* (No Comment) */ +"nDKAmn" = "Quel est le nom de l'ajustement que vous voulez définir?"; + +/* (No Comment) */ +"OcNxIj" = "Ajouter des glucides"; + +/* (No Comment) */ +"oLQSsJ" = "Activer l'ajustement '${overrideName}' "; + +/* (No Comment) */ +"XNNmtH" = "Activer le préréglage dans Loop"; + +/* (No Comment) */ +"yBzwCL" = "Selection Ajustement"; + +/* (No Comment) */ +"yc02Yq" = "Ajouter des glucides à Loop"; + +/* (No Comment) */ +"ZZ3mtM" = "Activer un ajustement préréglé dans Loop"; + diff --git a/Common/fr.lproj/Localizable.strings b/Common/fr.lproj/Localizable.strings new file mode 100644 index 0000000000..e58151d89a --- /dev/null +++ b/Common/fr.lproj/Localizable.strings @@ -0,0 +1,57 @@ +/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ +"%1$@ v%2$@" = "%1$@ v%2$@"; + +/* Title of the user activity for adding carbs */ +"Add Carb Entry" = "Ajouter des glucides"; + +/* Lesson subtitle */ +"Computes the percentage of glucose measurements within a specified range" = "Ceci calcule le pourcentage des mesures de glycémie dans une plage spécifique"; + +/* Title of the button to begin lesson execution */ +"Continue" = "Continuer"; + +/* The short unit display string for decibles */ +"dB" = "dB"; + +/* The short unit display string for grams */ +"g" = "g"; + +/* Placeholder for upper range entry */ +"Maximum" = "Maximum"; + +/* The short unit display string for milligrams of glucose per decilter */ +"mg/dL" = "mg/dL"; + +/* Placeholder for lower range entry */ +"Minimum" = "Minimum"; + +/* The short unit display string for millimoles of glucose per liter */ +"mmol/L" = "mmol/L"; + +/* Lesson title */ +"Modal Day" = "Journée type"; + +/* Lesson result text for no data */ +"No data available" = "Données indisponibles"; + +/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ +"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; + +/* Section title for glucose range */ +"Range" = "Plage"; + +/* Title of config entry */ +"Start Date" = "Date de démarrage"; + +/* Lesson title */ +"Time in Range" = "Durée dans la cible"; + +/* The short unit display string for international units of insulin */ +"U" = "U"; + +/* Lesson subtitle */ +"Visualizes the most frequent glucose values by time of day" = "Visualise les glycémies les plus fréquentes par heure de la journée"; + +/* Unit string for a count of calendar weeks */ +"Weeks" = "Semaines"; + diff --git a/Common/he.lproj/Intents.strings b/Common/he.lproj/Intents.strings new file mode 100644 index 0000000000..47f5e71f10 --- /dev/null +++ b/Common/he.lproj/Intents.strings @@ -0,0 +1,36 @@ +/* (No Comment) */ +"9KhaIS" = "ההגדרה נקבעה"; + +/* (No Comment) */ +"80eo5o" = "הוסף שורת פחמימות"; + +/* (No Comment) */ +"b085BW" = "לא הצלחתי לקבוע את ההגדרה."; + +/* (No Comment) */ +"I4OZy8" = "אפשר מעקף מוגדר"; + +/* (No Comment) */ +"lYMuWV" = "שם המעקף"; + +/* (No Comment) */ +"nDKAmn" = "מה שם המעקף שברצונך ליצור?"; + +/* (No Comment) */ +"OcNxIj" = "הוסף שורת פחמימות"; + +/* (No Comment) */ +"oLQSsJ" = "אפשר מעקף ${overrideName} מוגדר"; + +/* (No Comment) */ +"XNNmtH" = "אפשר את ההגדרה ב-Loop"; + +/* (No Comment) */ +"yBzwCL" = "בחירת מעקף"; + +/* (No Comment) */ +"yc02Yq" = "הוסף שורת פחמימות ל-Loop"; + +/* (No Comment) */ +"ZZ3mtM" = "אפשר את המעקף ב-Loop"; + diff --git a/Common/he.lproj/Localizable.strings b/Common/he.lproj/Localizable.strings new file mode 100644 index 0000000000..e0fb9dff1b --- /dev/null +++ b/Common/he.lproj/Localizable.strings @@ -0,0 +1,24 @@ +/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ +"%1$@ v%2$@" = "%1$@ v%2$@"; + +/* Title of the user activity for adding carbs */ +"Add Carb Entry" = "Add Carb Entry"; + +/* The short unit display string for decibles */ +"dB" = "dB"; + +/* The short unit display string for grams */ +"g" = "g"; + +/* The short unit display string for milligrams of glucose per decilter */ +"mg/dL" = "mg/dL"; + +/* The short unit display string for millimoles of glucose per liter */ +"mmol/L" = "mmol/L"; + +/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ +"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; + +/* The short unit display string for international units of insulin */ +"U" = "U"; + diff --git a/Common/hi.lproj/Intents.strings b/Common/hi.lproj/Intents.strings new file mode 100644 index 0000000000..69202aa99c --- /dev/null +++ b/Common/hi.lproj/Intents.strings @@ -0,0 +1,36 @@ +/* (No Comment) */ +"9KhaIS" = "I've set the preset"; + +/* (No Comment) */ +"80eo5o" = "Add Carb Entry"; + +/* (No Comment) */ +"b085BW" = "I wasn't able to set the preset."; + +/* (No Comment) */ +"I4OZy8" = "Enable Override Preset"; + +/* (No Comment) */ +"lYMuWV" = "Override Name"; + +/* (No Comment) */ +"nDKAmn" = "What's the name of the override you'd like to set?"; + +/* (No Comment) */ +"OcNxIj" = "Add Carb Entry"; + +/* (No Comment) */ +"oLQSsJ" = "Enable '${overrideName}' Override Preset"; + +/* (No Comment) */ +"XNNmtH" = "Enable preset in Loop"; + +/* (No Comment) */ +"yBzwCL" = "Override Selection"; + +/* (No Comment) */ +"yc02Yq" = "Add a carb entry to Loop"; + +/* (No Comment) */ +"ZZ3mtM" = "Enable an override preset in Loop"; + diff --git a/Common/hu.lproj/Intents.strings b/Common/hu.lproj/Intents.strings new file mode 100644 index 0000000000..69202aa99c --- /dev/null +++ b/Common/hu.lproj/Intents.strings @@ -0,0 +1,36 @@ +/* (No Comment) */ +"9KhaIS" = "I've set the preset"; + +/* (No Comment) */ +"80eo5o" = "Add Carb Entry"; + +/* (No Comment) */ +"b085BW" = "I wasn't able to set the preset."; + +/* (No Comment) */ +"I4OZy8" = "Enable Override Preset"; + +/* (No Comment) */ +"lYMuWV" = "Override Name"; + +/* (No Comment) */ +"nDKAmn" = "What's the name of the override you'd like to set?"; + +/* (No Comment) */ +"OcNxIj" = "Add Carb Entry"; + +/* (No Comment) */ +"oLQSsJ" = "Enable '${overrideName}' Override Preset"; + +/* (No Comment) */ +"XNNmtH" = "Enable preset in Loop"; + +/* (No Comment) */ +"yBzwCL" = "Override Selection"; + +/* (No Comment) */ +"yc02Yq" = "Add a carb entry to Loop"; + +/* (No Comment) */ +"ZZ3mtM" = "Enable an override preset in Loop"; + diff --git a/Common/it.lproj/Intents.strings b/Common/it.lproj/Intents.strings new file mode 100644 index 0000000000..a39b42da6f --- /dev/null +++ b/Common/it.lproj/Intents.strings @@ -0,0 +1,36 @@ +/* (No Comment) */ +"9KhaIS" = "Ho impostato il Preset"; + +/* (No Comment) */ +"80eo5o" = "Aggiungi inserimento carboidrati"; + +/* (No Comment) */ +"b085BW" = "Non sono riuscito a impostare il Preset."; + +/* (No Comment) */ +"I4OZy8" = "Attiva Preset Override"; + +/* (No Comment) */ +"lYMuWV" = "Nome Override"; + +/* (No Comment) */ +"nDKAmn" = "Qual'è il nome dell'Override che vuoi impostare?"; + +/* (No Comment) */ +"OcNxIj" = "Aggiungi inserimento carboidrati"; + +/* (No Comment) */ +"oLQSsJ" = "Attiva '${OverrideName}' Preset Override"; + +/* (No Comment) */ +"XNNmtH" = "Attiva Preset in Loop"; + +/* (No Comment) */ +"yBzwCL" = "Selezione Override"; + +/* (No Comment) */ +"yc02Yq" = "Aggiungi inserimento carboidrati a Loop"; + +/* (No Comment) */ +"ZZ3mtM" = "Attiva un Preset Override in Loop"; + diff --git a/Common/it.lproj/Localizable.strings b/Common/it.lproj/Localizable.strings new file mode 100644 index 0000000000..55e7848e7b --- /dev/null +++ b/Common/it.lproj/Localizable.strings @@ -0,0 +1,57 @@ +/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ +"%1$@ v%2$@" = "%1$@ v%2$@"; + +/* Title of the user activity for adding carbs */ +"Add Carb Entry" = "Agg. Carb. Assunti"; + +/* Lesson subtitle */ +"Computes the percentage of glucose measurements within a specified range" = "Calcola la percentuale di misurazioni della glicemia entro un intervallo specifico"; + +/* Title of the button to begin lesson execution */ +"Continue" = "Continua"; + +/* The short unit display string for decibles */ +"dB" = "dB"; + +/* The short unit display string for grams */ +"g" = "g"; + +/* Placeholder for upper range entry */ +"Maximum" = "Massimo"; + +/* The short unit display string for milligrams of glucose per decilter */ +"mg/dL" = "mg/dL"; + +/* Placeholder for lower range entry */ +"Minimum" = "Minimo"; + +/* The short unit display string for millimoles of glucose per liter */ +"mmol/L" = "mmol/L"; + +/* Lesson title */ +"Modal Day" = "Modalità giornaliera"; + +/* Lesson result text for no data */ +"No data available" = "Nessun dato disponibile"; + +/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ +"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; + +/* Section title for glucose range */ +"Range" = "Intervallo"; + +/* Title of config entry */ +"Start Date" = "Data di inizio"; + +/* Lesson title */ +"Time in Range" = "Tempo nell’intervallo"; + +/* The short unit display string for international units of insulin */ +"U" = "U"; + +/* Lesson subtitle */ +"Visualizes the most frequent glucose values by time of day" = "Visualizza i valori di glucosio più frequenti per ora del giorno"; + +/* Unit string for a count of calendar weeks */ +"Weeks" = "Settimane"; + diff --git a/Common/ja.lproj/Intents.strings b/Common/ja.lproj/Intents.strings new file mode 100644 index 0000000000..f59cfa9e62 --- /dev/null +++ b/Common/ja.lproj/Intents.strings @@ -0,0 +1,36 @@ +/* (No Comment) */ +"9KhaIS" = "I've set the preset"; + +/* (No Comment) */ +"80eo5o" = "糖質の記入を追加"; + +/* (No Comment) */ +"b085BW" = "I wasn't able to set the preset."; + +/* (No Comment) */ +"I4OZy8" = "Enable Override Preset"; + +/* (No Comment) */ +"lYMuWV" = "Override Name"; + +/* (No Comment) */ +"nDKAmn" = "What's the name of the override you'd like to set?"; + +/* (No Comment) */ +"OcNxIj" = "糖質の記入を追加"; + +/* (No Comment) */ +"oLQSsJ" = "Enable '${overrideName}' Override Preset"; + +/* (No Comment) */ +"XNNmtH" = "Enable preset in Loop"; + +/* (No Comment) */ +"yBzwCL" = "Override Selection"; + +/* (No Comment) */ +"yc02Yq" = "ループにカーボを追加"; + +/* (No Comment) */ +"ZZ3mtM" = "Enable an override preset in Loop"; + diff --git a/Common/ja.lproj/Localizable.strings b/Common/ja.lproj/Localizable.strings new file mode 100644 index 0000000000..807227d02a --- /dev/null +++ b/Common/ja.lproj/Localizable.strings @@ -0,0 +1,54 @@ +/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ +"%1$@ v%2$@" = "%1$@ v%2$@"; + +/* Title of the user activity for adding carbs */ +"Add Carb Entry" = "カーボを追加"; + +/* Lesson subtitle */ +"Computes the percentage of glucose measurements within a specified range" = "指定範囲内の測定値の割合を算出"; + +/* Title of the button to begin lesson execution */ +"Continue" = "次へ"; + +/* The short unit display string for decibles */ +"dB" = "dB"; + +/* The short unit display string for grams */ +"g" = "g"; + +/* Placeholder for upper range entry */ +"Maximum" = "最大"; + +/* The short unit display string for milligrams of glucose per decilter */ +"mg/dL" = "mg/dL"; + +/* Placeholder for lower range entry */ +"Minimum" = "最小"; + +/* The short unit display string for millimoles of glucose per liter */ +"mmol/L" = "mmol/L"; + +/* Lesson result text for no data */ +"No data available" = "データがありません"; + +/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ +"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; + +/* Section title for glucose range */ +"Range" = "範囲"; + +/* Title of config entry */ +"Start Date" = "開始日"; + +/* Lesson title */ +"Time in Range" = "タイムインレンジ"; + +/* The short unit display string for international units of insulin */ +"U" = "U"; + +/* Lesson subtitle */ +"Visualizes the most frequent glucose values by time of day" = "頻度の高い測定値を時間ごとに表示"; + +/* Unit string for a count of calendar weeks */ +"Weeks" = "週"; + diff --git a/Common/nb.lproj/Intents.strings b/Common/nb.lproj/Intents.strings new file mode 100644 index 0000000000..0fe45121af --- /dev/null +++ b/Common/nb.lproj/Intents.strings @@ -0,0 +1,36 @@ +/* (No Comment) */ +"9KhaIS" = "Jeg har angitt forhåndsinnstillingen"; + +/* (No Comment) */ +"80eo5o" = "Legg til karbohydrater"; + +/* (No Comment) */ +"b085BW" = "Jeg kunne ikke angi forhåndsinnstillingen."; + +/* (No Comment) */ +"I4OZy8" = "Aktiver forhåndsinnstillt overstyring"; + +/* (No Comment) */ +"lYMuWV" = "Navn på overstyring"; + +/* (No Comment) */ +"nDKAmn" = "Hva heter overstyringen du vil angi?"; + +/* (No Comment) */ +"OcNxIj" = "Legg til karbohydrater"; + +/* (No Comment) */ +"oLQSsJ" = "Enable '${overrideName}' Override Preset"; + +/* (No Comment) */ +"XNNmtH" = "Aktiver forhåndsinnstilling i Loop"; + +/* (No Comment) */ +"yBzwCL" = "Overstyr valg"; + +/* (No Comment) */ +"yc02Yq" = "Legg til karbohydrater i Loop"; + +/* (No Comment) */ +"ZZ3mtM" = "Aktiver en forhåndsinnstilling for overstyring i Loop"; + diff --git a/Common/nb.lproj/Localizable.strings b/Common/nb.lproj/Localizable.strings new file mode 100644 index 0000000000..056b65c455 --- /dev/null +++ b/Common/nb.lproj/Localizable.strings @@ -0,0 +1,57 @@ +/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ +"%1$@ v%2$@" = "%1$@ v%2$@"; + +/* Title of the user activity for adding carbs */ +"Add Carb Entry" = "Legg til karbohydrater"; + +/* Lesson subtitle */ +"Computes the percentage of glucose measurements within a specified range" = "Beregner prosentandelen av blodsukkermålinger innenfor et spesifisert område"; + +/* Title of the button to begin lesson execution */ +"Continue" = "Fortsett"; + +/* The short unit display string for decibles */ +"dB" = "dB"; + +/* The short unit display string for grams */ +"g" = "g"; + +/* Placeholder for upper range entry */ +"Maximum" = "Maksimum"; + +/* The short unit display string for milligrams of glucose per decilter */ +"mg/dL" = "mg/dL"; + +/* Placeholder for lower range entry */ +"Minimum" = "Minimum"; + +/* The short unit display string for millimoles of glucose per liter */ +"mmol/L" = "mmol/L"; + +/* Lesson title */ +"Modal Day" = "Modal dag"; + +/* Lesson result text for no data */ +"No data available" = "Ingen data tilgjengelig"; + +/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ +"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; + +/* Section title for glucose range */ +"Range" = "Målområde"; + +/* Title of config entry */ +"Start Date" = "Startdato"; + +/* Lesson title */ +"Time in Range" = "Tid i målområdet"; + +/* The short unit display string for international units of insulin */ +"U" = "E"; + +/* Lesson subtitle */ +"Visualizes the most frequent glucose values by time of day" = "Visualiser de nyeste blodsukkerverdier etter tid på døgnet"; + +/* Unit string for a count of calendar weeks */ +"Weeks" = "Uker"; + diff --git a/Common/nl.lproj/Intents.strings b/Common/nl.lproj/Intents.strings new file mode 100644 index 0000000000..e72963d83a --- /dev/null +++ b/Common/nl.lproj/Intents.strings @@ -0,0 +1,36 @@ +/* (No Comment) */ +"9KhaIS" = "Ik heb het programma ingesteld"; + +/* (No Comment) */ +"80eo5o" = "Kh. Inv. Toevoegen"; + +/* (No Comment) */ +"b085BW" = "Ik kon het programma niet instellen."; + +/* (No Comment) */ +"I4OZy8" = "Override Programma Inschakelen"; + +/* (No Comment) */ +"lYMuWV" = "Override Naam"; + +/* (No Comment) */ +"nDKAmn" = "Wat is de naam van de override die je zou willen instellen?"; + +/* (No Comment) */ +"OcNxIj" = "Kh. Inv. Toevoegen"; + +/* (No Comment) */ +"oLQSsJ" = "Override '${overrideName}' Inschakelen"; + +/* (No Comment) */ +"XNNmtH" = "Programma in Loop inschakelen"; + +/* (No Comment) */ +"yBzwCL" = "Overrideselectie"; + +/* (No Comment) */ +"yc02Yq" = "Voeg koolhydrateninvoer toe aan Loop"; + +/* (No Comment) */ +"ZZ3mtM" = "Een override programma in Loop inschakelen"; + diff --git a/Common/nl.lproj/Localizable.strings b/Common/nl.lproj/Localizable.strings new file mode 100644 index 0000000000..a272262f20 --- /dev/null +++ b/Common/nl.lproj/Localizable.strings @@ -0,0 +1,57 @@ +/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ +"%1$@ v%2$@" = "%1$@ v%2$@"; + +/* Title of the user activity for adding carbs */ +"Add Carb Entry" = "Koolhydraatinvoer Toevoegen"; + +/* Lesson subtitle */ +"Computes the percentage of glucose measurements within a specified range" = "Berekent het percentage glucosemetingen in een specifiek bereik"; + +/* Title of the button to begin lesson execution */ +"Continue" = "Ga Verder"; + +/* The short unit display string for decibles */ +"dB" = "dB"; + +/* The short unit display string for grams */ +"g" = "g"; + +/* Placeholder for upper range entry */ +"Maximum" = "Maximum"; + +/* The short unit display string for milligrams of glucose per decilter */ +"mg/dL" = "mg/dL"; + +/* Placeholder for lower range entry */ +"Minimum" = "Minimum"; + +/* The short unit display string for millimoles of glucose per liter */ +"mmol/L" = "mmol/L"; + +/* Lesson title */ +"Modal Day" = "Modale Dag"; + +/* Lesson result text for no data */ +"No data available" = "Geen gegevens beschikbaar"; + +/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ +"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; + +/* Section title for glucose range */ +"Range" = "Bereik"; + +/* Title of config entry */ +"Start Date" = "Startdatum"; + +/* Lesson title */ +"Time in Range" = "Tijd binnen Bereik"; + +/* The short unit display string for international units of insulin */ +"U" = "E"; + +/* Lesson subtitle */ +"Visualizes the most frequent glucose values by time of day" = "Geeft de meest voorkomende glucosewaarden weer per moment van de dag"; + +/* Unit string for a count of calendar weeks */ +"Weeks" = "Weken"; + diff --git a/Common/pl.lproj/Intents.strings b/Common/pl.lproj/Intents.strings new file mode 100644 index 0000000000..b60a23b43f --- /dev/null +++ b/Common/pl.lproj/Intents.strings @@ -0,0 +1,36 @@ +/* (No Comment) */ +"9KhaIS" = "Ustawiłem Cel Tymczasowy"; + +/* (No Comment) */ +"80eo5o" = "Wprowadź węglowodany"; + +/* (No Comment) */ +"b085BW" = "Nie mogłem ustawić celu."; + +/* (No Comment) */ +"I4OZy8" = "Włącz Cel Tymczasowy"; + +/* (No Comment) */ +"lYMuWV" = "Nazwa Celu Tymczas."; + +/* (No Comment) */ +"nDKAmn" = "Jak nazywa się Cel Tymczasowy, który chcesz włączyć?"; + +/* (No Comment) */ +"OcNxIj" = "Wprowadź węglowodany"; + +/* (No Comment) */ +"oLQSsJ" = "Włącz '${overrideName}' Cel Tymczasowy"; + +/* (No Comment) */ +"XNNmtH" = "Włącz Cel Tymczasowy w pętli"; + +/* (No Comment) */ +"yBzwCL" = "Wybierz Cel Tymczasowy"; + +/* (No Comment) */ +"yc02Yq" = "Dodaj węglowodany do pętli"; + +/* (No Comment) */ +"ZZ3mtM" = "Włącz Cel Tymczasowy w pętli"; + diff --git a/Common/pl.lproj/Localizable.strings b/Common/pl.lproj/Localizable.strings new file mode 100644 index 0000000000..904ee7d8a8 --- /dev/null +++ b/Common/pl.lproj/Localizable.strings @@ -0,0 +1,57 @@ +/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ +"%1$@ v%2$@" = "%1$@ v%2$@"; + +/* Title of the user activity for adding carbs */ +"Add Carb Entry" = "Dodaj pozycję dla węglowodanów"; + +/* Lesson subtitle */ +"Computes the percentage of glucose measurements within a specified range" = "Oblicza procent pomiarów glukozy w określonym zakresie"; + +/* Title of the button to begin lesson execution */ +"Continue" = "Kontynuuj"; + +/* The short unit display string for decibles */ +"dB" = "dB"; + +/* The short unit display string for grams */ +"g" = "g"; + +/* Placeholder for upper range entry */ +"Maximum" = "Maksymalnie"; + +/* The short unit display string for milligrams of glucose per decilter */ +"mg/dL" = "mg/dL"; + +/* Placeholder for lower range entry */ +"Minimum" = "Minimalnie"; + +/* The short unit display string for millimoles of glucose per liter */ +"mmol/L" = "mmol/L"; + +/* Lesson title */ +"Modal Day" = "Dzień modalny"; + +/* Lesson result text for no data */ +"No data available" = "Brak danych"; + +/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ +"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; + +/* Section title for glucose range */ +"Range" = "Zasięg"; + +/* Title of config entry */ +"Start Date" = "Data rozpoczęcia"; + +/* Lesson title */ +"Time in Range" = "Czas w zakresie (TIR)"; + +/* The short unit display string for international units of insulin */ +"U" = "J"; + +/* Lesson subtitle */ +"Visualizes the most frequent glucose values by time of day" = "Wizualizuje najczęstsze wartości glukozy według pory dnia"; + +/* Unit string for a count of calendar weeks */ +"Weeks" = "Tygodnie"; + diff --git a/Common/pt-BR.lproj/Intents.strings b/Common/pt-BR.lproj/Intents.strings new file mode 100644 index 0000000000..130b82cbb5 --- /dev/null +++ b/Common/pt-BR.lproj/Intents.strings @@ -0,0 +1,36 @@ +/* (No Comment) */ +"9KhaIS" = "I've set the preset"; + +/* (No Comment) */ +"80eo5o" = "Adicionar Carb"; + +/* (No Comment) */ +"b085BW" = "I wasn't able to set the preset."; + +/* (No Comment) */ +"I4OZy8" = "Enable Override Preset"; + +/* (No Comment) */ +"lYMuWV" = "Override Name"; + +/* (No Comment) */ +"nDKAmn" = "What's the name of the override you'd like to set?"; + +/* (No Comment) */ +"OcNxIj" = "Adicionar Carb"; + +/* (No Comment) */ +"oLQSsJ" = "Enable '${overrideName}' Override Preset"; + +/* (No Comment) */ +"XNNmtH" = "Enable preset in Loop"; + +/* (No Comment) */ +"yBzwCL" = "Override Selection"; + +/* (No Comment) */ +"yc02Yq" = "Adicionar Carboidratos ao Loop"; + +/* (No Comment) */ +"ZZ3mtM" = "Enable an override preset in Loop"; + diff --git a/Common/pt-BR.lproj/Localizable.strings b/Common/pt-BR.lproj/Localizable.strings new file mode 100644 index 0000000000..3d70695dd1 --- /dev/null +++ b/Common/pt-BR.lproj/Localizable.strings @@ -0,0 +1,54 @@ +/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ +"%1$@ v%2$@" = "%1$@ v%2$@"; + +/* Title of the user activity for adding carbs */ +"Add Carb Entry" = "Adicionar Carboidratos"; + +/* Title of the button to begin lesson execution */ +"Continue" = "Continuar"; + +/* The short unit display string for decibles */ +"dB" = "dB"; + +/* The short unit display string for grams */ +"g" = "g"; + +/* Placeholder for upper range entry */ +"Maximum" = "Máximo"; + +/* The short unit display string for milligrams of glucose per decilter */ +"mg/dL" = "mg/dL"; + +/* Placeholder for lower range entry */ +"Minimum" = "Mínimo"; + +/* The short unit display string for millimoles of glucose per liter */ +"mmol/L" = "mmol/L"; + +/* Lesson title */ +"Modal Day" = "Dia Modal"; + +/* Lesson result text for no data */ +"No data available" = "Não há dados disponíveis"; + +/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ +"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; + +/* Section title for glucose range */ +"Range" = "Variação"; + +/* Title of config entry */ +"Start Date" = "Data de Início"; + +/* Lesson title */ +"Time in Range" = "Tempo na Meta"; + +/* The short unit display string for international units of insulin */ +"U" = "U"; + +/* Lesson subtitle */ +"Visualizes the most frequent glucose values by time of day" = "Visualiza os valores de glicose mais frequentes por hora do dia"; + +/* Unit string for a count of calendar weeks */ +"Weeks" = "Semanas"; + diff --git a/Common/ro.lproj/Intents.strings b/Common/ro.lproj/Intents.strings new file mode 100644 index 0000000000..e6aa6c24d3 --- /dev/null +++ b/Common/ro.lproj/Intents.strings @@ -0,0 +1,36 @@ +/* (No Comment) */ +"9KhaIS" = "I've set the preset"; + +/* (No Comment) */ +"80eo5o" = "Adaugă carbohidrați"; + +/* (No Comment) */ +"b085BW" = "Nu am reușit să setez presetarea."; + +/* (No Comment) */ +"I4OZy8" = "Activare modificarea personalizată presetată"; + +/* (No Comment) */ +"lYMuWV" = "Denumirea modificării personalizate"; + +/* (No Comment) */ +"nDKAmn" = "Cum se numește modificarea pe care doriți să o setați?"; + +/* (No Comment) */ +"OcNxIj" = "Adaugă carbohidrați"; + +/* (No Comment) */ +"oLQSsJ" = "Enable '${overrideName}' Override Preset"; + +/* (No Comment) */ +"XNNmtH" = "Activați presetarea în buclă"; + +/* (No Comment) */ +"yBzwCL" = "Selecție modificare personalizată"; + +/* (No Comment) */ +"yc02Yq" = "Adaugă carbohidrați în Loop"; + +/* (No Comment) */ +"ZZ3mtM" = "Activați o modificare personalizată presetată în Buclă"; + diff --git a/Common/ro.lproj/Localizable.strings b/Common/ro.lproj/Localizable.strings new file mode 100644 index 0000000000..13e8f4fd76 --- /dev/null +++ b/Common/ro.lproj/Localizable.strings @@ -0,0 +1,57 @@ +/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ +"%1$@ v%2$@" = "%1$@ v%2$@"; + +/* Title of the user activity for adding carbs */ +"Add Carb Entry" = "Adăugare carbohidrați"; + +/* Lesson subtitle */ +"Computes the percentage of glucose measurements within a specified range" = "Calculează procentul măsurătorilor glicemice dintr-un interval specificat"; + +/* Title of the button to begin lesson execution */ +"Continue" = "Continuă"; + +/* The short unit display string for decibles */ +"dB" = "dB"; + +/* The short unit display string for grams */ +"g" = "g"; + +/* Placeholder for upper range entry */ +"Maximum" = "Maxim"; + +/* The short unit display string for milligrams of glucose per decilter */ +"mg/dL" = "mg/dL"; + +/* Placeholder for lower range entry */ +"Minimum" = "Minim"; + +/* The short unit display string for millimoles of glucose per liter */ +"mmol/L" = "mmol/L"; + +/* Lesson title */ +"Modal Day" = "Zi modală"; + +/* Lesson result text for no data */ +"No data available" = "Date inexistente"; + +/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ +"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; + +/* Section title for glucose range */ +"Range" = "Interval"; + +/* Title of config entry */ +"Start Date" = "Dată inițială"; + +/* Lesson title */ +"Time in Range" = "Timp petrecut în interval"; + +/* The short unit display string for international units of insulin */ +"U" = "U"; + +/* Lesson subtitle */ +"Visualizes the most frequent glucose values by time of day" = "Vizualizează cele mai frecvente valori glicemice în funcție de oră"; + +/* Unit string for a count of calendar weeks */ +"Weeks" = "Săptămâni"; + diff --git a/Common/ru.lproj/Intents.strings b/Common/ru.lproj/Intents.strings new file mode 100644 index 0000000000..43f070d0c0 --- /dev/null +++ b/Common/ru.lproj/Intents.strings @@ -0,0 +1,36 @@ +/* (No Comment) */ +"9KhaIS" = "Я установил пресет"; + +/* (No Comment) */ +"80eo5o" = "Добавить запись углеводов"; + +/* (No Comment) */ +"b085BW" = "Я не смог установить пресет."; + +/* (No Comment) */ +"I4OZy8" = "Включить пресет временной цели"; + +/* (No Comment) */ +"lYMuWV" = "Имя пресета"; + +/* (No Comment) */ +"nDKAmn" = "Как называется пресет, которое вы хотите установить?"; + +/* (No Comment) */ +"OcNxIj" = "Добавить запись углеводов"; + +/* (No Comment) */ +"oLQSsJ" = "Включить '${overrideName}' пресет"; + +/* (No Comment) */ +"XNNmtH" = "Включить пресет в Loop"; + +/* (No Comment) */ +"yBzwCL" = "Выбор временных целей"; + +/* (No Comment) */ +"yc02Yq" = "Добавьте запись углеводов в алгоритм цикла"; + +/* (No Comment) */ +"ZZ3mtM" = "Включение пресета ВЦ в Loop"; + diff --git a/Common/ru.lproj/Localizable.strings b/Common/ru.lproj/Localizable.strings new file mode 100644 index 0000000000..29b8b0e0e1 --- /dev/null +++ b/Common/ru.lproj/Localizable.strings @@ -0,0 +1,54 @@ +/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ +"%1$@ v%2$@" = "%1$@ v%2$@"; + +/* Title of the user activity for adding carbs */ +"Add Carb Entry" = "Введите углеводы"; + +/* Lesson subtitle */ +"Computes the percentage of glucose measurements within a specified range" = "Вычисляет процент измерений глюкозы в заданном диапазоне"; + +/* Title of the button to begin lesson execution */ +"Continue" = "Продолжить"; + +/* The short unit display string for decibles */ +"dB" = "dB"; + +/* The short unit display string for grams */ +"g" = "г"; + +/* Placeholder for upper range entry */ +"Maximum" = "Максимум"; + +/* The short unit display string for milligrams of glucose per decilter */ +"mg/dL" = "мг/дл"; + +/* Placeholder for lower range entry */ +"Minimum" = "Минимум"; + +/* The short unit display string for millimoles of glucose per liter */ +"mmol/L" = "ммоль/л"; + +/* Lesson result text for no data */ +"No data available" = "Данные недоступны"; + +/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ +"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; + +/* Section title for glucose range */ +"Range" = "Диапазон"; + +/* Title of config entry */ +"Start Date" = "Дата начала"; + +/* Lesson title */ +"Time in Range" = "Время в диапазоне"; + +/* The short unit display string for international units of insulin */ +"U" = "ед"; + +/* Lesson subtitle */ +"Visualizes the most frequent glucose values by time of day" = "Визуализация наиболее частых значений глюкозы по времени суток"; + +/* Unit string for a count of calendar weeks */ +"Weeks" = "Недели"; + diff --git a/Common/sk.lproj/Intents.strings b/Common/sk.lproj/Intents.strings new file mode 100644 index 0000000000..ef6d2d0920 --- /dev/null +++ b/Common/sk.lproj/Intents.strings @@ -0,0 +1,36 @@ +/* (No Comment) */ +"9KhaIS" = "Nastavil som predvoľbu"; + +/* (No Comment) */ +"80eo5o" = "Zadať sacharidy"; + +/* (No Comment) */ +"b085BW" = "I wasn't able to set the preset."; + +/* (No Comment) */ +"I4OZy8" = "Enable Override Preset"; + +/* (No Comment) */ +"lYMuWV" = "Override Name"; + +/* (No Comment) */ +"nDKAmn" = "What's the name of the override you'd like to set?"; + +/* (No Comment) */ +"OcNxIj" = "Zadať sacharidy"; + +/* (No Comment) */ +"oLQSsJ" = "Enable '$%1$@' Override Preset"; + +/* (No Comment) */ +"XNNmtH" = "Enable preset in Loop"; + +/* (No Comment) */ +"yBzwCL" = "Override Selection"; + +/* (No Comment) */ +"yc02Yq" = "Zadať sacharidy do Loop"; + +/* (No Comment) */ +"ZZ3mtM" = "Enable an override preset in Loop"; + diff --git a/Common/sk.lproj/Localizable.strings b/Common/sk.lproj/Localizable.strings new file mode 100644 index 0000000000..4e49851fe2 --- /dev/null +++ b/Common/sk.lproj/Localizable.strings @@ -0,0 +1,54 @@ +/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ +"%1$@ v%2$@" = "%1$@ v %2$@"; + +/* Title of the user activity for adding carbs */ +"Add Carb Entry" = "Zadať sacharidy"; + +/* Lesson subtitle */ +"Computes the percentage of glucose measurements within a specified range" = "Vypočíta percento meraní glykémie v rámci špecifikovaného rozsahu"; + +/* Title of the button to begin lesson execution */ +"Continue" = "Pokračovať"; + +/* The short unit display string for decibles */ +"dB" = "dB"; + +/* The short unit display string for grams */ +"g" = "g"; + +/* Placeholder for upper range entry */ +"Maximum" = "Maximum"; + +/* The short unit display string for milligrams of glucose per decilter */ +"mg/dL" = "mg/dL"; + +/* Placeholder for lower range entry */ +"Minimum" = "Minimum"; + +/* The short unit display string for millimoles of glucose per liter */ +"mmol/L" = "mmol/L"; + +/* Lesson result text for no data */ +"No data available" = "Údaje nie sú k dispozícii"; + +/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ +"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; + +/* Section title for glucose range */ +"Range" = "Rozsah"; + +/* Title of config entry */ +"Start Date" = "Dátum začiatku"; + +/* Lesson title */ +"Time in Range" = "Čas v rozsahu"; + +/* The short unit display string for international units of insulin */ +"U" = "j"; + +/* Lesson subtitle */ +"Visualizes the most frequent glucose values by time of day" = "Zobrazuje najčastejšie hodnoty glykémie podľa hodín dňa"; + +/* Unit string for a count of calendar weeks */ +"Weeks" = "Týždne"; + diff --git a/Common/sv.lproj/Intents.strings b/Common/sv.lproj/Intents.strings new file mode 100644 index 0000000000..25e1a6e213 --- /dev/null +++ b/Common/sv.lproj/Intents.strings @@ -0,0 +1,36 @@ +/* (No Comment) */ +"9KhaIS" = "I've set the preset"; + +/* (No Comment) */ +"80eo5o" = "Lägg till kolhydrater"; + +/* (No Comment) */ +"b085BW" = "I wasn't able to set the preset."; + +/* (No Comment) */ +"I4OZy8" = "Enable Override Preset"; + +/* (No Comment) */ +"lYMuWV" = "Override Name"; + +/* (No Comment) */ +"nDKAmn" = "What's the name of the override you'd like to set?"; + +/* (No Comment) */ +"OcNxIj" = "Lägg till kolhydrater"; + +/* (No Comment) */ +"oLQSsJ" = "Enable '${overrideName}' Override Preset"; + +/* (No Comment) */ +"XNNmtH" = "Enable preset in Loop"; + +/* (No Comment) */ +"yBzwCL" = "Override Selection"; + +/* (No Comment) */ +"yc02Yq" = "Lägg till kolhydrater för att kunna loopa"; + +/* (No Comment) */ +"ZZ3mtM" = "Enable an override preset in Loop"; + diff --git a/Common/sv.lproj/Localizable.strings b/Common/sv.lproj/Localizable.strings new file mode 100644 index 0000000000..c86447edaa --- /dev/null +++ b/Common/sv.lproj/Localizable.strings @@ -0,0 +1,57 @@ +/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ +"%1$@ v%2$@" = "%1$@ v%2$@"; + +/* Title of the user activity for adding carbs */ +"Add Carb Entry" = "Lägg till kolhydrater"; + +/* Lesson subtitle */ +"Computes the percentage of glucose measurements within a specified range" = "Beräknar procentandelen glukosmätningar inom ett specifikt målvärde"; + +/* Title of the button to begin lesson execution */ +"Continue" = "Fortsätt"; + +/* The short unit display string for decibles */ +"dB" = "dB"; + +/* The short unit display string for grams */ +"g" = "g"; + +/* Placeholder for upper range entry */ +"Maximum" = "Maximum"; + +/* The short unit display string for milligrams of glucose per decilter */ +"mg/dL" = "mg/dl"; + +/* Placeholder for lower range entry */ +"Minimum" = "Minimum"; + +/* The short unit display string for millimoles of glucose per liter */ +"mmol/L" = "mmol/l"; + +/* Lesson title */ +"Modal Day" = "Genomsnittlig dag"; + +/* Lesson result text for no data */ +"No data available" = "Ingen data tillgänglig"; + +/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ +"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; + +/* Section title for glucose range */ +"Range" = "Målvärde"; + +/* Title of config entry */ +"Start Date" = "Starttid"; + +/* Lesson title */ +"Time in Range" = "Tid inom målvärde"; + +/* The short unit display string for international units of insulin */ +"U" = "E"; + +/* Lesson subtitle */ +"Visualizes the most frequent glucose values by time of day" = "Visar de vanligaste glukosvärdena under olika tider på dagen"; + +/* Unit string for a count of calendar weeks */ +"Weeks" = "Veckor"; + diff --git a/Common/tr.lproj/Intents.strings b/Common/tr.lproj/Intents.strings new file mode 100644 index 0000000000..416c11d092 --- /dev/null +++ b/Common/tr.lproj/Intents.strings @@ -0,0 +1,36 @@ +/* (No Comment) */ +"9KhaIS" = "Ön ayarı yaptım"; + +/* (No Comment) */ +"80eo5o" = "Karb Girişi Ekle"; + +/* (No Comment) */ +"b085BW" = "Ön ayarı yapamadım."; + +/* (No Comment) */ +"I4OZy8" = "Ön Ayarı Geçersiz Kıl"; + +/* (No Comment) */ +"lYMuWV" = "Geçersiz Kılma Adı"; + +/* (No Comment) */ +"nDKAmn" = "Ayarlamak istediğiniz geçersiz kılmanın adı nedir?"; + +/* (No Comment) */ +"OcNxIj" = "Karb Girişi Ekle"; + +/* (No Comment) */ +"oLQSsJ" = "'${overrideName}' Ön Ayarını Geçersiz Kılmayı Etkinleştir"; + +/* (No Comment) */ +"XNNmtH" = "Döngüde ön ayarı etkinleştir"; + +/* (No Comment) */ +"yBzwCL" = "Geçersiz Kılma Seçimi"; + +/* (No Comment) */ +"yc02Yq" = "Döngüye bir karbonhidrat girişi ekleyin"; + +/* (No Comment) */ +"ZZ3mtM" = "Döngüde ön ayarı geçersiz kıl"; + diff --git a/Common/tr.lproj/Localizable.strings b/Common/tr.lproj/Localizable.strings new file mode 100644 index 0000000000..f924e32220 --- /dev/null +++ b/Common/tr.lproj/Localizable.strings @@ -0,0 +1,57 @@ +/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ +"%1$@ v%2$@" = "%1$@ v%2$@"; + +/* Title of the user activity for adding carbs */ +"Add Carb Entry" = "Karbonhidrat Girişi Ekle"; + +/* Lesson subtitle */ +"Computes the percentage of glucose measurements within a specified range" = "Belirli bir aralıktaki glikoz ölçümlerinin yüzdesini hesaplar"; + +/* Title of the button to begin lesson execution */ +"Continue" = "Devam et"; + +/* The short unit display string for decibles */ +"dB" = "dB"; + +/* The short unit display string for grams */ +"g" = "gr"; + +/* Placeholder for upper range entry */ +"Maximum" = "Maksimum"; + +/* The short unit display string for milligrams of glucose per decilter */ +"mg/dL" = "mg/dL"; + +/* Placeholder for lower range entry */ +"Minimum" = "Minimum"; + +/* The short unit display string for millimoles of glucose per liter */ +"mmol/L" = "mmol/L"; + +/* Lesson title */ +"Modal Day" = "Modal Gün"; + +/* Lesson result text for no data */ +"No data available" = "Kullanılabilir veri yok"; + +/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ +"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; + +/* Section title for glucose range */ +"Range" = "Aralık"; + +/* Title of config entry */ +"Start Date" = "Başlangıç tarihi"; + +/* Lesson title */ +"Time in Range" = "Aralıktaki Süre"; + +/* The short unit display string for international units of insulin */ +"U" = "Ü"; + +/* Lesson subtitle */ +"Visualizes the most frequent glucose values by time of day" = "En sık görülen glikoz değerlerini günün saatine göre görselleştirir"; + +/* Unit string for a count of calendar weeks */ +"Weeks" = "Hafta"; + diff --git a/Common/uk.lproj/Intents.strings b/Common/uk.lproj/Intents.strings new file mode 100644 index 0000000000..69202aa99c --- /dev/null +++ b/Common/uk.lproj/Intents.strings @@ -0,0 +1,36 @@ +/* (No Comment) */ +"9KhaIS" = "I've set the preset"; + +/* (No Comment) */ +"80eo5o" = "Add Carb Entry"; + +/* (No Comment) */ +"b085BW" = "I wasn't able to set the preset."; + +/* (No Comment) */ +"I4OZy8" = "Enable Override Preset"; + +/* (No Comment) */ +"lYMuWV" = "Override Name"; + +/* (No Comment) */ +"nDKAmn" = "What's the name of the override you'd like to set?"; + +/* (No Comment) */ +"OcNxIj" = "Add Carb Entry"; + +/* (No Comment) */ +"oLQSsJ" = "Enable '${overrideName}' Override Preset"; + +/* (No Comment) */ +"XNNmtH" = "Enable preset in Loop"; + +/* (No Comment) */ +"yBzwCL" = "Override Selection"; + +/* (No Comment) */ +"yc02Yq" = "Add a carb entry to Loop"; + +/* (No Comment) */ +"ZZ3mtM" = "Enable an override preset in Loop"; + diff --git a/Common/vi.lproj/Intents.strings b/Common/vi.lproj/Intents.strings new file mode 100644 index 0000000000..26f9af1fd2 --- /dev/null +++ b/Common/vi.lproj/Intents.strings @@ -0,0 +1,36 @@ +/* (No Comment) */ +"9KhaIS" = "I've set the preset"; + +/* (No Comment) */ +"80eo5o" = "Khai báo Carb"; + +/* (No Comment) */ +"b085BW" = "I wasn't able to set the preset."; + +/* (No Comment) */ +"I4OZy8" = "Enable Override Preset"; + +/* (No Comment) */ +"lYMuWV" = "Override Name"; + +/* (No Comment) */ +"nDKAmn" = "What's the name of the override you'd like to set?"; + +/* (No Comment) */ +"OcNxIj" = "Khai báo Carb"; + +/* (No Comment) */ +"oLQSsJ" = "Enable '${overrideName}' Override Preset"; + +/* (No Comment) */ +"XNNmtH" = "Enable preset in Loop"; + +/* (No Comment) */ +"yBzwCL" = "Override Selection"; + +/* (No Comment) */ +"yc02Yq" = "Khai báo khối lượng Carb cho Loop"; + +/* (No Comment) */ +"ZZ3mtM" = "Enable an override preset in Loop"; + diff --git a/Common/vi.lproj/Localizable.strings b/Common/vi.lproj/Localizable.strings new file mode 100644 index 0000000000..b97378302c --- /dev/null +++ b/Common/vi.lproj/Localizable.strings @@ -0,0 +1,24 @@ +/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ +"%1$@ v%2$@" = "%1$@ v%2$@"; + +/* Title of the user activity for adding carbs */ +"Add Carb Entry" = "Khai báo khối lượng Carb"; + +/* The short unit display string for decibles */ +"dB" = "dB"; + +/* The short unit display string for grams */ +"g" = "g"; + +/* The short unit display string for milligrams of glucose per decilter */ +"mg/dL" = "mg/dL"; + +/* The short unit display string for millimoles of glucose per liter */ +"mmol/L" = "mmol/L"; + +/* Format string for combining localized numeric value and unit. (1: numeric value)(2: unit) */ +"QUANTITY_VALUE_AND_UNIT" = "%1$@ %2$@"; + +/* The short unit display string for international units of insulin */ +"U" = "U"; + diff --git a/Common/zh-Hans.lproj/Intents.strings b/Common/zh-Hans.lproj/Intents.strings new file mode 100644 index 0000000000..3c86a8a391 --- /dev/null +++ b/Common/zh-Hans.lproj/Intents.strings @@ -0,0 +1,36 @@ +/* (No Comment) */ +"9KhaIS" = "I've set the preset"; + +/* (No Comment) */ +"80eo5o" = "添加碳水化合物"; + +/* (No Comment) */ +"b085BW" = "I wasn't able to set the preset."; + +/* (No Comment) */ +"I4OZy8" = "Enable Override Preset"; + +/* (No Comment) */ +"lYMuWV" = "Override Name"; + +/* (No Comment) */ +"nDKAmn" = "What's the name of the override you'd like to set?"; + +/* (No Comment) */ +"OcNxIj" = "添加碳水化合物"; + +/* (No Comment) */ +"oLQSsJ" = "Enable '${overrideName}' Override Preset"; + +/* (No Comment) */ +"XNNmtH" = "Enable preset in Loop"; + +/* (No Comment) */ +"yBzwCL" = "Override Selection"; + +/* (No Comment) */ +"yc02Yq" = "将碳水化合物添加到Loop"; + +/* (No Comment) */ +"ZZ3mtM" = "Enable an override preset in Loop"; + diff --git a/Common/zh-Hans.lproj/Localizable.strings b/Common/zh-Hans.lproj/Localizable.strings new file mode 100644 index 0000000000..62b18b0ecd --- /dev/null +++ b/Common/zh-Hans.lproj/Localizable.strings @@ -0,0 +1,45 @@ +/* Title of the user activity for adding carbs */ +"Add Carb Entry" = "添加碳水化合物"; + +/* Lesson subtitle */ +"Computes the percentage of glucose measurements within a specified range" = "计算在指定范围内的血糖测量值的百分比"; + +/* Title of the button to begin lesson execution */ +"Continue" = "继续"; + +/* The short unit display string for grams */ +"g" = "克"; + +/* Placeholder for upper range entry */ +"Maximum" = "最大"; + +/* The short unit display string for milligrams of glucose per decilter */ +"mg/dL" = "毫克/分升"; + +/* Placeholder for lower range entry */ +"Minimum" = "最小"; + +/* The short unit display string for millimoles of glucose per liter */ +"mmol/L" = "毫摩尔/升"; + +/* Lesson result text for no data */ +"No data available" = "无数据"; + +/* Section title for glucose range */ +"Range" = "范围"; + +/* Title of config entry */ +"Start Date" = "开始日期"; + +/* Lesson title */ +"Time in Range" = "在目标范围的时间"; + +/* The short unit display string for international units of insulin */ +"U" = "单位"; + +/* Lesson subtitle */ +"Visualizes the most frequent glucose values by time of day" = "全天血糖数据"; + +/* Unit string for a count of calendar weeks */ +"Weeks" = "周"; + diff --git a/Documentation/Testing/Images/mock_managers.png b/Documentation/Testing/Images/mock_managers.png new file mode 100644 index 0000000000..7c5e7b3dff Binary files /dev/null and b/Documentation/Testing/Images/mock_managers.png differ diff --git a/Documentation/Testing/Images/rewind.png b/Documentation/Testing/Images/rewind.png new file mode 100644 index 0000000000..1bd675e79d Binary files /dev/null and b/Documentation/Testing/Images/rewind.png differ diff --git a/Documentation/Testing/Images/scenarios_menu.png b/Documentation/Testing/Images/scenarios_menu.png new file mode 100644 index 0000000000..604b404de8 Binary files /dev/null and b/Documentation/Testing/Images/scenarios_menu.png differ diff --git a/Documentation/Testing/Images/scenarios_url.png b/Documentation/Testing/Images/scenarios_url.png new file mode 100644 index 0000000000..de2024994d Binary files /dev/null and b/Documentation/Testing/Images/scenarios_url.png differ diff --git a/Documentation/Testing/Scenarios.md b/Documentation/Testing/Scenarios.md new file mode 100644 index 0000000000..fabf589f97 --- /dev/null +++ b/Documentation/Testing/Scenarios.md @@ -0,0 +1,67 @@ +# Guide: Testing Scenarios + +## Purpose + +This document describes how to load data-based scenarios, including glucose values, dose history, and carb entries, into Loop on demand. + +## File Format + +A scenario consists of a single JSON file containing glucose, basal, bolus, and carb entry histories. Each history corresponds to a property of the scenario JSON object—a list of individual entries. Each entry has one or more properties describing its value (e.g. `unitsPerHourValue` and `duration`) and a _relative_ date offset, in seconds (e.g. 0 means 'right now' and -300 means '5 minutes ago'). + +For example, a carb entry history might look like this: + +```json +"carbEntries": [ + { + "gramValue": 30, + "dateOffset": -300, + "absorptionTime": 10800 + }, + { + "gramValue": 15, + "dateOffset": 900, + "absorptionTime": 7200, + "enteredAtOffset": -900 + } +] +``` + +Carb entries have two date offsets: `dateOffset`, which describes the date at which carbs were consumed, and `enteredAtOffset`, which describes the date at which the carb entry was created. The second carb entry in the example above was entered 30 minutes early. + +## Generating Scenarios + +A Python script with classes corresponding to the entry types is available at `/Scripts/make_scenario.py`. Running it will generate a sample script, which will allow you to inspect the file format in more detail. + +## Loading Scenarios + +Launch Loop in the Xcode simulator. + +Before loading scenarios, mock pump and CGM managers must be enabled in Loop. From the status screen, tap the settings icon in the bottom-right corner; then, tap on each of the pump and CGM rows and select the Simulator option from the presented action sheets: + +![](Images/mock_managers.png) + +Next, type 'scenario' in the search bar in the bottom-right corner of the Xcode console with the Loop app running: + +![](Images/scenarios_url.png) + +The first line will include `[TestingScenariosManager]` and a path to the simulator-specific directory in which to place scenario JSON files. + +With one or more scenarios placed in the listed directory, the debug menu can be activated by "shaking" the iPhone: in the simulator, press ^⌘Z. The scenario selection screen will appear: + +![](Images/scenarios_menu.png) + +Tap on a scenario to select it, then press 'Load' in the top-right corner to load it into Loop. + +With the app running, additional scenarios can be added to the scenarios directory; the changes will be detected, and the scenario list reloaded. + +## Time Travel + +Because all historic date offsets are relative, scenarios can be stepped through one or more loop iterations at a time, so long as the scenario contains sufficient past or future data. + +Swiping right or left on a scenario cell reveals the 'rewind' or 'advance' button, respectively: + +![](Images/rewind.png) + +Tap on the button, and you will be prompted for a number of loop iterations to progress backward or forward in time. Note that advancing forward will run the full algorithm for each step and in turn apply the suggested basal at each decision point. + +For convenience, an active scenario can be stepped through without leaving the status screen. Swipe right or left on the toolbar at the bottom of the screen to move one loop iteration into the past or future, respectively. diff --git a/DoseMathTests/Base.lproj/InfoPlist.strings b/DoseMathTests/Base.lproj/InfoPlist.strings new file mode 100644 index 0000000000..874e8a4532 --- /dev/null +++ b/DoseMathTests/Base.lproj/InfoPlist.strings @@ -0,0 +1 @@ +/* No Localized Strings */ diff --git a/DoseMathTests/DoseMathTests.swift b/DoseMathTests/DoseMathTests.swift deleted file mode 100644 index 4a52f5b772..0000000000 --- a/DoseMathTests/DoseMathTests.swift +++ /dev/null @@ -1,650 +0,0 @@ -// -// DoseMathTests.swift -// NateradeTests -// -// Created by Nathan Racklyeft on 3/8/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import XCTest -import HealthKit -import InsulinKit -import LoopKit - - -extension XCTestCase { - public var bundle: Bundle { - return Bundle(for: type(of: self)) - } - - public func loadFixture(_ resourceName: String) -> T { - let path = bundle.path(forResource: resourceName, ofType: "json")! - return try! JSONSerialization.jsonObject(with: Data(contentsOf: URL(fileURLWithPath: path)), options: []) as! T - } -} - - -public typealias JSONDictionary = [String: Any] - - -extension DateFormatter { - static func ISO8601LocalTimeDateFormatter() -> Self { - let dateFormatter = self.init() - - dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" - dateFormatter.locale = Locale(identifier: "en_US_POSIX") - - return dateFormatter - } -} - - -struct GlucoseFixtureValue: GlucoseValue { - let startDate: Date - let quantity: HKQuantity - - init(startDate: Date, quantity: HKQuantity) { - self.startDate = startDate - self.quantity = quantity - } -} - - -class RecommendTempBasalTests: XCTestCase { - - fileprivate let maxBasalRate = 3.0 - - func loadGlucoseValueFixture(_ resourceName: String) -> [GlucoseValue] { - let fixture: [JSONDictionary] = loadFixture(resourceName) - let dateFormatter = DateFormatter.ISO8601LocalTimeDateFormatter() - - return fixture.map { - return GlucoseFixtureValue( - startDate: dateFormatter.date(from: $0["date"] as! String)!, - quantity: HKQuantity(unit: HKUnit.milligramsPerDeciliterUnit(), doubleValue: $0["amount"] as! Double) - ) - } - } - - func loadBasalRateScheduleFixture(_ resourceName: String) -> BasalRateSchedule { - let fixture: [JSONDictionary] = loadFixture(resourceName) - - let items = fixture.map { - return RepeatingScheduleValue(startTime: TimeInterval(minutes: $0["minutes"] as! Double), value: $0["rate"] as! Double) - } - - return BasalRateSchedule(dailyItems: items)! - } - - var basalRateSchedule: BasalRateSchedule { - return loadBasalRateScheduleFixture("read_selected_basal_profile") - } - - var glucoseTargetRange: GlucoseRangeSchedule { - return GlucoseRangeSchedule(unit: HKUnit.milligramsPerDeciliterUnit(), dailyItems: [RepeatingScheduleValue(startTime: TimeInterval(0), value: DoubleRange(minValue: 90, maxValue: 120))], workoutRange: nil)! - } - - var insulinSensitivitySchedule: InsulinSensitivitySchedule { - return InsulinSensitivitySchedule(unit: HKUnit.milligramsPerDeciliterUnit(), dailyItems: [RepeatingScheduleValue(startTime: 0.0, value: 60.0)])! - } - - func testNoChange() { - let glucose = loadGlucoseValueFixture("recommend_temp_basal_no_change_glucose") - - let dose = DoseMath.recommendTempBasalFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - lastTempBasal: nil, - maxBasalRate: maxBasalRate, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule - ) - - XCTAssertNil(dose) - } - - func testStartHighEndInRange() { - let glucose = loadGlucoseValueFixture("recommend_temp_basal_start_high_end_in_range") - - var dose = DoseMath.recommendTempBasalFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - lastTempBasal: nil, - maxBasalRate: maxBasalRate, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule - ) - - XCTAssertNil(dose) - - // Cancel existing temp basal - let lastTempBasal = DoseEntry( - type: .tempBasal, - startDate: glucose.first!.startDate.addingTimeInterval(TimeInterval(minutes: -11)), - endDate: glucose.first!.startDate.addingTimeInterval(TimeInterval(minutes: 19)), - value: 0.125, - unit: .unitsPerHour - ) - - dose = DoseMath.recommendTempBasalFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - lastTempBasal: lastTempBasal, - maxBasalRate: maxBasalRate, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule - ) - - XCTAssertEqual(0, dose!.rate) - XCTAssertEqual(TimeInterval(minutes: 0), dose!.duration) - } - - func testStartLowEndInRange() { - let glucose = loadGlucoseValueFixture("recommend_temp_basal_start_low_end_in_range") - - var dose = DoseMath.recommendTempBasalFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - lastTempBasal: nil, - maxBasalRate: maxBasalRate, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule - ) - - XCTAssertNil(dose) - - let lastTempBasal = DoseEntry( - type: .tempBasal, - startDate: glucose.first!.startDate.addingTimeInterval(TimeInterval(minutes: -11)), - endDate: glucose.first!.startDate.addingTimeInterval(TimeInterval(minutes: 19)), - value: 1.225, - unit: .unitsPerHour - ) - - dose = DoseMath.recommendTempBasalFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - lastTempBasal: lastTempBasal, - maxBasalRate: maxBasalRate, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule - ) - - XCTAssertEqual(0, dose!.rate) - XCTAssertEqual(TimeInterval(minutes: 0), dose!.duration) - } - - func testCorrectLowAtMin() { - let glucose = loadGlucoseValueFixture("recommend_temp_basal_correct_low_at_min") - - // Cancel existing dose - var lastTempBasal = DoseEntry( - type: .tempBasal, - startDate: glucose.first!.startDate.addingTimeInterval(TimeInterval(minutes: -21)), - endDate: glucose.first!.startDate.addingTimeInterval(TimeInterval(minutes: 9)), - value: 0.125, - unit: .unitsPerHour - ) - - var dose = DoseMath.recommendTempBasalFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - lastTempBasal: lastTempBasal, - maxBasalRate: maxBasalRate, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule - ) - - XCTAssertEqual(0, dose!.rate) - XCTAssertEqual(TimeInterval(minutes: 0), dose!.duration) - - // Allow predictive temp below range - dose = DoseMath.recommendTempBasalFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - lastTempBasal: nil, - maxBasalRate: maxBasalRate, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule - ) - - XCTAssertNil(dose) - - lastTempBasal = DoseEntry( - type: .tempBasal, - startDate: glucose.first!.startDate.addingTimeInterval(TimeInterval(minutes: -21)), - endDate: glucose.first!.startDate.addingTimeInterval(TimeInterval(minutes: 9)), - value: 0.125, - unit: .unitsPerHour - ) - - dose = DoseMath.recommendTempBasalFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - lastTempBasal: lastTempBasal, - maxBasalRate: maxBasalRate, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule - ) - - XCTAssertEqual(0, dose!.rate) - XCTAssertEqual(TimeInterval(minutes: 0), dose!.duration) - } - - func testStartHighEndLow() { - let glucose = loadGlucoseValueFixture("recommend_temp_basal_start_high_end_low") - - let dose = DoseMath.recommendTempBasalFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - lastTempBasal: nil, - maxBasalRate: maxBasalRate, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule - ) - - XCTAssertEqual(0, dose!.rate) - XCTAssertEqual(TimeInterval(minutes: 30), dose!.duration) - } - - func testStartLowEndHigh() { - let glucose = loadGlucoseValueFixture("recommend_temp_basal_start_low_end_high") - - // Allow predictive temp below range - var dose = DoseMath.recommendTempBasalFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - lastTempBasal: nil, - maxBasalRate: maxBasalRate, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule - ) - - XCTAssertNil(dose) - - let lastTempBasal = DoseEntry( - type: .tempBasal, - startDate: glucose.first!.startDate.addingTimeInterval(TimeInterval(minutes: -11)), - endDate: glucose.first!.startDate.addingTimeInterval(TimeInterval(minutes: 19)), - value: 1.225, - unit: .unitsPerHour - ) - - dose = DoseMath.recommendTempBasalFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - lastTempBasal: lastTempBasal, - maxBasalRate: maxBasalRate, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule - ) - - XCTAssertEqual(0, dose!.rate) - XCTAssertEqual(TimeInterval(minutes: 0), dose!.duration) - } - - func testFlatAndHigh() { - let glucose = loadGlucoseValueFixture("recommend_temp_basal_flat_and_high") - - let dose = DoseMath.recommendTempBasalFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - lastTempBasal: nil, - maxBasalRate: maxBasalRate, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule - ) - - XCTAssertEqual(3.0, dose!.rate) - XCTAssertEqual(TimeInterval(minutes: 30), dose!.duration) - } - - func testHighAndFalling() { - let glucose = loadGlucoseValueFixture("recommend_temp_basal_high_and_falling") - - let dose = DoseMath.recommendTempBasalFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - lastTempBasal: nil, - maxBasalRate: maxBasalRate, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule - ) - - XCTAssertEqualWithAccuracy(1.425, dose!.rate, accuracy: 1.0 / 40.0) - XCTAssertEqual(TimeInterval(minutes: 30), dose!.duration) - } - - func testInRangeAndRising() { - let glucose = loadGlucoseValueFixture("recommend_temp_basal_in_range_and_rising") - - let dose = DoseMath.recommendTempBasalFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - lastTempBasal: nil, - maxBasalRate: maxBasalRate, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule - ) - - XCTAssertEqualWithAccuracy(1.475, dose!.rate, accuracy: 1.0 / 40.0) - XCTAssertEqual(TimeInterval(minutes: 30), dose!.duration) - } - - func testHighAndRising() { - let glucose = loadGlucoseValueFixture("recommend_temp_basal_high_and_rising") - - var dose = DoseMath.recommendTempBasalFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - lastTempBasal: nil, - maxBasalRate: maxBasalRate, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: self.insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule - ) - - XCTAssertEqual(3.0, dose!.rate) - XCTAssertEqual(TimeInterval(minutes: 30), dose!.duration) - - // Use mmol sensitivity value - let insulinSensitivitySchedule = InsulinSensitivitySchedule(unit: HKUnit.millimolesPerLiterUnit(), dailyItems: [RepeatingScheduleValue(startTime: 0.0, value: 3.33)])! - - dose = DoseMath.recommendTempBasalFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - lastTempBasal: nil, - maxBasalRate: maxBasalRate, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule - ) - - XCTAssertEqualWithAccuracy(2.975, dose!.rate, accuracy: 1.0 / 40.0) - XCTAssertEqual(TimeInterval(minutes: 30), dose!.duration) - } - - func testVeryLowAndRising() { - let glucose = loadGlucoseValueFixture("recommend_tamp_basal_very_low_end_in_range") - - let dose = DoseMath.recommendTempBasalFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - lastTempBasal: nil, - maxBasalRate: maxBasalRate, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: self.insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule - ) - - XCTAssertEqual(0.0, dose!.rate) - XCTAssertEqual(TimeInterval(minutes: 30), dose!.duration) - } - - - func testNoInputGlucose() { - let dose = DoseMath.recommendTempBasalFromPredictedGlucose([], - lastTempBasal: nil, - maxBasalRate: maxBasalRate, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule - ) - - XCTAssertNil(dose) - } -} - - -class RecommendBolusTests: XCTestCase { - - fileprivate let maxBolus = 10.0 - - func loadGlucoseValueFixture(_ resourceName: String) -> [GlucoseValue] { - let fixture: [JSONDictionary] = loadFixture(resourceName) - let dateFormatter = DateFormatter.ISO8601LocalTimeDateFormatter() - - return fixture.map { - return GlucoseFixtureValue( - startDate: dateFormatter.date(from: $0["date"] as! String)!, - quantity: HKQuantity(unit: HKUnit.milligramsPerDeciliterUnit(), doubleValue: $0["amount"] as! Double) - ) - } - } - - func loadBasalRateScheduleFixture(_ resourceName: String) -> BasalRateSchedule { - let fixture: [JSONDictionary] = loadFixture(resourceName) - - let items = fixture.map { - return RepeatingScheduleValue(startTime: TimeInterval(minutes: $0["minutes"] as! Double), value: $0["rate"] as! Double) - } - - return BasalRateSchedule(dailyItems: items)! - } - - var basalRateSchedule: BasalRateSchedule { - return loadBasalRateScheduleFixture("read_selected_basal_profile") - } - - var glucoseTargetRange: GlucoseRangeSchedule { - return GlucoseRangeSchedule(unit: HKUnit.milligramsPerDeciliterUnit(), dailyItems: [RepeatingScheduleValue(startTime: TimeInterval(0), value: DoubleRange(minValue: 90, maxValue: 120))], workoutRange: nil)! - } - - var insulinSensitivitySchedule: InsulinSensitivitySchedule { - return InsulinSensitivitySchedule(unit: HKUnit.milligramsPerDeciliterUnit(), dailyItems: [RepeatingScheduleValue(startTime: 0.0, value: 60.0)])! - } - - func testNoChange() { - let glucose = loadGlucoseValueFixture("recommend_temp_basal_no_change_glucose") - - let dose = DoseMath.recommendBolusFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - lastTempBasal: nil, - maxBolus: maxBolus, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule - ) - - XCTAssertEqual(0, dose) - } - - func testStartHighEndInRange() { - let glucose = loadGlucoseValueFixture("recommend_temp_basal_start_high_end_in_range") - - var dose = DoseMath.recommendBolusFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - lastTempBasal: nil, - maxBolus: maxBolus, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule - ) - - XCTAssertEqual(0, dose) - - // Don't consider net-negative temp basal - let lastTempBasal = DoseEntry( - type: .tempBasal, - startDate: glucose.first!.startDate.addingTimeInterval(TimeInterval(minutes: -11)), - endDate: glucose.first!.startDate.addingTimeInterval(TimeInterval(minutes: 19)), - value: 0.01, - unit: .unitsPerHour - ) - - dose = DoseMath.recommendBolusFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - lastTempBasal: lastTempBasal, - maxBolus: maxBolus, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule - ) - - XCTAssertEqual(0, dose) - } - - func testStartLowEndInRange() { - let glucose = loadGlucoseValueFixture("recommend_temp_basal_start_low_end_in_range") - - let dose = DoseMath.recommendBolusFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - lastTempBasal: nil, - maxBolus: maxBolus, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule - ) - - XCTAssertEqual(0, dose) - } - - func testStartHighEndLow() { - let glucose = loadGlucoseValueFixture("recommend_temp_basal_start_high_end_low") - - let dose = DoseMath.recommendBolusFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - lastTempBasal: nil, - maxBolus: maxBolus, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule - ) - - XCTAssertEqual(0, dose) - } - - func testStartLowEndHigh() { - let glucose = loadGlucoseValueFixture("recommend_temp_basal_start_low_end_high") - - let dose = DoseMath.recommendBolusFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - lastTempBasal: nil, - maxBolus: maxBolus, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule - ) - - XCTAssertEqual(0, dose) - } - - func testFlatAndHigh() { - let glucose = loadGlucoseValueFixture("recommend_temp_basal_flat_and_high") - - let dose = DoseMath.recommendBolusFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - lastTempBasal: nil, - maxBolus: maxBolus, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule - ) - - XCTAssertEqualWithAccuracy(1.333, dose, accuracy: 1.0 / 40.0) - } - - func testHighAndFalling() { - let glucose = loadGlucoseValueFixture("recommend_temp_basal_high_and_falling") - - let dose = DoseMath.recommendBolusFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - lastTempBasal: nil, - maxBolus: maxBolus, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule - ) - - XCTAssertEqualWithAccuracy(0.067, dose, accuracy: 1.0 / 40.0) - } - - func testInRangeAndRising() { - let glucose = loadGlucoseValueFixture("recommend_temp_basal_in_range_and_rising") - - var dose = DoseMath.recommendBolusFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - lastTempBasal: nil, - maxBolus: maxBolus, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule - ) - - XCTAssertEqualWithAccuracy(0.083, dose, accuracy: 1.0 / 40.0) - - // Less existing temp - var lastTempBasal = DoseEntry( - type: .tempBasal, - startDate: glucose.first!.startDate.addingTimeInterval(TimeInterval(minutes: -11)), - endDate: glucose.first!.startDate.addingTimeInterval(TimeInterval(minutes: 19)), - value: 1.225, - unit: .unitsPerHour - ) - - dose = DoseMath.recommendBolusFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - lastTempBasal: lastTempBasal, - maxBolus: maxBolus, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule - ) - - XCTAssertEqualWithAccuracy(0, dose, accuracy: 1e-13) - - // But not a finished temp - lastTempBasal = DoseEntry( - type: .tempBasal, - startDate: glucose.first!.startDate.addingTimeInterval(TimeInterval(minutes: -35)), - endDate: glucose.first!.startDate.addingTimeInterval(TimeInterval(minutes: -5)), - value: 1.225, - unit: .unitsPerHour - ) - - dose = DoseMath.recommendBolusFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - lastTempBasal: lastTempBasal, - maxBolus: maxBolus, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule - ) - - XCTAssertEqualWithAccuracy(0.083, dose, accuracy: 1.0 / 40.0) - } - - func testHighAndRising() { - let glucose = loadGlucoseValueFixture("recommend_temp_basal_high_and_rising") - - var dose = DoseMath.recommendBolusFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - lastTempBasal: nil, - maxBolus: maxBolus, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: self.insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule - ) - - XCTAssertEqual(1.0, dose) - - // Use mmol sensitivity value - let insulinSensitivitySchedule = InsulinSensitivitySchedule(unit: HKUnit.millimolesPerLiterUnit(), dailyItems: [RepeatingScheduleValue(startTime: 0.0, value: 10.0 / 3)])! - - dose = DoseMath.recommendBolusFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - lastTempBasal: nil, - maxBolus: maxBolus, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule - ) - - XCTAssertEqualWithAccuracy(1.0, dose, accuracy: 1.0 / 40.0) - } - - func testNoInputGlucose() { - let dose = DoseMath.recommendBolusFromPredictedGlucose([], lastTempBasal: nil, maxBolus: 4, glucoseTargetRange: glucoseTargetRange, insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule) - - XCTAssertEqual(0, dose) - } - -} diff --git a/DoseMathTests/Fixtures/read_selected_basal_profile.json b/DoseMathTests/Fixtures/read_selected_basal_profile.json deleted file mode 100644 index ff046c85eb..0000000000 --- a/DoseMathTests/Fixtures/read_selected_basal_profile.json +++ /dev/null @@ -1,44 +0,0 @@ -[ - { - "i": 0, - "start": "00:00:00", - "rate": 0.9, - "minutes": 0 - }, - { - "i": 1, - "start": "04:00:00", - "rate": 0.925, - "minutes": 240 - }, - { - "i": 2, - "start": "07:00:00", - "rate": 0.85, - "minutes": 420 - }, - { - "i": 3, - "start": "10:00:00", - "rate": 0.85, - "minutes": 600 - }, - { - "i": 4, - "start": "12:00:00", - "rate": 0.75, - "minutes": 720 - }, - { - "i": 5, - "start": "15:00:00", - "rate": 0.8, - "minutes": 900 - }, - { - "i": 6, - "start": "22:00:00", - "rate": 0.9, - "minutes": 1320 - } -] \ No newline at end of file diff --git a/DoseMathTests/Fixtures/recommend_tamp_basal_very_low_end_in_range.json b/DoseMathTests/Fixtures/recommend_tamp_basal_very_low_end_in_range.json deleted file mode 100644 index 04f31ba3bf..0000000000 --- a/DoseMathTests/Fixtures/recommend_tamp_basal_very_low_end_in_range.json +++ /dev/null @@ -1,7 +0,0 @@ - [ - {"date": "2015-07-19T18:00:00", "amount": 60}, - {"date": "2015-07-19T18:30:00", "amount": 50}, - {"date": "2015-07-19T19:00:00", "amount": 60}, - {"date": "2015-07-19T19:30:00", "amount": 70}, - {"date": "2015-07-19T20:00:00", "amount": 100} -] diff --git a/DoseMathTests/Fixtures/recommend_temp_basal_correct_low_at_min.json b/DoseMathTests/Fixtures/recommend_temp_basal_correct_low_at_min.json deleted file mode 100644 index cb69338839..0000000000 --- a/DoseMathTests/Fixtures/recommend_temp_basal_correct_low_at_min.json +++ /dev/null @@ -1,7 +0,0 @@ -[ - {"date": "2015-07-19T18:00:00", "amount": 100}, - {"date": "2015-07-19T18:30:00", "amount": 90}, - {"date": "2015-07-19T19:00:00", "amount": 85}, - {"date": "2015-07-19T19:30:00", "amount": 90}, - {"date": "2015-07-19T20:00:00", "amount": 100} -] \ No newline at end of file diff --git a/DoseMathTests/Fixtures/recommend_temp_basal_flat_and_high.json b/DoseMathTests/Fixtures/recommend_temp_basal_flat_and_high.json deleted file mode 100644 index 92c0afadb0..0000000000 --- a/DoseMathTests/Fixtures/recommend_temp_basal_flat_and_high.json +++ /dev/null @@ -1,4 +0,0 @@ -[ - {"date": "2015-07-19T18:00:00", "amount": 200}, - {"date": "2015-07-19T18:30:00", "amount": 200}, -] \ No newline at end of file diff --git a/DoseMathTests/Fixtures/recommend_temp_basal_high_and_falling.json b/DoseMathTests/Fixtures/recommend_temp_basal_high_and_falling.json deleted file mode 100644 index 027174487e..0000000000 --- a/DoseMathTests/Fixtures/recommend_temp_basal_high_and_falling.json +++ /dev/null @@ -1,7 +0,0 @@ -[ - {"date": "2015-07-19T18:00:00", "amount": 240}, - {"date": "2015-07-19T18:30:00", "amount": 220}, - {"date": "2015-07-19T19:00:00", "amount": 200}, - {"date": "2015-07-19T19:30:00", "amount": 160}, - {"date": "2015-07-19T20:00:00", "amount": 124} -] \ No newline at end of file diff --git a/DoseMathTests/Fixtures/recommend_temp_basal_high_and_rising.json b/DoseMathTests/Fixtures/recommend_temp_basal_high_and_rising.json deleted file mode 100644 index 0e9b4e2dfc..0000000000 --- a/DoseMathTests/Fixtures/recommend_temp_basal_high_and_rising.json +++ /dev/null @@ -1,7 +0,0 @@ -[ - {"date": "2015-07-19T18:00:00", "amount": 140}, - {"date": "2015-07-19T18:30:00", "amount": 150}, - {"date": "2015-07-19T19:00:00", "amount": 160}, - {"date": "2015-07-19T19:30:00", "amount": 170}, - {"date": "2015-07-19T20:00:00", "amount": 180} -] \ No newline at end of file diff --git a/DoseMathTests/Fixtures/recommend_temp_basal_in_range_and_rising.json b/DoseMathTests/Fixtures/recommend_temp_basal_in_range_and_rising.json deleted file mode 100644 index 23bfe8e576..0000000000 --- a/DoseMathTests/Fixtures/recommend_temp_basal_in_range_and_rising.json +++ /dev/null @@ -1,7 +0,0 @@ -[ - {"date": "2015-07-19T18:00:00", "amount": 90}, - {"date": "2015-07-19T18:30:00", "amount": 100}, - {"date": "2015-07-19T19:00:00", "amount": 110}, - {"date": "2015-07-19T19:30:00", "amount": 120}, - {"date": "2015-07-19T20:00:00", "amount": 125} -] \ No newline at end of file diff --git a/DoseMathTests/Fixtures/recommend_temp_basal_no_change_glucose.json b/DoseMathTests/Fixtures/recommend_temp_basal_no_change_glucose.json deleted file mode 100644 index ec953d40f0..0000000000 --- a/DoseMathTests/Fixtures/recommend_temp_basal_no_change_glucose.json +++ /dev/null @@ -1,4 +0,0 @@ -[ - {"date": "2015-07-19T20:00:00", "amount": 100}, - {"date": "2015-07-19T20:30:00", "amount": 100} -] diff --git a/DoseMathTests/Fixtures/recommend_temp_basal_start_high_end_in_range.json b/DoseMathTests/Fixtures/recommend_temp_basal_start_high_end_in_range.json deleted file mode 100644 index 61c06d2397..0000000000 --- a/DoseMathTests/Fixtures/recommend_temp_basal_start_high_end_in_range.json +++ /dev/null @@ -1,7 +0,0 @@ -[ - {"date": "2015-07-19T18:00:00", "amount": 200}, - {"date": "2015-07-19T18:30:00", "amount": 180}, - {"date": "2015-07-19T19:00:00", "amount": 150}, - {"date": "2015-07-19T19:30:00", "amount": 120}, - {"date": "2015-07-19T20:00:00", "amount": 100} -] \ No newline at end of file diff --git a/DoseMathTests/Fixtures/recommend_temp_basal_start_high_end_low.json b/DoseMathTests/Fixtures/recommend_temp_basal_start_high_end_low.json deleted file mode 100644 index 30e88c87b3..0000000000 --- a/DoseMathTests/Fixtures/recommend_temp_basal_start_high_end_low.json +++ /dev/null @@ -1,7 +0,0 @@ -[ - {"date": "2015-07-19T18:00:00", "amount": 200}, - {"date": "2015-07-19T18:30:00", "amount": 160}, - {"date": "2015-07-19T19:00:00", "amount": 120}, - {"date": "2015-07-19T19:30:00", "amount": 80}, - {"date": "2015-07-19T20:00:00", "amount": 60} -] \ No newline at end of file diff --git a/DoseMathTests/Fixtures/recommend_temp_basal_start_low_end_high.json b/DoseMathTests/Fixtures/recommend_temp_basal_start_low_end_high.json deleted file mode 100644 index 65c92e7796..0000000000 --- a/DoseMathTests/Fixtures/recommend_temp_basal_start_low_end_high.json +++ /dev/null @@ -1,7 +0,0 @@ -[ - {"date": "2015-07-19T18:00:00", "amount": 60}, - {"date": "2015-07-19T18:30:00", "amount": 80}, - {"date": "2015-07-19T19:00:00", "amount": 120}, - {"date": "2015-07-19T19:30:00", "amount": 160}, - {"date": "2015-07-19T20:00:00", "amount": 200} -] \ No newline at end of file diff --git a/DoseMathTests/Fixtures/recommend_temp_basal_start_low_end_in_range.json b/DoseMathTests/Fixtures/recommend_temp_basal_start_low_end_in_range.json deleted file mode 100644 index 0f4c80901d..0000000000 --- a/DoseMathTests/Fixtures/recommend_temp_basal_start_low_end_in_range.json +++ /dev/null @@ -1,7 +0,0 @@ -[ - {"date": "2015-07-19T18:00:00", "amount": 60}, - {"date": "2015-07-19T18:30:00", "amount": 70}, - {"date": "2015-07-19T19:00:00", "amount": 80}, - {"date": "2015-07-19T19:30:00", "amount": 90}, - {"date": "2015-07-19T20:00:00", "amount": 100} -] \ No newline at end of file diff --git a/DoseMathTests/Info.plist b/DoseMathTests/Info.plist deleted file mode 100644 index 631d44c4bd..0000000000 --- a/DoseMathTests/Info.plist +++ /dev/null @@ -1,24 +0,0 @@ - - - - - CFBundleDevelopmentRegion - en - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - BNDL - CFBundleShortVersionString - 1.2.0 - CFBundleSignature - ???? - CFBundleVersion - 1 - - diff --git a/Interface.strings b/Interface.strings new file mode 100644 index 0000000000..c746b40682 --- /dev/null +++ b/Interface.strings @@ -0,0 +1,120 @@ + +/* Class = "WKInterfaceButton"; title = "🌮"; ObjectID = "0fo-Z3-hTi"; */ +"0fo-Z3-hTi.title" = "🌮"; + +/* Class = "WKInterfaceLabel"; text = "15"; ObjectID = "CWt-7U-cnK"; */ +"CWt-7U-cnK.text" = "15"; + +/* Class = "WKInterfaceLabel"; text = "—"; ObjectID = "CsQ-fc-KLC"; */ +"CsQ-fc-KLC.text" = "—"; + +/* Class = "WKInterfaceButton"; accessibilityLabel = "Add"; ObjectID = "DZc-Gn-RLu"; */ +"DZc-Gn-RLu.accessibilityLabel" = "Add"; + +/* Class = "WKInterfaceButton"; title = "+"; ObjectID = "DZc-Gn-RLu"; */ +"DZc-Gn-RLu.title" = "+"; + +/* Class = "WKInterfaceButton"; accessibilityLabel = "Subtract"; ObjectID = "Dh9-HV-fXy"; */ +"Dh9-HV-fXy.accessibilityLabel" = "Subtract"; + +/* Class = "WKInterfaceButton"; title = "−"; ObjectID = "Dh9-HV-fXy"; */ +"Dh9-HV-fXy.title" = "−"; + +/* Class = "WKInterfaceLabel"; text = "---"; ObjectID = "Dt1-kz-jMZ"; */ +"Dt1-kz-jMZ.text" = "---"; + +/* Class = "WKInterfaceLabel"; text = "—"; ObjectID = "IRi-4t-ESO"; */ +"IRi-4t-ESO.text" = "—"; + +/* Class = "WKInterfaceLabel"; text = "Running"; ObjectID = "JXa-s1-PJx"; */ +"JXa-s1-PJx.text" = "Running"; + +/* Class = "WKInterfaceLabel"; text = "TITLE"; ObjectID = "MZU-QV-PtZ"; */ +"MZU-QV-PtZ.text" = "TITLE"; + +/* Class = "WKInterfaceLabel"; text = "—"; ObjectID = "Mhe-aR-kQQ"; */ +"Mhe-aR-kQQ.text" = "—"; + +/* Class = "WKInterfaceButton"; title = "Bolus"; ObjectID = "Qsq-p5-1J0"; */ +"Qsq-p5-1J0.title" = "Bolus"; + +/* Class = "WKInterfaceLabel"; text = "Label"; ObjectID = "T4U-wP-dSW"; */ +"T4U-wP-dSW.text" = "Label"; + +/* Class = "WKInterfaceLabel"; text = "🏃‍♀️"; ObjectID = "UVY-pa-SUL"; */ +"UVY-pa-SUL.text" = "🏃‍♀️"; + +/* Class = "WKInterfaceLabel"; text = "10:09 AM"; ObjectID = "Ury-of-vQg"; */ +"Ury-of-vQg.text" = "10:09 AM"; + +/* Class = "WKInterfaceLabel"; text = ""; ObjectID = "XkS-y5-khE"; */ +"XkS-y5-khE.text" = ""; + +/* Class = "WKInterfaceButton"; title = "Add Carbs"; ObjectID = "b6f-3I-jki"; */ +"b6f-3I-jki.title" = "Add Carbs"; + +/* Class = "WKInterfaceButton"; title = "🍕"; ObjectID = "dPF-QZ-sh6"; */ +"dPF-QZ-sh6.title" = "🍕"; + +/* Class = "WKInterfaceMenuItem"; title = "2 hours"; ObjectID = "dPh-7b-Tfv"; */ +"dPh-7b-Tfv.title" = "2 hours"; + +/* Class = "WKInterfaceLabel"; text = "TOTAL CARBS"; ObjectID = "dea-qG-va8"; */ +"dea-qG-va8.text" = "TOTAL CARBS"; + +/* Class = "WKInterfaceButton"; accessibilityLabel = "Add"; ObjectID = "eu3-pj-GH3"; */ +"eu3-pj-GH3.accessibilityLabel" = "Add"; + +/* Class = "WKInterfaceButton"; title = "+"; ObjectID = "eu3-pj-GH3"; */ +"eu3-pj-GH3.title" = "+"; + +/* Class = "WKInterfaceLabel"; text = "Pre-Meal"; ObjectID = "f5G-bS-9pd"; */ +"f5G-bS-9pd.text" = "Pre-Meal"; + +/* Class = "WKInterfaceMenuItem"; title = "3 hours"; ObjectID = "fR1-7h-SNe"; */ +"fR1-7h-SNe.title" = "3 hours"; + +/* Class = "WKInterfaceButton"; title = "🍭"; ObjectID = "gAn-qe-OvX"; */ +"gAn-qe-OvX.title" = "🍭"; + +/* Class = "WKInterfaceButton"; accessibilityLabel = "Subtract"; ObjectID = "hjF-xr-cwO"; */ +"hjF-xr-cwO.accessibilityLabel" = "Subtract"; + +/* Class = "WKInterfaceButton"; title = "−"; ObjectID = "hjF-xr-cwO"; */ +"hjF-xr-cwO.title" = "−"; + +/* Class = "WKInterfaceLabel"; text = "Carbs"; ObjectID = "hln-CI-MRP"; */ +"hln-CI-MRP.text" = "Carbs"; + +/* Class = "WKInterfaceLabel"; text = "Bolus Failed"; ObjectID = "jj3-Gq-HBy"; */ +"jj3-Gq-HBy.text" = "Bolus Failed"; + +/* Class = "WKInterfaceLabel"; text = "0.000"; ObjectID = "mpK-zY-UvA"; */ +"mpK-zY-UvA.text" = "0.000"; + +/* Class = "WKInterfaceLabel"; text = "Override"; ObjectID = "nC0-X3-oFJ"; */ +"nC0-X3-oFJ.text" = "Override"; + +/* Class = "WKInterfaceController"; title = "Loop"; ObjectID = "rNf-Mh-tID"; */ +"rNf-Mh-tID.title" = "Loop"; + +/* Class = "WKInterfaceLabel"; text = "UNITS"; ObjectID = "rV7-d9-n6u"; */ +"rV7-d9-n6u.text" = "UNITS"; + +/* Class = "WKInterfaceLabel"; text = "Bolus"; ObjectID = "smL-Rc-IZh"; */ +"smL-Rc-IZh.text" = "Bolus"; + +/* Class = "WKInterfaceController"; title = "Loop"; ObjectID = "v5b-sO-bb8"; */ +"v5b-sO-bb8.title" = "Loop"; + +/* Class = "WKInterfaceMenuItem"; title = "1 hour"; ObjectID = "vL1-NA-WZ1"; */ +"vL1-NA-WZ1.title" = "1 hour"; + +/* Class = "WKInterfaceLabel"; text = "ACTIVE CARBS"; ObjectID = "ycL-5X-a05"; */ +"ycL-5X-a05.text" = "ACTIVE CARBS"; + +/* Class = "WKInterfaceLabel"; text = "---"; ObjectID = "yl8-ZP-c3l"; */ +"yl8-ZP-c3l.text" = "---"; + +/* Class = "WKInterfaceLabel"; text = "Label"; ObjectID = "zO8-x6-bZd"; */ +"zO8-x6-bZd.text" = "Label"; diff --git a/LICENSE.md b/LICENSE.md index 333c3ab974..15256eda23 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -41,7 +41,7 @@ Copyright (c) 2015 Nathan Racklyeft Copyright (c) 2016 LoopKit Authors ## RileyLinkKit.framework -*Including MinimedKit.framework, NightscoutUploadKit.framework, and RileyLinkBLEKit.framework* +*Including MinimedKit.framework, NightscoutUploadKit.framework, OmniKit.framework, and RileyLinkBLEKit.framework* Copyright (c) 2015 Pete Schwamb @@ -58,6 +58,11 @@ Copyright (c) 2016 Mark Wilson Copyright (c) 2015 Nathan Racklyeft Copyright (c) 2016 LoopKit Authors +## MKRingProgressView.framework + +Copyright (c) 2015 Max Konovalov + + > Permission is hereby granted, free of charge, to any person obtaining a copy > of this software and associated documentation files (the "Software"), to deal > in the Software without restriction, including without limitation the rights diff --git a/Learn/AppDelegate.swift b/Learn/AppDelegate.swift new file mode 100644 index 0000000000..53abb62dbb --- /dev/null +++ b/Learn/AppDelegate.swift @@ -0,0 +1,62 @@ +// +// AppDelegate.swift +// Learn +// +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import UIKit + +@UIApplicationMain +class AppDelegate: UIResponder, UIApplicationDelegate { + + var window: UIWindow? + + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + + guard + let nav = window?.rootViewController as? UINavigationController, + let lessonsVC = nav.topViewController as? LessonsViewController + else { + return false + } + + let dataManager = DataManager() + dataManager.authorize({ + DispatchQueue.main.async { + lessonsVC.lessons = [ + TimeInRangeLesson(dataManager: dataManager), + ModalDayLesson(dataManager: dataManager), + ] + } + }) + + return true + } + + func applicationWillResignActive(_ application: UIApplication) { + // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. + // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. + } + + func applicationDidEnterBackground(_ application: UIApplication) { + // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. + // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. + } + + func applicationWillEnterForeground(_ application: UIApplication) { + // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. + } + + func applicationDidBecomeActive(_ application: UIApplication) { + // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. + } + + func applicationWillTerminate(_ application: UIApplication) { + // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. + } + + +} + diff --git a/Learn/Assets.xcassets/AppIcon.appiconset/Contents.json b/Learn/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..d8db8d65fd --- /dev/null +++ b/Learn/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,98 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "size" : "20x20", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "20x20", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "29x29", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "29x29", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "40x40", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "40x40", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "60x60", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "60x60", + "scale" : "3x" + }, + { + "idiom" : "ipad", + "size" : "20x20", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "20x20", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "29x29", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "29x29", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "40x40", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "40x40", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "76x76", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "76x76", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "83.5x83.5", + "scale" : "2x" + }, + { + "idiom" : "ios-marketing", + "size" : "1024x1024", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Loop/Assets.xcassets/Contents.json b/Learn/Assets.xcassets/Contents.json similarity index 100% rename from Loop/Assets.xcassets/Contents.json rename to Learn/Assets.xcassets/Contents.json diff --git a/Learn/Base.lproj/Main.storyboard b/Learn/Base.lproj/Main.storyboard new file mode 100644 index 0000000000..e11dcdf980 --- /dev/null +++ b/Learn/Base.lproj/Main.storyboard @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Learn/Configuration/DateEntry.swift b/Learn/Configuration/DateEntry.swift new file mode 100644 index 0000000000..a36a9c5b98 --- /dev/null +++ b/Learn/Configuration/DateEntry.swift @@ -0,0 +1,43 @@ +// +// DateEntry.swift +// Learn +// +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import LoopKitUI + + +class DateEntry { + private(set) var date: Date + let title: String + let mode: UIDatePicker.Mode + + init(date: Date, title: String, mode: UIDatePicker.Mode) { + self.date = date + self.title = title + self.mode = mode + } +} + +extension DateEntry: LessonCellProviding { + func registerCell(for tableView: UITableView) { + tableView.register(DateAndDurationTableViewCell.nib(), forCellReuseIdentifier: DateAndDurationTableViewCell.className) + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: DateAndDurationTableViewCell.className, for: indexPath) as! DateAndDurationTableViewCell + cell.delegate = self + cell.titleLabel.text = title + cell.datePicker.isEnabled = true + cell.datePicker.datePickerMode = mode + cell.date = date + return cell + } +} + +extension DateEntry: DatePickerTableViewCellDelegate { + func datePickerTableViewCellDidUpdateDate(_ cell: DatePickerTableViewCell) { + date = cell.datePicker.date + } +} diff --git a/Learn/Configuration/DateIntervalEntry.swift b/Learn/Configuration/DateIntervalEntry.swift new file mode 100644 index 0000000000..3763b3bb9e --- /dev/null +++ b/Learn/Configuration/DateIntervalEntry.swift @@ -0,0 +1,52 @@ +// +// DateIntervalEntry.swift +// Learn +// +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import UIKit + + +class DateIntervalEntry: LessonSectionProviding { + let headerTitle: String? + + let footerTitle: String? + + let dateEntry: DateEntry + let numberEntry: NumberEntry + + let cells: [LessonCellProviding] + + init(headerTitle: String? = nil, footerTitle: String? = nil, start: Date, weeks: Int) { + self.headerTitle = headerTitle + self.footerTitle = footerTitle + + self.dateEntry = DateEntry(date: start, title: NSLocalizedString("Start Date", comment: "Title of config entry"), mode: .date) + self.numberEntry = NumberEntry.integerEntry(value: weeks, unitString: NSLocalizedString("Weeks", comment: "Unit string for a count of calendar weeks")) + + self.cells = [ + self.dateEntry, + self.numberEntry + ] + } +} + +extension DateIntervalEntry { + convenience init(headerTitle: String? = nil, footerTitle: String? = nil, end: Date, weeks: Int, calendar: Calendar = .current) { + let start = calendar.date(byAdding: DateComponents(weekOfYear: -weeks), to: end)! + self.init(headerTitle: headerTitle, footerTitle: footerTitle, start: calendar.startOfDay(for: start), weeks: weeks) + } + + var dateInterval: DateInterval? { + let start = dateEntry.date + + guard let weeks = numberEntry.number?.intValue, + let end = Calendar.current.date(byAdding: DateComponents(weekOfYear: weeks), to: start) + else { + return nil + } + + return DateInterval(start: start, end: end) + } +} diff --git a/Learn/Configuration/NumberEntry.swift b/Learn/Configuration/NumberEntry.swift new file mode 100644 index 0000000000..50eaaa04e4 --- /dev/null +++ b/Learn/Configuration/NumberEntry.swift @@ -0,0 +1,110 @@ +// +// NumberEntry.swift +// Learn +// +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKitUI + + +class TextEntry: TextFieldTableViewCellDelegate { + + func registerCell(for tableView: UITableView) { + tableView.register(TextFieldTableViewCell.nib(), forCellReuseIdentifier: TextFieldTableViewCell.className) + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> TextFieldTableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: TextFieldTableViewCell.className, for: indexPath) as! TextFieldTableViewCell + cell.delegate = self + return cell + } + + // MARK: - TextFieldTableViewCellDelegate + + func textFieldTableViewCellDidBeginEditing(_ cell: TextFieldTableViewCell) { + + } + + func textFieldTableViewCellDidEndEditing(_ cell: TextFieldTableViewCell) { + + } +} + + +class NumberEntry: TextEntry, LessonCellProviding { + + let formatter: NumberFormatter + private(set) var number: NSNumber? + let keyboardType: UIKeyboardType + let placeholder: String? + let unitString: String? + + init(number: NSNumber?, formatter: NumberFormatter, placeholder: String?, unitString: String?, keyboardType: UIKeyboardType) { + self.number = number + self.formatter = formatter + self.placeholder = placeholder + self.unitString = unitString + self.keyboardType = keyboardType + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = super.tableView(tableView, cellForRowAt: indexPath) + cell.textField.placeholder = placeholder + cell.unitLabel?.text = unitString + cell.textField.keyboardType = keyboardType + updateText(for: cell) + return cell + } + + override func textFieldTableViewCellDidEndEditing(_ cell: TextFieldTableViewCell) { + if let text = cell.textField.text { + number = formatter.number(from: text) + } else { + number = nil + } + + updateText(for: cell) + } + + private func updateText(for cell: TextFieldTableViewCell) { + if let number = number { + cell.textField.text = formatter.string(from: number) + } else { + cell.textField.text = nil + } + } +} + + +extension NumberEntry { + class func decimalEntry(value: Double?, unitString: String?) -> NumberEntry { + let number: NSNumber? + if let value = value { + number = NSNumber(value: value) + } else { + number = nil + } + + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + + return NumberEntry(number: number, formatter: formatter, placeholder: nil, unitString: unitString, keyboardType: .decimalPad) + } + + class func integerEntry(value: Int?, unitString: String?) -> NumberEntry { + let number: NSNumber? + if let value = value { + number = NSNumber(value: value) + } else { + number = nil + } + + + let formatter = NumberFormatter() + formatter.numberStyle = .none + + return NumberEntry(number: number, formatter: formatter, placeholder: nil, unitString: unitString, keyboardType: .decimalPad) + } +} diff --git a/Learn/Configuration/NumberRangeEntry.swift b/Learn/Configuration/NumberRangeEntry.swift new file mode 100644 index 0000000000..8eca26d09c --- /dev/null +++ b/Learn/Configuration/NumberRangeEntry.swift @@ -0,0 +1,64 @@ +// +// NumberRangeEntry.swift +// Learn +// +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import UIKit + + +class NumberRangeEntry: LessonSectionProviding { + let headerTitle: String? + + var cells: [LessonCellProviding] { + return numberCells + } + + var minValue: NSNumber? { + return numberCells.compactMap({ $0.number }).min() + } + + var maxValue: NSNumber? { + return numberCells.compactMap({ $0.number }).max() + } + + var range: Range? { + guard let minValue = minValue, let maxValue = maxValue else { + return nil + } + + return minValue..? { + guard let minValue = minValue, let maxValue = maxValue else { + return nil + } + + return minValue...maxValue + } + + private var numberCells: [NumberEntry] + + init(headerTitle: String?, minValue: NSNumber?, maxValue: NSNumber?, formatter: NumberFormatter, unitString: String?, keyboardType: UIKeyboardType) { + self.headerTitle = headerTitle + + self.numberCells = [ + NumberEntry( + number: minValue, + formatter: formatter, + placeholder: NSLocalizedString("Minimum", comment: "Placeholder for lower range entry"), + unitString: unitString, + keyboardType: keyboardType + ), + NumberEntry( + number: maxValue, + formatter: formatter, + placeholder: NSLocalizedString("Maximum", comment: "Placeholder for upper range entry"), + unitString: unitString, + keyboardType: keyboardType + ), + ] + } +} diff --git a/Learn/Configuration/QuantityRangeEntry.swift b/Learn/Configuration/QuantityRangeEntry.swift new file mode 100644 index 0000000000..abb7ac344e --- /dev/null +++ b/Learn/Configuration/QuantityRangeEntry.swift @@ -0,0 +1,88 @@ +// +// QuantityRangeEntry.swift +// Learn +// +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import HealthKit +import LoopKit +import UIKit + + +class QuantityRangeEntry: LessonSectionProviding { + var headerTitle: String? { + return numberRange.headerTitle + } + + var footerTitle: String? { + return numberRange.footerTitle + } + + var cells: [LessonCellProviding] { + return numberRange.cells + } + + var minValue: HKQuantity? { + if let minValue = numberRange.minValue?.doubleValue { + return HKQuantity(unit: unit, doubleValue: minValue) + } else { + return nil + } + } + + var maxValue: HKQuantity? { + if let maxValue = numberRange.maxValue?.doubleValue { + return HKQuantity(unit: unit, doubleValue: maxValue) + } else { + return nil + } + } + + var range: Range? { + guard let minValue = minValue, let maxValue = maxValue else { + return nil + } + + return minValue..? { + guard let minValue = minValue, let maxValue = maxValue else { + return nil + } + + return minValue...maxValue + } + + private let numberRange: NumberRangeEntry + + let quantityFormatter: QuantityFormatter + + let unit: HKUnit + + init(headerTitle: String?, minValue: HKQuantity?, maxValue: HKQuantity?, quantityFormatter: QuantityFormatter, unit: HKUnit, keyboardType: UIKeyboardType) { + self.quantityFormatter = quantityFormatter + self.unit = unit + + numberRange = NumberRangeEntry( + headerTitle: headerTitle, + minValue: (minValue?.doubleValue(for: unit)).map(NSNumber.init(value:)), + maxValue: (maxValue?.doubleValue(for: unit)).map(NSNumber.init(value:)), + formatter: quantityFormatter.numberFormatter, + unitString: quantityFormatter.string(from: unit), + keyboardType: keyboardType + ) + } + + internal class func glucoseRange(minValue: HKQuantity, maxValue: HKQuantity, quantityFormatter: QuantityFormatter, unit: HKUnit) -> QuantityRangeEntry { + return QuantityRangeEntry( + headerTitle: NSLocalizedString("Range", comment: "Section title for glucose range"), + minValue: minValue, + maxValue: maxValue, + quantityFormatter: quantityFormatter, + unit: unit, + keyboardType: unit == .milligramsPerDeciliter ? .numberPad : .decimalPad + ) + } +} diff --git a/Learn/Display/DatesAndNumberCell.swift b/Learn/Display/DatesAndNumberCell.swift new file mode 100644 index 0000000000..be663f716a --- /dev/null +++ b/Learn/Display/DatesAndNumberCell.swift @@ -0,0 +1,43 @@ +// +// DatesAndNumberCell.swift +// Learn +// +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import UIKit + + +class DatesAndNumberCell: LessonCellProviding { + static let cellIdentifier = "DatesAndNumberCell" + + let date: DateInterval + let value: NSNumber + let dateFormatter: DateIntervalFormatter + let numberFormatter: NumberFormatter + + init(date: DateInterval, value: NSNumber, dateFormatter: DateIntervalFormatter, numberFormatter: NumberFormatter) { + self.date = date + self.value = value + self.dateFormatter = dateFormatter + self.numberFormatter = numberFormatter + } + + func registerCell(for tableView: UITableView) { + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell: UITableViewCell + + if let existingCell = tableView.dequeueReusableCell(withIdentifier: DatesAndNumberCell.cellIdentifier) { + cell = existingCell + } else { + cell = UITableViewCell(style: .value1, reuseIdentifier: DatesAndNumberCell.cellIdentifier) + } + + cell.textLabel?.text = dateFormatter.string(from: date) + cell.detailTextLabel?.text = numberFormatter.string(from: value) + + return cell + } +} diff --git a/Learn/Display/TextCell.swift b/Learn/Display/TextCell.swift new file mode 100644 index 0000000000..1b96291536 --- /dev/null +++ b/Learn/Display/TextCell.swift @@ -0,0 +1,51 @@ +// +// TextCell.swift +// Learn +// +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import UIKit + + +class TextCell: LessonCellProviding { + static let cellIdentifier = "TextCell" + + let text: String + let detailText: String? + let textColor: UIColor? + let detailTextColor: UIColor? + + init(text: String, detailText: String? = nil, textColor: UIColor? = nil, detailTextColor: UIColor? = nil) { + self.text = text + self.detailText = detailText + self.textColor = textColor + self.detailTextColor = detailTextColor + } + + func registerCell(for tableView: UITableView) { + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell: UITableViewCell + + if let existingCell = tableView.dequeueReusableCell(withIdentifier: TextCell.cellIdentifier) { + cell = existingCell + } else { + cell = UITableViewCell(style: .value1, reuseIdentifier: TextCell.cellIdentifier) + } + + cell.textLabel?.text = text + cell.detailTextLabel?.text = detailText + + if let color = textColor { + cell.textLabel?.textColor = color + } + + if let color = detailTextColor { + cell.detailTextLabel?.textColor = color + } + + return cell + } +} diff --git a/Learn/Extensions/Date.swift b/Learn/Extensions/Date.swift new file mode 100644 index 0000000000..bd2f73b890 --- /dev/null +++ b/Learn/Extensions/Date.swift @@ -0,0 +1,21 @@ +// +// Date.swift +// Learn +// +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import Foundation + + +extension Date: Strideable { + public typealias Stride = TimeInterval + + public func distance(to other: Date) -> TimeInterval { + return other.timeIntervalSince(self) + } + + public func advanced(by n: TimeInterval) -> Date { + return addingTimeInterval(n) + } +} diff --git a/Learn/Extensions/DateIntervalFormatter.swift b/Learn/Extensions/DateIntervalFormatter.swift new file mode 100644 index 0000000000..fe2ed19009 --- /dev/null +++ b/Learn/Extensions/DateIntervalFormatter.swift @@ -0,0 +1,17 @@ +// +// DateIntervalFormatter.swift +// Learn +// +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import Foundation + + +extension DateIntervalFormatter { + convenience init(dateStyle: DateIntervalFormatter.Style = .none, timeStyle: DateIntervalFormatter.Style = .none) { + self.init() + self.dateStyle = dateStyle + self.timeStyle = timeStyle + } +} diff --git a/Learn/Extensions/NSNumber.swift b/Learn/Extensions/NSNumber.swift new file mode 100644 index 0000000000..4557f6f805 --- /dev/null +++ b/Learn/Extensions/NSNumber.swift @@ -0,0 +1,15 @@ +// +// NSNumber.swift +// Learn +// +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import Foundation + + +extension NSNumber: Comparable { + public static func < (lhs: NSNumber, rhs: NSNumber) -> Bool { + return lhs.compare(rhs) == .orderedAscending + } +} diff --git a/Learn/Extensions/OSLog.swift b/Learn/Extensions/OSLog.swift new file mode 100644 index 0000000000..0e2001d042 --- /dev/null +++ b/Learn/Extensions/OSLog.swift @@ -0,0 +1,15 @@ +// +// OSLog.swift +// Learn +// +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import os.log + + +extension OSLog { + convenience init(category: String) { + self.init(subsystem: "com.loopkit.Learn", category: category) + } +} diff --git a/Learn/Extensions/Sequence.swift b/Learn/Extensions/Sequence.swift new file mode 100644 index 0000000000..c3913cd474 --- /dev/null +++ b/Learn/Extensions/Sequence.swift @@ -0,0 +1,34 @@ +// +// Sequence.swift +// Learn +// +// Created by Pete Schwamb on 4/10/19. +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import Foundation + +extension Sequence { + func proportion(where isIncluded: (Element) -> Bool) -> Double? { + return average(by: { isIncluded($0) ? 1 : 0 }) + } + + func average(by getMetric: (Element) -> T) -> T? { + let (sum, count) = reduce(into: (sum: 0 as T, count: 0)) { result, element in + result.0 += getMetric(element) + result.1 += 1 + } + + guard count > 0 else { + return nil + } + + return sum / T(count) + } +} + +extension Sequence where Element: FloatingPoint { + func average() -> Element? { + return average(by: { $0 }) + } +} diff --git a/Learn/Extensions/UITableViewCell.swift b/Learn/Extensions/UITableViewCell.swift new file mode 100644 index 0000000000..369855d0f6 --- /dev/null +++ b/Learn/Extensions/UITableViewCell.swift @@ -0,0 +1,16 @@ +// +// UITableViewCell.swift +// Learn +// +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import LoopCore +import LoopKitUI +import LoopUI + + +extension DateAndDurationTableViewCell: NibLoadable { } + + +extension TextButtonTableViewCell: IdentifiableClass { } diff --git a/Learn/Info.plist b/Learn/Info.plist new file mode 100644 index 0000000000..df6adf9510 --- /dev/null +++ b/Learn/Info.plist @@ -0,0 +1,53 @@ + + + + + MainAppBundleIdentifier + $(MAIN_APP_BUNDLE_IDENTIFIER) + AppGroupIdentifier + $(APP_GROUP_IDENTIFIER) + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(LOOP_MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSRequiresIPhoneOS + + NSHealthShareUsageDescription + Meal data from the Health database is used to determine glucose effects. Glucose data from the Health database is used for graphing and momentum calculation. Sleep data from the Health database is used to optimize delivery of Apple Watch complication updates during the time you are awake. + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIRequiredDeviceCapabilities + + armv7 + healthkit + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + UIInterfaceOrientationPortraitUpsideDown + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/Learn/Learn.entitlements b/Learn/Learn.entitlements new file mode 100644 index 0000000000..b09682c70b --- /dev/null +++ b/Learn/Learn.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.developer.healthkit + + com.apple.developer.healthkit.access + + com.apple.security.application-groups + + $(APP_GROUP_IDENTIFIER) + + + diff --git a/Learn/Lesson.swift b/Learn/Lesson.swift new file mode 100644 index 0000000000..35c56a2ee1 --- /dev/null +++ b/Learn/Lesson.swift @@ -0,0 +1,63 @@ +// +// Lesson.swift +// Learn +// +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import Foundation +import UIKit + + +protocol Lesson { + init(dataManager: DataManager) + + var title: String { get } + + var subtitle: String { get } + + var configurationSections: [LessonSectionProviding] { get } + + func execute(completion: @escaping (_ resultSections: [LessonSectionProviding]) -> Void) +} + + +protocol LessonSectionProviding { + var headerTitle: String? { get } + + var footerTitle: String? { get } + + var cells: [LessonCellProviding] { get } +} + +extension LessonSectionProviding { + var headerTitle: String? { + return nil + } + + var footerTitle: String? { + return nil + } +} + + +protocol LessonCellProviding { + func registerCell(for tableView: UITableView) + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell +} + + +struct LessonSection: LessonSectionProviding { + let headerTitle: String? + + let footerTitle: String? + + let cells: [LessonCellProviding] + + init(headerTitle: String? = nil, footerTitle: String? = nil, cells: [LessonCellProviding]) { + self.headerTitle = headerTitle + self.footerTitle = footerTitle + self.cells = cells + } +} diff --git a/Learn/Lessons/ModalDayLesson.swift b/Learn/Lessons/ModalDayLesson.swift new file mode 100644 index 0000000000..612e55e41a --- /dev/null +++ b/Learn/Lessons/ModalDayLesson.swift @@ -0,0 +1,205 @@ +// +// ModalDayLesson.swift +// Learn +// +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import Foundation +import HealthKit +import LoopCore +import LoopKit +import os.log + +final class ModalDayLesson: Lesson { + let title = NSLocalizedString("Modal Day", comment: "Lesson title") + + let subtitle = NSLocalizedString("Visualizes the most frequent glucose values by time of day", comment: "Lesson subtitle") + + let configurationSections: [LessonSectionProviding] + + private let dataManager: DataManager + + private let dateIntervalEntry: DateIntervalEntry + + private let glucoseUnit: HKUnit + + init(dataManager: DataManager) { + self.dataManager = dataManager + self.glucoseUnit = dataManager.glucoseStore.preferredUnit ?? .milligramsPerDeciliter + + dateIntervalEntry = DateIntervalEntry( + end: Date(), + weeks: 2 + ) + + self.configurationSections = [ + dateIntervalEntry + ] + } + + func execute(completion: @escaping ([LessonSectionProviding]) -> Void) { + guard let dates = dateIntervalEntry.dateInterval else { + // TODO: Cleaner error presentation + completion([LessonSection(headerTitle: "Error: Please fill out all fields", footerTitle: nil, cells: [])]) + return + } + + let calendar = Calendar.current + + let calculator = ModalDayCalculator(dataManager: dataManager, dates: dates, bucketSize: .minutes(60), unit: glucoseUnit, calendar: calendar) + calculator.execute { (result) in + switch result { + case .failure(let error): + completion([ + LessonSection(cells: [TextCell(text: String(describing: error))]) + ]) + case .success(let buckets): + guard buckets.count > 0 else { + completion([ + LessonSection(cells: [TextCell(text: NSLocalizedString("No data available", comment: "Lesson result text for no data"))]) + ]) + return + } + + let dateFormatter = DateIntervalFormatter(timeStyle: .short) + let glucoseFormatter = QuantityFormatter() + glucoseFormatter.setPreferredNumberFormatter(for: self.glucoseUnit) + + completion([ + LessonSection(cells: buckets.compactMap({ (bucket) -> TextCell? in + guard let start = calendar.date(from: bucket.time.lowerBound.dateComponents), + let end = calendar.date(from: bucket.time.upperBound.dateComponents), + let time = dateFormatter.string(from: DateInterval(start: start, end: end)), + let median = bucket.median, + let medianString = glucoseFormatter.string(from: median, for: bucket.unit) + else { + return nil + } + + return TextCell(text: time, detailText: medianString) + })) + ]) + } + } + } +} + +fileprivate struct ModalDayBucket { + let time: Range + let orderedValues: [Double] + let unit: HKUnit + + init(time: Range, unorderedValues: [Double], unit: HKUnit) { + self.time = time + self.orderedValues = unorderedValues.sorted() + self.unit = unit + } + + var median: HKQuantity? { + let count = orderedValues.count + guard count > 0 else { + return nil + } + + if count % 2 == 1 { + return HKQuantity(unit: unit, doubleValue: orderedValues[count / 2]) + } else { + let mid = count / 2 + let lower = orderedValues[mid - 1] + let upper = orderedValues[mid] + return HKQuantity(unit: unit, doubleValue: (lower + upper) / 2) + } + } +} + + +fileprivate struct ModalDayBuilder { + let calendar: Calendar + let bucketSize: TimeInterval + let unit: HKUnit + private(set) var unorderedValuesByBucket: [Range: [Double]] + + init(calendar: Calendar, bucketSize: TimeInterval, unit: HKUnit) { + self.calendar = calendar + self.bucketSize = bucketSize + self.unit = unit + self.unorderedValuesByBucket = [:] + } + + mutating func add(_ value: Double, at time: TimeComponents) { + let bucket = time.bucket(withBucketSize: bucketSize) + var values = unorderedValuesByBucket[bucket] ?? [] + values.append(value) + unorderedValuesByBucket[bucket] = values + } + + mutating func add(_ value: Double, at date: DateComponents) { + guard let time = TimeComponents(dateComponents: date) else { + return + } + add(value, at: time) + } + + mutating func add(_ value: Double, at date: Date) { + add(value, at: calendar.dateComponents([.hour, .minute], from: date)) + } + + mutating func add(_ quantity: HKQuantity, at date: Date) { + add(quantity.doubleValue(for: unit), at: date) + } + + var allBuckets: [ModalDayBucket] { + return unorderedValuesByBucket.sorted(by: { $0.0.lowerBound < $1.0.lowerBound }).map { pair -> ModalDayBucket in + return ModalDayBucket(time: pair.key, unorderedValues: pair.value, unit: unit) + } + } +} + + +fileprivate class ModalDayCalculator { + let calculator: DayCalculator + let bucketSize: TimeInterval + let calendar: Calendar + private let log: OSLog + + init(dataManager: DataManager, dates: DateInterval, bucketSize: TimeInterval, unit: HKUnit, calendar: Calendar) { + self.calculator = DayCalculator(dataManager: dataManager, dates: dates, initial: ModalDayBuilder(calendar: calendar, bucketSize: bucketSize, unit: unit)) + self.bucketSize = bucketSize + self.calendar = calendar + + log = OSLog(category: String(describing: type(of: self))) + } + + func execute(completion: @escaping (_ result: Result<[ModalDayBucket]>) -> Void) { + os_log(.default, log: log, "Computing Modal day in %{public}@", String(describing: calculator.dates)) + + calculator.execute(calculator: { (dataManager, day, mutableResult, completion) in + os_log(.default, log: self.log, "Fetching samples in %{public}@", String(describing: day)) + + dataManager.glucoseStore.getGlucoseSamples(start: day.start, end: day.end, completion: { (result) in + switch result { + case .failure(let error): + os_log(.error, log: self.log, "Failure getting glucose samples: %{public}@", String(describing: error)) + completion(error) + case .success(let samples): + os_log(.error, log: self.log, "Found %d samples", samples.count) + + for sample in samples { + _ = mutableResult.mutate({ (result) in + result.add(sample.quantity, at: sample.startDate) + }) + } + completion(nil) + } + }) + }, completion: { (result) in + switch result { + case .failure(let error): + completion(.failure(error)) + case .success(let builder): + completion(.success(builder.allBuckets)) + } + }) + } +} diff --git a/Learn/Lessons/TimeInRangeLesson.swift b/Learn/Lessons/TimeInRangeLesson.swift new file mode 100644 index 0000000000..f01b3967cb --- /dev/null +++ b/Learn/Lessons/TimeInRangeLesson.swift @@ -0,0 +1,190 @@ +// +// LessonPlayground.swift +// Learn +// +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopCore +import LoopKit +import LoopKitUI +import LoopUI +import HealthKit +import os.log + + +final class TimeInRangeLesson: Lesson { + let title = NSLocalizedString("Time in Range", comment: "Lesson title") + + let subtitle = NSLocalizedString("Computes the percentage of glucose measurements within a specified range", comment: "Lesson subtitle") + + let configurationSections: [LessonSectionProviding] + + private let dataManager: DataManager + + private let glucoseUnit: HKUnit + + private let glucoseFormatter = QuantityFormatter() + + private let dateIntervalEntry: DateIntervalEntry + + private let rangeEntry: QuantityRangeEntry + + init(dataManager: DataManager) { + self.dataManager = dataManager + self.glucoseUnit = dataManager.glucoseStore.preferredUnit ?? .milligramsPerDeciliter + + glucoseFormatter.setPreferredNumberFormatter(for: glucoseUnit) + + dateIntervalEntry = DateIntervalEntry( + end: Date(), + weeks: 2 + ) + + rangeEntry = QuantityRangeEntry.glucoseRange( + minValue: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 80), + maxValue: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 160), + quantityFormatter: glucoseFormatter, + unit: glucoseUnit) + + self.configurationSections = [ + dateIntervalEntry, + rangeEntry + ] + } + + func execute(completion: @escaping ([LessonSectionProviding]) -> Void) { + guard let dates = dateIntervalEntry.dateInterval, let closedRange = rangeEntry.closedRange else { + // TODO: Cleaner error presentation + completion([LessonSection(headerTitle: "Error: Please fill out all fields", footerTitle: nil, cells: [])]) + return + } + + let calculator = TimeInRangeCalculator(dataManager: dataManager, dates: dates, range: closedRange) + + calculator.execute { result in + switch result { + case .failure(let error): + completion([ + LessonSection(cells: [TextCell(text: String(describing: error))]) + ]) + case .success(let resultsByDay): + guard resultsByDay.count > 0 else { + completion([ + LessonSection(cells: [TextCell(text: NSLocalizedString("No data available", comment: "Lesson result text for no data"))]) + ]) + return + } + + let dateFormatter = DateIntervalFormatter(dateStyle: .short, timeStyle: .none) + let numberFormatter = NumberFormatter() + numberFormatter.numberStyle = .percent + + var aggregator = TimeInRangeAggregator() + resultsByDay.forEach({ (pair) in + aggregator.add(percentInRange: pair.value, for: pair.key) + }) + + completion([ + TimesInRangeSection( + ranges: aggregator.results.map { [$0.range:$0.value] } ?? [:], + dateFormatter: dateFormatter, + numberFormatter: numberFormatter + ), + TimesInRangeSection( + ranges: resultsByDay, + dateFormatter: dateFormatter, + numberFormatter: numberFormatter + ) + ]) + } + } + } +} + +class TimesInRangeSection: LessonSectionProviding { + + let cells: [LessonCellProviding] + + init(ranges: [DateInterval: Double], dateFormatter: DateIntervalFormatter, numberFormatter: NumberFormatter) { + cells = ranges.sorted(by: { $0.0 < $1.0 }).map { pair -> LessonCellProviding in + DatesAndNumberCell(date: pair.key, value: NSNumber(value: pair.value), dateFormatter: dateFormatter, numberFormatter: numberFormatter) + } + } +} + + +struct TimeInRangeAggregator { + private var count = 0 + private var sum: Double = 0 + var allDates: DateInterval? + + var averagePercentInRange: Double? { + guard count > 0 else { + return nil + } + + return sum / Double(count) + } + + var results: (range: DateInterval, value: Double)? { + guard let allDates = allDates, let averagePercentInRange = averagePercentInRange else { + return nil + } + + return (range: allDates, value: averagePercentInRange) + } + + mutating func add(percentInRange: Double, for dates: DateInterval) { + sum += percentInRange + count += 1 + + if let allDates = self.allDates { + self.allDates = DateInterval(start: min(allDates.start, dates.start), end: max(allDates.end, dates.end)) + } else { + self.allDates = dates + } + } +} + + +/// Time-in-range, e.g. "2 weeks starting on March 5" +private class TimeInRangeCalculator { + let calculator: DayCalculator<[DateInterval: Double]> + let range: ClosedRange + + private let log: OSLog + + private let unit = HKUnit.milligramsPerDeciliter + + init(dataManager: DataManager, dates: DateInterval, range: ClosedRange) { + self.calculator = DayCalculator(dataManager: dataManager, dates: dates, initial: [:]) + self.range = range + + log = OSLog(category: String(describing: type(of: self))) + } + + func execute(completion: @escaping (_ result: Result<[DateInterval: Double]>) -> Void) { + os_log(.default, log: log, "Computing Time in range from %{public}@ between %{public}@", String(describing: calculator.dates), String(describing: range)) + + calculator.execute(calculator: { (dataManager, day, results, completion) in + os_log(.default, log: self.log, "Fetching samples in %{public}@", String(describing: day)) + + dataManager.glucoseStore.getGlucoseSamples(start: day.start, end: day.end) { (result) in + switch result { + case .failure(let error): + os_log(.error, log: self.log, "Failure getting glucose samples: %{public}@", String(describing: error)) + completion(error) + case .success(let samples): + if let timeInRange = samples.proportion(where: { self.range.contains($0.quantity) }) { + _ = results.mutate({ (results) in + results[day] = timeInRange + }) + } + completion(nil) + } + } + }, completion: completion) + } +} diff --git a/Learn/Managers/DataManager.swift b/Learn/Managers/DataManager.swift new file mode 100644 index 0000000000..80e958a02f --- /dev/null +++ b/Learn/Managers/DataManager.swift @@ -0,0 +1,130 @@ +// +// DataManager.swift +// Learn +// +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import Foundation +import HealthKit +import LoopKit +import LoopCore + + +final class DataManager { + let carbStore: CarbStore + + let doseStore: DoseStore + + let glucoseStore: GlucoseStore + + let settings: LoopSettings + + init( + basalRateSchedule: BasalRateSchedule? = UserDefaults.appGroup?.legacyBasalRateSchedule, + carbRatioSchedule: CarbRatioSchedule? = UserDefaults.appGroup?.legacyCarbRatioSchedule, + defaultRapidActingModel: ExponentialInsulinModelPreset? = UserDefaults.appGroup?.legacyDefaultRapidActingModel, + insulinSensitivitySchedule: InsulinSensitivitySchedule? = UserDefaults.appGroup?.legacyInsulinSensitivitySchedule, + settings: LoopSettings = UserDefaults.appGroup?.legacyLoopSettings ?? LoopSettings() + ) { + self.settings = settings + + let healthStore = HKHealthStore() + let cacheStore = PersistenceController.controllerInAppGroupDirectory(isReadOnly: true) + + carbStore = CarbStore( + healthStore: healthStore, + cacheStore: cacheStore, + cacheLength: .hours(24), + defaultAbsorptionTimes: (fast: .minutes(30), medium: .hours(3), slow: .hours(5)), + observationInterval: 0, + carbRatioSchedule: carbRatioSchedule, + insulinSensitivitySchedule: insulinSensitivitySchedule, + provenanceIdentifier: HKSource.default().bundleIdentifier + ) + + doseStore = DoseStore( + healthStore: healthStore, + cacheStore: cacheStore, + observationEnabled: false, + insulinModelProvider: PresetInsulinModelProvider(defaultRapidActingModel: defaultRapidActingModel), + longestEffectDuration: ExponentialInsulinModelPreset.rapidActingAdult.effectDuration, + basalProfile: basalRateSchedule, + insulinSensitivitySchedule: insulinSensitivitySchedule, + provenanceIdentifier: HKSource.default().bundleIdentifier + ) + + glucoseStore = GlucoseStore( + healthStore: healthStore, + cacheStore: cacheStore, + observationEnabled: false, + provenanceIdentifier: HKSource.default().bundleIdentifier + ) + } +} + + +// MARK: - Thread-safe Preferences +extension DataManager { + /// The daily schedule of basal insulin rates + var basalRateSchedule: BasalRateSchedule? { + return doseStore.basalProfile + } + + /// The daily schedule of carbs-to-insulin ratios + /// This is measured in grams/Unit + var carbRatioSchedule: CarbRatioSchedule? { + return carbStore.carbRatioSchedule + } + + /// The daily schedule of insulin sensitivity (also known as ISF) + /// This is measured in /Unit + var insulinSensitivitySchedule: InsulinSensitivitySchedule? { + return carbStore.insulinSensitivitySchedule + } +} + + +// MARK: - HealthKit Setup +extension DataManager { + var healthStore: HKHealthStore { + return carbStore.healthStore + } + + /// All the HealthKit types to be read and shared by stores + private var sampleTypes: Set { + return Set([ + glucoseStore.sampleType, + carbStore.sampleType, + doseStore.sampleType, + ].compactMap { $0 }) + } + + /// True if any stores require HealthKit authorization + var authorizationRequired: Bool { + return glucoseStore.authorizationRequired || + carbStore.authorizationRequired || + doseStore.authorizationRequired + } + + /// True if the user has explicitly denied access to any stores' HealthKit types + private var sharingDenied: Bool { + return glucoseStore.sharingDenied || + carbStore.sharingDenied || + doseStore.sharingDenied + } + + func authorize(_ completion: @escaping () -> Void) { + // Authorize all types at once for simplicity + carbStore.healthStore.requestAuthorization(toShare: [], read: sampleTypes) { (success, error) in + if success { + // Call the individual authorization methods to trigger query creation + self.carbStore.authorize(toShare: false, { _ in }) + self.doseStore.insulinDeliveryStore.authorize(toShare: false, { _ in }) + self.glucoseStore.authorize(toShare: false, { _ in }) + } + + completion() + } + } +} diff --git a/Learn/Managers/DayCalculator.swift b/Learn/Managers/DayCalculator.swift new file mode 100644 index 0000000000..f46ffc9747 --- /dev/null +++ b/Learn/Managers/DayCalculator.swift @@ -0,0 +1,64 @@ +// +// DayCalculator.swift +// Learn +// +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopCore +import LoopKit + +class DayCalculator { + typealias Calculator = (_ dataManager: DataManager, _ day: DateInterval, _ results: Locked, _ completion: @escaping (_ error: Error?) -> Void) -> Void + + let dataManager: DataManager + let dates: DateInterval + private var lockedResults: Locked + + init(dataManager: DataManager, dates: DateInterval, initial: ResultType) { + self.dataManager = dataManager + self.dates = dates + self.lockedResults = Locked(initial) + } + + func execute(calculator: @escaping Calculator, completion: @escaping (_ result: Result) -> Void) { + var anyError: Error? + + let group = DispatchGroup() + + var segmentStart = dates.start + + Calendar.current.enumerateDates(startingAfter: dates.start, matching: DateComponents(hour: 0), matchingPolicy: .nextTime) { (date, exactMatch, stop) in + guard let date = date else { + stop = true + return + } + + let interval = DateInterval(start: segmentStart, end: min(dates.end, date)) + + guard interval.duration > 0 else { + stop = true + return + } + + group.enter() + calculator(dataManager, interval, lockedResults) { error in + if let error = error { + anyError = error + } + + group.leave() + } + segmentStart = interval.end + } + + group.notify(queue: .main) { + if let error = anyError { + completion(.failure(error)) + } else { + completion(.success(self.lockedResults.value)) + } + } + } +} diff --git a/Learn/Models/TimeComponents.swift b/Learn/Models/TimeComponents.swift new file mode 100644 index 0000000000..cf93a09635 --- /dev/null +++ b/Learn/Models/TimeComponents.swift @@ -0,0 +1,73 @@ +// +// TimeComponents.swift +// Learn +// +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import Foundation + + +struct TimeComponents: Equatable, Hashable { + let hour: Int + let minute: Int + + init(hour: Int, minute: Int) { + if hour >= 24, minute >= 60 { + assertionFailure("Invalid time components: \(hour):\(minute)") + } + + self.hour = hour + self.minute = minute + } + + init?(dateComponents: DateComponents) { + guard let hour = dateComponents.hour, let minute = dateComponents.minute else { + return nil + } + + self.init(hour: hour, minute: minute) + } + + init(timeIntervalSinceMidnight timeInterval: TimeInterval) { + self.init(hour: Int(timeInterval.hours), minute: Int(timeInterval.minutes) % 60) + } + + var dateComponents: DateComponents { + return DateComponents(hour: hour, minute: minute, second: 0) + } + + var timeIntervalSinceMidnight: TimeInterval { + return TimeInterval(hours: Double(hour)) + TimeInterval(minutes: Double(minute)) + } + + func floored(to timeInterval: TimeInterval) -> TimeComponents { + let floored = floor(timeIntervalSinceMidnight / timeInterval) * timeInterval + return TimeComponents(timeIntervalSinceMidnight: floored) + } + + func bucket(withBucketSize bucketSize: TimeInterval) -> Range { + let lowerBound = floored(to: bucketSize) + return lowerBound..<(lowerBound + bucketSize) + } + + private func adding(_ timeInterval: TimeInterval) -> TimeComponents { + return TimeComponents(timeIntervalSinceMidnight: timeIntervalSinceMidnight + timeInterval) + } +} + +extension TimeComponents: Comparable { + static func < (lhs: TimeComponents, rhs: TimeComponents) -> Bool { + if lhs.hour == rhs.hour { + return lhs.minute < rhs.minute + } else { + return lhs.hour < rhs.hour + } + } +} + +extension TimeComponents { + static func + (lhs: TimeComponents, rhs: TimeInterval) -> TimeComponents { + return lhs.adding(rhs) + } +} diff --git a/Learn/View Controllers/LessonConfigurationViewController.swift b/Learn/View Controllers/LessonConfigurationViewController.swift new file mode 100644 index 0000000000..c6476f2119 --- /dev/null +++ b/Learn/View Controllers/LessonConfigurationViewController.swift @@ -0,0 +1,161 @@ +// +// LessonConfigurationViewController.swift +// Learn +// +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import UIKit +import LoopKitUI + +class LessonConfigurationViewController: UITableViewController { + + var lesson: Lesson! + + private enum State { + case editing + case executing + } + + private var state = State.editing { + didSet { + guard state != oldValue else { + return + } + + if let cell = tableView.cellForRow(at: IndexPath(row: 0, section: lesson.configurationSections.count)) as? TextButtonTableViewCell { + cell.isLoading = state == .executing + cell.isEnabled = state == .editing + } + } + } + + override func viewDidLoad() { + super.viewDidLoad() + + title = lesson.title + tableView.estimatedRowHeight = 44 + tableView.rowHeight = UITableView.automaticDimension + + for section in lesson.configurationSections { + for cell in section.cells { + cell.registerCell(for: self.tableView) + } + } + + tableView.register(TextButtonTableViewCell.self, forCellReuseIdentifier: TextButtonTableViewCell.className) + } + + /// If a tap occurs in the table view, but not on any cells, dismiss any active edits + @IBAction private func dismissActiveEditing(gestureRecognizer: UITapGestureRecognizer) { + let tapPoint = gestureRecognizer.location(in: tableView) + guard tableView.indexPathForRow(at: tapPoint) == nil else { + return + } + + tableView.endEditing(false) + tableView.beginUpdates() + hideDatePickerCells() + tableView.endUpdates() + } + + // MARK: - Table view data source + + override func numberOfSections(in tableView: UITableView) -> Int { + return lesson.configurationSections.count + 1 + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + if section == lesson.configurationSections.count { + return 1 + } else { + return lesson.configurationSections[section].cells.count + } + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + if indexPath.section == lesson.configurationSections.count { + let cell = tableView.dequeueReusableCell(withIdentifier: TextButtonTableViewCell.className, for: indexPath) as! TextButtonTableViewCell + cell.textLabel?.text = NSLocalizedString("Continue", comment: "Title of the button to begin lesson execution") + + switch state { + case .editing: + cell.isEnabled = true + cell.isLoading = false + case .executing: + cell.isEnabled = false + cell.isLoading = true + } + + return cell + } else { + return lesson.configurationSections[indexPath.section].cells[indexPath.item].tableView(tableView, cellForRowAt: indexPath) + } + } + + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + if section < lesson.configurationSections.count { + return lesson.configurationSections[section].headerTitle + } else { + return nil + } + } + + override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { + if section < lesson.configurationSections.count { + return lesson.configurationSections[section].footerTitle + } else { + return nil + } + } + + // MARK: - UITableViewDelegate + + override func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool { + return state == .editing + } + + override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { + guard case .editing = state else { + return nil + } + + tableView.endEditing(false) + tableView.beginUpdates() + hideDatePickerCells(excluding: indexPath) + return indexPath + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.endUpdates() + tableView.deselectRow(at: indexPath, animated: true) + + if indexPath.section == lesson.configurationSections.count { + state = .executing + + lesson.execute { resultSections in + dispatchPrecondition(condition: .onQueue(.main)) + + self.performSegue(withIdentifier: LessonResultsViewController.className, sender: resultSections) + + self.state = .editing + } + } + } + + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + super.prepare(for: segue, sender: sender) + + if let results = sender as? [LessonSectionProviding], let destination = segue.destination as? LessonResultsViewController { + destination.lesson = lesson + destination.results = results + } + } +} + + +extension LessonConfigurationViewController: DatePickerTableViewCellDelegate { + func datePickerTableViewCellDidUpdateDate(_ cell: DatePickerTableViewCell) { + + } +} diff --git a/Learn/View Controllers/LessonResultsViewController.swift b/Learn/View Controllers/LessonResultsViewController.swift new file mode 100644 index 0000000000..e9c45b6b44 --- /dev/null +++ b/Learn/View Controllers/LessonResultsViewController.swift @@ -0,0 +1,58 @@ +// +// LessonResultsViewController.swift +// Learn +// +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import LoopCore +import UIKit + + +class LessonResultsViewController: UITableViewController, IdentifiableClass { + + var lesson: Lesson! + + var results: [LessonSectionProviding] = [] { + didSet { + if isViewLoaded { + tableView.reloadData() + } + } + } + + override func viewDidLoad() { + super.viewDidLoad() + + title = lesson.title + + for section in results { + for cell in section.cells { + cell.registerCell(for: self.tableView) + } + } + } + + // MARK: - Table view data source + + override func numberOfSections(in tableView: UITableView) -> Int { + return results.count + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return results[section].cells.count + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + return results[indexPath.section].cells[indexPath.row].tableView(tableView, cellForRowAt: indexPath) + } + + override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { + return nil + } + + override func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool { + return false + } + +} diff --git a/Learn/View Controllers/LessonsViewController.swift b/Learn/View Controllers/LessonsViewController.swift new file mode 100644 index 0000000000..a5c3b4ef7a --- /dev/null +++ b/Learn/View Controllers/LessonsViewController.swift @@ -0,0 +1,46 @@ +// +// LessonsViewController.swift +// Learn +// +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import UIKit + +class LessonsViewController: UITableViewController { + + var lessons: [Lesson] = [] { + didSet { + if isViewLoaded { + tableView.reloadData() + } + } + } + + // MARK: - Table view data source + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return lessons.count + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "Lesson", for: indexPath) + let lesson = lessons[indexPath.row] + + cell.textLabel?.text = lesson.title + cell.detailTextLabel?.text = lesson.subtitle + + return cell + } + + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + super.prepare(for: segue, sender: sender) + + if let configVC = segue.destination as? LessonConfigurationViewController, + let cell = sender as? UITableViewCell, + let indexPath = tableView.indexPath(for: cell) + { + configVC.lesson = lessons[indexPath.row] + } + } +} diff --git a/Learn/ar.lproj/Localizable.strings b/Learn/ar.lproj/Localizable.strings new file mode 100644 index 0000000000..7e34e94c30 --- /dev/null +++ b/Learn/ar.lproj/Localizable.strings @@ -0,0 +1,32 @@ +/* Lesson subtitle */ +"Computes the percentage of glucose measurements within a specified range" = "حساب النسبة المئوية لقراءات سكر الدم ضمن نطاق محدد"; + +/* Title of the button to begin lesson execution */ +"Continue" = "متابعة"; + +/* Placeholder for upper range entry */ +"Maximum" = "الحد الأعلى"; + +/* Placeholder for lower range entry */ +"Minimum" = "الحد الأدنى"; + +/* Lesson title */ +"Modal Day" = "يوم طبيعي"; + +/* Lesson result text for no data */ +"No data available" = "لا يوجد بيانات متاحة"; + +/* Section title for glucose range */ +"Range" = "النطاق"; + +/* Title of config entry */ +"Start Date" = "تاريخ البداية"; + +/* Lesson title */ +"Time in Range" = "الوقت في النطاق"; + +/* Lesson subtitle */ +"Visualizes the most frequent glucose values by time of day" = "يعرض قراءات سكر الدم الأكثر شيوعًا في أوقات مختلفة من اليوم"; + +/* Unit string for a count of calendar weeks */ +"Weeks" = "أسابيع"; diff --git a/Learn/ar.lproj/Main.strings b/Learn/ar.lproj/Main.strings new file mode 100644 index 0000000000..6359029045 --- /dev/null +++ b/Learn/ar.lproj/Main.strings @@ -0,0 +1,3 @@ +/* Class = "UINavigationItem"; title = "Learn"; ObjectID = "8hF-Ij-B7m"; */ +"8hF-Ij-B7m.title" = "تعلم"; + diff --git a/Learn/da.lproj/InfoPlist.strings b/Learn/da.lproj/InfoPlist.strings new file mode 100644 index 0000000000..bf67d54ddb --- /dev/null +++ b/Learn/da.lproj/InfoPlist.strings @@ -0,0 +1,6 @@ +/* Bundle name */ +"CFBundleName" = "Lær"; + +/* Privacy - Health Share Usage Description */ +"NSHealthShareUsageDescription" = "Mad-data fra Health-databasen bruges til at bestemme blodsukkereffekten. Blodsukkerdata fra Health-databasen bruges til graftegning og momentumberegning. Søvndata fra sundhedsdatabasen bruges til at optimere leveringen af opdateringer om komplikationer af Apple Watch i den tid, du er vågen."; + diff --git a/Learn/da.lproj/Localizable.strings b/Learn/da.lproj/Localizable.strings new file mode 100644 index 0000000000..e165a1d655 --- /dev/null +++ b/Learn/da.lproj/Localizable.strings @@ -0,0 +1,33 @@ +/* Lesson subtitle */ +"Computes the percentage of glucose measurements within a specified range" = "Beregner procentdelen af ​​glukosemålinger inden for et specificeret interval"; + +/* Title of the button to begin lesson execution */ +"Continue" = "Fortsæt"; + +/* Placeholder for upper range entry */ +"Maximum" = "Maximum"; + +/* Placeholder for lower range entry */ +"Minimum" = "Minimum"; + +/* Lesson title */ +"Modal Day" = "Modal Dag"; + +/* Lesson result text for no data */ +"No data available" = "Ingen data tilgængelige"; + +/* Section title for glucose range */ +"Range" = "Interval"; + +/* Title of config entry */ +"Start Date" = "Start Dato"; + +/* Lesson title */ +"Time in Range" = "Tme in Range"; + +/* Lesson subtitle */ +"Visualizes the most frequent glucose values by time of day" = "Visualiserer de hyppigste blodsukker værdier fordelt på dagen"; + +/* Unit string for a count of calendar weeks */ +"Weeks" = "Weeks"; + diff --git a/Learn/da.lproj/Main.strings b/Learn/da.lproj/Main.strings new file mode 100644 index 0000000000..d8d90ac21a --- /dev/null +++ b/Learn/da.lproj/Main.strings @@ -0,0 +1,3 @@ +/* Class = "UINavigationItem"; title = "Learn"; ObjectID = "8hF-Ij-B7m"; */ +"8hF-Ij-B7m.title" = "Lær"; + diff --git a/Learn/de.lproj/InfoPlist.strings b/Learn/de.lproj/InfoPlist.strings new file mode 100644 index 0000000000..7b3a9db485 --- /dev/null +++ b/Learn/de.lproj/InfoPlist.strings @@ -0,0 +1,6 @@ +/* Bundle name */ +"CFBundleName" = "Lerne"; + +/* Privacy - Health Share Usage Description */ +"NSHealthShareUsageDescription" = "Mahlzeitendaten aus der Health Datenbank werden verwendet, um die Glukoseeffekte zu bestimmen. Glukosedaten aus der Health Datenbank werden zur grafischen Darstellung und Impulsberechnung verwendet. Schlafdaten aus der Health-Datenbank werden verwendet, um die Bereitstellung von Apple Watch-Komplikationsupdates während Ihrer Wachzeit zu optimieren."; + diff --git a/Learn/de.lproj/Localizable.strings b/Learn/de.lproj/Localizable.strings new file mode 100644 index 0000000000..40e3ea7b99 --- /dev/null +++ b/Learn/de.lproj/Localizable.strings @@ -0,0 +1,33 @@ +/* Lesson subtitle */ +"Computes the percentage of glucose measurements within a specified range" = "Computes the percentage of glucose measurements within a specified range"; + +/* Title of the button to begin lesson execution */ +"Continue" = "Continue"; + +/* Placeholder for upper range entry */ +"Maximum" = "Maximum"; + +/* Placeholder for lower range entry */ +"Minimum" = "Minimum"; + +/* Lesson title */ +"Modal Day" = "Modal Day"; + +/* Lesson result text for no data */ +"No data available" = "No data available"; + +/* Section title for glucose range */ +"Range" = "Range"; + +/* Title of config entry */ +"Start Date" = "Start Date"; + +/* Lesson title */ +"Time in Range" = "Time in Range"; + +/* Lesson subtitle */ +"Visualizes the most frequent glucose values by time of day" = "Visualizes the most frequent glucose values by time of day"; + +/* Unit string for a count of calendar weeks */ +"Weeks" = "Weeks"; + diff --git a/Learn/de.lproj/Main.strings b/Learn/de.lproj/Main.strings new file mode 100644 index 0000000000..08a8018222 --- /dev/null +++ b/Learn/de.lproj/Main.strings @@ -0,0 +1,3 @@ +/* Class = "UINavigationItem"; title = "Learn"; ObjectID = "8hF-Ij-B7m"; */ +"8hF-Ij-B7m.title" = "Lernen"; + diff --git a/Learn/en.lproj/Localizable.strings b/Learn/en.lproj/Localizable.strings new file mode 100644 index 0000000000..44fdc3083b --- /dev/null +++ b/Learn/en.lproj/Localizable.strings @@ -0,0 +1,32 @@ +/* Lesson subtitle */ +"Computes the percentage of glucose measurements within a specified range" = "Computes the percentage of glucose measurements within a specified range"; + +/* Title of the button to begin lesson execution */ +"Continue" = "Continue"; + +/* Placeholder for upper range entry */ +"Maximum" = "Maximum"; + +/* Placeholder for lower range entry */ +"Minimum" = "Minimum"; + +/* Lesson title */ +"Modal Day" = "Modal Day"; + +/* Lesson result text for no data */ +"No data available" = "No data available"; + +/* Section title for glucose range */ +"Range" = "Range"; + +/* Title of config entry */ +"Start Date" = "Start Date"; + +/* Lesson title */ +"Time in Range" = "Time in Range"; + +/* Lesson subtitle */ +"Visualizes the most frequent glucose values by time of day" = "Visualizes the most frequent glucose values by time of day"; + +/* Unit string for a count of calendar weeks */ +"Weeks" = "Weeks"; diff --git a/Learn/en.lproj/Main.strings b/Learn/en.lproj/Main.strings new file mode 100644 index 0000000000..6b8f04c045 --- /dev/null +++ b/Learn/en.lproj/Main.strings @@ -0,0 +1,3 @@ + +/* Class = "UINavigationItem"; title = "Learn"; ObjectID = "8hF-Ij-B7m"; */ +"8hF-Ij-B7m.title" = "Learn"; diff --git a/Learn/es.lproj/InfoPlist.strings b/Learn/es.lproj/InfoPlist.strings new file mode 100644 index 0000000000..9ca3ae0719 --- /dev/null +++ b/Learn/es.lproj/InfoPlist.strings @@ -0,0 +1,6 @@ +/* Bundle name */ +"CFBundleName" = "Aprender"; + +/* Privacy - Health Share Usage Description */ +"NSHealthShareUsageDescription" = "Los datos de alimentos de la base de datos de Salud se utilizan para determinar los efectos en el nivel de glucosa. Los datos de glucosa de la base de datos de Salud se utilizan para graficar y determinar cálculos de momento. Los datos de Sueño de la base de datos de Salud se utilizan para optimizar la entrega de actualizaciones de las complicaciones del Apple Watch durante el tiempo que está despierto."; + diff --git a/Learn/es.lproj/Localizable.strings b/Learn/es.lproj/Localizable.strings new file mode 100644 index 0000000000..c4c2a22a88 --- /dev/null +++ b/Learn/es.lproj/Localizable.strings @@ -0,0 +1,32 @@ +/* Lesson subtitle */ +"Computes the percentage of glucose measurements within a specified range" = "Calcula el porcentaje de mediciones de glucosa en un rango específico"; + +/* Title of the button to begin lesson execution */ +"Continue" = "Continuar"; + +/* Placeholder for upper range entry */ +"Maximum" = "Máximo"; + +/* Placeholder for lower range entry */ +"Minimum" = "Mínimo"; + +/* Lesson title */ +"Modal Day" = "Día modal"; + +/* Lesson result text for no data */ +"No data available" = "No hay datos disponibles"; + +/* Section title for glucose range */ +"Range" = "Rango"; + +/* Title of config entry */ +"Start Date" = "Fecha de inicio"; + +/* Lesson title */ +"Time in Range" = "Tiempo en rango"; + +/* Lesson subtitle */ +"Visualizes the most frequent glucose values by time of day" = "Muestra los valores de glucosa más frecuentes en un momento del día"; + +/* Unit string for a count of calendar weeks */ +"Weeks" = "Semanas"; diff --git a/Learn/es.lproj/Main.strings b/Learn/es.lproj/Main.strings new file mode 100644 index 0000000000..d28daef2a2 --- /dev/null +++ b/Learn/es.lproj/Main.strings @@ -0,0 +1,3 @@ +/* Class = "UINavigationItem"; title = "Learn"; ObjectID = "8hF-Ij-B7m"; */ +"8hF-Ij-B7m.title" = "Aprender"; + diff --git a/Learn/fi.lproj/InfoPlist.strings b/Learn/fi.lproj/InfoPlist.strings new file mode 100644 index 0000000000..72a3c2f38b --- /dev/null +++ b/Learn/fi.lproj/InfoPlist.strings @@ -0,0 +1,6 @@ +/* Bundle name */ +"CFBundleName" = "Opettele"; + +/* Privacy - Health Share Usage Description */ +"NSHealthShareUsageDescription" = "Terveys-sovelluksen ateriatietoja käytetään glukoosivaikutusten määrittämiseen. Terveys-sovelluksen glukoositietoja käytetään graafeissa ja laskelmissa. Unitietoja käytetään Apple Watch -komplikaation toiminnan optimointiin hereillä olon aikana."; + diff --git a/Learn/fi.lproj/Localizable.strings b/Learn/fi.lproj/Localizable.strings new file mode 100644 index 0000000000..4b9c0d0d94 --- /dev/null +++ b/Learn/fi.lproj/Localizable.strings @@ -0,0 +1,33 @@ +/* Lesson subtitle */ +"Computes the percentage of glucose measurements within a specified range" = "Laskee glukoosimittausten prosenttimäärän määritellyllä alueella"; + +/* Title of the button to begin lesson execution */ +"Continue" = "Jatka"; + +/* Placeholder for upper range entry */ +"Maximum" = "Maksimi"; + +/* Placeholder for lower range entry */ +"Minimum" = "Minimi"; + +/* Lesson title */ +"Modal Day" = "Tyypillinen päivä"; + +/* Lesson result text for no data */ +"No data available" = "Tietoja ei saatavilla"; + +/* Section title for glucose range */ +"Range" = "Alue"; + +/* Title of config entry */ +"Start Date" = "Aloitusaika"; + +/* Lesson title */ +"Time in Range" = "Aika tavoitealueella"; + +/* Lesson subtitle */ +"Visualizes the most frequent glucose values by time of day" = "Näyttää yleisimmät glukoosiarvot vuorokaudenajan mukaan"; + +/* Unit string for a count of calendar weeks */ +"Weeks" = "Viikkoa"; + diff --git a/Learn/fi.lproj/Main.strings b/Learn/fi.lproj/Main.strings new file mode 100644 index 0000000000..50fa41e306 --- /dev/null +++ b/Learn/fi.lproj/Main.strings @@ -0,0 +1,3 @@ +/* Class = "UINavigationItem"; title = "Learn"; ObjectID = "8hF-Ij-B7m"; */ +"8hF-Ij-B7m.title" = "Learn"; + diff --git a/Learn/fr.lproj/InfoPlist.strings b/Learn/fr.lproj/InfoPlist.strings new file mode 100644 index 0000000000..904ee70a5b --- /dev/null +++ b/Learn/fr.lproj/InfoPlist.strings @@ -0,0 +1,6 @@ +/* Bundle name */ +"CFBundleName" = "Apprendre"; + +/* Privacy - Health Share Usage Description */ +"NSHealthShareUsageDescription" = "Les données sur les repas provenant de la base de données Santé sont utilisées pour déterminer les effets du glucose. Les données relatives au glucose provenant de la base de données Santé sont utilisées pour la création de graphiques et le calcul de l'élan. Les données relatives au sommeil provenant de la base de données Santé sont utilisées pour optimiser l'envoi des mises à jour des complications de l'Apple Watch pendant la période où vous êtes éveillé(e)."; + diff --git a/Learn/fr.lproj/Localizable.strings b/Learn/fr.lproj/Localizable.strings new file mode 100644 index 0000000000..330760a768 --- /dev/null +++ b/Learn/fr.lproj/Localizable.strings @@ -0,0 +1,33 @@ +/* Lesson subtitle */ +"Computes the percentage of glucose measurements within a specified range" = "Ceci calcule le pourcentage des mesures de glycémie dans une plage spécifique"; + +/* Title of the button to begin lesson execution */ +"Continue" = "Continuer"; + +/* Placeholder for upper range entry */ +"Maximum" = "Maximum"; + +/* Placeholder for lower range entry */ +"Minimum" = "Minimum"; + +/* Lesson title */ +"Modal Day" = "Journée type"; + +/* Lesson result text for no data */ +"No data available" = "Données indisponibles"; + +/* Section title for glucose range */ +"Range" = "Plage"; + +/* Title of config entry */ +"Start Date" = "Date de commencement"; + +/* Lesson title */ +"Time in Range" = "Temps passé dans la cible"; + +/* Lesson subtitle */ +"Visualizes the most frequent glucose values by time of day" = "Visualisation des glycémies les plus fréquentes selon l’heure"; + +/* Unit string for a count of calendar weeks */ +"Weeks" = "Semaines"; + diff --git a/Learn/fr.lproj/Main.strings b/Learn/fr.lproj/Main.strings new file mode 100644 index 0000000000..8cb49e7404 --- /dev/null +++ b/Learn/fr.lproj/Main.strings @@ -0,0 +1,3 @@ +/* Class = "UINavigationItem"; title = "Learn"; ObjectID = "8hF-Ij-B7m"; */ +"8hF-Ij-B7m.title" = "Apprendre"; + diff --git a/Learn/he.lproj/Localizable.strings b/Learn/he.lproj/Localizable.strings new file mode 100644 index 0000000000..44fdc3083b --- /dev/null +++ b/Learn/he.lproj/Localizable.strings @@ -0,0 +1,32 @@ +/* Lesson subtitle */ +"Computes the percentage of glucose measurements within a specified range" = "Computes the percentage of glucose measurements within a specified range"; + +/* Title of the button to begin lesson execution */ +"Continue" = "Continue"; + +/* Placeholder for upper range entry */ +"Maximum" = "Maximum"; + +/* Placeholder for lower range entry */ +"Minimum" = "Minimum"; + +/* Lesson title */ +"Modal Day" = "Modal Day"; + +/* Lesson result text for no data */ +"No data available" = "No data available"; + +/* Section title for glucose range */ +"Range" = "Range"; + +/* Title of config entry */ +"Start Date" = "Start Date"; + +/* Lesson title */ +"Time in Range" = "Time in Range"; + +/* Lesson subtitle */ +"Visualizes the most frequent glucose values by time of day" = "Visualizes the most frequent glucose values by time of day"; + +/* Unit string for a count of calendar weeks */ +"Weeks" = "Weeks"; diff --git a/Learn/he.lproj/Main.strings b/Learn/he.lproj/Main.strings new file mode 100644 index 0000000000..50fa41e306 --- /dev/null +++ b/Learn/he.lproj/Main.strings @@ -0,0 +1,3 @@ +/* Class = "UINavigationItem"; title = "Learn"; ObjectID = "8hF-Ij-B7m"; */ +"8hF-Ij-B7m.title" = "Learn"; + diff --git a/Learn/it.lproj/InfoPlist.strings b/Learn/it.lproj/InfoPlist.strings new file mode 100644 index 0000000000..3cf2b9683c --- /dev/null +++ b/Learn/it.lproj/InfoPlist.strings @@ -0,0 +1,6 @@ +/* Bundle name */ +"CFBundleName" = "Impara"; + +/* Privacy - Health Share Usage Description */ +"NSHealthShareUsageDescription" = "I dati sui pasti del database Salute vengono utilizzati per determinare gli effetti del glucosio. I dati sul glucosio del database Salute vengono utilizzati per la rappresentazione grafica e il calcolo del momento. I dati sul sonno del database Salute vengono utilizzati per ottimizzare la consegna degli aggiornamenti delle complicazioni di Apple Watch durante il periodo di veglia."; + diff --git a/Learn/it.lproj/Localizable.strings b/Learn/it.lproj/Localizable.strings new file mode 100644 index 0000000000..aa9e31ecde --- /dev/null +++ b/Learn/it.lproj/Localizable.strings @@ -0,0 +1,33 @@ +/* Lesson subtitle */ +"Computes the percentage of glucose measurements within a specified range" = "Calcola la percentuale delle misurazioni di glucosio all’interno di un intervallo specifico"; + +/* Title of the button to begin lesson execution */ +"Continue" = "Continua"; + +/* Placeholder for upper range entry */ +"Maximum" = "Massimo"; + +/* Placeholder for lower range entry */ +"Minimum" = "Minimo"; + +/* Lesson title */ +"Modal Day" = "Modalità giornaliera"; + +/* Lesson result text for no data */ +"No data available" = "Nessun dato disponibile"; + +/* Section title for glucose range */ +"Range" = "Intervallo"; + +/* Title of config entry */ +"Start Date" = "Data di inizio"; + +/* Lesson title */ +"Time in Range" = "Tempo nell’intervallo"; + +/* Lesson subtitle */ +"Visualizes the most frequent glucose values by time of day" = "Visualizza i valori di glucosio più frequenti per ora del giorno"; + +/* Unit string for a count of calendar weeks */ +"Weeks" = "Settimane"; + diff --git a/Learn/it.lproj/Main.strings b/Learn/it.lproj/Main.strings new file mode 100644 index 0000000000..ee4118be50 --- /dev/null +++ b/Learn/it.lproj/Main.strings @@ -0,0 +1,3 @@ +/* Class = "UINavigationItem"; title = "Learn"; ObjectID = "8hF-Ij-B7m"; */ +"8hF-Ij-B7m.title" = "Impara"; + diff --git a/Learn/ja.lproj/Localizable.strings b/Learn/ja.lproj/Localizable.strings new file mode 100644 index 0000000000..b1fd2f7e6b --- /dev/null +++ b/Learn/ja.lproj/Localizable.strings @@ -0,0 +1,33 @@ +/* Lesson subtitle */ +"Computes the percentage of glucose measurements within a specified range" = "指定範囲内の測定値の割合を算出"; + +/* Title of the button to begin lesson execution */ +"Continue" = "次へ"; + +/* Placeholder for upper range entry */ +"Maximum" = "最大"; + +/* Placeholder for lower range entry */ +"Minimum" = "最小"; + +/* Lesson title */ +"Modal Day" = "Modal Day"; + +/* Lesson result text for no data */ +"No data available" = "データがありません"; + +/* Section title for glucose range */ +"Range" = "範囲"; + +/* Title of config entry */ +"Start Date" = "開始日"; + +/* Lesson title */ +"Time in Range" = "タイムインレンジ"; + +/* Lesson subtitle */ +"Visualizes the most frequent glucose values by time of day" = "頻度の高い測定値を時間ごとに表示"; + +/* Unit string for a count of calendar weeks */ +"Weeks" = "週"; + diff --git a/Learn/ja.lproj/Main.strings b/Learn/ja.lproj/Main.strings new file mode 100644 index 0000000000..50fa41e306 --- /dev/null +++ b/Learn/ja.lproj/Main.strings @@ -0,0 +1,3 @@ +/* Class = "UINavigationItem"; title = "Learn"; ObjectID = "8hF-Ij-B7m"; */ +"8hF-Ij-B7m.title" = "Learn"; + diff --git a/Learn/nb.lproj/InfoPlist.strings b/Learn/nb.lproj/InfoPlist.strings new file mode 100644 index 0000000000..874720150f --- /dev/null +++ b/Learn/nb.lproj/InfoPlist.strings @@ -0,0 +1,6 @@ +/* Bundle name */ +"CFBundleName" = "Lære"; + +/* Privacy - Health Share Usage Description */ +"NSHealthShareUsageDescription" = "Måltidsdata fra helsedatabasen brukes til å bestemme glukoseeffekter. Glukosedata fra helsedatabasen brukes til grafer og momentumberegning. Søvndata fra helsedatabasen brukes til å optimalisere leveringen av Apple Watch-komplikasjonsoppdateringer når du er våken."; + diff --git a/Learn/nb.lproj/Localizable.strings b/Learn/nb.lproj/Localizable.strings new file mode 100644 index 0000000000..a8c11c17c1 --- /dev/null +++ b/Learn/nb.lproj/Localizable.strings @@ -0,0 +1,33 @@ +/* Lesson subtitle */ +"Computes the percentage of glucose measurements within a specified range" = "Computes the percentage of glucose measurements within a specified range"; + +/* Title of the button to begin lesson execution */ +"Continue" = "Fortsett"; + +/* Placeholder for upper range entry */ +"Maximum" = "Maksimum"; + +/* Placeholder for lower range entry */ +"Minimum" = "Minimum"; + +/* Lesson title */ +"Modal Day" = "Modal dag"; + +/* Lesson result text for no data */ +"No data available" = "Ingen data tilgjengelig"; + +/* Section title for glucose range */ +"Range" = "Målområde"; + +/* Title of config entry */ +"Start Date" = "Startdato"; + +/* Lesson title */ +"Time in Range" = "Tid i målområdet"; + +/* Lesson subtitle */ +"Visualizes the most frequent glucose values by time of day" = "Visualiser de nyeste blodsukkerverdier etter tid på døgnet"; + +/* Unit string for a count of calendar weeks */ +"Weeks" = "Uker"; + diff --git a/Learn/nb.lproj/Main.strings b/Learn/nb.lproj/Main.strings new file mode 100644 index 0000000000..d8d90ac21a --- /dev/null +++ b/Learn/nb.lproj/Main.strings @@ -0,0 +1,3 @@ +/* Class = "UINavigationItem"; title = "Learn"; ObjectID = "8hF-Ij-B7m"; */ +"8hF-Ij-B7m.title" = "Lær"; + diff --git a/Learn/nl.lproj/InfoPlist.strings b/Learn/nl.lproj/InfoPlist.strings new file mode 100644 index 0000000000..2d6020792e --- /dev/null +++ b/Learn/nl.lproj/InfoPlist.strings @@ -0,0 +1,6 @@ +/* Bundle name */ +"CFBundleName" = "Leer"; + +/* Privacy - Health Share Usage Description */ +"NSHealthShareUsageDescription" = "Maaltijdgegevens uit de database Gezondheid worden gebruikt om glucose-effecenten te bepalen. Glucosegegevens uit de database Gezondheid worden gebruikt voor grafieken en het berekenen van trendlijnen. Slaapgegevens uit de database Gezondheid worden gebruikt om de Apple Watch complicatie bij te werken wanneer je wakker bent."; + diff --git a/Learn/nl.lproj/Localizable.strings b/Learn/nl.lproj/Localizable.strings new file mode 100644 index 0000000000..b436db44fe --- /dev/null +++ b/Learn/nl.lproj/Localizable.strings @@ -0,0 +1,33 @@ +/* Lesson subtitle */ +"Computes the percentage of glucose measurements within a specified range" = "Berekent het percentage glucosemetingen in een specifiek bereik"; + +/* Title of the button to begin lesson execution */ +"Continue" = "Ga verder"; + +/* Placeholder for upper range entry */ +"Maximum" = "Maximum"; + +/* Placeholder for lower range entry */ +"Minimum" = "Minimum"; + +/* Lesson title */ +"Modal Day" = "Model dag"; + +/* Lesson result text for no data */ +"No data available" = "Geen data aanwezig"; + +/* Section title for glucose range */ +"Range" = "Bereik"; + +/* Title of config entry */ +"Start Date" = "Start datum"; + +/* Lesson title */ +"Time in Range" = "Tijd in bereik"; + +/* Lesson subtitle */ +"Visualizes the most frequent glucose values by time of day" = "Geeft de meest voorkomende glucose waardes weer per moment van de dag"; + +/* Unit string for a count of calendar weeks */ +"Weeks" = "Weken"; + diff --git a/Learn/nl.lproj/Main.strings b/Learn/nl.lproj/Main.strings new file mode 100644 index 0000000000..7929420678 --- /dev/null +++ b/Learn/nl.lproj/Main.strings @@ -0,0 +1,3 @@ +/* Class = "UINavigationItem"; title = "Learn"; ObjectID = "8hF-Ij-B7m"; */ +"8hF-Ij-B7m.title" = "Leer"; + diff --git a/Learn/pl.lproj/InfoPlist.strings b/Learn/pl.lproj/InfoPlist.strings new file mode 100644 index 0000000000..afba6db611 --- /dev/null +++ b/Learn/pl.lproj/InfoPlist.strings @@ -0,0 +1,6 @@ +/* Bundle name */ +"CFBundleName" = "Learn"; + +/* Privacy - Health Share Usage Description */ +"NSHealthShareUsageDescription" = "Dane posiłków z bazy danych aplikacji Zdrowie służą do określania wpływu glukozy. Dane dotyczące glukozy z bazy danych aplikacji Zdrowie są wykorzystywane do tworzenia wykresów i wyznaczania trendu. Dane dotyczące snu z bazy danych aplikacji Zdrowie służą do optymalizacji dostarczania aktualizacji komplikacji Apple Watch w czasie, gdy nie śpisz."; + diff --git a/Learn/pl.lproj/Localizable.strings b/Learn/pl.lproj/Localizable.strings new file mode 100644 index 0000000000..257d2c7a5a --- /dev/null +++ b/Learn/pl.lproj/Localizable.strings @@ -0,0 +1,32 @@ +/* Lesson subtitle */ +"Computes the percentage of glucose measurements within a specified range" = "Oblicza odsetek pomiarów glukozy w określonym zakresie"; + +/* Title of the button to begin lesson execution */ +"Continue" = "Kontynuuj"; + +/* Placeholder for upper range entry */ +"Maximum" = "Maks."; + +/* Placeholder for lower range entry */ +"Minimum" = "Min."; + +/* Lesson title */ +"Modal Day" = "Dzień modalny"; + +/* Lesson result text for no data */ +"No data available" = "Brak dostępnych danych"; + +/* Section title for glucose range */ +"Range" = "Zakres"; + +/* Title of config entry */ +"Start Date" = "Data rozpoczęcia"; + +/* Lesson title */ +"Time in Range" = "Czas w zakresie"; + +/* Lesson subtitle */ +"Visualizes the most frequent glucose values by time of day" = "Wizualizuje najczęstsze wartości glukozy według pory dnia"; + +/* Unit string for a count of calendar weeks */ +"Weeks" = "Tygodnie"; diff --git a/Learn/pl.lproj/Main.strings b/Learn/pl.lproj/Main.strings new file mode 100644 index 0000000000..50fa41e306 --- /dev/null +++ b/Learn/pl.lproj/Main.strings @@ -0,0 +1,3 @@ +/* Class = "UINavigationItem"; title = "Learn"; ObjectID = "8hF-Ij-B7m"; */ +"8hF-Ij-B7m.title" = "Learn"; + diff --git a/Learn/pt-BR.lproj/Localizable.strings b/Learn/pt-BR.lproj/Localizable.strings new file mode 100644 index 0000000000..cad806a303 --- /dev/null +++ b/Learn/pt-BR.lproj/Localizable.strings @@ -0,0 +1,33 @@ +/* Lesson subtitle */ +"Computes the percentage of glucose measurements within a specified range" = "Calcula a porcentagem de medições de glicose dentro de um intervalo especificado"; + +/* Title of the button to begin lesson execution */ +"Continue" = "Continuar"; + +/* Placeholder for upper range entry */ +"Maximum" = "Máximo"; + +/* Placeholder for lower range entry */ +"Minimum" = "Mínimo"; + +/* Lesson title */ +"Modal Day" = "Dia Modal"; + +/* Lesson result text for no data */ +"No data available" = "Não há dados disponíveis"; + +/* Section title for glucose range */ +"Range" = "Variação"; + +/* Title of config entry */ +"Start Date" = "Data de Início"; + +/* Lesson title */ +"Time in Range" = "Tempo na Meta"; + +/* Lesson subtitle */ +"Visualizes the most frequent glucose values by time of day" = "Visualiza os valores de glicose mais frequentes por hora do dia"; + +/* Unit string for a count of calendar weeks */ +"Weeks" = "Semanas"; + diff --git a/Learn/pt-BR.lproj/Main.strings b/Learn/pt-BR.lproj/Main.strings new file mode 100644 index 0000000000..50fa41e306 --- /dev/null +++ b/Learn/pt-BR.lproj/Main.strings @@ -0,0 +1,3 @@ +/* Class = "UINavigationItem"; title = "Learn"; ObjectID = "8hF-Ij-B7m"; */ +"8hF-Ij-B7m.title" = "Learn"; + diff --git a/Learn/ro.lproj/InfoPlist.strings b/Learn/ro.lproj/InfoPlist.strings new file mode 100644 index 0000000000..7b3f99c93d --- /dev/null +++ b/Learn/ro.lproj/InfoPlist.strings @@ -0,0 +1,6 @@ +/* Bundle name */ +"CFBundleName" = "Learn"; + +/* Privacy - Health Share Usage Description */ +"NSHealthShareUsageDescription" = "Datele mesei din baza de date din aplicația Sănătate sunt folosite pentru a determina efectele glicemice. Datele despre glicemie din baza de date Sănătate sunt folosite pentru construirea graficelor și calcularea influențelor glicemice. Datele de somn din baza de date Sănătate sunt folosite pentru a optimiza livrarea de actualizări de datele ale ceasului Apple pe perioada când sunteți treaz."; + diff --git a/Learn/ro.lproj/Localizable.strings b/Learn/ro.lproj/Localizable.strings new file mode 100644 index 0000000000..8ee962ab12 --- /dev/null +++ b/Learn/ro.lproj/Localizable.strings @@ -0,0 +1,33 @@ +/* Lesson subtitle */ +"Computes the percentage of glucose measurements within a specified range" = "Calculează procentul măsurătorilor glicemice dintr-un interval specificat"; + +/* Title of the button to begin lesson execution */ +"Continue" = "Continuă"; + +/* Placeholder for upper range entry */ +"Maximum" = "Maxim"; + +/* Placeholder for lower range entry */ +"Minimum" = "Minim"; + +/* Lesson title */ +"Modal Day" = "Zi modală"; + +/* Lesson result text for no data */ +"No data available" = "Date inexistente"; + +/* Section title for glucose range */ +"Range" = "Interval"; + +/* Title of config entry */ +"Start Date" = "Dată inițială"; + +/* Lesson title */ +"Time in Range" = "Timp petrecut în interval"; + +/* Lesson subtitle */ +"Visualizes the most frequent glucose values by time of day" = "Vizualizează cele mai frecvente valori glicemice în funcție de oră"; + +/* Unit string for a count of calendar weeks */ +"Weeks" = "Săptămâni"; + diff --git a/Learn/ro.lproj/Main.strings b/Learn/ro.lproj/Main.strings new file mode 100644 index 0000000000..50fa41e306 --- /dev/null +++ b/Learn/ro.lproj/Main.strings @@ -0,0 +1,3 @@ +/* Class = "UINavigationItem"; title = "Learn"; ObjectID = "8hF-Ij-B7m"; */ +"8hF-Ij-B7m.title" = "Learn"; + diff --git a/Learn/ru.lproj/InfoPlist.strings b/Learn/ru.lproj/InfoPlist.strings new file mode 100644 index 0000000000..c0198b35f9 --- /dev/null +++ b/Learn/ru.lproj/InfoPlist.strings @@ -0,0 +1,3 @@ +/* Bundle name */ +"CFBundleName" = "Узнать"; + diff --git a/Learn/ru.lproj/Localizable.strings b/Learn/ru.lproj/Localizable.strings new file mode 100644 index 0000000000..2de0b3d286 --- /dev/null +++ b/Learn/ru.lproj/Localizable.strings @@ -0,0 +1,33 @@ +/* Lesson subtitle */ +"Computes the percentage of glucose measurements within a specified range" = "Вычисляет процент замеров ГК в указанном диапазоне"; + +/* Title of the button to begin lesson execution */ +"Continue" = "Продолжить"; + +/* Placeholder for upper range entry */ +"Maximum" = "Максимум"; + +/* Placeholder for lower range entry */ +"Minimum" = "Минимум"; + +/* Lesson title */ +"Modal Day" = "Модальный день"; + +/* Lesson result text for no data */ +"No data available" = "Нет данных"; + +/* Section title for glucose range */ +"Range" = "Диапазон"; + +/* Title of config entry */ +"Start Date" = "Дата начала"; + +/* Lesson title */ +"Time in Range" = "Время в диапазоне"; + +/* Lesson subtitle */ +"Visualizes the most frequent glucose values by time of day" = "Показывает самые частые значения ГК по времени суток"; + +/* Unit string for a count of calendar weeks */ +"Weeks" = "Недель"; + diff --git a/Learn/ru.lproj/Main.strings b/Learn/ru.lproj/Main.strings new file mode 100644 index 0000000000..a6bda48fca --- /dev/null +++ b/Learn/ru.lproj/Main.strings @@ -0,0 +1,3 @@ +/* Class = "UINavigationItem"; title = "Learn"; ObjectID = "8hF-Ij-B7m"; */ +"8hF-Ij-B7m.title" = "Обучение"; + diff --git a/Learn/sk.lproj/InfoPlist.strings b/Learn/sk.lproj/InfoPlist.strings new file mode 100644 index 0000000000..da8422aedb --- /dev/null +++ b/Learn/sk.lproj/InfoPlist.strings @@ -0,0 +1,3 @@ +/* Privacy - Health Share Usage Description */ +"NSHealthShareUsageDescription" = "Údaje o jedle z databázy Health sa používajú na určenie účinkov glukózy. Údaje o glukóze z databázy Health sa používajú na vytváranie grafov a výpočet hybnosti. Údaje o spánku z databázy Health sa používajú na optimalizáciu doručovania aktualizácií komplikácií Apple Watch v čase, keď ste hore."; + diff --git a/Learn/sv.lproj/InfoPlist.strings b/Learn/sv.lproj/InfoPlist.strings new file mode 100644 index 0000000000..55302cd95c --- /dev/null +++ b/Learn/sv.lproj/InfoPlist.strings @@ -0,0 +1,6 @@ +/* Bundle name */ +"CFBundleName" = "Learn"; + +/* Privacy - Health Share Usage Description */ +"NSHealthShareUsageDescription" = "Kolhydratdata från Apple Health-databasen används för att avgöra blodsockereffekt. Blodsockervärden från Apple Health-databasen används i diagram och för beräkning av förändring."; + diff --git a/Learn/sv.lproj/Localizable.strings b/Learn/sv.lproj/Localizable.strings new file mode 100644 index 0000000000..ac2501e87b --- /dev/null +++ b/Learn/sv.lproj/Localizable.strings @@ -0,0 +1,33 @@ +/* Lesson subtitle */ +"Computes the percentage of glucose measurements within a specified range" = "Beräknar procentandelen glukosmätningar inom ett specifikt målvärde"; + +/* Title of the button to begin lesson execution */ +"Continue" = "Fortsätt"; + +/* Placeholder for upper range entry */ +"Maximum" = "Maximum"; + +/* Placeholder for lower range entry */ +"Minimum" = "Minimum"; + +/* Lesson title */ +"Modal Day" = "Genomsnittlig dag"; + +/* Lesson result text for no data */ +"No data available" = "Ingen data tillgänglig"; + +/* Section title for glucose range */ +"Range" = "Målvärde"; + +/* Title of config entry */ +"Start Date" = "Starttid"; + +/* Lesson title */ +"Time in Range" = "Tid inom målvärde"; + +/* Lesson subtitle */ +"Visualizes the most frequent glucose values by time of day" = "Visar de vanligaste glukosvärdena under olika tider på dygnet"; + +/* Unit string for a count of calendar weeks */ +"Weeks" = "Veckor"; + diff --git a/Learn/sv.lproj/Main.strings b/Learn/sv.lproj/Main.strings new file mode 100644 index 0000000000..fe2ae42d97 --- /dev/null +++ b/Learn/sv.lproj/Main.strings @@ -0,0 +1,3 @@ +/* Class = "UINavigationItem"; title = "Learn"; ObjectID = "8hF-Ij-B7m"; */ +"8hF-Ij-B7m.title" = "Utbildning"; + diff --git a/Learn/tr.lproj/InfoPlist.strings b/Learn/tr.lproj/InfoPlist.strings new file mode 100644 index 0000000000..7ff69b11e8 --- /dev/null +++ b/Learn/tr.lproj/InfoPlist.strings @@ -0,0 +1,6 @@ +/* Bundle name */ +"CFBundleName" = "Öğren"; + +/* Privacy - Health Share Usage Description */ +"NSHealthShareUsageDescription" = "Sağlık veri tabanından alınan yemek verileri, glikoz etkilerini belirlemek için kullanılır. Sağlık veri tabanından alınan glikoz verileri, grafik ve momentum hesaplaması için kullanılır. Sağlık veritabanındaki uyku verileri, uyanık olduğunuz süre boyunca Apple Watch komplikasyon güncellemelerinin teslimini optimize etmek için kullanılır."; + diff --git a/Learn/tr.lproj/Localizable.strings b/Learn/tr.lproj/Localizable.strings new file mode 100644 index 0000000000..44fdc3083b --- /dev/null +++ b/Learn/tr.lproj/Localizable.strings @@ -0,0 +1,32 @@ +/* Lesson subtitle */ +"Computes the percentage of glucose measurements within a specified range" = "Computes the percentage of glucose measurements within a specified range"; + +/* Title of the button to begin lesson execution */ +"Continue" = "Continue"; + +/* Placeholder for upper range entry */ +"Maximum" = "Maximum"; + +/* Placeholder for lower range entry */ +"Minimum" = "Minimum"; + +/* Lesson title */ +"Modal Day" = "Modal Day"; + +/* Lesson result text for no data */ +"No data available" = "No data available"; + +/* Section title for glucose range */ +"Range" = "Range"; + +/* Title of config entry */ +"Start Date" = "Start Date"; + +/* Lesson title */ +"Time in Range" = "Time in Range"; + +/* Lesson subtitle */ +"Visualizes the most frequent glucose values by time of day" = "Visualizes the most frequent glucose values by time of day"; + +/* Unit string for a count of calendar weeks */ +"Weeks" = "Weeks"; diff --git a/Learn/tr.lproj/Main.strings b/Learn/tr.lproj/Main.strings new file mode 100644 index 0000000000..92d1c6c547 --- /dev/null +++ b/Learn/tr.lproj/Main.strings @@ -0,0 +1,3 @@ +/* Class = "UINavigationItem"; title = "Learn"; ObjectID = "8hF-Ij-B7m"; */ +"8hF-Ij-B7m.title" = "Öğren"; + diff --git a/Learn/vi.lproj/Localizable.strings b/Learn/vi.lproj/Localizable.strings new file mode 100644 index 0000000000..e35055c296 --- /dev/null +++ b/Learn/vi.lproj/Localizable.strings @@ -0,0 +1,33 @@ +/* Lesson subtitle */ +"Computes the percentage of glucose measurements within a specified range" = "Tính tỉ lệ phần trăm của glucose trong một phạm vi nhất định"; + +/* Title of the button to begin lesson execution */ +"Continue" = "Tiếp tục"; + +/* Placeholder for upper range entry */ +"Maximum" = "Tối đa"; + +/* Placeholder for lower range entry */ +"Minimum" = "Tối thiểu"; + +/* Lesson title */ +"Modal Day" = "Ngày chuẩn"; + +/* Lesson result text for no data */ +"No data available" = "Dữ liệu không tồn tại"; + +/* Section title for glucose range */ +"Range" = "Phạm vi"; + +/* Title of config entry */ +"Start Date" = "Ngày bắt đầu"; + +/* Lesson title */ +"Time in Range" = "Thời gian trong phạm vi kiểm soát"; + +/* Lesson subtitle */ +"Visualizes the most frequent glucose values by time of day" = "Hình dung các giá trị glucose thường xuyên nhất theo thời gian trong ngày"; + +/* Unit string for a count of calendar weeks */ +"Weeks" = "Tuần"; + diff --git a/Learn/vi.lproj/Main.strings b/Learn/vi.lproj/Main.strings new file mode 100644 index 0000000000..50fa41e306 --- /dev/null +++ b/Learn/vi.lproj/Main.strings @@ -0,0 +1,3 @@ +/* Class = "UINavigationItem"; title = "Learn"; ObjectID = "8hF-Ij-B7m"; */ +"8hF-Ij-B7m.title" = "Learn"; + diff --git a/Learn/zh-Hans.lproj/Localizable.strings b/Learn/zh-Hans.lproj/Localizable.strings new file mode 100644 index 0000000000..9db52c7bac --- /dev/null +++ b/Learn/zh-Hans.lproj/Localizable.strings @@ -0,0 +1,33 @@ +/* Lesson subtitle */ +"Computes the percentage of glucose measurements within a specified range" = "计算在指定范围内的血糖测量值的百分比"; + +/* Title of the button to begin lesson execution */ +"Continue" = "继续"; + +/* Placeholder for upper range entry */ +"Maximum" = "最大"; + +/* Placeholder for lower range entry */ +"Minimum" = "最小"; + +/* Lesson title */ +"Modal Day" = "Modal Day"; + +/* Lesson result text for no data */ +"No data available" = "无数据"; + +/* Section title for glucose range */ +"Range" = "范围"; + +/* Title of config entry */ +"Start Date" = "开始日期"; + +/* Lesson title */ +"Time in Range" = "在目标范围的时间"; + +/* Lesson subtitle */ +"Visualizes the most frequent glucose values by time of day" = "全天血糖数据"; + +/* Unit string for a count of calendar weeks */ +"Weeks" = "周"; + diff --git a/Learn/zh-Hans.lproj/Main.strings b/Learn/zh-Hans.lproj/Main.strings new file mode 100644 index 0000000000..50fa41e306 --- /dev/null +++ b/Learn/zh-Hans.lproj/Main.strings @@ -0,0 +1,3 @@ +/* Class = "UINavigationItem"; title = "Learn"; ObjectID = "8hF-Ij-B7m"; */ +"8hF-Ij-B7m.title" = "Learn"; + diff --git a/Loop Intent Extension/Info.plist b/Loop Intent Extension/Info.plist new file mode 100644 index 0000000000..30d9e49550 --- /dev/null +++ b/Loop Intent Extension/Info.plist @@ -0,0 +1,45 @@ + + + + + AppGroupIdentifier + $(APP_GROUP_IDENTIFIER) + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Loop Intent Extension + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(LOOP_MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionAttributes + + IntentsRestrictedWhileLocked + + IntentsRestrictedWhileProtectedDataUnavailable + + IntentsSupported + + EnableOverridePresetIntent + NewCarbEntryIntent + + + NSExtensionPointIdentifier + com.apple.intents-service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).IntentHandler + + + diff --git a/Loop Intent Extension/InfoPlist.xcstrings b/Loop Intent Extension/InfoPlist.xcstrings new file mode 100644 index 0000000000..5e570491f1 --- /dev/null +++ b/Loop Intent Extension/InfoPlist.xcstrings @@ -0,0 +1,198 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "CFBundleDisplayName" : { + "comment" : "Bundle display name", + "extractionState" : "extracted_with_value", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop Intent-udvidelse" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop Absichts Erweiterung" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Loop Intent Extension" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Extensión de Intención de Loop" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop Intent Extension" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop Intent Extension" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop Intent Extension" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estensione dell'intento di Loop" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utvidelse av Loop intensjon" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop Intent Extensie" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop Intent Extension" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Extensie Intent Loop" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop Intent Extension" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop Intent Extension" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Döngü Amaç Uzantısı" + } + } + } + }, + "CFBundleName" : { + "comment" : "Bundle name", + "extractionState" : "extracted_with_value", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop Intent-udvidelse" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop Absichts Erweiterung" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Loop Intent Extension" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Extensión de Intención de Loop" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop Intent Extension" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop Intent Extension" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop Intent Extension" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estensione dell'intento di Loop" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utvidelse av Loop intensjon" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop Intent Extensie" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop Intent Extension" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Extensie Intent Loop" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop Intent Extension" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop Intent Extension" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Döngü Amaç Uzantısı" + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/Loop Intent Extension/IntentHandler.swift b/Loop Intent Extension/IntentHandler.swift new file mode 100644 index 0000000000..20c293a0da --- /dev/null +++ b/Loop Intent Extension/IntentHandler.swift @@ -0,0 +1,18 @@ +// +// IntentHandler.swift +// Loop Intent Extension +// +// Created by Anna Quinlan on 10/17/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import Intents + +class IntentHandler: INExtension { + override func handler(for intent: INIntent) -> Any { + if intent is EnableOverridePresetIntent { + return OverrideIntentHandler() + } + return self + } +} diff --git a/Loop Intent Extension/Localizable.xcstrings b/Loop Intent Extension/Localizable.xcstrings new file mode 100644 index 0000000000..0952583062 --- /dev/null +++ b/Loop Intent Extension/Localizable.xcstrings @@ -0,0 +1,131 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "%1$@ v%2$@" : { + "comment" : "The format string for the app name and version number. (1: bundle name)(2: bundle version)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v %2$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ версии %2$@" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v %2$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/Loop Intent Extension/Loop Intent Extension.entitlements b/Loop Intent Extension/Loop Intent Extension.entitlements new file mode 100644 index 0000000000..d9849a816d --- /dev/null +++ b/Loop Intent Extension/Loop Intent Extension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + $(APP_GROUP_IDENTIFIER) + + + diff --git a/Loop Intent Extension/OverrideIntentHandler.swift b/Loop Intent Extension/OverrideIntentHandler.swift new file mode 100644 index 0000000000..af06e080b5 --- /dev/null +++ b/Loop Intent Extension/OverrideIntentHandler.swift @@ -0,0 +1,65 @@ +// +// OverrideIntentHandler.swift +// Loop Intent Extension +// +// Created by Anna Quinlan on 10/17/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import Foundation +import Intents + +class OverrideIntentHandler: NSObject, EnableOverridePresetIntentHandling { + lazy var defaults = UserDefaults(suiteName: Bundle.main.appGroupSuiteName) + + var presetOptions: [String]? { + guard let defaults = self.defaults, let names = defaults.intentExtensionInfo?.overridePresetNames else { + return nil + } + + return names + } + + @available(iOSApplicationExtension 14.0, watchOSApplicationExtension 7.0, *) + func provideOverrideNameOptionsCollection(for intent: EnableOverridePresetIntent, with completion: @escaping (INObjectCollection?, Error?) -> Void) { + guard let presets = presetOptions else { + completion(nil, nil) + return + } + completion(INObjectCollection(items: presets.map { NSString(string: $0) } ), nil) + } + + func containsOverrideName(name: String) -> Bool { + let lowercasedName = name.lowercased() + + if presetOptions?.first(where: {$0.lowercased() == lowercasedName}) != nil { + return true + } + return false + } + + func handle(intent: EnableOverridePresetIntent, completion: @escaping (EnableOverridePresetIntentResponse) -> Void) { + guard let defaults = self.defaults, let overrideName = intent.overrideName?.lowercased(), containsOverrideName(name: overrideName) else { + completion(EnableOverridePresetIntentResponse(code: .failure, userActivity: nil)) + return + } + + defaults.intentExtensionOverrideToSet = overrideName + // Continue in app because the UserDefaults KVO doesn't work in the background + completion(EnableOverridePresetIntentResponse(code: .continueInApp, userActivity: nil)) + } + + func resolveOverrideName(for intent: EnableOverridePresetIntent, with completion: @escaping (INStringResolutionResult) -> Void) { + guard let overrideName = intent.overrideName?.lowercased() else { + completion(INStringResolutionResult.needsValue()) + return + } + + guard containsOverrideName(name: overrideName) else { + completion(INStringResolutionResult.unsupported()) + return + } + + completion(INStringResolutionResult.success(with: overrideName)) + } +} diff --git a/Loop Status Extension/Base.lproj/InfoPlist.strings b/Loop Status Extension/Base.lproj/InfoPlist.strings new file mode 100644 index 0000000000..f8e9a2b43f --- /dev/null +++ b/Loop Status Extension/Base.lproj/InfoPlist.strings @@ -0,0 +1,3 @@ +/* (No Comment) */ +"CFBundleName" = "$(PRODUCT_NAME)"; + diff --git a/Loop Status Extension/Base.lproj/MainInterface.storyboard b/Loop Status Extension/Base.lproj/MainInterface.storyboard index 05b95fa8dd..78d5e1c465 100644 --- a/Loop Status Extension/Base.lproj/MainInterface.storyboard +++ b/Loop Status Extension/Base.lproj/MainInterface.storyboard @@ -1,12 +1,8 @@ - - - - - + + + - - - + @@ -19,30 +15,78 @@ - + - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - + + + + @@ -50,10 +94,14 @@ - + - - + + + + + + diff --git a/Loop Status Extension/Info.plist b/Loop Status Extension/Info.plist index 9b2b100320..98c5c3e989 100644 --- a/Loop Status Extension/Info.plist +++ b/Loop Status Extension/Info.plist @@ -2,12 +2,12 @@ + AppGroupIdentifier + $(APP_GROUP_IDENTIFIER) CFBundleDevelopmentRegion en - MainAppBundleIdentifier - $(MAIN_APP_BUNDLE_IDENTIFIER) CFBundleDisplayName - Loop + $(MAIN_APP_DISPLAY_NAME) CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -19,11 +19,11 @@ CFBundlePackageType XPC! CFBundleShortVersionString - 1.2.0 + $(LOOP_MARKETING_VERSION) CFBundleVersion $(CURRENT_PROJECT_VERSION) - AppGroupIdentifier - $(APP_GROUP_IDENTIFIER) + MainAppBundleIdentifier + $(MAIN_APP_BUNDLE_IDENTIFIER) NSExtension NSExtensionMainStoryboard diff --git a/Loop Status Extension/InfoPlist.xcstrings b/Loop Status Extension/InfoPlist.xcstrings new file mode 100644 index 0000000000..cabc0b8b20 --- /dev/null +++ b/Loop Status Extension/InfoPlist.xcstrings @@ -0,0 +1,234 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "CFBundleDisplayName" : { + "comment" : "Bundle display name", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Loop" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ループ" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop" + } + } + } + }, + "CFBundleName" : { + "comment" : "Bundle name", + "extractionState" : "extracted_with_value", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop-statusudvidelse" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop Status-Erweiterung" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Loop Status Extension" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Extensión de Estado de Loop" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop Status Extension" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop Status Extension" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop Status Extension" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estensione dello stato di funzionamento di Loop" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utvidelse av Loop status" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop Status Extensie" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop Status Extension" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Extensie stare Loop" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop Status Extension" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop Status Extension" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop Durum Uzantısı" + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/Loop Status Extension/Localizable.xcstrings b/Loop Status Extension/Localizable.xcstrings new file mode 100644 index 0000000000..7f0ecaa2e7 --- /dev/null +++ b/Loop Status Extension/Localizable.xcstrings @@ -0,0 +1,1776 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "? g" : { + "comment" : "Displayed in the widget when the amount of active carbs cannot be determined.", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "? g" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "? g" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "? gr" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "? g" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "? g" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "? g" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "? g" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "? g" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "? g" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "? g" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "? g" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "? г" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "? g" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "? g" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "? gr" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "? g" + } + } + } + }, + "? U" : { + "comment" : "Displayed in the widget when the amount of active insulin cannot be determined.", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "? E" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "? IE" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "? U" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "? U" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "? U" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "? U" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "? U" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "? E" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "? E" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "? J" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "? U" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "? ед." + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "? j" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "? E" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "? Ü" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "? U" + } + } + } + }, + "%1$@" : { + "comment" : "The subtitle format describing the grams of active carbs. (1: localized carb value description)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@" + } + } + } + }, + "%1$@ U" : { + "comment" : "The subtitle format describing units of active insulin. (1: localized insulin value description)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ E" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ IE" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "U %1$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ E" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ E" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ J" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ Ед" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ j" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ E" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ Ü" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U" + } + } + } + }, + "%1$@ v%2$@" : { + "comment" : "The format string for the app name and version number. (1: bundle name)(2: bundle version)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ версии %2$@" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v %2$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + } + } + }, + "Active Carbs" : { + "comment" : "Widget label title describing the active carbs", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "كارب النشط" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktive kulhydrater" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktive KH" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carbohidratos Activos" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Akt. hiilari" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glucides" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "פחמימות פעילות" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carboidrati attivi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "残存糖質" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktive karbohydrater" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actieve Koolhydraten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktywne węglowodany" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carboidratos Ativos" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carbohidrați activi" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Активные углеводы" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktívne sacharidy" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktiva kolhydrater" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktif Karb." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lượng Carbs còn hoạt động" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "活性碳水" + } + } + } + }, + "Active Insulin" : { + "comment" : "Widget label title describing the active insulin", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "أنسولين نشط" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktivt insulin" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktives Insulin" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulina activa" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Akt. insuliini" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insuline" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אינסולין פעיל" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulina attiva" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "残存インスリン" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktivt insulin" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actieve Insuline" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktywna insulina" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulina Ativa" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulină activă" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Активный инсулин" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktívny inzulín" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktivt insulin" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktif İnsülin" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lượng Insulin còn hoạt động" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "活性胰岛素" + } + } + } + }, + "dB" : { + "comment" : "The short unit display string for decibles", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "дБ" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + } + } + }, + "Eventually %1$@" : { + "comment" : "The subtitle format describing eventual glucose. (1: localized glucose value description)", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "متوقع %1$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Med tiden %1$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voraussichtlich %1$@" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eventually %1$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eventualmente %1$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ennuste %1$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Finalement %1$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "בדרך ל-%1$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alla fine %1$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "予想 %1$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Omsider %1$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uiteindelijk %1$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Docelowo %1$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eventualmente %1$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "În cele din urmă %1$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "В конечном итоге %1$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Förväntat %1$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nihai KŞ %1$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kết quả là %1$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "最终 %1$@" + } + } + } + }, + "g" : { + "comment" : "The short unit display string for grams", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "г" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "gr" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "克" + } + } + } + }, + "IOB %1$@ U" : { + "comment" : "The subtitle format describing units of active insulin. (1: localized insulin value description)", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "أنسولين نشط %1$@ وحدة" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "IOB %1$@ E" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "IOB %1$@ IE" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "IOB %1$@ U" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "IOB %1$@ U" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "IOB %1$@ U" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "IOB %1$@ U" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "IOB %1$@ U" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "IOB %1$@ U" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "IOB %1$@ U" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "IOB %1$@ E" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "IOB %1$@ E" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "IOB %1$@ J" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "IOB %1$@ U" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "IOB %1$@ U" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "IOB %1$@ ед" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "IOB %1$@ j" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "IOB %1$@ E" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "AİNS %1$@ Ü" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "IOB %1$@ U" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "活性胰岛素 %1$@ U" + } + } + } + }, + "mg/dL" : { + "comment" : "The short unit display string for milligrams of glucose per decilter", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dl" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "мг/дл" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dl" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + } + } + }, + "mmol/L" : { + "comment" : "The short unit display string for millimoles of glucose per liter", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ммоль/л" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + } + } + }, + "QUANTITY_VALUE_AND_UNIT" : { + "comment" : "Format string for combining localized numeric value and unit. (1: numeric value)(2: unit)", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ %2$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@%2$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + } + } + }, + "U" : { + "comment" : "The short unit display string for international units of insulin", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "وحدة" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "E" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "IE" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "U" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "U" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "U" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "U" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "U" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "U" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "E" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "E" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "J" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "U" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "U" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ед" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "j" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "E" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ü" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "U" + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/Loop Status Extension/StateColorPalette.swift b/Loop Status Extension/StateColorPalette.swift new file mode 100644 index 0000000000..e6f18b436a --- /dev/null +++ b/Loop Status Extension/StateColorPalette.swift @@ -0,0 +1,17 @@ +// +// StateColorPalette.swift +// Loop +// +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +import LoopUI +import LoopKitUI + +extension StateColorPalette { + static let loopStatus = StateColorPalette(unknown: .unknownColor, normal: .freshColor, warning: .agingColor, error: .staleColor) + + static let cgmStatus = loopStatus + + static let pumpStatus = StateColorPalette(unknown: .unknownColor, normal: .pumpStatusNormal, warning: .agingColor, error: .staleColor) +} diff --git a/Loop Status Extension/StatusChartsManager.swift b/Loop Status Extension/StatusChartsManager.swift new file mode 100644 index 0000000000..c75041e52f --- /dev/null +++ b/Loop Status Extension/StatusChartsManager.swift @@ -0,0 +1,21 @@ +// +// StatusChartsManager.swift +// Loop Status Extension +// +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopUI +import LoopKitUI +import SwiftCharts +import UIKit + +class StatusChartsManager: ChartsManager { + let predictedGlucose = PredictedGlucoseChart(predictedGlucoseBounds: FeatureFlags.predictedGlucoseChartClampEnabled ? .default : nil, + yAxisStepSizeMGDLOverride: FeatureFlags.predictedGlucoseChartClampEnabled ? 40 : nil) + + init(colors: ChartColorPalette, settings: ChartSettings, traitCollection: UITraitCollection) { + super.init(colors: colors, settings: settings, charts: [predictedGlucose], traitCollection: traitCollection) + } +} diff --git a/Loop Status Extension/StatusViewController.swift b/Loop Status Extension/StatusViewController.swift index f7123e6c15..e3c57a98d9 100644 --- a/Loop Status Extension/StatusViewController.swift +++ b/Loop Status Extension/StatusViewController.swift @@ -8,86 +8,167 @@ import CoreData import HealthKit +import LoopKit +import LoopKitUI +import LoopCore import LoopUI import NotificationCenter import UIKit +import SwiftCharts class StatusViewController: UIViewController, NCWidgetProviding { - @IBOutlet weak var hudView: HUDView! - @IBOutlet weak var subtitleLabel: UILabel! - - var statusExtensionContext: StatusExtensionContext? - var defaults: UserDefaults? - final var observationContext = 1 - - var loopCompletionHUD: LoopCompletionHUDView! { - get { - return hudView.loopCompletionHUD + @IBOutlet weak var hudView: StatusBarHUDView! { + didSet { + hudView.loopCompletionHUD.stateColors = .loopStatus + hudView.cgmStatusHUD.stateColors = .cgmStatus + hudView.cgmStatusHUD.tintColor = .label + hudView.pumpStatusHUD.tintColor = .insulinTintColor + hudView.backgroundColor = .clear + + // given the reduced width of the widget, allow for tighter spacing + hudView.containerView.spacing = 6.0 } } + @IBOutlet weak var activeCarbsTitleLabel: UILabel! + @IBOutlet weak var activeCarbsAmountLabel: UILabel! + @IBOutlet weak var activeInsulinTitleLabel: UILabel! + @IBOutlet weak var activeInsulinAmountLabel: UILabel! + @IBOutlet weak var glucoseChartContentView: LoopKitUI.ChartContainerView! - var glucoseHUD: GlucoseHUDView! { - get { - return hudView.glucoseHUD + private lazy var charts: StatusChartsManager = { + let charts = StatusChartsManager( + colors: ChartColorPalette( + axisLine: .axisLineColor, + axisLabel: .axisLabelColor, + grid: .gridColor, + glucoseTint: .glucoseTintColor, + insulinTint: .insulinTintColor, + carbTint: .carbTintColor + ), + settings: { + var settings = ChartSettings() + settings.top = 8 + settings.bottom = 8 + settings.trailing = 8 + settings.axisTitleLabelsToLabelsSpacing = 0 + settings.labelsToAxisSpacingX = 6 + settings.clipInnerFrame = false + return settings + }(), + traitCollection: traitCollection + ) + + if FeatureFlags.predictedGlucoseChartClampEnabled { + charts.predictedGlucose.glucoseDisplayRange = ChartConstants.glucoseChartDefaultDisplayBoundClamped + } else { + charts.predictedGlucose.glucoseDisplayRange = ChartConstants.glucoseChartDefaultDisplayBound } - } - var basalRateHUD: BasalRateHUDView! { - get { - return hudView.basalRateHUD - } - } + return charts + }() - var reservoirVolumeHUD: ReservoirVolumeHUDView! { - get { - return hudView.reservoirVolumeHUD - } - } + var statusExtensionContext: StatusExtensionContext? - var batteryHUD: BatteryLevelHUDView! { - get { - return hudView.batteryHUD - } - } + lazy var defaults = UserDefaults.appGroup + + private var observers: [Any] = [] + + lazy var healthStore = HKHealthStore() + + lazy var cacheStore = PersistenceController.controllerInAppGroupDirectory() + + lazy var localCacheDuration = Bundle.main.localCacheDuration + + lazy var settingsStore: SettingsStore = SettingsStore( + store: cacheStore, + expireAfter: localCacheDuration) + + lazy var glucoseStore = GlucoseStore( + cacheStore: cacheStore, + provenanceIdentifier: HKSource.default().bundleIdentifier + ) + + lazy var doseStore = DoseStore( + cacheStore: cacheStore, + insulinModelProvider: PresetInsulinModelProvider(defaultRapidActingModel: settingsStore.latestSettings?.defaultRapidActingModel?.presetForRapidActingInsulin), + longestEffectDuration: ExponentialInsulinModelPreset.rapidActingAdult.effectDuration, + basalProfile: settingsStore.latestSettings?.basalRateSchedule, + insulinSensitivitySchedule: settingsStore.latestSettings?.insulinSensitivitySchedule, + provenanceIdentifier: HKSource.default().bundleIdentifier + ) + + private var pluginManager: PluginManager = { + let containingAppFrameworksURL = Bundle.main.privateFrameworksURL?.deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent().appendingPathComponent("Frameworks") + return PluginManager(pluginsURL: containingAppFrameworksURL) + }() override func viewDidLoad() { super.viewDidLoad() - subtitleLabel.alpha = 0 - subtitleLabel.textColor = UIColor.secondaryLabelColor + + activeCarbsTitleLabel.text = NSLocalizedString("Active Carbs", comment: "Widget label title describing the active carbs") + activeInsulinTitleLabel.text = NSLocalizedString("Active Insulin", comment: "Widget label title describing the active insulin") + activeCarbsTitleLabel.textColor = .secondaryLabel + activeCarbsAmountLabel.textColor = .label + activeInsulinTitleLabel.textColor = .secondaryLabel + activeInsulinAmountLabel.textColor = .label let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(openLoopApp(_:))) view.addGestureRecognizer(tapGestureRecognizer) - defaults = UserDefaults(suiteName: Bundle.main.appGroupSuiteName) - if let defaults = defaults { - defaults.addObserver( - self, - forKeyPath: defaults.statusExtensionContextObservableKey, - options: [], - context: &observationContext) + self.charts.prerender() + glucoseChartContentView.chartGenerator = { [weak self] (frame) in + return self?.charts.chart(atIndex: 0, frame: frame)?.view + } + + extensionContext?.widgetLargestAvailableDisplayMode = .expanded + + switch extensionContext?.widgetActiveDisplayMode ?? .compact { + case .expanded: + glucoseChartContentView.isHidden = false + case .compact: + fallthrough + @unknown default: + glucoseChartContentView.isHidden = true } + + observers = [ + // TODO: Observe cross-process notifications of Loop status updating + ] } - + deinit { - if let defaults = defaults { - defaults.removeObserver(self, forKeyPath: defaults.statusExtensionContextObservableKey, context: &observationContext) + for observer in observers { + NotificationCenter.default.removeObserver(observer) } } - override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { - guard context == &observationContext else { - super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context) - return + func widgetActiveDisplayModeDidChange(_ activeDisplayMode: NCWidgetDisplayMode, withMaximumSize maxSize: CGSize) { + let compactHeight = hudView.systemLayoutSizeFitting(maxSize).height + activeCarbsTitleLabel.systemLayoutSizeFitting(maxSize).height + + switch activeDisplayMode { + case .expanded: + preferredContentSize = CGSize(width: maxSize.width, height: compactHeight + 135) + case .compact: + fallthrough + @unknown default: + preferredContentSize = CGSize(width: maxSize.width, height: compactHeight) } - - update() } - - override func didReceiveMemoryWarning() { - super.didReceiveMemoryWarning() + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + + coordinator.animate(alongsideTransition: { + (UIViewControllerTransitionCoordinatorContext) -> Void in + self.glucoseChartContentView.isHidden = self.extensionContext?.widgetActiveDisplayMode != .expanded + }) } + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + charts.traitCollection = traitCollection + } + @objc private func openLoopApp(_: Any) { if let url = Bundle.main.mainAppUrl { self.extensionContext?.open(url) @@ -101,62 +182,147 @@ class StatusViewController: UIViewController, NCWidgetProviding { @discardableResult func update() -> NCUpdateResult { - guard - let context = defaults?.statusExtensionContext - else { - return NCUpdateResult.failed - } - - // We should never have the case where there's glucose values but no preferred - // unit. However, if that case were to happen we might show quantities against - // the wrong units and that could be very harmful. So unless there's a preferred - // unit, assume that none of the rest of the data is reliable. - guard - let preferredUnitString = context.preferredUnitString - else { - return NCUpdateResult.failed - } - - if let glucose = context.latestGlucose { - glucoseHUD.set(glucoseQuantity: glucose.quantity, - at: glucose.startDate, - unitString: preferredUnitString, - from: glucose.sensor) - } - - if let batteryPercentage = context.batteryPercentage { - batteryHUD.batteryLevel = Double(batteryPercentage) - } - - if let reservoir = context.reservoir { - reservoirVolumeHUD.reservoirLevel = min(1, max(0, Double(reservoir.unitVolume / Double(reservoir.capacity)))) - reservoirVolumeHUD.setReservoirVolume(volume: reservoir.unitVolume, at: reservoir.startDate) + let group = DispatchGroup() + + var activeInsulin: Double? + let carbUnit = HKUnit.gram() + var glucose: [StoredGlucoseSample] = [] + + group.enter() + doseStore.insulinOnBoard(at: Date()) { (result) in + switch result { + case .success(let iobValue): + activeInsulin = iobValue.value + case .failure: + activeInsulin = nil + } + group.leave() } + + charts.startDate = Calendar.current.nextDate(after: Date(timeIntervalSinceNow: .minutes(-5)), matching: DateComponents(minute: 0), matchingPolicy: .strict, direction: .backward) ?? Date() - if let netBasal = context.netBasal { - basalRateHUD.setNetBasalRate(netBasal.rate, percent: netBasal.percentage, at: netBasal.startDate) + // Showing the whole history plus full prediction in the glucose plot + // is a little crowded, so limit it to three hours in the future: + charts.maxEndDate = charts.startDate.addingTimeInterval(TimeInterval(hours: 3)) + + group.enter() + glucoseStore.getGlucoseSamples(start: charts.startDate) { (result) in + switch result { + case .failure: + glucose = [] + case .success(let samples): + glucose = samples + } + group.leave() } - if let loop = context.loop { - loopCompletionHUD.dosingEnabled = loop.dosingEnabled - loopCompletionHUD.lastLoopCompleted = loop.lastCompleted + group.notify(queue: .main) { + guard let defaults = self.defaults, let context = defaults.statusExtensionContext else { + return + } + + // Pump Status + let pumpManagerHUDView: BaseHUDView + if let hudViewContext = context.pumpManagerHUDViewContext, + let contextHUDView = PumpManagerHUDViewFromRawValue(hudViewContext.pumpManagerHUDViewRawValue, pluginManager: self.pluginManager) + { + pumpManagerHUDView = contextHUDView + } else { + pumpManagerHUDView = ReservoirVolumeHUDView.instantiate() + } + pumpManagerHUDView.stateColors = .pumpStatus + self.hudView.removePumpManagerProvidedView() + self.hudView.addPumpManagerProvidedHUDView(pumpManagerHUDView) + + if let netBasal = context.netBasal { + self.hudView.pumpStatusHUD.basalRateHUD.setNetBasalRate(netBasal.rate, percent: netBasal.percentage, at: netBasal.start) + } + + if let lastCompleted = context.lastLoopCompleted { + self.hudView.loopCompletionHUD.lastLoopCompleted = lastCompleted + } + + if let isClosedLoop = context.isClosedLoop { + self.hudView.loopCompletionHUD.loopIconClosed = isClosedLoop + } + + let insulinFormatter: NumberFormatter = { + let numberFormatter = NumberFormatter() + + numberFormatter.numberStyle = .decimal + numberFormatter.minimumFractionDigits = 2 + numberFormatter.maximumFractionDigits = 2 + + return numberFormatter + }() + + if let activeInsulin = activeInsulin, + let valueStr = insulinFormatter.string(from: activeInsulin) + { + self.activeInsulinAmountLabel.text = String(format: NSLocalizedString("%1$@ U", comment: "The subtitle format describing units of active insulin. (1: localized insulin value description)"), valueStr) + } else { + self.activeInsulinAmountLabel.text = NSLocalizedString("? U", comment: "Displayed in the widget when the amount of active insulin cannot be determined.") + } + + self.hudView.pumpStatusHUD.presentStatusHighlight(context.pumpStatusHighlightContext) + self.hudView.pumpStatusHUD.lifecycleProgress = context.pumpLifecycleProgressContext + + // Active carbs + let carbsFormatter = QuantityFormatter(for: carbUnit) + + if let carbsOnBoard = context.carbsOnBoard, + let activeCarbsNumberString = carbsFormatter.string(from: HKQuantity(unit: carbUnit, doubleValue: carbsOnBoard)) + { + self.activeCarbsAmountLabel.text = String(format: NSLocalizedString("%1$@", comment: "The subtitle format describing the grams of active carbs. (1: localized carb value description)"), activeCarbsNumberString) + } else { + self.activeCarbsAmountLabel.text = NSLocalizedString("? g", comment: "Displayed in the widget when the amount of active carbs cannot be determined.") + } + + // CGM Status + self.hudView.cgmStatusHUD.presentStatusHighlight(context.cgmStatusHighlightContext) + self.hudView.cgmStatusHUD.lifecycleProgress = context.cgmLifecycleProgressContext + + guard let unit = context.predictedGlucose?.unit else { + return + } + + if let lastGlucose = glucose.last { + self.hudView.cgmStatusHUD.setGlucoseQuantity( + lastGlucose.quantity.doubleValue(for: unit), + at: lastGlucose.startDate, + unit: unit, + staleGlucoseAge: LoopCoreConstants.inputDataRecencyInterval, + glucoseDisplay: context.glucoseDisplay, + wasUserEntered: lastGlucose.wasUserEntered, + isDisplayOnly: lastGlucose.isDisplayOnly + ) + } + + // Charts + self.charts.predictedGlucose.glucoseUnit = unit + self.charts.predictedGlucose.setGlucoseValues(glucose) + + if let predictedGlucose = context.predictedGlucose?.samples, context.isClosedLoop == true { + self.charts.predictedGlucose.setPredictedGlucoseValues(predictedGlucose) + } else { + self.charts.predictedGlucose.setPredictedGlucoseValues([]) + } + + self.charts.predictedGlucose.targetGlucoseSchedule = self.settingsStore.latestSettings?.glucoseTargetRangeSchedule + self.charts.invalidateChart(atIndex: 0) + self.charts.prerender() + self.glucoseChartContentView.reloadChart() } - let preferredUnit = HKUnit(from: preferredUnitString) - let formatter = NumberFormatter.glucoseFormatter(for: preferredUnit) - if let eventualGlucose = context.eventualGlucose, - let eventualGlucoseNumberString = formatter.string(from: NSNumber(value: eventualGlucose)) { - subtitleLabel.text = String( - format: NSLocalizedString( - "Eventually %1$@ %2$@", - comment: "The subtitle format describing eventual glucose. (1: localized glucose value description) (2: localized glucose units description)"), - eventualGlucoseNumberString, - preferredUnit.glucoseUnitDisplayString) - subtitleLabel.alpha = 1 - } else { - subtitleLabel.alpha = 0 + switch extensionContext?.widgetActiveDisplayMode ?? .compact { + case .expanded: + glucoseChartContentView.isHidden = false + case .compact: + fallthrough + @unknown default: + glucoseChartContentView.isHidden = true } - + // Right now we always act as if there's new data. // TODO: keep track of data changes and return .noData if necessary return NCUpdateResult.newData diff --git a/Loop Status Extension/mul.lproj/MainInterface.xcstrings b/Loop Status Extension/mul.lproj/MainInterface.xcstrings new file mode 100644 index 0000000000..10f4f8a972 --- /dev/null +++ b/Loop Status Extension/mul.lproj/MainInterface.xcstrings @@ -0,0 +1,484 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "9iF-xY-Bh4.text" : { + "comment" : "Class = \"UILabel\"; text = \"Active Carbs\"; ObjectID = \"9iF-xY-Bh4\";", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "كارب النشط" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktive kulhydrater" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktive KH" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eventually 92 mg/dL" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carbohidratos Activos" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Akt. hiilari" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glucides actifs" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "פחמימות פעילות" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carboidrati attivi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "残存糖質" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktive karbohydrater" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actieve Koolhydraten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktywne węglowodany" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carboidratos Ativos" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carbohidrați activi" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Активные углеводы" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktívne sacharidy" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktiva kolhydrater" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktif Karb." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lượng Carbs còn hoạt động" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "最终血糖为92 毫克/分升" + } + } + } + }, + "dPp-lJ-5sh.text" : { + "comment" : "Class = \"UILabel\"; text = \"0 g\"; ObjectID = \"dPp-lJ-5sh\";", + "extractionState" : "extracted_with_value", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "0 g" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "0 g" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "0 g" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "0 gr" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "0 g" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "0 g" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "0 g" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "0 g" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "0 g" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "0 g" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "0 g" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "0 g" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "0 г" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "0 g" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "0 g" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "0 gr" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "0 g" + } + } + } + }, + "UPi-dG-yYD.text" : { + "comment" : "Class = \"UILabel\"; text = \"Active Insulin\"; ObjectID = \"UPi-dG-yYD\";", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "أنسولين نشط" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktivt insulin" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktives Insulin" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "IOB 1.0 U" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulina activa" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Akt. insuliini" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insuline active" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אינסולין פעיל" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulina attiva" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "残存インスリン" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktivt insulin" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actieve Insuline" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktywna insulina" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulina Ativa" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulină activă" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Активный инсулин" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktívny inzulín" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktivt insulin" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktif İnsülin" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lượng Insulin còn hoạt động" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "IOB 1.0 单位" + } + } + } + }, + "Vgf-p1-2QP.text" : { + "comment" : "Class = \"UILabel\"; text = \"0 U\"; ObjectID = \"Vgf-p1-2QP\";", + "extractionState" : "extracted_with_value", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "0 E" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "0 IE" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "0 U" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "0 U" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "0 U" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "0 U" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "U 0" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "0 U" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "0 E" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "0 E" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "0 J" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "0 U" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "0 ед." + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "0 j" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "0 E" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "0 Ü" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "0 U" + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/Loop Widget Extension/Bootstrap/Bootstrap.swift b/Loop Widget Extension/Bootstrap/Bootstrap.swift new file mode 100644 index 0000000000..00823471c1 --- /dev/null +++ b/Loop Widget Extension/Bootstrap/Bootstrap.swift @@ -0,0 +1,11 @@ +// +// Bootstrap.swift +// Loop Widget Extension +// +// Created by Bastiaan Verhaar on 25/06/2024. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import Foundation + +class Bootstrap{} diff --git a/Loop Widget Extension/Bootstrap/Info.plist b/Loop Widget Extension/Bootstrap/Info.plist new file mode 100644 index 0000000000..faa19344b3 --- /dev/null +++ b/Loop Widget Extension/Bootstrap/Info.plist @@ -0,0 +1,33 @@ + + + + + AppGroupIdentifier + $(APP_GROUP_IDENTIFIER) + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Loop Widgets + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(LOOP_MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + NSHumanReadableCopyright + Copyright © 2022 LoopKit Authors. All rights reserved. + + diff --git a/Loop Widget Extension/Bootstrap/InfoPlist.xcstrings b/Loop Widget Extension/Bootstrap/InfoPlist.xcstrings new file mode 100644 index 0000000000..aab9a9f921 --- /dev/null +++ b/Loop Widget Extension/Bootstrap/InfoPlist.xcstrings @@ -0,0 +1,240 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "CFBundleDisplayName" : { + "comment" : "Bundle display name", + "extractionState" : "extracted_with_value", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop Widgets" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop Widgets" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Loop Widgets" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop Widgets" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop Widgets" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop Widgets" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop Widgets" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop Widgets" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop Widgets" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Widgeturi Loop" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop Widgets" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop Widgets" + } + } + } + }, + "CFBundleName" : { + "comment" : "Bundle name", + "extractionState" : "extracted_with_value", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "LoopWidgetExtension" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "LoopWidgetExtension" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Loop Widget Extension" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "LoopWidgetExtension" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "LoopWidgetExtension" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "LoopWidgetExtension" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "LoopWidgetExtension" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "LoopWidgetExtension" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "LoopWidgetExtension" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "LoopWidgetExtension" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "LoopWidgetExtension" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "LoopWidgetExtension" + } + } + } + }, + "NSHumanReadableCopyright" : { + "comment" : "Copyright (human-readable)", + "extractionState" : "extracted_with_value", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copyright © 2022 LoopKit Authors. All rights reserved." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copyright © 2022 LoopKit-Autoren. Alle Rechte vorbehalten." + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Copyright © 2022 LoopKit Authors. All rights reserved." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copyright © 2022 Autores del LoopKit. Todos los derechos reservados." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copyright © 2022 LoopKit Authors. Tous droits réservés." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copyright © 2022 Autori di LoopKit. Tutti i diritti riservati." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copyright © 2022 LoopKit Authors. Alle rettigheter forbeholdt." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copyright © 2022 LoopKit Auteurs. Alle rechten voorbehouden." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copyright © 2023 Autorzy LoopKit. Wszelkie prawa zastrzeżone." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copyright © 2022 Autori LoopKit. Toate drepturile rezervate." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copyright © 2022 LoopKit Authors. Все права защищены." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Telif Hakkı © 2022 LoopKit Yazarları. Tüm hakları Saklıdır." + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/Loop Widget Extension/Bootstrap/Localizable.xcstrings b/Loop Widget Extension/Bootstrap/Localizable.xcstrings new file mode 100644 index 0000000000..17bac774ed --- /dev/null +++ b/Loop Widget Extension/Bootstrap/Localizable.xcstrings @@ -0,0 +1,1557 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "---" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "---" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "---" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "---" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "---" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "---" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "---" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "---" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "--" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "---" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "---" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "---" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "---" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "---" + } + } + } + }, + "-U" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "-E" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "-IE" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "-U" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "-U" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "-U" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "-E" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "-E" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "-J" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "-U" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "-Ед" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "-Ü" + } + } + } + }, + "??" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "??" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "??" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "??" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "??" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "??" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "??" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "??" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "??" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "??" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "??" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "??" + } + } + } + }, + "%@" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + } + } + }, + "%@ U" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ U" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ E" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ IE" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ U" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ U" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ U" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ U" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ U" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ U" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ E" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ E" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ J" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ U" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ U" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ ед" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ j" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ E" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ Ü" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ U" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ U" + } + } + } + }, + "%@%@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@%2$@" + } + } + } + }, + "%@U" : { + + }, + "%1$@ v%2$@" : { + "comment" : "The format string for the app name and version number. (1: bundle name)(2: bundle version)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v %2$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ против %2$@" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v %2$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + } + } + }, + "Color" : { + + }, + "Date" : { + + }, + "dB" : { + "comment" : "The short unit display string for decibles", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "дБ" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + } + } + }, + "End" : { + + }, + "Eventual" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eventuel" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eventuell" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eventual" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Éventuel" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "סופי" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eventuale" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eventuell" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uiteindelijk" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Możliwy" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eventual" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "В конечном итоге" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nihai" + } + } + } + }, + "g" : { + "comment" : "The short unit display string for grams", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "г" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "gr" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "克" + } + } + } + }, + "Glucose level" : { + + }, + "Glucose range" : { + + }, + "Loop Status Widget" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Widget til loop-status" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop Status Widget" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Widget de Estado de Loop" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Widget d'état de Loop" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Widget di stato del ciclo" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop Status Widget" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Widżet Stanu Pętli" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Extensie stare Loop" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Виджет состояния петли" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Döngü Durumu Widget'ı" + } + } + } + }, + "mg/dL" : { + "comment" : "The short unit display string for milligrams of glucose per decilter", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dl" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "мг/дл" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dl" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + } + } + }, + "mmol/L" : { + "comment" : "The short unit display string for millimoles of glucose per liter", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + } + } + }, + "Open the app to update the widget" : { + "comment" : "No comment" + }, + "Preset override" : { + + }, + "QUANTITY_VALUE_AND_UNIT" : { + "comment" : "Format string for combining localized numeric value and unit. (1: numeric value)(2: unit)", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ %2$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@%2$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + } + } + }, + "See your current blood glucose and insulin delivery." : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se dit nuværende blodsukker og insulindosering." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sehen Sie sich Ihre aktuelle Blutzucker- und Insulinabgabe an." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mira tu glucosa en sangre actual y la administración de insulina." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voir votre glycémie actuelle et votre administration d'insuline." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vedi la tua attuale glicemia e l'erogazione d'insulina." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se ditt nåværende blodsukker- og insulintilførsel." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zie je huidige bloedglucose en insulinetoediening." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zobacz aktualną dawkę glukozy i insuliny we krwi." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vedeți nivelul actual al glicemiei și al administrării de insulină." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Смотрите текущий уровень глюкозы в крови и ввод инсулина." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mevcut kan şekerinizi ve insülin iletiminizi görün." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "查看您当前的血糖值和胰岛素输注情况。" + } + } + } + }, + "Start" : { + + }, + "U" : { + "comment" : "The short unit display string for international units of insulin", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "وحدة" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "E" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "IE" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "U" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "U" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "U" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "U" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "U" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "U" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "E" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "E" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "J" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "U" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "U" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ед" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "j" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "E" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ü" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "U" + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/Loop Widget Extension/Bootstrap/LoopWidgetExtension.entitlements b/Loop Widget Extension/Bootstrap/LoopWidgetExtension.entitlements new file mode 100644 index 0000000000..d9849a816d --- /dev/null +++ b/Loop Widget Extension/Bootstrap/LoopWidgetExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + $(APP_GROUP_IDENTIFIER) + + + diff --git a/Loop Widget Extension/Components/BasalView.swift b/Loop Widget Extension/Components/BasalView.swift new file mode 100644 index 0000000000..b64bc9f338 --- /dev/null +++ b/Loop Widget Extension/Components/BasalView.swift @@ -0,0 +1,79 @@ +// +// BasalView.swift +// Loop +// +// Created by Noah Brauner on 8/15/22. +// Copyright © 2022 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +struct BasalView: View { + let netBasal: NetBasalContext + let isOld: Bool + + + var body: some View { + let percent = netBasal.percentage + let rate = netBasal.rate + + VStack(spacing: 1) { + BasalRateView(percent: percent) + .overlay( + BasalRateView(percent: percent) + .stroke(isOld ? Color(UIColor.systemGray3) : Color("insulin"), lineWidth: 2) + ) + .foregroundColor((isOld ? Color(UIColor.systemGray3) : Color("insulin")).opacity(0.5)) + .frame(width: 44, height: 22) + + if let rateString = decimalFormatter.string(from: NSNumber(value: rate)) { + Text("\(rateString) U") + .font(.footnote) + .foregroundColor(Color(isOld ? UIColor.systemGray3 : UIColor.secondaryLabel)) + } + else { + Text("-U") + .font(.footnote) + .foregroundColor(Color(isOld ? UIColor.systemGray3 : UIColor.secondaryLabel)) + } + } + } + + private let decimalFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.minimumFractionDigits = 1 + formatter.minimumIntegerDigits = 1 + formatter.positiveFormat = "+0.0##" + formatter.negativeFormat = "-0.0##" + + return formatter + }() +} + +struct BasalRateView: Shape { + // Needs a basal percentage + var percent: Double + + func path(in rect: CGRect) -> Path { + let startX = rect.minX + let endX = rect.maxX + let midY = rect.midY + + var path = Path() + path.move(to: CGPoint(x: startX, y: midY)) + + let leftAnchor = startX + 1/6 * rect.size.width + let rightAnchor = startX + 5/6 * rect.size.width + + let yAnchor = rect.midY - CGFloat(percent) * (rect.size.height - 2) / 2 + + path.addLine(to: CGPoint(x: leftAnchor, y: midY)) + path.addLine(to: CGPoint(x: leftAnchor, y: yAnchor)) + path.addLine(to: CGPoint(x: rightAnchor, y: yAnchor)) + path.addLine(to: CGPoint(x: rightAnchor, y: midY)) + path.addLine(to: CGPoint(x: endX, y: midY)) + + return path + } +} diff --git a/Loop Widget Extension/Components/GlucoseView.swift b/Loop Widget Extension/Components/GlucoseView.swift new file mode 100644 index 0000000000..a0d5c5c26b --- /dev/null +++ b/Loop Widget Extension/Components/GlucoseView.swift @@ -0,0 +1,89 @@ +// +// GlucoseView.swift +// Loop +// +// Created by Noah Brauner on 8/15/22. +// Copyright © 2022 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import HealthKit +import LoopCore + +struct GlucoseView: View { + + var entry: StatusWidgetTimelimeEntry + + var body: some View { + VStack(alignment: .center, spacing: 0) { + HStack(spacing: 2) { + if let glucose = entry.currentGlucose, + !entry.glucoseIsStale, + let unit = entry.unit + { + let quantity = glucose.quantity + let glucoseFormatter = NumberFormatter.glucoseFormatter(for: unit) + if let glucoseString = glucoseFormatter.string(from: quantity.doubleValue(for: unit)) { + Text(glucoseString) + .font(.system(size: 24, weight: .heavy, design: .default)) + } + else { + Text("??") + .font(.system(size: 24, weight: .heavy, design: .default)) + } + } + else { + Text("---") + .font(.system(size: 24, weight: .heavy, design: .default)) + } + + if let trendImageName = getArrowImage() { + Image(systemName: trendImageName) + } + } + // Prevent truncation of text + .fixedSize(horizontal: true, vertical: false) + .foregroundColor(entry.glucoseStatusIsStale ? Color(UIColor.systemGray3) : .primary) + + let unitString = entry.unit == nil ? "-" : entry.unit!.localizedShortUnitString + if let delta = entry.delta, let unit = entry.unit { + let deltaValue = delta.doubleValue(for: unit) + let numberFormatter = NumberFormatter.glucoseFormatter(for: unit) + let deltaString = (deltaValue < 0 ? "-" : "+") + numberFormatter.string(from: abs(deltaValue))! + + Text(deltaString + " " + unitString) + // Dynamic text causes string to be cut off + .font(.system(size: 13)) + .foregroundColor(entry.glucoseStatusIsStale ? Color(UIColor.systemGray3) : Color(UIColor.secondaryLabel)) + .fixedSize(horizontal: true, vertical: true) + } + else { + Text(unitString) + .font(.footnote) + .foregroundColor(entry.glucoseStatusIsStale ? Color(UIColor.systemGray3) : Color(UIColor.secondaryLabel)) + } + } + } + + private func getArrowImage() -> String? { + switch entry.sensor?.trendType { + case .upUpUp: + return "arrow.double.up.circle" + case .upUp: + return "arrow.up.circle" + case .up: + return "arrow.up.right.circle" + case .flat: + return "arrow.right.circle" + case .down: + return "arrow.down.right.circle" + case .downDown: + return "arrow.down.circle" + case .downDownDown: + return "arrow.double.down.circle" + case .none: + return nil + } + } +} diff --git a/Loop Widget Extension/Components/LoopCircleView.swift b/Loop Widget Extension/Components/LoopCircleView.swift new file mode 100644 index 0000000000..b45bd47990 --- /dev/null +++ b/Loop Widget Extension/Components/LoopCircleView.swift @@ -0,0 +1,40 @@ +// +// LoopCircleView.swift +// Loop +// +// Created by Noah Brauner on 8/15/22. +// Copyright © 2022 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopCore + +struct LoopCircleView: View { + var entry: StatusWidgetTimelimeEntry + + var body: some View { + let closeLoop = entry.closeLoop + let lastLoopCompleted = entry.lastLoopCompleted ?? Date().addingTimeInterval(.minutes(16)) + let age = abs(min(0, lastLoopCompleted.timeIntervalSinceNow)) + let freshness = LoopCompletionFreshness(age: age) + + let loopColor = getLoopColor(freshness: freshness) + + Circle() + .trim(from: closeLoop ? 0 : 0.2, to: 1) + .stroke(entry.contextIsStale ? Color(UIColor.systemGray3) : loopColor, lineWidth: 8) + .rotationEffect(Angle(degrees: -126)) + .frame(width: 36, height: 36) + } + + func getLoopColor(freshness: LoopCompletionFreshness) -> Color { + switch freshness { + case .fresh: + return Color("fresh") + case .aging: + return Color("warning") + case .stale: + return Color.red + } + } +} diff --git a/Loop Widget Extension/Components/PumpView.swift b/Loop Widget Extension/Components/PumpView.swift new file mode 100644 index 0000000000..bee09c1217 --- /dev/null +++ b/Loop Widget Extension/Components/PumpView.swift @@ -0,0 +1,50 @@ +// +// PumpView.swift +// Loop Widget Extension +// +// Created by Cameron Ingham on 6/26/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +struct PumpView: View { + + var entry: StatusWidgetTimelineProvider.Entry + + var body: some View { + HStack(alignment: .center) { + if let pumpHighlight = entry.pumpHighlight { + HStack { + Image(systemName: pumpHighlight.imageName) + .foregroundColor(pumpHighlight.state == .critical ? .critical : .warning) + Text(pumpHighlight.localizedMessage) + .fontWeight(.heavy) + } + } + else if let netBasal = entry.netBasal { + BasalView(netBasal: netBasal, isOld: entry.contextIsStale) + + if let eventualGlucose = entry.eventualGlucose { + let glucoseFormatter = NumberFormatter.glucoseFormatter(for: eventualGlucose.unit) + if let glucoseString = glucoseFormatter.string(from: eventualGlucose.quantity.doubleValue(for: eventualGlucose.unit)) { + VStack { + Text("Eventual") + .font(.footnote) + .foregroundColor(entry.contextIsStale ? Color(UIColor.systemGray3) : Color(UIColor.secondaryLabel)) + + Text("\(glucoseString)") + .font(.subheadline) + .fontWeight(.heavy) + + Text(eventualGlucose.unit.shortLocalizedUnitString()) + .font(.footnote) + .foregroundColor(entry.contextIsStale ? Color(UIColor.systemGray3) : Color(UIColor.secondaryLabel)) + } + } + } + } + + } + } +} diff --git a/Loop Widget Extension/Components/SystemActionLink.swift b/Loop Widget Extension/Components/SystemActionLink.swift new file mode 100644 index 0000000000..7962ec1a9f --- /dev/null +++ b/Loop Widget Extension/Components/SystemActionLink.swift @@ -0,0 +1,88 @@ +// +// SystemActionLink.swift +// Loop Widget Extension +// +// Created by Cameron Ingham on 6/26/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation +import SwiftUI + +@available(iOS 16.1, *) +struct SystemActionLink: View { + + @Environment(\.widgetRenderingMode) private var widgetRenderingMode + + enum Destination: String, CaseIterable { + case carbEntry = "carb-entry" + case bolus = "manual-bolus" + case preMeal = "pre-meal-preset" + case customPreset = "custom-presets" + + var deeplink: URL { + URL(string: "loop://\(rawValue)")! + } + } + + let destination: Destination + let active: Bool + + init(to destination: Destination, active: Bool = false) { + self.destination = destination + self.active = active + } + + private func foregroundColor(active: Bool) -> Color { + switch destination { + case .carbEntry: + return Color("fresh") + case .bolus: + return Color("insulin") + case .preMeal: + return active ? Color("WidgetBackground") : Color("fresh") + case .customPreset: + return active ? Color("WidgetBackground") : Color("glucose") + } + } + + private func backgroundColor(active: Bool) -> Color { + if widgetRenderingMode == .accented { + Color(UIColor.systemBackground).opacity(active ? 0.45 : 0.15) + } else { + switch destination { + case .carbEntry: + active ? Color("fresh") : Color("WidgetSecondaryBackground") + case .bolus: + active ? Color("insulin") : Color("WidgetSecondaryBackground") + case .preMeal: + active ? Color("fresh") : Color("WidgetSecondaryBackground") + case .customPreset: + active ? Color("glucose") : Color("WidgetSecondaryBackground") + } + } + } + + private var icon: Image { + switch destination { + case .carbEntry: + return Image("carbs") + case .bolus: + return Image("bolus") + case .preMeal: + return Image("premeal") + case .customPreset: + return Image("workout") + } + } + + var body: some View { + Link(destination: destination.deeplink) { + icon + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .foregroundColor(foregroundColor(active: active)) + .background(backgroundColor(active: active)) + .clipShape(ContainerRelativeShape()) + } + } +} diff --git a/Loop Widget Extension/DefaultAssets.xcassets/Contents.json b/Loop Widget Extension/DefaultAssets.xcassets/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/Loop Widget Extension/DefaultAssets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop Widget Extension/DefaultAssets.xcassets/WidgetBackground.colorset/Contents.json b/Loop Widget Extension/DefaultAssets.xcassets/WidgetBackground.colorset/Contents.json new file mode 100644 index 0000000000..44ca681726 --- /dev/null +++ b/Loop Widget Extension/DefaultAssets.xcassets/WidgetBackground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.966", + "green" : "0.946", + "red" : "0.949" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.118", + "green" : "0.110", + "red" : "0.110" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop Widget Extension/DefaultAssets.xcassets/WidgetSecondaryBackground.colorset/Contents.json b/Loop Widget Extension/DefaultAssets.xcassets/WidgetSecondaryBackground.colorset/Contents.json new file mode 100644 index 0000000000..04256378ab --- /dev/null +++ b/Loop Widget Extension/DefaultAssets.xcassets/WidgetSecondaryBackground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.000", + "green" : "0.000", + "red" : "0.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop Widget Extension/DefaultAssets.xcassets/bolus.imageset/Contents.json b/Loop Widget Extension/DefaultAssets.xcassets/bolus.imageset/Contents.json new file mode 100644 index 0000000000..85501a89ad --- /dev/null +++ b/Loop Widget Extension/DefaultAssets.xcassets/bolus.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "bolus.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Loop/Assets.xcassets/bolus.imageset/bolus.pdf b/Loop Widget Extension/DefaultAssets.xcassets/bolus.imageset/bolus.pdf similarity index 100% rename from Loop/Assets.xcassets/bolus.imageset/bolus.pdf rename to Loop Widget Extension/DefaultAssets.xcassets/bolus.imageset/bolus.pdf diff --git a/Loop Widget Extension/DefaultAssets.xcassets/carbs.imageset/Contents.json b/Loop Widget Extension/DefaultAssets.xcassets/carbs.imageset/Contents.json new file mode 100644 index 0000000000..634a10d366 --- /dev/null +++ b/Loop Widget Extension/DefaultAssets.xcassets/carbs.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Meal.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Loop Widget Extension/DefaultAssets.xcassets/carbs.imageset/Meal.pdf b/Loop Widget Extension/DefaultAssets.xcassets/carbs.imageset/Meal.pdf new file mode 100644 index 0000000000..5e1c948dad Binary files /dev/null and b/Loop Widget Extension/DefaultAssets.xcassets/carbs.imageset/Meal.pdf differ diff --git a/Loop Widget Extension/DefaultAssets.xcassets/premeal.imageset/Contents.json b/Loop Widget Extension/DefaultAssets.xcassets/premeal.imageset/Contents.json new file mode 100644 index 0000000000..06dbd71bc0 --- /dev/null +++ b/Loop Widget Extension/DefaultAssets.xcassets/premeal.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "Pre-Meal.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Loop Widget Extension/DefaultAssets.xcassets/premeal.imageset/Pre-Meal.pdf b/Loop Widget Extension/DefaultAssets.xcassets/premeal.imageset/Pre-Meal.pdf new file mode 100644 index 0000000000..0992a4a286 Binary files /dev/null and b/Loop Widget Extension/DefaultAssets.xcassets/premeal.imageset/Pre-Meal.pdf differ diff --git a/Loop Widget Extension/DefaultAssets.xcassets/workout.imageset/Contents.json b/Loop Widget Extension/DefaultAssets.xcassets/workout.imageset/Contents.json new file mode 100644 index 0000000000..fa3f0c55e9 --- /dev/null +++ b/Loop Widget Extension/DefaultAssets.xcassets/workout.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "workout.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Loop/Assets.xcassets/workout.imageset/workout.pdf b/Loop Widget Extension/DefaultAssets.xcassets/workout.imageset/workout.pdf similarity index 100% rename from Loop/Assets.xcassets/workout.imageset/workout.pdf rename to Loop Widget Extension/DefaultAssets.xcassets/workout.imageset/workout.pdf diff --git a/Loop Widget Extension/DerivedAssets.xcassets/Contents.json b/Loop Widget Extension/DerivedAssets.xcassets/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/Loop Widget Extension/DerivedAssets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop Widget Extension/DerivedAssetsBase.xcassets/Contents.json b/Loop Widget Extension/DerivedAssetsBase.xcassets/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/Loop Widget Extension/DerivedAssetsBase.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop Widget Extension/DerivedAssetsBase.xcassets/fresh.colorset/Contents.json b/Loop Widget Extension/DerivedAssetsBase.xcassets/fresh.colorset/Contents.json new file mode 100644 index 0000000000..da43d6f422 --- /dev/null +++ b/Loop Widget Extension/DerivedAssetsBase.xcassets/fresh.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.392", + "green" : "0.851", + "red" : "0.298" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop Widget Extension/DerivedAssetsBase.xcassets/glucose.colorset/Contents.json b/Loop Widget Extension/DerivedAssetsBase.xcassets/glucose.colorset/Contents.json new file mode 100644 index 0000000000..4061e0f057 --- /dev/null +++ b/Loop Widget Extension/DerivedAssetsBase.xcassets/glucose.colorset/Contents.json @@ -0,0 +1,68 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "0.690", + "red" : "0.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "0.729", + "red" : "0.390" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "contrast", + "value" : "high" + } + ], + "color" : { + "platform" : "ios", + "reference" : "systemBlueColor" + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + }, + { + "appearance" : "contrast", + "value" : "high" + } + ], + "color" : { + "platform" : "ios", + "reference" : "systemBlueColor" + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop Widget Extension/DerivedAssetsBase.xcassets/insulin.colorset/Contents.json b/Loop Widget Extension/DerivedAssetsBase.xcassets/insulin.colorset/Contents.json new file mode 100644 index 0000000000..b431287b2a --- /dev/null +++ b/Loop Widget Extension/DerivedAssetsBase.xcassets/insulin.colorset/Contents.json @@ -0,0 +1,15 @@ +{ + "colors" : [ + { + "color" : { + "platform" : "ios", + "reference" : "systemOrangeColor" + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop Widget Extension/DerivedAssetsBase.xcassets/warning.colorset/Contents.json b/Loop Widget Extension/DerivedAssetsBase.xcassets/warning.colorset/Contents.json new file mode 100644 index 0000000000..c26d0f7a13 --- /dev/null +++ b/Loop Widget Extension/DerivedAssetsBase.xcassets/warning.colorset/Contents.json @@ -0,0 +1,33 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0.269", + "green" : "0.763", + "red" : "0.917" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "platform" : "ios", + "reference" : "systemYellowColor" + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop Widget Extension/Helpers/ContentMargin.swift b/Loop Widget Extension/Helpers/ContentMargin.swift new file mode 100644 index 0000000000..92a2d41786 --- /dev/null +++ b/Loop Widget Extension/Helpers/ContentMargin.swift @@ -0,0 +1,20 @@ +// +// ContentMargin.swift +// Loop Widget Extension +// +// Created by Cameron Ingham on 9/29/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import WidgetKit + +extension WidgetConfiguration { + func contentMarginsDisabledIfAvailable() -> some WidgetConfiguration { + if #available(iOSApplicationExtension 17.0, *) { + return self.contentMarginsDisabled() + } else { + return self + } + } +} diff --git a/Loop Widget Extension/Helpers/Date.swift b/Loop Widget Extension/Helpers/Date.swift new file mode 100644 index 0000000000..ef81f35bd7 --- /dev/null +++ b/Loop Widget Extension/Helpers/Date.swift @@ -0,0 +1,15 @@ +// +// Date.swift +// Loop Widget Extension +// +// Created by Cameron Ingham on 6/26/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation + +extension Date { + static func - (lhs: Date, rhs: Date) -> TimeInterval { + return lhs.timeIntervalSinceReferenceDate - rhs.timeIntervalSinceReferenceDate + } +} diff --git a/Loop Widget Extension/Helpers/LocalizedString.swift b/Loop Widget Extension/Helpers/LocalizedString.swift new file mode 100644 index 0000000000..158181755d --- /dev/null +++ b/Loop Widget Extension/Helpers/LocalizedString.swift @@ -0,0 +1,21 @@ +// +// LocalizedString.swift +// Loop Widget Extension +// +// Created by Bastiaan Verhaar on 25/06/2024. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import Foundation + +private class FrameworkBundle { + static let main = Bundle(for: Bootstrap.self) +} + +func LocalizedString(_ key: String, tableName: String? = nil, value: String? = nil, comment: String) -> String { + if let value = value { + return NSLocalizedString(key, tableName: tableName, bundle: FrameworkBundle.main, value: value, comment: comment) + } else { + return NSLocalizedString(key, tableName: tableName, bundle: FrameworkBundle.main, comment: comment) + } +} diff --git a/Loop Widget Extension/Helpers/WidgetBackground.swift b/Loop Widget Extension/Helpers/WidgetBackground.swift new file mode 100644 index 0000000000..f5202f092c --- /dev/null +++ b/Loop Widget Extension/Helpers/WidgetBackground.swift @@ -0,0 +1,22 @@ +// +// WidgetBackground.swift +// Loop Widget Extension +// +// Created by Cameron Ingham on 6/26/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +extension View { + @ViewBuilder + func widgetBackground() -> some View { + if #available(iOSApplicationExtension 17.0, *) { + self.containerBackground(for: .widget) { + Color("WidgetBackground") + } + } else { + self.background { Color("WidgetBackground") } + } + } +} diff --git a/Loop Widget Extension/Live Activity/BasalViewActivity.swift b/Loop Widget Extension/Live Activity/BasalViewActivity.swift new file mode 100644 index 0000000000..915335c5fb --- /dev/null +++ b/Loop Widget Extension/Live Activity/BasalViewActivity.swift @@ -0,0 +1,46 @@ +// +// BasalView.swift +// Loop +// +// Created by Noah Brauner on 8/15/22. +// Copyright © 2022 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +struct BasalViewActivity: View { + let percent: Double + let rate: Double + + var body: some View { + VStack(spacing: 1) { + BasalRateView(percent: percent) + .overlay( + BasalRateView(percent: percent) + .stroke(Color("insulin"), lineWidth: 2) + ) + .foregroundColor(Color("insulin").opacity(0.5)) + .frame(width: 44, height: 22) + + if let rateString = decimalFormatter.string(from: NSNumber(value: rate)) { + Text("\(rateString)U") + .font(.subheadline) + } + else { + Text("-U") + .font(.subheadline) + } + } + } + + private let decimalFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.minimumFractionDigits = 1 + formatter.minimumIntegerDigits = 1 + formatter.positiveFormat = "+0.0##" + formatter.negativeFormat = "-0.0##" + + return formatter + }() +} diff --git a/Loop Widget Extension/Live Activity/ChartView.swift b/Loop Widget Extension/Live Activity/ChartView.swift new file mode 100644 index 0000000000..b69bf3397c --- /dev/null +++ b/Loop Widget Extension/Live Activity/ChartView.swift @@ -0,0 +1,161 @@ +// +// ChartValues.swift +// Loop Widget Extension +// +// Created by Bastiaan Verhaar on 25/06/2024. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import Foundation +import SwiftUI +import Charts + +@available(iOS 16.2, *) +struct ChartView: View { + private let glucoseSampleData: [ChartValues] + private let predicatedData: [ChartValues] + private let glucoseRanges: [GlucoseRangeValue] + private let preset: Preset? + private let yAxisMarks: [Double] + + init(glucoseSamples: [GlucoseSampleAttributes], predicatedGlucose: [Double], predicatedStartDate: Date?, predicatedInterval: TimeInterval?, useLimits: Bool, lowerLimit: Double, upperLimit: Double, glucoseRanges: [GlucoseRangeValue], preset: Preset?, yAxisMarks: [Double]) { + self.glucoseSampleData = ChartValues.convert(data: glucoseSamples, useLimits: useLimits, lowerLimit: lowerLimit, upperLimit: upperLimit) + self.predicatedData = ChartValues.convert( + data: predicatedGlucose, + startDate: predicatedStartDate ?? Date.now, + interval: predicatedInterval ?? .minutes(5), + useLimits: useLimits, + lowerLimit: lowerLimit, + upperLimit: upperLimit + ) + self.preset = preset + self.glucoseRanges = glucoseRanges + self.yAxisMarks = yAxisMarks + } + + init(glucoseSamples: [GlucoseSampleAttributes], useLimits: Bool, lowerLimit: Double, upperLimit: Double, glucoseRanges: [GlucoseRangeValue], preset: Preset?, yAxisMarks: [Double]) { + self.glucoseSampleData = ChartValues.convert(data: glucoseSamples, useLimits: useLimits, lowerLimit: lowerLimit, upperLimit: upperLimit) + self.predicatedData = [] + self.preset = preset + self.glucoseRanges = glucoseRanges + self.yAxisMarks = yAxisMarks + } + + var body: some View { + ZStack(alignment: Alignment(horizontal: .trailing, vertical: .top)){ + Chart { + if let preset = self.preset, predicatedData.count > 0, preset.endDate > Date.now.addingTimeInterval(.hours(-6)) { + RectangleMark( + xStart: .value("Start", preset.startDate), + xEnd: .value("End", preset.endDate), + yStart: .value("Preset override", preset.minValue), + yEnd: .value("Preset override", preset.maxValue) + ) + .foregroundStyle(.primary) + .opacity(0.6) + } + + ForEach(glucoseRanges) { item in + RectangleMark( + xStart: .value("Start", item.startDate), + xEnd: .value("End", item.endDate), + yStart: .value("Glucose range", item.minValue), + yEnd: .value("Glucose range", item.maxValue) + ) + .foregroundStyle(.primary) + .opacity(0.3) + } + + ForEach(glucoseSampleData) { item in + PointMark (x: .value("Date", item.x), + y: .value("Glucose level", item.y) + ) + .symbolSize(10) + .foregroundStyle(by: .value("Color", item.color)) + } + + ForEach(predicatedData) { item in + LineMark (x: .value("Date", item.x), + y: .value("Glucose level", item.y) + ) + .lineStyle(StrokeStyle(lineWidth: 2, dash: [6, 5])) + .foregroundStyle(by: .value("Color", item.color)) + } + } + .chartForegroundStyleScale([ + "Good": .green, + "High": .orange, + "Low": .red, + "Default": Color("glucose") + ]) + .chartPlotStyle { plotContent in + plotContent.background(.cyan.opacity(0.15)) + } + .chartLegend(.hidden) + .chartYScale(domain: [yAxisMarks.first ?? 0, yAxisMarks.last ?? 0]) + .chartYAxis { + AxisMarks(values: yAxisMarks) + } + .chartYAxis { + AxisMarks(position: .leading) { _ in + AxisValueLabel().foregroundStyle(Color.primary) + AxisGridLine(stroke: .init(lineWidth: 0.1, dash: [2, 3])) + .foregroundStyle(Color.primary) + } + } + .chartXAxis { + AxisMarks(position: .automatic, values: .stride(by: .hour)) { _ in + AxisValueLabel(format: .dateTime.hour(.twoDigits(amPM: .narrow)), anchor: .top) + .foregroundStyle(Color.primary) + AxisGridLine(stroke: .init(lineWidth: 0.1, dash: [2, 3])) + .foregroundStyle(Color.primary) + } + } + + if let preset = self.preset, preset.endDate > Date.now { + Text(preset.title) + .font(.footnote) + .padding(.trailing, 5) + .padding(.top, 2) + } + } + } +} + +struct ChartValues: Identifiable { + public let id: UUID + public let x: Date + public let y: Double + public let color: String + + init(x: Date, y: Double, color: String) { + self.id = UUID() + self.x = x + self.y = y + self.color = color + } + + static func convert(data: [Double], startDate: Date, interval: TimeInterval, useLimits: Bool, lowerLimit: Double, upperLimit: Double) -> [ChartValues] { + let twoHours = Date.now.addingTimeInterval(.hours(4)) + + return data.enumerated().filter { (index, item) in + return startDate.addingTimeInterval(interval * Double(index)) < twoHours + }.map { (index, item) in + return ChartValues( + x: startDate.addingTimeInterval(interval * Double(index)), + y: item, + color: !useLimits ? "Default" : item < lowerLimit ? "Low" : item > upperLimit ? "High" : "Good" + ) + } + } + + static func convert(data: [GlucoseSampleAttributes], useLimits: Bool, lowerLimit: Double, upperLimit: Double) -> [ChartValues] { + return data.map { item in + return ChartValues( + x: item.x, + y: item.y, + color: !useLimits ? "Default" : item.y < lowerLimit ? "Low" : item.y > upperLimit ? "High" : "Good" + ) + } + } +} diff --git a/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift b/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift new file mode 100644 index 0000000000..b288c71458 --- /dev/null +++ b/Loop Widget Extension/Live Activity/GlucoseLiveActivityConfiguration.swift @@ -0,0 +1,490 @@ +// +// LiveActivityConfiguration.swift +// Loop Widget Extension +// +// Created by Bastiaan Verhaar on 23/06/2024. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import ActivityKit +import Charts +import HealthKit +import LoopCore +import LoopKit +import SwiftUI +import WidgetKit + +@available(iOS 16.2, *) +struct GlucoseLiveActivityConfiguration: Widget { + var body: some WidgetConfiguration { + if #available(iOS 18.0, *) { + return ActivityConfiguration(for: GlucoseActivityAttributes.self) { + context in + lockScreenView(context: context) + } dynamicIsland: { context in + dynamicIslandView(context: context) + } + .supplementalActivityFamilies([.small]) + } else { + return ActivityConfiguration(for: GlucoseActivityAttributes.self) { + context in + lockScreenView(context: context) + } dynamicIsland: { context in + dynamicIslandView(context: context) + } + } + } + + // MARK: - Lock Screen View + + @ViewBuilder + private func lockScreenView( + context: ActivityViewContext + ) -> some View { + // Create the presentation that appears on the Lock Screen and as a + // banner on the Home Screen of devices that don't support the Dynamic Island. + if #available(iOS 18.0, *) { + AdaptiveLockScreenView(context: context) + } else { + fullLockScreenView(context: context) + } + } + + @available(iOS 18.0, *) + struct AdaptiveLockScreenView: View { + let context: ActivityViewContext + @Environment(\.activityFamily) private var activityFamily + + var body: some View { + if activityFamily == .small { + // WatchOS & CarPlay supplemental view - show only bottom row + compactLockScreenView(context: context) + } else { + // Lock screen - show full view with chart + fullLockScreenView(context: context) + } + } + + @ViewBuilder + private func fullLockScreenView( + context: ActivityViewContext + ) -> some View { + GlucoseLiveActivityConfiguration().fullLockScreenView( + context: context + ) + } + + @ViewBuilder + private func compactLockScreenView( + context: ActivityViewContext + ) -> some View { + GlucoseLiveActivityConfiguration().compactLockScreenView( + context: context + ) + } + } + + @ViewBuilder + private func fullLockScreenView( + context: ActivityViewContext + ) -> some View { + ZStack { + VStack { + if context.attributes.mode == .large { + HStack(spacing: 15) { + loopIcon(context) + if context.attributes.addPredictiveLine { + ChartView( + glucoseSamples: context.state.glucoseSamples, + predicatedGlucose: context.state + .predicatedGlucose, + predicatedStartDate: context.state + .predicatedStartDate, + predicatedInterval: context.state + .predicatedInterval, + useLimits: context.attributes.useLimits, + lowerLimit: context.state.isMmol + ? context.attributes.lowerLimitChartMmol + : context.attributes.lowerLimitChartMg, + upperLimit: context.state.isMmol + ? context.attributes.upperLimitChartMmol + : context.attributes.upperLimitChartMg, + glucoseRanges: context.state.glucoseRanges, + preset: context.state.preset, + yAxisMarks: context.state.yAxisMarks + ) + .frame(height: 85) + } else { + ChartView( + glucoseSamples: context.state.glucoseSamples, + useLimits: context.attributes.useLimits, + lowerLimit: context.state.isMmol + ? context.attributes.lowerLimitChartMmol + : context.attributes.lowerLimitChartMg, + upperLimit: context.state.isMmol + ? context.attributes.upperLimitChartMmol + : context.attributes.upperLimitChartMg, + glucoseRanges: context.state.glucoseRanges, + preset: context.state.preset, + yAxisMarks: context.state.yAxisMarks + ) + .frame(height: 85) + } + } + } + + HStack { + bottomSpacer(border: false) + + let endIndex = context.state.bottomRow.endIndex - 1 + ForEach( + Array(context.state.bottomRow.enumerated()), + id: \.element + ) { (index, item) in + switch item.type { + case .generic: + bottomItemGeneric( + title: item.label, + value: item.value, + unit: LocalizedString( + item.unit, + comment: "No comment" + ) + ) + + case .basal: + BasalViewActivity( + percent: item.percentage, + rate: item.rate + ) + + case .currentBg: + bottomItemCurrentBG( + value: item.value, + trend: item.trend, + context: context + ) + } + + if index != endIndex { + bottomSpacer(border: true) + } + } + + bottomSpacer(border: false) + } + } + if context.state.ended { + VStack { + Spacer() + HStack { + Spacer() + Text( + NSLocalizedString( + "Open the app to update the widget", + comment: "No comment" + ) + ) + Spacer() + } + Spacer() + } + .background(.ultraThinMaterial.opacity(0.8)) + .padding(.all, -15) + } + } + .privacySensitive() + .padding(.all, 15) + .background(BackgroundStyle.background.opacity(0.4)) + .activityBackgroundTint(Color.clear) + } + + @ViewBuilder + private func compactLockScreenView( + context: ActivityViewContext + ) -> some View { + let glucoseFormatter = NumberFormatter.glucoseFormatter( + for: context.state.isMmol + ? HKUnit.millimolesPerLiter + : HKUnit.milligramsPerDeciliter + ) + let unit = context.state.isMmol + ? HKUnit.millimolesPerLiter.localizedShortUnitString + : HKUnit.milligramsPerDeciliter.localizedShortUnitString + + let glucoseColor = !context.attributes.useLimits ? .primary : getGlucoseColor(context: context) + let currentBG = (glucoseFormatter.string(from: context.state.currentGlucose) ?? "??") + getArrowImage(context.state.trendType) + let eventualBG = formatEventualBG(value: context.state.eventualGlucose, formatter: glucoseFormatter) + + HStack(spacing: 10) { + loopIcon(context, size: 24) + + HStack(alignment: .top) { + VStack(alignment: .leading) { + Text(currentBG) + .font(.headline) + .foregroundStyle(glucoseColor) + Text(context.state.delta + " " + unit) + .font(.caption2) + .foregroundStyle(Color(white: 0.7)) + } + + Spacer() + + VStack(alignment: .trailing) { + Text(eventualBG) + .font(.headline) + .foregroundStyle(.primary) + + Text(unit) + .font(.caption2) + .foregroundStyle(Color(white: 0.7)) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) // Allow HStack to use full available width + .privacySensitive() + .padding(.all, 14) + .background(Color.clear) + } + + private func formatEventualBG(value: Double?, formatter: NumberFormatter) -> String { + guard let value = value else { + return "??" + } + + return formatter.string(from: NSNumber(value: value)) ?? "??" + } + + // MARK: - Dynamic Island View + + private func dynamicIslandView( + context: ActivityViewContext + ) -> DynamicIsland { + let glucoseFormatter = NumberFormatter.glucoseFormatter( + for: context.state.isMmol + ? HKUnit.millimolesPerLiter : HKUnit.milligramsPerDeciliter + ) + + return DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + HStack(alignment: .center) { + loopIcon(context) + .frame(width: 40, height: 40, alignment: .trailing) + Spacer() + Text( + "\(glucoseFormatter.string(from: context.state.currentGlucose) ?? "??")\(getArrowImage(context.state.trendType))" + ) + .foregroundStyle(getGlucoseColor(context: context)) + .font(.headline) + .fontWeight(.heavy) + } + } + DynamicIslandExpandedRegion(.trailing) { + HStack { + Text(context.state.delta) + .foregroundStyle(Color(white: 0.9)) + .font(.headline) + Text( + context.state.isMmol + ? HKUnit.millimolesPerLiter.localizedShortUnitString + : HKUnit.milligramsPerDeciliter + .localizedShortUnitString + ) + .foregroundStyle(Color(white: 0.7)) + .font(.subheadline) + } + } + DynamicIslandExpandedRegion(.bottom) { + if context.attributes.addPredictiveLine { + ChartView( + glucoseSamples: context.state.glucoseSamples, + predicatedGlucose: context.state.predicatedGlucose, + predicatedStartDate: context.state.predicatedStartDate, + predicatedInterval: context.state.predicatedInterval, + useLimits: context.attributes.useLimits, + lowerLimit: context.state.isMmol + ? context.attributes.lowerLimitChartMmol + : context.attributes.lowerLimitChartMg, + upperLimit: context.state.isMmol + ? context.attributes.upperLimitChartMmol + : context.attributes.upperLimitChartMg, + glucoseRanges: context.state.glucoseRanges, + preset: context.state.preset, + yAxisMarks: context.state.yAxisMarks + ) + .frame(height: 75) + } else { + ChartView( + glucoseSamples: context.state.glucoseSamples, + useLimits: context.attributes.useLimits, + lowerLimit: context.state.isMmol + ? context.attributes.lowerLimitChartMmol + : context.attributes.lowerLimitChartMg, + upperLimit: context.state.isMmol + ? context.attributes.upperLimitChartMmol + : context.attributes.upperLimitChartMg, + glucoseRanges: context.state.glucoseRanges, + preset: context.state.preset, + yAxisMarks: context.state.yAxisMarks + ) + .frame(height: 75) + } + } + } compactLeading: { + Text( + "\(glucoseFormatter.string(from: context.state.currentGlucose) ?? "??")\(getArrowImage(context.state.trendType))" + ) + .foregroundStyle( + getGlucoseColor(context: context) + ) + .minimumScaleFactor(0.1) + } compactTrailing: { + Text(context.state.delta) + .foregroundStyle(Color(white: 0.9)) + .minimumScaleFactor(0.1) + } minimal: { + Text( + glucoseFormatter.string(from: context.state.currentGlucose) + ?? "??" + ) + .foregroundStyle( + getGlucoseColor(context: context) + ) + .minimumScaleFactor(0.1) + } + } + + @ViewBuilder + private func loopIcon( + _ context: ActivityViewContext, + size: CGFloat = 36 + ) -> some View { + Circle() + .trim(from: context.state.isCloseLoop ? 0 : 0.2, to: 1) + .stroke(getLoopColor(context.state.lastCompleted), lineWidth: size/4.5) + .rotationEffect(Angle(degrees: -126)) + .frame(width: size, height: size) + } + + @ViewBuilder + private func bottomItemGeneric(title: String, value: String, unit: String) + -> some View + { + VStack(alignment: .center) { + Text("\(value)\(unit)") + .font(.headline) + .foregroundStyle(.primary) + .fontWeight(.heavy) + .font(Font.body.leading(.tight)) + Text(title) + .font(.caption2) + } + } + + @ViewBuilder + private func bottomItemCurrentBG( + value: String, + trend: GlucoseTrend?, + context: ActivityViewContext + ) -> some View { + VStack(alignment: .center) { + HStack { + Text(value + getArrowImage(trend)) + .font(.title) + .foregroundStyle( + !context.attributes.useLimits + ? .primary + : getGlucoseColor(context: context) + ) + .fontWeight(.heavy) + .font(Font.body.leading(.tight)) + } + } + } + + @ViewBuilder + private func bottomItemLoopCircle( + context: ActivityViewContext + ) -> some View { + VStack(alignment: .center) { + loopIcon(context) + } + } + + @ViewBuilder + private func bottomSpacer(border: Bool) -> some View { + Spacer() + if border { + Divider() + .background(.secondary) + Spacer() + } + + } + + private func getArrowImage(_ trendType: GlucoseTrend?) -> String { + switch trendType { + case .upUpUp: + return "\u{2191}\u{2191}" // ↑↑ + case .upUp: + return "\u{2191}" // ↑ + case .up: + return "\u{2197}" // ↗ + case .flat: + return "\u{2192}" // → + case .down: + return "\u{2198}" // ↘ + case .downDown: + return "\u{2193}" // ↓ + case .downDownDown: + return "\u{2193}\u{2193}" // ↓↓ + case .none: + return "" + } + } + + private func getLoopColor(_ age: Date?) -> Color { + var freshness: LoopCompletionFreshness = .stale + if let age = age { + freshness = LoopCompletionFreshness( + age: abs(min(0, age.timeIntervalSinceNow)) + ) + } + + switch freshness { + case .fresh: + return Color("fresh") + case .aging: + return Color("warning") + case .stale: + return .red + } + } + + private func getGlucoseColor(context: ActivityViewContext) -> Color { + guard context.attributes.useLimits else { + return .primary + } + + let value = context.state.currentGlucose + if context.state.isMmol + && value < context.attributes.lowerLimitChartMmol + || !context.state.isMmol + && value < context.attributes.lowerLimitChartMg + { + return .red + } + + if context.state.isMmol + && value > context.attributes.upperLimitChartMmol + || !context.state.isMmol + && value > context.attributes.upperLimitChartMg + { + return .orange + } + + return .green + } + +} diff --git a/Loop Widget Extension/LoopWidgets.swift b/Loop Widget Extension/LoopWidgets.swift new file mode 100644 index 0000000000..f87c2b89ff --- /dev/null +++ b/Loop Widget Extension/LoopWidgets.swift @@ -0,0 +1,23 @@ +// +// LoopWidgets.swift +// Loop Widget Extension +// +// Created by Cameron Ingham on 6/26/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +@main +struct LoopWidgets: WidgetBundle { + + @WidgetBundleBuilder + var body: some Widget { + if #available(iOS 16.1, *) { + SystemStatusWidget() + } + if #available(iOS 16.2, *) { + GlucoseLiveActivityConfiguration() + } + } +} diff --git a/Loop Widget Extension/Timeline/StatusWidgetTimelimeEntry.swift b/Loop Widget Extension/Timeline/StatusWidgetTimelimeEntry.swift new file mode 100644 index 0000000000..45271bbe14 --- /dev/null +++ b/Loop Widget Extension/Timeline/StatusWidgetTimelimeEntry.swift @@ -0,0 +1,58 @@ +// +// StatusWidgetTimelimeEntry.swift +// Loop Widget Extension +// +// Created by Cameron Ingham on 6/26/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import HealthKit +import LoopCore +import LoopKit +import WidgetKit + +struct StatusWidgetTimelimeEntry: TimelineEntry { + var date: Date + + let contextUpdatedAt: Date + + let lastLoopCompleted: Date? + let closeLoop: Bool + + let currentGlucose: GlucoseValue? + let glucoseFetchedAt: Date? + let delta: HKQuantity? + let unit: HKUnit? + let sensor: GlucoseDisplayableContext? + + let pumpHighlight: DeviceStatusHighlightContext? + let netBasal: NetBasalContext? + + let eventualGlucose: GlucoseContext? + + let preMealPresetAllowed: Bool + let preMealPresetActive: Bool + let customPresetActive: Bool + + // Whether context data is old + var contextIsStale: Bool { + return (date - contextUpdatedAt) >= StatusWidgetTimelineProvider.stalenessAge + } + + var glucoseStatusIsStale: Bool { + guard let glucoseFetchedAt = glucoseFetchedAt else { + return true + } + let glucoseStatusAge = date - glucoseFetchedAt + return glucoseStatusAge >= StatusWidgetTimelineProvider.stalenessAge + } + + var glucoseIsStale: Bool { + guard let glucoseDate = currentGlucose?.startDate else { + return true + } + let glucoseAge = date - glucoseDate + + return glucoseAge >= LoopCoreConstants.inputDataRecencyInterval + } +} diff --git a/Loop Widget Extension/Timeline/StatusWidgetTimelineProvider.swift b/Loop Widget Extension/Timeline/StatusWidgetTimelineProvider.swift new file mode 100644 index 0000000000..beb8bd2f70 --- /dev/null +++ b/Loop Widget Extension/Timeline/StatusWidgetTimelineProvider.swift @@ -0,0 +1,181 @@ +// +// StatusWidgetTimelineProvider.swift +// Loop Widget Extension +// +// Created by Cameron Ingham on 6/26/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import HealthKit +import LoopCore +import LoopKit +import OSLog +import WidgetKit + +class StatusWidgetTimelineProvider: TimelineProvider { + lazy var defaults = UserDefaults.appGroup + + lazy var healthStore = HKHealthStore() + + private let log = OSLog(category: "LoopWidgets") + + static let stalenessAge = TimeInterval(minutes: 6) + + lazy var cacheStore = PersistenceController.controllerInAppGroupDirectory() + + lazy var localCacheDuration = Bundle.main.localCacheDuration + + lazy var settingsStore: SettingsStore = SettingsStore( + store: cacheStore, + expireAfter: localCacheDuration) + + lazy var glucoseStore = GlucoseStore( + cacheStore: cacheStore, + provenanceIdentifier: HKSource.default().bundleIdentifier + ) + + func placeholder(in context: Context) -> StatusWidgetTimelimeEntry { + log.default("%{public}@: context=%{public}@", #function, String(describing: context)) + + return StatusWidgetTimelimeEntry(date: Date(), contextUpdatedAt: Date(), lastLoopCompleted: nil, closeLoop: true, currentGlucose: nil, glucoseFetchedAt: Date(), delta: nil, unit: .milligramsPerDeciliter, sensor: nil, pumpHighlight: nil, netBasal: nil, eventualGlucose: nil, preMealPresetAllowed: true, preMealPresetActive: false, customPresetActive: false) + } + + func getSnapshot(in context: Context, completion: @escaping (StatusWidgetTimelimeEntry) -> ()) { + log.default("%{public}@: context=%{public}@", #function, String(describing: context)) + update { newEntry in + completion(newEntry) + } + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> ()) { + log.default("%{public}@: context=%{public}@", #function, String(describing: context)) + update { newEntry in + var entries = [newEntry] + var datesToRefreshWidget: [Date] = [] + + // Dates Loop completion staleness changes + if let lastLoopCompleted = newEntry.lastLoopCompleted { + datesToRefreshWidget.append(lastLoopCompleted.addingTimeInterval(LoopCompletionFreshness.fresh.maxAge!+1)) // Turns yellow + datesToRefreshWidget.append(lastLoopCompleted.addingTimeInterval(LoopCompletionFreshness.aging.maxAge!+1)) // Turns red + } + + // Date glucose status staleness changes + if let lastGlucoseFetch = newEntry.glucoseFetchedAt { + let glucoseFetchStaleAt = lastGlucoseFetch.addingTimeInterval(StatusWidgetTimelineProvider.stalenessAge+1) + datesToRefreshWidget.append(glucoseFetchStaleAt) + } + + // Date glucose staleness changes + if let lastBGTime = newEntry.currentGlucose?.startDate { + let staleBgRefreshTime = lastBGTime.addingTimeInterval(LoopCoreConstants.inputDataRecencyInterval+1) + datesToRefreshWidget.append(staleBgRefreshTime) + } + + // Date context staleness changes + datesToRefreshWidget.append(newEntry.contextUpdatedAt.addingTimeInterval(StatusWidgetTimelineProvider.stalenessAge+1)) + + for date in datesToRefreshWidget { + // Copy the previous entry but mark it as stale + var copiedEntry = newEntry + copiedEntry.date = date + entries.append(copiedEntry) + } + + let nextHour = Date().addingTimeInterval(.hours(1)) + let timeline = Timeline(entries: entries, policy: .after(nextHour)) + self.log.default("Returning timeline: %{public}@", String(describing: datesToRefreshWidget)) + completion(timeline) + } + } + + func update(completion: @escaping (StatusWidgetTimelimeEntry) -> Void) { + let group = DispatchGroup() + + var glucose: [StoredGlucoseSample] = [] + + let startDate = Date(timeIntervalSinceNow: -LoopCoreConstants.inputDataRecencyInterval) + + group.enter() + glucoseStore.getGlucoseSamples(start: startDate) { (result) in + switch result { + case .failure: + self.log.error("Failed to fetch glucose after %{public}@", String(describing: startDate)) + glucose = [] + case .success(let samples): + self.log.default("Fetched glucose: last = %{public}@, %{public}@", String(describing: samples.last?.startDate), String(describing: samples.last?.quantity)) + glucose = samples + } + group.leave() + } + group.wait() + + let finalGlucose = glucose + + Task { @MainActor in + guard let defaults = self.defaults, + let context = defaults.statusExtensionContext, + let contextUpdatedAt = context.createdAt, + let unit = await healthStore.cachedPreferredUnits(for: .bloodGlucose) + else { + return + } + + let lastCompleted = context.lastLoopCompleted + + let closeLoop = context.isClosedLoop ?? false + + let preMealPresetAllowed = context.preMealPresetAllowed ?? true + let preMealPresetActive = context.preMealPresetActive ?? false + let customPresetActive = context.customPresetActive ?? false + + let netBasal = context.netBasal + + let currentGlucose = finalGlucose.last + var previousGlucose: GlucoseValue? + + if finalGlucose.count > 1 { + previousGlucose = finalGlucose[finalGlucose.count - 2] + } + + var delta: HKQuantity? + + // Making sure that previous glucose is within 6 mins of last glucose to avoid large deltas on sensor changes, missed readings, etc. + if let prevGlucose = previousGlucose, + let currGlucose = currentGlucose, + currGlucose.startDate.timeIntervalSince(prevGlucose.startDate).minutes < 6 + { + let deltaMGDL = currGlucose.quantity.doubleValue(for: .milligramsPerDeciliter) - prevGlucose.quantity.doubleValue(for: .milligramsPerDeciliter) + delta = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: deltaMGDL) + } + + let predictedGlucose = context.predictedGlucose?.samples + + let eventualGlucose = predictedGlucose?.last + + let updateDate = Date() + + let entry = StatusWidgetTimelimeEntry( + date: updateDate, + contextUpdatedAt: contextUpdatedAt, + lastLoopCompleted: lastCompleted, + closeLoop: closeLoop, + currentGlucose: currentGlucose, + glucoseFetchedAt: updateDate, + delta: delta, + unit: unit, + sensor: context.glucoseDisplay, + pumpHighlight: context.pumpStatusHighlightContext, + netBasal: netBasal, + eventualGlucose: eventualGlucose, + preMealPresetAllowed: preMealPresetAllowed, + preMealPresetActive: preMealPresetActive, + customPresetActive: customPresetActive + ) + + self.log.default("StatusWidgetTimelimeEntry = %{public}@", String(describing: entry)) + self.log.default("pumpHighlight = %{public}@", String(describing: entry.pumpHighlight)) + + completion(entry) + } + } +} diff --git a/Loop Widget Extension/Widgets/SystemStatusWidget.swift b/Loop Widget Extension/Widgets/SystemStatusWidget.swift new file mode 100644 index 0000000000..015f6c5d50 --- /dev/null +++ b/Loop Widget Extension/Widgets/SystemStatusWidget.swift @@ -0,0 +1,88 @@ +// +// SystemStatusWidget.swift +// Loop +// +// Created by Noah Brauner on 8/15/22. +// Copyright © 2022 LoopKit Authors. All rights reserved. +// + +import LoopUI +import SwiftUI +import WidgetKit + +@available(iOS 16.1, *) +struct SystemStatusWidgetEntryView : View { + + @Environment(\.widgetFamily) private var widgetFamily + @Environment(\.widgetRenderingMode) private var widgetRenderingMode + + var entry: StatusWidgetTimelineProvider.Entry + + var body: some View { + HStack(alignment: .center, spacing: 5) { + VStack(alignment: .center, spacing: 5) { + HStack(alignment: .center, spacing: 15) { + LoopCircleView(entry: entry) + + GlucoseView(entry: entry) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .padding(5) + .background( + widgetRenderingMode == .accented + ? Color(UIColor.systemBackground).opacity(0.15) + : Color("WidgetSecondaryBackground") + ) + .clipShape(ContainerRelativeShape()) + + PumpView(entry: entry) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .padding(5) + .background( + widgetRenderingMode == .accented + ? Color(UIColor.systemBackground).opacity(0.15) + : Color("WidgetSecondaryBackground") + ) + .clipShape(ContainerRelativeShape()) + } + + if widgetFamily != .systemSmall { + VStack(alignment: .center, spacing: 5) { + HStack(alignment: .center, spacing: 5) { + SystemActionLink(to: .carbEntry) + + SystemActionLink(to: .bolus) + } + + HStack(alignment: .center, spacing: 5) { + if entry.preMealPresetAllowed { + SystemActionLink(to: .preMeal, active: entry.preMealPresetActive) + } + + SystemActionLink(to: .customPreset, active: entry.customPresetActive) + } + } + .buttonStyle(.plain) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } + } + .foregroundColor(entry.contextIsStale ? Color(UIColor.systemGray3) : nil) + .padding(5) + .widgetBackground() + } +} + +@available(iOS 16.1, *) +struct SystemStatusWidget: Widget { + let kind: String = "SystemStatusWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: StatusWidgetTimelineProvider()) { entry in + SystemStatusWidgetEntryView(entry: entry) + } + .configurationDisplayName("Loop Status Widget") + .description("See your current blood glucose and insulin delivery.") + .supportedFamilies([.systemSmall, .systemMedium]) + .contentMarginsDisabledIfAvailable() + } +} diff --git a/Loop.xcconfig b/Loop.xcconfig index d092e89a38..7827f574d5 100644 --- a/Loop.xcconfig +++ b/Loop.xcconfig @@ -6,6 +6,51 @@ // Copyright © 2016 Nathan Racklyeft. All rights reserved. // -// Change this on first setup to your own unique organization identifier in -// reverse-domain name syntax. -MAIN_APP_BUNDLE_IDENTIFIER = com.loopkit +// This is automatically disambiguated by development team, but you may choose to change this to +// support running multiple apps simultaneously. +MAIN_APP_BUNDLE_IDENTIFIER = com.${DEVELOPMENT_TEAM}.loopkit + +// Application name [DEFAULT] +MAIN_APP_DISPLAY_NAME = Loop + +// Appication icon [DEFAULT] +APPICON_NAME = AppIcon + +// App Store URL +APP_STORE_URL = + +// Features [DEFAULT] +SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) + +// General [DEFAULT] +LOOP_LOCAL_CACHE_DURATION_DAYS = 7 + +// Entitlements [DEFAULT] +LOOP_ENTITLEMENTS = Loop/Loop.entitlements + +// Code signing and provisioning [DEFAULT] +LOOP_CODE_SIGN_IDENTITY_DEBUG = Apple Development +LOOP_CODE_SIGN_IDENTITY_RELEASE = Apple Development +LOOP_CODE_SIGN_STYLE = Automatic +LOOP_DEVELOPMENT_TEAM = +LOOP_PROVISIONING_PROFILE_SPECIFIER_DEBUG = +LOOP_PROVISIONING_PROFILE_SPECIFIER_STATUS_EXTENSION_DEBUG = +LOOP_PROVISIONING_PROFILE_SPECIFIER_WATCHAPP_DEBUG = +LOOP_PROVISIONING_PROFILE_SPECIFIER_WATCHAPP_EXTENSION_DEBUG = +LOOP_PROVISIONING_PROFILE_SPECIFIER_RELEASE = +LOOP_PROVISIONING_PROFILE_SPECIFIER_STATUS_EXTENSION_RELEASE = +LOOP_PROVISIONING_PROFILE_SPECIFIER_WATCHAPP_RELEASE = +LOOP_PROVISIONING_PROFILE_SPECIFIER_WATCHAPP_EXTENSION_RELEASE = + +// Min iOS Version [DEFAULT] +IPHONEOS_DEPLOYMENT_TARGET = 16.2 + +// Base string for opening app via URL [DEFAULT] +URL_SCHEME_NAME = $(MAIN_APP_DISPLAY_NAME) + +// Version [DEFAULT] +#include? "Version.xcconfig" + +// Optional overrides +#include? "LoopOverride.xcconfig" +#include? "../LoopConfigOverride.xcconfig" diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 96a426f520..4767ba3142 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -3,192 +3,608 @@ archiveVersion = 1; classes = { }; - objectVersion = 48; + objectVersion = 60; objects = { /* Begin PBXBuildFile section */ + 1419606428D9550400BA86E0 /* LoopKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 437AFEE6203688CF008C4892 /* LoopKitUI.framework */; }; + 1419606928D9554E00BA86E0 /* LoopKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 437AFEE6203688CF008C4892 /* LoopKitUI.framework */; }; + 1419606A28D955BC00BA86E0 /* MockKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C101947127DD473C004E7EB8 /* MockKitUI.framework */; }; + 142CB7592A60BF2E0075748A /* EditMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 142CB7582A60BF2E0075748A /* EditMode.swift */; }; + 142CB75B2A60BFC30075748A /* FavoriteFoodsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 142CB75A2A60BFC30075748A /* FavoriteFoodsView.swift */; }; + 1452F4A92A851C9400F8B9E4 /* AddEditFavoriteFoodViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1452F4A82A851C9400F8B9E4 /* AddEditFavoriteFoodViewModel.swift */; }; + 1452F4AB2A851EDF00F8B9E4 /* AddEditFavoriteFoodView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1452F4AA2A851EDF00F8B9E4 /* AddEditFavoriteFoodView.swift */; }; + 1452F4AD2A851F8800F8B9E4 /* HowAbsorptionTimeWorksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1452F4AC2A851F8800F8B9E4 /* HowAbsorptionTimeWorksView.swift */; }; + 147EFE8E2A8BCC5500272438 /* DefaultAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 147EFE8D2A8BCC5500272438 /* DefaultAssets.xcassets */; }; + 147EFE902A8BCD8000272438 /* DerivedAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 147EFE8F2A8BCD8000272438 /* DerivedAssets.xcassets */; }; + 147EFE922A8BCD8A00272438 /* DerivedAssetsBase.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 147EFE912A8BCD8A00272438 /* DerivedAssetsBase.xcassets */; }; + 1481F9BB28DA26F4004C5AEB /* LoopUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4F75288B1DFE1DC600C322D6 /* LoopUI.framework */; }; + 149A28BB2A853E5100052EDF /* CarbEntryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 149A28BA2A853E5100052EDF /* CarbEntryViewModel.swift */; }; + 149A28BD2A853E6C00052EDF /* CarbEntryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 149A28BC2A853E6C00052EDF /* CarbEntryView.swift */; }; + 149A28E42A8A63A700052EDF /* FavoriteFoodDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 149A28E32A8A63A700052EDF /* FavoriteFoodDetailView.swift */; }; + 14B1735E28AED9EC006CCD7C /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 14B1735D28AED9EC006CCD7C /* WidgetKit.framework */; }; + 14B1736028AED9EC006CCD7C /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 14B1735F28AED9EC006CCD7C /* SwiftUI.framework */; }; + 14B1736928AED9EE006CCD7C /* Loop Widget Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 14B1735C28AED9EC006CCD7C /* Loop Widget Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 14B1737228AEDBF6006CCD7C /* BasalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14B1736E28AEDBF6006CCD7C /* BasalView.swift */; }; + 14B1737328AEDBF6006CCD7C /* SystemStatusWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14B1736F28AEDBF6006CCD7C /* SystemStatusWidget.swift */; }; + 14B1737428AEDBF6006CCD7C /* GlucoseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14B1737028AEDBF6006CCD7C /* GlucoseView.swift */; }; + 14B1737528AEDBF6006CCD7C /* LoopCircleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14B1737128AEDBF6006CCD7C /* LoopCircleView.swift */; }; + 14B1737628AEDC6C006CCD7C /* HKUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F526D5E1DF2459000A04910 /* HKUnit.swift */; }; + 14B1737728AEDC6C006CCD7C /* NSBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430DA58D1D4AEC230097D1CA /* NSBundle.swift */; }; + 14B1737828AEDC6C006CCD7C /* NSTimeInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439897341CD2F7DE00223065 /* NSTimeInterval.swift */; }; + 14B1737928AEDC6C006CCD7C /* NSUserDefaults+StatusExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC8C8001DEB93E400A1452E /* NSUserDefaults+StatusExtension.swift */; }; + 14B1737A28AEDC6C006CCD7C /* NumberFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43BFF0B31E45C1BE00FF19A9 /* NumberFormatter.swift */; }; + 14B1737B28AEDC6C006CCD7C /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4374B5EE209D84BE00D17AA8 /* OSLog.swift */; }; + 14B1737C28AEDC6C006CCD7C /* PumpManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C3B6F620BBCAA30026CAFA /* PumpManager.swift */; }; + 14B1737D28AEDC6C006CCD7C /* PumpManagerUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1FB428E217921D600FAB378 /* PumpManagerUI.swift */; }; + 14B1737E28AEDC6C006CCD7C /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89E267FB2292456700A3F2AF /* FeatureFlags.swift */; }; + 14B1737F28AEDC6C006CCD7C /* PluginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16DA84122E8E112008624C2 /* PluginManager.swift */; }; + 14B1738028AEDC6C006CCD7C /* Debug.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892A5D58222F0A27008961AB /* Debug.swift */; }; + 14B1738128AEDC70006CCD7C /* StatusExtensionContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F70C2111DE900EA006380B7 /* StatusExtensionContext.swift */; }; + 14D906F42A846510006EB79A /* FavoriteFoodsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14D906F32A846510006EB79A /* FavoriteFoodsViewModel.swift */; }; + 1D05219B2469E9DF000EBBDE /* StoredAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D05219A2469E9DF000EBBDE /* StoredAlert.swift */; }; + 1D05219D2469F1F5000EBBDE /* AlertStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D05219C2469F1F5000EBBDE /* AlertStore.swift */; }; + 1D080CBD2473214A00356610 /* AlertStore.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 1D080CBB2473214A00356610 /* AlertStore.xcdatamodeld */; }; + 1D12D3B92548EFDD00B53E8B /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D12D3B82548EFDD00B53E8B /* main.swift */; }; + 1D3F0F7526D59B6C004A5960 /* Debug.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892A5D58222F0A27008961AB /* Debug.swift */; }; + 1D3F0F7626D59DCD004A5960 /* Debug.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892A5D58222F0A27008961AB /* Debug.swift */; }; + 1D3F0F7726D59DCE004A5960 /* Debug.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892A5D58222F0A27008961AB /* Debug.swift */; }; + 1D49795824E7289700948F05 /* ServicesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D49795724E7289700948F05 /* ServicesViewModel.swift */; }; + 1D4990E824A25931005CC357 /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89E267FB2292456700A3F2AF /* FeatureFlags.swift */; }; + 1D4A3E2D2478628500FD601B /* StoredAlert+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D4A3E2B2478628500FD601B /* StoredAlert+CoreDataClass.swift */; }; + 1D4A3E2E2478628500FD601B /* StoredAlert+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D4A3E2C2478628500FD601B /* StoredAlert+CoreDataProperties.swift */; }; + 1D63DEA526E950D400F46FA5 /* SupportManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D63DEA426E950D400F46FA5 /* SupportManager.swift */; }; + 1D6B1B6726866D89009AC446 /* AlertPermissionsChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D6B1B6626866D89009AC446 /* AlertPermissionsChecker.swift */; }; + 1D70C40126EC0F9D00C62570 /* SupportManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D70C40026EC0F9D00C62570 /* SupportManagerTests.swift */; }; + 1D80313D24746274002810DF /* AlertStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D80313C24746274002810DF /* AlertStoreTests.swift */; }; + 1D82E6A025377C6B009131FB /* TrustedTimeChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D82E69F25377C6B009131FB /* TrustedTimeChecker.swift */; }; + 1D8D55BC252274650044DBB6 /* BolusEntryViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D8D55BB252274650044DBB6 /* BolusEntryViewModelTests.swift */; }; + 1D9650C82523FBA100A1370B /* DeviceDataManager+BolusEntryViewModelDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D9650C72523FBA100A1370B /* DeviceDataManager+BolusEntryViewModelDelegate.swift */; }; + 1DA649A7244126CD00F61E75 /* UserNotificationAlertScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA649A6244126CD00F61E75 /* UserNotificationAlertScheduler.swift */; }; + 1DA649A9244126DA00F61E75 /* InAppModalAlertScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA649A8244126DA00F61E75 /* InAppModalAlertScheduler.swift */; }; + 1DA7A84224476EAD008257F0 /* AlertManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA7A84124476EAD008257F0 /* AlertManagerTests.swift */; }; + 1DA7A84424477698008257F0 /* InAppModalAlertSchedulerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA7A84324477698008257F0 /* InAppModalAlertSchedulerTests.swift */; }; + 1DB1065124467E18005542BD /* AlertManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DB1065024467E18005542BD /* AlertManager.swift */; }; + 1DB1CA4D24A55F0000B3B94C /* Image.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DB1CA4C24A55F0000B3B94C /* Image.swift */; }; + 1DB619AC270BAD3D006C9D07 /* VersionUpdateViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DB619AB270BAD3D006C9D07 /* VersionUpdateViewModel.swift */; }; + 1DDE273D24AEA4B000796622 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DB1CA4E24A56D7600B3B94C /* SettingsViewModel.swift */; }; + 1DDE273E24AEA4B000796622 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DE09BA824A3E23F009EE9F9 /* SettingsView.swift */; }; + 1DDE274024AEA4F200796622 /* NotificationsCriticalAlertPermissionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA46B5F2492E2E300D71A63 /* NotificationsCriticalAlertPermissionsView.swift */; }; + 1DFE9E172447B6270082C280 /* UserNotificationAlertSchedulerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DFE9E162447B6270082C280 /* UserNotificationAlertSchedulerTests.swift */; }; + 3ED3198A2EB659E600820BCF /* GlucoseLiveActivityConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ED319882EB659E600820BCF /* GlucoseLiveActivityConfiguration.swift */; }; + 3ED3198B2EB659E600820BCF /* ChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ED319872EB659E600820BCF /* ChartView.swift */; }; + 3ED3198C2EB659E600820BCF /* BasalViewActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ED319862EB659E600820BCF /* BasalViewActivity.swift */; }; + 3ED319912EB65A2D00820BCF /* GlucoseActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ED3198E2EB65A2D00820BCF /* GlucoseActivityAttributes.swift */; }; + 3ED319922EB65A2D00820BCF /* LiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ED3198F2EB65A2D00820BCF /* LiveActivityManager.swift */; }; + 3ED319932EB65A2D00820BCF /* ChartAxisGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ED3198D2EB65A2D00820BCF /* ChartAxisGenerator.swift */; }; + 3ED319942EB65A3E00820BCF /* GlucoseActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ED3198E2EB65A2D00820BCF /* GlucoseActivityAttributes.swift */; }; + 3ED319962EB65A5C00820BCF /* LiveActivityManagementViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ED319952EB65A5C00820BCF /* LiveActivityManagementViewModel.swift */; }; + 3ED319992EB65A6900820BCF /* LiveActivityManagementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ED319982EB65A6900820BCF /* LiveActivityManagementView.swift */; }; + 3ED3199A2EB65A6900820BCF /* LiveActivityBottomRowManagerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ED319972EB65A6900820BCF /* LiveActivityBottomRowManagerView.swift */; }; + 3ED3199C2EB65A9B00820BCF /* LiveActivitySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ED3199B2EB65A9B00820BCF /* LiveActivitySettings.swift */; }; + 3ED3199D2EB65A9B00820BCF /* LiveActivitySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ED3199B2EB65A9B00820BCF /* LiveActivitySettings.swift */; }; + 3ED3199F2EB65AFE00820BCF /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ED3199E2EB65AFE00820BCF /* LocalizedString.swift */; }; + 3ED319A12EB65B4100820BCF /* Bootstrap.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ED319A02EB65B4100820BCF /* Bootstrap.swift */; }; + 3ED319A32EB65DA800820BCF /* LiveActivityManagerProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ED319A22EB65DA300820BCF /* LiveActivityManagerProxy.swift */; }; + 43027F0F1DFE0EC900C51989 /* HKUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F526D5E1DF2459000A04910 /* HKUnit.swift */; }; 4302F4E11D4E9C8900F0FCAF /* TextFieldTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4302F4E01D4E9C8900F0FCAF /* TextFieldTableViewController.swift */; }; 4302F4E31D4EA54200F0FCAF /* InsulinDeliveryTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4302F4E21D4EA54200F0FCAF /* InsulinDeliveryTableViewController.swift */; }; - 4302F4E51D4EA75100F0FCAF /* DoseStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4302F4E41D4EA75100F0FCAF /* DoseStore.swift */; }; - 430DA58E1D4AEC230097D1CA /* NSBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430DA58D1D4AEC230097D1CA /* NSBundle.swift */; }; - 430DA5901D4B0E4C0097D1CA /* MySentryPumpStatusMessageBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430DA58F1D4B0E4C0097D1CA /* MySentryPumpStatusMessageBody.swift */; }; - 4313EDE01D8A6BF90060FA79 /* ChartContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4313EDDF1D8A6BF90060FA79 /* ChartContentView.swift */; }; - 4315D2871CA5CC3B00589052 /* CarbEntryEditTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4315D2861CA5CC3B00589052 /* CarbEntryEditTableViewController.swift */; }; - 4315D28A1CA5F45E00589052 /* DiagnosticLogger+LoopKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4315D2891CA5F45E00589052 /* DiagnosticLogger+LoopKit.swift */; }; - 4328E01A1CFBE1DA00E199AA /* StatusInterfaceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4328E0151CFBE1DA00E199AA /* StatusInterfaceController.swift */; }; - 4328E01B1CFBE1DA00E199AA /* BolusInterfaceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4328E0161CFBE1DA00E199AA /* BolusInterfaceController.swift */; }; - 4328E01E1CFBE25F00E199AA /* AddCarbsInterfaceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4328E01D1CFBE25F00E199AA /* AddCarbsInterfaceController.swift */; }; - 4328E0261CFBE2C500E199AA /* IdentifiableClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4328E0201CFBE2C500E199AA /* IdentifiableClass.swift */; }; + 430B29932041F5B300BA9F93 /* UserDefaults+Loop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430B29922041F5B200BA9F93 /* UserDefaults+Loop.swift */; }; + 430D85891F44037000AF2D4F /* HUDViewTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430D85881F44037000AF2D4F /* HUDViewTableViewCell.swift */; }; + 4311FB9B1F37FE1B00D4C0A7 /* TitleSubtitleTextFieldTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4311FB9A1F37FE1B00D4C0A7 /* TitleSubtitleTextFieldTableViewCell.swift */; }; + 431A8C401EC6E8AB00823B9C /* CircleMaskView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431A8C3F1EC6E8AB00823B9C /* CircleMaskView.swift */; }; + 4326BA641F3A44D9007CCAD4 /* ChartLineModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4326BA631F3A44D9007CCAD4 /* ChartLineModel.swift */; }; + 4328E01A1CFBE1DA00E199AA /* ActionHUDController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4328E0151CFBE1DA00E199AA /* ActionHUDController.swift */; }; + 4328E01E1CFBE25F00E199AA /* CarbAndBolusFlowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4328E01D1CFBE25F00E199AA /* CarbAndBolusFlowController.swift */; }; 4328E0281CFBE2C500E199AA /* CLKComplicationTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4328E0221CFBE2C500E199AA /* CLKComplicationTemplate.swift */; }; - 4328E0291CFBE2C500E199AA /* NSUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4328E0231CFBE2C500E199AA /* NSUserDefaults.swift */; }; 4328E02A1CFBE2C500E199AA /* UIColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4328E0241CFBE2C500E199AA /* UIColor.swift */; }; 4328E02B1CFBE2C500E199AA /* WKAlertAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4328E0251CFBE2C500E199AA /* WKAlertAction.swift */; }; 4328E02F1CFBF81800E199AA /* WKInterfaceImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4328E02E1CFBF81800E199AA /* WKInterfaceImage.swift */; }; 4328E0331CFC091100E199AA /* WatchContext+LoopKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4328E0311CFC068900E199AA /* WatchContext+LoopKit.swift */; }; 4328E0351CFC0AE100E199AA /* WatchDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4328E0341CFC0AE100E199AA /* WatchDataManager.swift */; }; - 432E73CB1D24B3D6009AD15D /* RemoteDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 432E73CA1D24B3D6009AD15D /* RemoteDataManager.swift */; }; - 4331E0781C85302200FBE832 /* CGPoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4331E0771C85302200FBE832 /* CGPoint.swift */; }; - 4331E07A1C85650D00FBE832 /* ChartAxisValueDoubleLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4331E0791C85650D00FBE832 /* ChartAxisValueDoubleLog.swift */; }; - 433EA4C21D9F39C900CD78FB /* PumpIDTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 433EA4C11D9F39C900CD78FB /* PumpIDTableViewController.swift */; }; + 432CF87520D8AC950066B889 /* NSUserDefaults+WatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4328E0231CFBE2C500E199AA /* NSUserDefaults+WatchApp.swift */; }; + 432E73CB1D24B3D6009AD15D /* RemoteDataServicesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 432E73CA1D24B3D6009AD15D /* RemoteDataServicesManager.swift */; }; 433EA4C41D9F71C800CD78FB /* CommandResponseViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 433EA4C31D9F71C800CD78FB /* CommandResponseViewController.swift */; }; - 4346D1E71C77F5FE00ABAFE3 /* ChartTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4346D1E61C77F5FE00ABAFE3 /* ChartTableViewCell.swift */; }; - 4346D1F01C781BEA00ABAFE3 /* SwiftCharts.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4346D1EF1C781BEA00ABAFE3 /* SwiftCharts.framework */; }; - 4346D1F61C78501000ABAFE3 /* ChartPoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4346D1F51C78501000ABAFE3 /* ChartPoint.swift */; }; - 434F54571D287FDB002A9274 /* NibLoadable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 434F54561D287FDB002A9274 /* NibLoadable.swift */; }; - 434F54591D28805E002A9274 /* ButtonTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 434F54581D28805E002A9274 /* ButtonTableViewCell.xib */; }; - 434F545B1D2880D4002A9274 /* AuthenticationTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 434F545A1D2880D4002A9274 /* AuthenticationTableViewCell.xib */; }; - 434F545F1D288345002A9274 /* ShareService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 434F545E1D288345002A9274 /* ShareService.swift */; }; - 434F54611D28859B002A9274 /* ServiceCredential.swift in Sources */ = {isa = PBXBuildFile; fileRef = 434F54601D28859B002A9274 /* ServiceCredential.swift */; }; - 434F54631D28DD80002A9274 /* ValidatingIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 434F54621D28DD80002A9274 /* ValidatingIndicatorView.swift */; }; - 434FB6461D68F1CD007B9C70 /* Amplitude.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 434FB6451D68F1CD007B9C70 /* Amplitude.framework */; }; - 434FF1EA1CF26C29000DB779 /* IdentifiableClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 434FF1E91CF26C29000DB779 /* IdentifiableClass.swift */; }; + 4344628220A7A37F00C4BE6F /* CoreBluetooth.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4344628120A7A37E00C4BE6F /* CoreBluetooth.framework */; }; + 4344629220A7C19800C4BE6F /* ButtonGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4344629120A7C19800C4BE6F /* ButtonGroup.swift */; }; + 4344629820A8B2D700C4BE6F /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4374B5EE209D84BE00D17AA8 /* OSLog.swift */; }; + 4345E3F421F036FC009E00E5 /* Result.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43D848AF1E7DCBE100DADCBC /* Result.swift */; }; + 4345E3F521F036FC009E00E5 /* Result.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43D848AF1E7DCBE100DADCBC /* Result.swift */; }; + 4345E3FB21F04911009E00E5 /* UIColor+HIG.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43BFF0C31E4659E700FF19A9 /* UIColor+HIG.swift */; }; + 4345E3FC21F04911009E00E5 /* UIColor+HIG.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43BFF0C31E4659E700FF19A9 /* UIColor+HIG.swift */; }; + 4345E40121F67300009E00E5 /* PotentialCarbEntryUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DE92581C5479E4001FFDE1 /* PotentialCarbEntryUserInfo.swift */; }; + 4345E40221F67300009E00E5 /* PotentialCarbEntryUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DE92581C5479E4001FFDE1 /* PotentialCarbEntryUserInfo.swift */; }; + 4345E40421F68AD9009E00E5 /* TextRowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4345E40321F68AD9009E00E5 /* TextRowController.swift */; }; + 4345E40621F68E18009E00E5 /* CarbEntryListController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4345E40521F68E18009E00E5 /* CarbEntryListController.swift */; }; 434FF1EE1CF27EEF000DB779 /* UITableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 434FF1ED1CF27EEF000DB779 /* UITableViewCell.swift */; }; - 43523EDB1CC35083001850F1 /* RileyLinkKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43523EDA1CC35083001850F1 /* RileyLinkKit.framework */; }; - 435400311C9F744E00D5819C /* BolusSuggestionUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 435400301C9F744E00D5819C /* BolusSuggestionUserInfo.swift */; }; - 435400321C9F745500D5819C /* BolusSuggestionUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 435400301C9F744E00D5819C /* BolusSuggestionUserInfo.swift */; }; + 43511CEE220FC61700566C63 /* HUDRowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43511CED220FC61700566C63 /* HUDRowController.swift */; }; + 43517917230A0E1A0072ECC0 /* WKInterfaceLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43517916230A0E1A0072ECC0 /* WKInterfaceLabel.swift */; }; 435400341C9F878D00D5819C /* SetBolusUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 435400331C9F878D00D5819C /* SetBolusUserInfo.swift */; }; 435400351C9F878D00D5819C /* SetBolusUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 435400331C9F878D00D5819C /* SetBolusUserInfo.swift */; }; - 43649A631C7A347F00523D7F /* CollectionType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43649A621C7A347F00523D7F /* CollectionType.swift */; }; 436A0DA51D236A2A00104B24 /* LoopError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436A0DA41D236A2A00104B24 /* LoopError.swift */; }; - 436FACEE1D0BA636004E2427 /* InsulinDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436FACED1D0BA636004E2427 /* InsulinDataSource.swift */; }; + 4372E484213A63FB0068E043 /* ChartHUDController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FFEDFBE20E5CF22000BFC58 /* ChartHUDController.swift */; }; + 4372E487213C86240068E043 /* SampleValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4372E486213C86240068E043 /* SampleValue.swift */; }; + 4372E488213C862B0068E043 /* SampleValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4372E486213C86240068E043 /* SampleValue.swift */; }; + 4372E48B213CB5F00068E043 /* Double.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4372E48A213CB5F00068E043 /* Double.swift */; }; + 4372E48C213CB6750068E043 /* Double.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4372E48A213CB5F00068E043 /* Double.swift */; }; + 4372E490213CFCE70068E043 /* LoopSettingsUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4372E48F213CFCE70068E043 /* LoopSettingsUserInfo.swift */; }; + 4372E491213D05F90068E043 /* LoopSettingsUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4372E48F213CFCE70068E043 /* LoopSettingsUserInfo.swift */; }; + 4372E492213D956C0068E043 /* GlucoseRangeSchedule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C513181E864C4E001547C7 /* GlucoseRangeSchedule.swift */; }; + 4372E496213DCDD30068E043 /* GlucoseChartValueHashable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4372E495213DCDD30068E043 /* GlucoseChartValueHashable.swift */; }; + 4374B5EF209D84BF00D17AA8 /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4374B5EE209D84BE00D17AA8 /* OSLog.swift */; }; + 4374B5F0209D857E00D17AA8 /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4374B5EE209D84BE00D17AA8 /* OSLog.swift */; }; 43776F901B8022E90074EA36 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43776F8F1B8022E90074EA36 /* AppDelegate.swift */; }; 43776F971B8022E90074EA36 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 43776F951B8022E90074EA36 /* Main.storyboard */; }; - 43776F991B8022E90074EA36 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 43776F981B8022E90074EA36 /* Assets.xcassets */; }; - 437CCADA1D284ADF0075D2C3 /* AuthenticationTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437CCAD91D284ADF0075D2C3 /* AuthenticationTableViewCell.swift */; }; - 437CCADC1D284B830075D2C3 /* ButtonTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437CCADB1D284B830075D2C3 /* ButtonTableViewCell.swift */; }; - 437CCADE1D2858FD0075D2C3 /* AuthenticationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437CCADD1D2858FD0075D2C3 /* AuthenticationViewController.swift */; }; - 437CCAE01D285C7B0075D2C3 /* ServiceAuthentication.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437CCADF1D285C7B0075D2C3 /* ServiceAuthentication.swift */; }; + 43785E932120A01B0057DED1 /* NewCarbEntryIntent+Loop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43785E922120A01B0057DED1 /* NewCarbEntryIntent+Loop.swift */; }; + 43785E972120E4500057DED1 /* INRelevantShortcutStore+Loop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43785E952120E4010057DED1 /* INRelevantShortcutStore+Loop.swift */; }; + 43785E982120E7060057DED1 /* Intents.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = 43785E9B2120E7060057DED1 /* Intents.intentdefinition */; }; 437CEEE41CDE5C0A003C8C80 /* UIImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437CEEE31CDE5C0A003C8C80 /* UIImage.swift */; }; 437D9BA31D7BC977007245E8 /* PredictionTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437D9BA21D7BC977007245E8 /* PredictionTableViewController.swift */; }; - 43846AD51D8FA67800799272 /* Base.lproj in Resources */ = {isa = PBXBuildFile; fileRef = 43846AD41D8FA67800799272 /* Base.lproj */; }; - 43846AD91D8FA84B00799272 /* gallery.ckcomplication in Resources */ = {isa = PBXBuildFile; fileRef = 43846AD81D8FA84B00799272 /* gallery.ckcomplication */; }; - 43846ADB1D91057000799272 /* ContextUpdatable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43846ADA1D91057000799272 /* ContextUpdatable.swift */; }; - 43880F951D9CD54A009061A8 /* ChartPointsScatterDownTrianglesLayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43880F941D9CD54A009061A8 /* ChartPointsScatterDownTrianglesLayer.swift */; }; - 438849EA1D297CB6003B3F23 /* NightscoutService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 438849E91D297CB6003B3F23 /* NightscoutService.swift */; }; - 438849EC1D29EC34003B3F23 /* AmplitudeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 438849EB1D29EC34003B3F23 /* AmplitudeService.swift */; }; - 438849EE1D2A1EBB003B3F23 /* MLabService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 438849ED1D2A1EBB003B3F23 /* MLabService.swift */; }; - 438A95A81D8B9B24009D12E1 /* xDripG5.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 438A95A71D8B9B24009D12E1 /* xDripG5.framework */; }; + 4389916B1E91B689000EEF90 /* ChartSettings+Loop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4389916A1E91B689000EEF90 /* ChartSettings+Loop.swift */; }; 438D42F91D7C88BC003244B0 /* PredictionInputEffect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 438D42F81D7C88BC003244B0 /* PredictionInputEffect.swift */; }; 438D42FB1D7D11A4003244B0 /* PredictionInputEffectTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 438D42FA1D7D11A4003244B0 /* PredictionInputEffectTableViewCell.swift */; }; - 439897371CD2F80600223065 /* AnalyticsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439897361CD2F80600223065 /* AnalyticsManager.swift */; }; - 4398973B1CD2FC2000223065 /* NSDateFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4398973A1CD2FC2000223065 /* NSDateFormatter.swift */; }; + 4396BD50225159C0005AA4D3 /* HealthKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43D9002C21EB225D00AF44BF /* HealthKit.framework */; }; + 439706E622D2E84900C81566 /* PredictionSettingTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439706E522D2E84900C81566 /* PredictionSettingTableViewCell.swift */; }; + 439897371CD2F80600223065 /* AnalyticsServicesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439897361CD2F80600223065 /* AnalyticsServicesManager.swift */; }; + 439A7942211F631C0041B75F /* RootNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439A7941211F631C0041B75F /* RootNavigationController.swift */; }; + 439A7944211FE22F0041B75F /* NSUserActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439A7943211FE22F0041B75F /* NSUserActivity.swift */; }; + 439A7945211FE23A0041B75F /* NSUserActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439A7943211FE22F0041B75F /* NSUserActivity.swift */; }; + 439BED2A1E76093C00B0AED5 /* CGMManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439BED291E76093C00B0AED5 /* CGMManager.swift */; }; + 43A51E1F1EB6D62A000736CC /* CarbAbsorptionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A51E1E1EB6D62A000736CC /* CarbAbsorptionViewController.swift */; }; + 43A51E211EB6DBDD000736CC /* LoopChartsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A51E201EB6DBDD000736CC /* LoopChartsTableViewController.swift */; }; 43A567691C94880B00334FAC /* LoopDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A567681C94880B00334FAC /* LoopDataManager.swift */; }; - 43A5676B1C96155700334FAC /* SwitchTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A5676A1C96155700334FAC /* SwitchTableViewCell.swift */; }; 43A943761B926B7B0051FA24 /* Interface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 43A943741B926B7B0051FA24 /* Interface.storyboard */; }; - 43A943781B926B7B0051FA24 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 43A943771B926B7B0051FA24 /* Assets.xcassets */; }; 43A9437F1B926B7B0051FA24 /* WatchApp Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 43A9437E1B926B7B0051FA24 /* WatchApp Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 43A943881B926B7B0051FA24 /* ExtensionDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A943871B926B7B0051FA24 /* ExtensionDelegate.swift */; }; 43A9438A1B926B7B0051FA24 /* NotificationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A943891B926B7B0051FA24 /* NotificationController.swift */; }; 43A9438E1B926B7B0051FA24 /* ComplicationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A9438D1B926B7B0051FA24 /* ComplicationController.swift */; }; 43A943901B926B7B0051FA24 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 43A9438F1B926B7B0051FA24 /* Assets.xcassets */; }; 43A943941B926B7B0051FA24 /* WatchApp.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 43A943721B926B7B0051FA24 /* WatchApp.app */; }; - 43B371881CE597D10013C5A6 /* ShareClient.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43B371871CE597D10013C5A6 /* ShareClient.framework */; }; + 43B260491ED248FB008CAA77 /* CarbEntryTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B260481ED248FB008CAA77 /* CarbEntryTableViewCell.swift */; }; + 43BFF0B51E45C1E700FF19A9 /* NumberFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43BFF0B31E45C1BE00FF19A9 /* NumberFormatter.swift */; }; + 43BFF0B71E45C20C00FF19A9 /* NumberFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43BFF0B31E45C1BE00FF19A9 /* NumberFormatter.swift */; }; + 43BFF0C61E465A4400FF19A9 /* UIColor+HIG.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43BFF0C31E4659E700FF19A9 /* UIColor+HIG.swift */; }; + 43BFF0CD1E466C8400FF19A9 /* StateColorPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43BFF0CC1E466C8400FF19A9 /* StateColorPalette.swift */; }; + 43C05CA821EB2B26006FB252 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431E73471FF95A900069B5F7 /* PersistenceController.swift */; }; + 43C05CA921EB2B26006FB252 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431E73471FF95A900069B5F7 /* PersistenceController.swift */; }; + 43C05CAA21EB2B49006FB252 /* NSBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430DA58D1D4AEC230097D1CA /* NSBundle.swift */; }; + 43C05CAB21EB2B4A006FB252 /* NSBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430DA58D1D4AEC230097D1CA /* NSBundle.swift */; }; + 43C05CAC21EB2B8B006FB252 /* NSBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430DA58D1D4AEC230097D1CA /* NSBundle.swift */; }; + 43C05CAD21EB2BBF006FB252 /* NSUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430B29892041F54A00BA9F93 /* NSUserDefaults.swift */; }; + 43C05CAF21EB2C24006FB252 /* NSBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430DA58D1D4AEC230097D1CA /* NSBundle.swift */; }; + 43C05CB221EBD88A006FB252 /* LoopCore.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 43D9002A21EB209400AF44BF /* LoopCore.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 43C05CB821EBEA54006FB252 /* HKUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C05CB721EBEA54006FB252 /* HKUnit.swift */; }; + 43C05CB921EBEA54006FB252 /* HKUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C05CB721EBEA54006FB252 /* HKUnit.swift */; }; + 43C05CC521EC29E3006FB252 /* TextFieldTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4374B5F3209D89A900D17AA8 /* TextFieldTableViewCell.swift */; }; + 43C05CC721EC2ABC006FB252 /* IdentifiableClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 434FF1E91CF26C29000DB779 /* IdentifiableClass.swift */; }; + 43C05CC821EC2ABC006FB252 /* IdentifiableClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 434FF1E91CF26C29000DB779 /* IdentifiableClass.swift */; }; 43C0944A1CACCC73001F6403 /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C094491CACCC73001F6403 /* NotificationManager.swift */; }; - 43C246A81D89990F0031F8D1 /* Crypto.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43C246A71D89990F0031F8D1 /* Crypto.framework */; }; - 43C418B51CE0575200405B6A /* ShareGlucose+GlucoseKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C418B41CE0575200405B6A /* ShareGlucose+GlucoseKit.swift */; }; - 43C6407C1DA051850093E25D /* InsulinKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43C6407B1DA051850093E25D /* InsulinKit.framework */; }; - 43CA93371CB98079000026B5 /* MinimedKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43CA93361CB98079000026B5 /* MinimedKit.framework */; }; + 43C2FAE11EB656A500364AFF /* GlucoseEffectVelocity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C2FAE01EB656A500364AFF /* GlucoseEffectVelocity.swift */; }; + 43C513191E864C4E001547C7 /* GlucoseRangeSchedule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C513181E864C4E001547C7 /* GlucoseRangeSchedule.swift */; }; 43CB2B2B1D924D450079823D /* WCSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43CB2B2A1D924D450079823D /* WCSession.swift */; }; 43CE7CDE1CA8B63E003CC1B0 /* Data.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43CE7CDD1CA8B63E003CC1B0 /* Data.swift */; }; - 43DBF04C1C93B8D700B3C386 /* BolusViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DBF04B1C93B8D700B3C386 /* BolusViewController.swift */; }; + 43D381621EBD9759007F8C8F /* HeaderValuesTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43D381611EBD9759007F8C8F /* HeaderValuesTableViewCell.swift */; }; + 43D9001E21EB209400AF44BF /* LoopCore.h in Headers */ = {isa = PBXBuildFile; fileRef = 43D9FFD121EAE05D00AF44BF /* LoopCore.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 43D9002021EB209400AF44BF /* NSTimeInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439897341CD2F7DE00223065 /* NSTimeInterval.swift */; }; + 43D9002D21EB225D00AF44BF /* HealthKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43D9002C21EB225D00AF44BF /* HealthKit.framework */; }; + 43D9002F21EB234400AF44BF /* LoopCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43D9002A21EB209400AF44BF /* LoopCore.framework */; }; + 43D9FFD321EAE05D00AF44BF /* LoopCore.h in Headers */ = {isa = PBXBuildFile; fileRef = 43D9FFD121EAE05D00AF44BF /* LoopCore.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 43D9FFD621EAE05D00AF44BF /* LoopCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43D9FFCF21EAE05D00AF44BF /* LoopCore.framework */; }; + 43D9FFD721EAE05D00AF44BF /* LoopCore.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 43D9FFCF21EAE05D00AF44BF /* LoopCore.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 43D9FFFB21EAF3D300AF44BF /* NSTimeInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439897341CD2F7DE00223065 /* NSTimeInterval.swift */; }; 43DBF0531C93EC8200B3C386 /* DeviceDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DBF0521C93EC8200B3C386 /* DeviceDataManager.swift */; }; - 43DBF0591C93F73800B3C386 /* CarbEntryTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DBF0581C93F73800B3C386 /* CarbEntryTableViewController.swift */; }; - 43DE92591C5479E4001FFDE1 /* CarbEntryUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DE92581C5479E4001FFDE1 /* CarbEntryUserInfo.swift */; }; - 43DE925A1C5479E4001FFDE1 /* CarbEntryUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DE92581C5479E4001FFDE1 /* CarbEntryUserInfo.swift */; }; - 43DE92611C555C26001FFDE1 /* AbsorptionTimeType+CarbKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DE92601C555C26001FFDE1 /* AbsorptionTimeType+CarbKit.swift */; }; - 43E2D8C61D204678004DA55F /* KeychainManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43E2D8C51D204678004DA55F /* KeychainManager.swift */; }; - 43E2D8C81D208D5B004DA55F /* KeychainManager+Loop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43E2D8C71D208D5B004DA55F /* KeychainManager+Loop.swift */; }; - 43E2D8D41D20BF42004DA55F /* DoseMathTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43E2D8D31D20BF42004DA55F /* DoseMathTests.swift */; }; - 43E2D8DB1D20C03B004DA55F /* NSTimeInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439897341CD2F7DE00223065 /* NSTimeInterval.swift */; }; - 43E2D8DC1D20C049004DA55F /* DoseMath.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43F78D251C8FC000002152D1 /* DoseMath.swift */; }; - 43E2D8EC1D20C0DB004DA55F /* read_selected_basal_profile.json in Resources */ = {isa = PBXBuildFile; fileRef = 43E2D8E11D20C0DB004DA55F /* read_selected_basal_profile.json */; }; - 43E2D8ED1D20C0DB004DA55F /* recommend_temp_basal_correct_low_at_min.json in Resources */ = {isa = PBXBuildFile; fileRef = 43E2D8E21D20C0DB004DA55F /* recommend_temp_basal_correct_low_at_min.json */; }; - 43E2D8EE1D20C0DB004DA55F /* recommend_temp_basal_flat_and_high.json in Resources */ = {isa = PBXBuildFile; fileRef = 43E2D8E31D20C0DB004DA55F /* recommend_temp_basal_flat_and_high.json */; }; - 43E2D8EF1D20C0DB004DA55F /* recommend_temp_basal_high_and_falling.json in Resources */ = {isa = PBXBuildFile; fileRef = 43E2D8E41D20C0DB004DA55F /* recommend_temp_basal_high_and_falling.json */; }; - 43E2D8F01D20C0DB004DA55F /* recommend_temp_basal_high_and_rising.json in Resources */ = {isa = PBXBuildFile; fileRef = 43E2D8E51D20C0DB004DA55F /* recommend_temp_basal_high_and_rising.json */; }; - 43E2D8F11D20C0DB004DA55F /* recommend_temp_basal_in_range_and_rising.json in Resources */ = {isa = PBXBuildFile; fileRef = 43E2D8E61D20C0DB004DA55F /* recommend_temp_basal_in_range_and_rising.json */; }; - 43E2D8F21D20C0DB004DA55F /* recommend_temp_basal_no_change_glucose.json in Resources */ = {isa = PBXBuildFile; fileRef = 43E2D8E71D20C0DB004DA55F /* recommend_temp_basal_no_change_glucose.json */; }; - 43E2D8F31D20C0DB004DA55F /* recommend_temp_basal_start_high_end_in_range.json in Resources */ = {isa = PBXBuildFile; fileRef = 43E2D8E81D20C0DB004DA55F /* recommend_temp_basal_start_high_end_in_range.json */; }; - 43E2D8F41D20C0DB004DA55F /* recommend_temp_basal_start_high_end_low.json in Resources */ = {isa = PBXBuildFile; fileRef = 43E2D8E91D20C0DB004DA55F /* recommend_temp_basal_start_high_end_low.json */; }; - 43E2D8F51D20C0DB004DA55F /* recommend_temp_basal_start_low_end_high.json in Resources */ = {isa = PBXBuildFile; fileRef = 43E2D8EA1D20C0DB004DA55F /* recommend_temp_basal_start_low_end_high.json */; }; - 43E2D8F61D20C0DB004DA55F /* recommend_temp_basal_start_low_end_in_range.json in Resources */ = {isa = PBXBuildFile; fileRef = 43E2D8EB1D20C0DB004DA55F /* recommend_temp_basal_start_low_end_in_range.json */; }; - 43E2D9151D20C5A2004DA55F /* KeychainManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43E2D8C91D20B9E7004DA55F /* KeychainManagerTests.swift */; }; - 43E2D9171D2226BD004DA55F /* LoopKit.framework in CopyFiles */ = {isa = PBXBuildFile; fileRef = 43F78D4B1C914197002152D1 /* LoopKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 43E2D9191D222759004DA55F /* LoopKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43F78D4B1C914197002152D1 /* LoopKit.framework */; }; + 43DFB62320D4CAE7008A7BAE /* PumpManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C3B6F620BBCAA30026CAFA /* PumpManager.swift */; }; 43E3449F1B9D68E900C85C07 /* StatusTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43E3449E1B9D68E900C85C07 /* StatusTableViewController.swift */; }; - 43E344A41B9E1B1C00C85C07 /* NSUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43E344A31B9E1B1C00C85C07 /* NSUserDefaults.swift */; }; - 43E397A31D56B9E40028E321 /* Glucose.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43E397A21D56B9E40028E321 /* Glucose.swift */; }; - 43EB40861C82646A00472A8C /* StatusChartManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43EB40851C82646A00472A8C /* StatusChartManager.swift */; }; - 43F41C331D3A17AA00C11ED6 /* ChartAxisValueDoubleUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43F41C321D3A17AA00C11ED6 /* ChartAxisValueDoubleUnit.swift */; }; - 43F41C351D3B623800C11ED6 /* ChartPointsTouchHighlightLayerViewCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43F41C341D3B623800C11ED6 /* ChartPointsTouchHighlightLayerViewCache.swift */; }; + 43E93FB51E4675E800EAB8DB /* NumberFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43BFF0B31E45C1BE00FF19A9 /* NumberFormatter.swift */; }; + 43E93FB61E469A4000EAB8DB /* NumberFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43BFF0B31E45C1BE00FF19A9 /* NumberFormatter.swift */; }; + 43E93FB71E469A5100EAB8DB /* HKUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F526D5E1DF2459000A04910 /* HKUnit.swift */; }; 43F41C371D3BF32400C11ED6 /* UIAlertController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43F41C361D3BF32400C11ED6 /* UIAlertController.swift */; }; - 43F4EF1D1BA2A57600526CE1 /* DiagnosticLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43F4EF1C1BA2A57600526CE1 /* DiagnosticLogger.swift */; }; - 43F5173D1D713DB0000FA422 /* RadioSelectionTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43F5173C1D713DB0000FA422 /* RadioSelectionTableViewController.swift */; }; 43F5C2C91B929C09003EB13D /* HealthKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43F5C2C81B929C09003EB13D /* HealthKit.framework */; }; - 43F5C2DB1B92A5E1003EB13D /* SettingsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43F5C2DA1B92A5E1003EB13D /* SettingsTableViewController.swift */; }; 43F64DD91D9C92C900D24DC6 /* TitleSubtitleTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43F64DD81D9C92C900D24DC6 /* TitleSubtitleTableViewCell.swift */; }; - 43F78D261C8FC000002152D1 /* DoseMath.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43F78D251C8FC000002152D1 /* DoseMath.swift */; }; - 43F78D4C1C914197002152D1 /* CarbKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43F78D481C914197002152D1 /* CarbKit.framework */; }; - 43F78D4D1C914197002152D1 /* GlucoseKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43F78D491C914197002152D1 /* GlucoseKit.framework */; }; - 43F78D4F1C914197002152D1 /* LoopKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43F78D4B1C914197002152D1 /* LoopKit.framework */; }; - 4D3B40041D4A9E1A00BC6334 /* G4ShareSpy.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4D3B40021D4A9DFE00BC6334 /* G4ShareSpy.framework */; }; - 4D5B7A4B1D457CCA00796CA9 /* GlucoseG4.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D5B7A4A1D457CCA00796CA9 /* GlucoseG4.swift */; }; + 43F89CA322BDFBBD006BB54E /* UIActivityIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43F89CA222BDFBBC006BB54E /* UIActivityIndicatorView.swift */; }; + 43FCBBC21E51710B00343C1B /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 43776F9A1B8022E90074EA36 /* LaunchScreen.storyboard */; }; + 43FCEEA9221A615B0013DD30 /* StatusChartsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43FCEEA8221A615B0013DD30 /* StatusChartsManager.swift */; }; + 43FCEEAD221A66780013DD30 /* DateFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43FCEEAC221A66780013DD30 /* DateFormatter.swift */; }; + 43FCEEB1221A863E0013DD30 /* StatusChartsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43FCEEB0221A863E0013DD30 /* StatusChartsManager.swift */; }; + 4F08DE8F1E7BB871006741EA /* CollectionType+Loop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F08DE8E1E7BB871006741EA /* CollectionType+Loop.swift */; }; + 4F11D3C020DCBEEC006E072C /* GlucoseBackfillRequestUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F11D3BF20DCBEEC006E072C /* GlucoseBackfillRequestUserInfo.swift */; }; + 4F11D3C220DD80B3006E072C /* WatchHistoricalGlucose.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F11D3C120DD80B3006E072C /* WatchHistoricalGlucose.swift */; }; + 4F11D3C320DD84DB006E072C /* GlucoseBackfillRequestUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F11D3BF20DCBEEC006E072C /* GlucoseBackfillRequestUserInfo.swift */; }; + 4F11D3C420DD881A006E072C /* WatchHistoricalGlucose.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F11D3C120DD80B3006E072C /* WatchHistoricalGlucose.swift */; }; 4F2C15741E0209F500E160D4 /* NSTimeInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439897341CD2F7DE00223065 /* NSTimeInterval.swift */; }; - 4F2C15751E0209FA00E160D4 /* GlucoseTrend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43EA285E1D50ED3D001BC233 /* GlucoseTrend.swift */; }; 4F2C15811E0495B200E160D4 /* WatchContext+WatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F2C15801E0495B200E160D4 /* WatchContext+WatchApp.swift */; }; 4F2C15821E074FC600E160D4 /* NSTimeInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439897341CD2F7DE00223065 /* NSTimeInterval.swift */; }; 4F2C15831E0757E600E160D4 /* HKUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F526D5E1DF2459000A04910 /* HKUnit.swift */; }; 4F2C15851E075B8700E160D4 /* LoopUI.h in Headers */ = {isa = PBXBuildFile; fileRef = 4F75288D1DFE1DC600C322D6 /* LoopUI.h */; settings = {ATTRIBUTES = (Public, ); }; }; 4F2C15931E09BF2C00E160D4 /* HUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F2C15921E09BF2C00E160D4 /* HUDView.swift */; }; 4F2C15951E09BF3C00E160D4 /* HUDView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 4F2C15941E09BF3C00E160D4 /* HUDView.xib */; }; - 4F2C15971E09E94E00E160D4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4F2C15961E09E94E00E160D4 /* Assets.xcassets */; }; + 4F2C15971E09E94E00E160D4 /* HUDAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4F2C15961E09E94E00E160D4 /* HUDAssets.xcassets */; }; 4F2C159A1E0C9E5600E160D4 /* LoopUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 4F75288B1DFE1DC600C322D6 /* LoopUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 4F526D5D1DF0FD6500A04910 /* InsulinKit.framework in CopyFiles */ = {isa = PBXBuildFile; fileRef = 43C6407B1DA051850093E25D /* InsulinKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 4F526D611DF8D9A900A04910 /* NetBasal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F526D601DF8D9A900A04910 /* NetBasal.swift */; }; - 4F526D621DF9D95200A04910 /* NSBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430DA58D1D4AEC230097D1CA /* NSBundle.swift */; }; - 4F70C1DE1DE8DCA7006380B7 /* NotificationCenter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4F70C1DD1DE8DCA7006380B7 /* NotificationCenter.framework */; }; + 4F6663941E905FD2009E74FC /* ChartColorPalette+Loop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F6663931E905FD2009E74FC /* ChartColorPalette+Loop.swift */; }; 4F70C1E11DE8DCA7006380B7 /* StatusViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F70C1E01DE8DCA7006380B7 /* StatusViewController.swift */; }; 4F70C1E41DE8DCA7006380B7 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 4F70C1E21DE8DCA7006380B7 /* MainInterface.storyboard */; }; 4F70C1E81DE8DCA7006380B7 /* Loop Status Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 4F70C1DC1DE8DCA7006380B7 /* Loop Status Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - 4F70C1FC1DE8E5FB006380B7 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 43776F981B8022E90074EA36 /* Assets.xcassets */; }; - 4F70C2101DE8FAC5006380B7 /* StatusExtensionDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F70C20F1DE8FAC5006380B7 /* StatusExtensionDataManager.swift */; }; + 4F70C2101DE8FAC5006380B7 /* ExtensionDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F70C20F1DE8FAC5006380B7 /* ExtensionDataManager.swift */; }; 4F70C2121DE900EA006380B7 /* StatusExtensionContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F70C2111DE900EA006380B7 /* StatusExtensionContext.swift */; }; 4F70C2131DE90339006380B7 /* StatusExtensionContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F70C2111DE900EA006380B7 /* StatusExtensionContext.swift */; }; 4F7528941DFE1E9500C322D6 /* LoopUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4F75288B1DFE1DC600C322D6 /* LoopUI.framework */; }; - 4F7528951DFE1E9B00C322D6 /* LoopUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4F75288B1DFE1DC600C322D6 /* LoopUI.framework */; }; 4F75289A1DFE1F6000C322D6 /* BasalRateHUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437CEEBF1CD6FCD8003C8C80 /* BasalRateHUDView.swift */; }; - 4F75289B1DFE1F6000C322D6 /* BatteryLevelHUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437CEEC91CD84DB7003C8C80 /* BatteryLevelHUDView.swift */; }; 4F75289C1DFE1F6000C322D6 /* GlucoseHUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4337615E1D52F487004A3647 /* GlucoseHUDView.swift */; }; - 4F75289D1DFE1F6000C322D6 /* BaseHUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437CEEBB1CD6DE6A003C8C80 /* BaseHUDView.swift */; }; 4F75289E1DFE1F6000C322D6 /* LoopCompletionHUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437CEEBD1CD6E0CB003C8C80 /* LoopCompletionHUDView.swift */; }; - 4F75289F1DFE1F6000C322D6 /* ReservoirVolumeHUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437CEEC71CD84CBB003C8C80 /* ReservoirVolumeHUDView.swift */; }; 4F7528A01DFE1F9D00C322D6 /* LoopStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 438DADC71CDE8F8B007697A5 /* LoopStateView.swift */; }; 4F7528A11DFE200B00C322D6 /* BasalStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B371851CE583890013C5A6 /* BasalStateView.swift */; }; - 4F7528A21DFE200B00C322D6 /* LevelMaskView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43FBEDD71D73843700B21F22 /* LevelMaskView.swift */; }; - 4F7528A41DFE204900C322D6 /* UIColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DE92501C541832001FFDE1 /* UIColor.swift */; }; 4F7528A51DFE208C00C322D6 /* NSTimeInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439897341CD2F7DE00223065 /* NSTimeInterval.swift */; }; - 4F7528A71DFE20CE00C322D6 /* SensorDisplayable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43EA28611D517E42001BC233 /* SensorDisplayable.swift */; }; - 4F7528A81DFE20CE00C322D6 /* NSNumberFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436A0E7A1D7DE13400D6475D /* NSNumberFormatter.swift */; }; - 4F7528A91DFE212600C322D6 /* GlucoseTrend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43EA285E1D50ED3D001BC233 /* GlucoseTrend.swift */; }; 4F7528AA1DFE215100C322D6 /* HKUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F526D5E1DF2459000A04910 /* HKUnit.swift */; }; + 4F75F00220FCFE8C00B5570E /* GlucoseChartScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F75F00120FCFE8C00B5570E /* GlucoseChartScene.swift */; }; + 4F7E8AC520E2AB9600AEA65E /* Date.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7E8AC420E2AB9600AEA65E /* Date.swift */; }; + 4F7E8AC720E2AC0300AEA65E /* WatchPredictedGlucose.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7E8AC620E2AC0300AEA65E /* WatchPredictedGlucose.swift */; }; + 4F7E8ACB20E2ACB500AEA65E /* WatchPredictedGlucose.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7E8AC620E2AC0300AEA65E /* WatchPredictedGlucose.swift */; }; + 4F82655020E69F9A0031A8F5 /* HUDInterfaceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F82654F20E69F9A0031A8F5 /* HUDInterfaceController.swift */; }; + 4FAC02541E22F6B20087A773 /* NSTimeInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439897341CD2F7DE00223065 /* NSTimeInterval.swift */; }; 4FC8C8011DEB93E400A1452E /* NSUserDefaults+StatusExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC8C8001DEB93E400A1452E /* NSUserDefaults+StatusExtension.swift */; }; 4FC8C8021DEB943800A1452E /* NSUserDefaults+StatusExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FC8C8001DEB93E400A1452E /* NSUserDefaults+StatusExtension.swift */; }; + 4FDDD23720DC51DF00D04B16 /* LoopDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FDDD23620DC51DF00D04B16 /* LoopDataManager.swift */; }; 4FF4D0F81E1725B000846527 /* NibLoadable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 434F54561D287FDB002A9274 /* NibLoadable.swift */; }; - 4FF4D0F91E17268800846527 /* IdentifiableClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 434FF1E91CF26C29000DB779 /* IdentifiableClass.swift */; }; 4FF4D1001E18374700846527 /* WatchContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FF4D0FF1E18374700846527 /* WatchContext.swift */; }; 4FF4D1011E18375000846527 /* WatchContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FF4D0FF1E18374700846527 /* WatchContext.swift */; }; - C10428971D17BAD400DD539A /* NightscoutUploadKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C10428961D17BAD400DD539A /* NightscoutUploadKit.framework */; }; - C12F21A71DFA79CB00748193 /* recommend_tamp_basal_very_low_end_in_range.json in Resources */ = {isa = PBXBuildFile; fileRef = C12F21A61DFA79CB00748193 /* recommend_tamp_basal_very_low_end_in_range.json */; }; - C15713821DAC6983005BC4D2 /* MealBolusNightscoutTreatment.swift in Sources */ = {isa = PBXBuildFile; fileRef = C15713811DAC6983005BC4D2 /* MealBolusNightscoutTreatment.swift */; }; - C17884631D51A7A400405663 /* BatteryIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17884621D51A7A400405663 /* BatteryIndicator.swift */; }; - C18C8C511D5A351900E043FB /* NightscoutDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C18C8C501D5A351900E043FB /* NightscoutDataManager.swift */; }; - C1C73EF71DE3D0230022FC89 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = C1C73EF91DE3D0230022FC89 /* InfoPlist.strings */; }; - C1C73F021DE3D0250022FC89 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = C1C73F041DE3D0250022FC89 /* Localizable.strings */; }; - C1C73F081DE3D0260022FC89 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = C1C73F0A1DE3D0260022FC89 /* InfoPlist.strings */; }; - C1C73F0D1DE3D0270022FC89 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = C1C73F0F1DE3D0270022FC89 /* InfoPlist.strings */; }; + 7D23667D21250C7E0028B67D /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D23667C21250C7E0028B67D /* LocalizedString.swift */; }; + 7E69CFFC2A16A77E00203CBD /* ResetLoopManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E69CFFB2A16A77E00203CBD /* ResetLoopManager.swift */; }; + 84AA81D32A4A27A3000B658B /* LoopWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81D22A4A27A3000B658B /* LoopWidgets.swift */; }; + 84AA81D62A4A28AF000B658B /* WidgetBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81D52A4A28AF000B658B /* WidgetBackground.swift */; }; + 84AA81D82A4A2910000B658B /* StatusWidgetTimelimeEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81D72A4A2910000B658B /* StatusWidgetTimelimeEntry.swift */; }; + 84AA81DB2A4A2973000B658B /* Date.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81DA2A4A2973000B658B /* Date.swift */; }; + 84AA81DD2A4A2999000B658B /* StatusWidgetTimelineProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81DC2A4A2999000B658B /* StatusWidgetTimelineProvider.swift */; }; + 84AA81E32A4A36FB000B658B /* SystemActionLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81E22A4A36FB000B658B /* SystemActionLink.swift */; }; + 84AA81E52A4A3981000B658B /* DeeplinkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81E42A4A3981000B658B /* DeeplinkManager.swift */; }; + 84AA81E72A4A4DEF000B658B /* PumpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84AA81E62A4A4DEF000B658B /* PumpView.swift */; }; + 84D2879F2AC756C8007ED283 /* ContentMargin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84D2879E2AC756C8007ED283 /* ContentMargin.swift */; }; + 891B508524342BE1005DA578 /* CarbAndBolusFlowViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 891B508424342BE1005DA578 /* CarbAndBolusFlowViewModel.swift */; }; + 892A5D59222F0A27008961AB /* Debug.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892A5D58222F0A27008961AB /* Debug.swift */; }; + 892A5D692230C41D008961AB /* RangeReplaceableCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892A5D682230C41D008961AB /* RangeReplaceableCollection.swift */; }; + 892FB4CD22040104005293EC /* OverridePresetRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892FB4CC22040104005293EC /* OverridePresetRow.swift */; }; + 892FB4CF220402C0005293EC /* OverrideSelectionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892FB4CE220402C0005293EC /* OverrideSelectionController.swift */; }; + 894F6DD3243BCBDB00CCE676 /* Environment+SizeClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 894F6DD2243BCBDB00CCE676 /* Environment+SizeClass.swift */; }; + 894F6DD7243C047300CCE676 /* View+Position.swift in Sources */ = {isa = PBXBuildFile; fileRef = 894F6DD6243C047300CCE676 /* View+Position.swift */; }; + 894F6DD9243C060600CCE676 /* ScalablePositionedText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 894F6DD8243C060600CCE676 /* ScalablePositionedText.swift */; }; + 894F6DDB243C07CF00CCE676 /* GramLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 894F6DDA243C07CF00CCE676 /* GramLabel.swift */; }; + 894F6DDD243C0A2300CCE676 /* CarbAmountLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 894F6DDC243C0A2300CCE676 /* CarbAmountLabel.swift */; }; + 895788AD242E69A2002CB114 /* AbsorptionTimeSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 895788A5242E69A1002CB114 /* AbsorptionTimeSelection.swift */; }; + 895788AE242E69A2002CB114 /* CarbAndBolusFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 895788A6242E69A1002CB114 /* CarbAndBolusFlow.swift */; }; + 895788AF242E69A2002CB114 /* BolusInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 895788A7242E69A1002CB114 /* BolusInput.swift */; }; + 895788B1242E69A2002CB114 /* Color.swift in Sources */ = {isa = PBXBuildFile; fileRef = 895788A9242E69A1002CB114 /* Color.swift */; }; + 895788B2242E69A2002CB114 /* CircularAccessoryButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 895788AA242E69A1002CB114 /* CircularAccessoryButtonStyle.swift */; }; + 895788B3242E69A2002CB114 /* ActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 895788AB242E69A2002CB114 /* ActionButton.swift */; }; + 895FE0952201234000FCF18A /* OverrideSelectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 895FE0942201234000FCF18A /* OverrideSelectionViewController.swift */; }; + 8968B1122408B3520074BB48 /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8968B1112408B3520074BB48 /* UIFont.swift */; }; + 8968B114240C55F10074BB48 /* LoopSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8968B113240C55F10074BB48 /* LoopSettingsTests.swift */; }; + 897A5A9624C2175B00C4E71D /* BolusEntryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 897A5A9524C2175B00C4E71D /* BolusEntryView.swift */; }; + 897A5A9924C22DE800C4E71D /* BolusEntryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 897A5A9824C22DE800C4E71D /* BolusEntryViewModel.swift */; }; + 898ECA60218ABD17001E9D35 /* GlucoseChartScaler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 898ECA5E218ABD17001E9D35 /* GlucoseChartScaler.swift */; }; + 898ECA61218ABD17001E9D35 /* GlucoseChartData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 898ECA5F218ABD17001E9D35 /* GlucoseChartData.swift */; }; + 898ECA63218ABD21001E9D35 /* ComplicationChartManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 898ECA62218ABD21001E9D35 /* ComplicationChartManager.swift */; }; + 898ECA65218ABD9B001E9D35 /* CGRect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 898ECA64218ABD9A001E9D35 /* CGRect.swift */; }; + 898ECA69218ABDA9001E9D35 /* CLKTextProvider+Compound.m in Sources */ = {isa = PBXBuildFile; fileRef = 898ECA67218ABDA8001E9D35 /* CLKTextProvider+Compound.m */; }; + 899433B823FE129800FA4BEA /* OverrideBadgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 899433B723FE129700FA4BEA /* OverrideBadgeView.swift */; }; + 89A1B66E24ABFDF800117AC2 /* SupportedBolusVolumesUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89A1B66D24ABFDF800117AC2 /* SupportedBolusVolumesUserInfo.swift */; }; + 89A1B66F24ABFDF800117AC2 /* SupportedBolusVolumesUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89A1B66D24ABFDF800117AC2 /* SupportedBolusVolumesUserInfo.swift */; }; + 89A605E324327DFE009C1096 /* CarbAmountInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89A605E224327DFE009C1096 /* CarbAmountInput.swift */; }; + 89A605E524327F45009C1096 /* DoseVolumeInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89A605E424327F45009C1096 /* DoseVolumeInput.swift */; }; + 89A605E72432860C009C1096 /* PeriodicPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89A605E62432860C009C1096 /* PeriodicPublisher.swift */; }; + 89A605E924328862009C1096 /* Checkmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89A605E824328862009C1096 /* Checkmark.swift */; }; + 89A605EB243288E4009C1096 /* TopDownTriangle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89A605EA243288E4009C1096 /* TopDownTriangle.swift */; }; + 89A605ED24328972009C1096 /* BolusArrow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89A605EC24328972009C1096 /* BolusArrow.swift */; }; + 89A605EF2432925D009C1096 /* CompletionCheckmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89A605EE2432925D009C1096 /* CompletionCheckmark.swift */; }; + 89A605F12432BD18009C1096 /* BolusConfirmationVisual.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89A605F02432BD18009C1096 /* BolusConfirmationVisual.swift */; }; + 89ADE13B226BFA0F0067222B /* TestingScenariosManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89ADE13A226BFA0F0067222B /* TestingScenariosManager.swift */; }; + 89CA2B30226C0161004D9350 /* DirectoryObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89CA2B2F226C0161004D9350 /* DirectoryObserver.swift */; }; + 89CA2B32226C18B8004D9350 /* TestingScenariosTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89CA2B31226C18B8004D9350 /* TestingScenariosTableViewController.swift */; }; + 89CA2B3D226E6B13004D9350 /* LocalTestingScenariosManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89CA2B3C226E6B13004D9350 /* LocalTestingScenariosManager.swift */; }; + 89CAB36324C8FE96009EE3CE /* PredictedGlucoseChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89CAB36224C8FE95009EE3CE /* PredictedGlucoseChartView.swift */; }; + 89D1503E24B506EB00EDE253 /* Dictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89D1503D24B506EB00EDE253 /* Dictionary.swift */; }; + 89D6953E23B6DF8A002B3066 /* PotentialCarbEntryTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89D6953D23B6DF8A002B3066 /* PotentialCarbEntryTableViewCell.swift */; }; + 89E08FC2242E73DC000D719B /* CarbAmountPositionKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89E08FC1242E73DC000D719B /* CarbAmountPositionKey.swift */; }; + 89E08FC4242E73F0000D719B /* GramLabelPositionKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89E08FC3242E73F0000D719B /* GramLabelPositionKey.swift */; }; + 89E08FC6242E7506000D719B /* CarbAndDateInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89E08FC5242E7506000D719B /* CarbAndDateInput.swift */; }; + 89E08FC8242E76E9000D719B /* AnyTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89E08FC7242E76E9000D719B /* AnyTransition.swift */; }; + 89E08FCA242E7714000D719B /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89E08FC9242E7714000D719B /* UIFont.swift */; }; + 89E08FCC242E790C000D719B /* Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89E08FCB242E790C000D719B /* Comparable.swift */; }; + 89E08FD0242E8B2B000D719B /* BolusConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89E08FCF242E8B2B000D719B /* BolusConfirmationView.swift */; }; + 89E267FC2292456700A3F2AF /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89E267FB2292456700A3F2AF /* FeatureFlags.swift */; }; + 89E267FD2292456700A3F2AF /* FeatureFlags.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89E267FB2292456700A3F2AF /* FeatureFlags.swift */; }; + 89E267FF229267DF00A3F2AF /* Optional.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89E267FE229267DF00A3F2AF /* Optional.swift */; }; + 89E26800229267DF00A3F2AF /* Optional.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89E267FE229267DF00A3F2AF /* Optional.swift */; }; + 89F9118F24352F1600ECCAF3 /* DigitalCrownRotation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89F9118E24352F1600ECCAF3 /* DigitalCrownRotation.swift */; }; + 89F9119224358E2B00ECCAF3 /* CarbEntryInputMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89F9119124358E2B00ECCAF3 /* CarbEntryInputMode.swift */; }; + 89F9119424358E4500ECCAF3 /* CarbAbsorptionTime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89F9119324358E4500ECCAF3 /* CarbAbsorptionTime.swift */; }; + 89F9119624358E6900ECCAF3 /* BolusPickerValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89F9119524358E6900ECCAF3 /* BolusPickerValues.swift */; }; + 89FE21AD24AC57E30033F501 /* Collection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89FE21AC24AC57E30033F501 /* Collection.swift */; }; + A90EF53C25DEF06200F32D61 /* PluginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16DA84122E8E112008624C2 /* PluginManager.swift */; }; + A90EF54425DEF0A000F32D61 /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4374B5EE209D84BE00D17AA8 /* OSLog.swift */; }; + A91D2A3F26CF0FF80023B075 /* IconTitleSubtitleTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = A91D2A3E26CF0FF80023B075 /* IconTitleSubtitleTableViewCell.swift */; }; + A91E4C2124F867A700BE9213 /* StoredAlertTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A91E4C2024F867A700BE9213 /* StoredAlertTests.swift */; }; + A91E4C2324F86F1000BE9213 /* CriticalEventLogExportManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A91E4C2224F86F1000BE9213 /* CriticalEventLogExportManagerTests.swift */; }; + A9347F2F24E7508A00C99C34 /* WatchHistoricalCarbs.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9347F2E24E7508A00C99C34 /* WatchHistoricalCarbs.swift */; }; + A9347F3124E7521800C99C34 /* CarbBackfillRequestUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9347F3024E7521800C99C34 /* CarbBackfillRequestUserInfo.swift */; }; + A9347F3224E7522400C99C34 /* CarbBackfillRequestUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9347F3024E7521800C99C34 /* CarbBackfillRequestUserInfo.swift */; }; + A9347F3324E7522900C99C34 /* WatchHistoricalCarbs.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9347F2E24E7508A00C99C34 /* WatchHistoricalCarbs.swift */; }; + A963B27A252CEBAE0062AA12 /* SetBolusUserInfoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A963B279252CEBAE0062AA12 /* SetBolusUserInfoTests.swift */; }; + A966152623EA5A26005D8B29 /* DefaultAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A966152423EA5A25005D8B29 /* DefaultAssets.xcassets */; }; + A966152723EA5A26005D8B29 /* DerivedAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A966152523EA5A25005D8B29 /* DerivedAssets.xcassets */; }; + A966152A23EA5A37005D8B29 /* DefaultAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A966152823EA5A37005D8B29 /* DefaultAssets.xcassets */; }; + A966152B23EA5A37005D8B29 /* DerivedAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A966152923EA5A37005D8B29 /* DerivedAssets.xcassets */; }; + A967D94C24F99B9300CDDF8A /* OutputStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = A967D94B24F99B9300CDDF8A /* OutputStream.swift */; }; + A96DAC242838325900D94E38 /* DiagnosticLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = A96DAC232838325900D94E38 /* DiagnosticLog.swift */; }; + A96DAC2A2838EF8A00D94E38 /* DiagnosticLogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A96DAC292838EF8A00D94E38 /* DiagnosticLogTests.swift */; }; + A96DAC2C2838F31200D94E38 /* SharedLogging.swift in Sources */ = {isa = PBXBuildFile; fileRef = A96DAC2B2838F31200D94E38 /* SharedLogging.swift */; }; + A977A2F424ACFECF0059C207 /* CriticalEventLogExportManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A977A2F324ACFECF0059C207 /* CriticalEventLogExportManager.swift */; }; + A97F250825E056D500F0EE19 /* OnboardingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97F250725E056D500F0EE19 /* OnboardingManager.swift */; }; + A98556852493F901000FD662 /* AlertStore+SimulatedCoreData.swift in Sources */ = {isa = PBXBuildFile; fileRef = A98556842493F901000FD662 /* AlertStore+SimulatedCoreData.swift */; }; + A987CD4924A58A0100439ADC /* ZipArchive.swift in Sources */ = {isa = PBXBuildFile; fileRef = A987CD4824A58A0100439ADC /* ZipArchive.swift */; }; + A999D40624663D18004C89D4 /* PumpManagerError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A999D40524663D18004C89D4 /* PumpManagerError.swift */; }; + A9A056B324B93C62007CF06D /* CriticalEventLogExportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9A056B224B93C62007CF06D /* CriticalEventLogExportView.swift */; }; + A9A056B524B94123007CF06D /* CriticalEventLogExportViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9A056B424B94123007CF06D /* CriticalEventLogExportViewModel.swift */; }; + A9A63F8E246B271600588D5B /* NSTimeInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439897341CD2F7DE00223065 /* NSTimeInterval.swift */; }; + A9B607B0247F000F00792BE4 /* UserNotifications+Loop.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B607AF247F000F00792BE4 /* UserNotifications+Loop.swift */; }; + A9B996F027235191002DC09C /* LoopWarning.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B996EF27235191002DC09C /* LoopWarning.swift */; }; + A9B996F227238705002DC09C /* DosingDecisionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B996F127238705002DC09C /* DosingDecisionStore.swift */; }; + A9BD28E7272226B40071DF15 /* TestLocalizedError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BD28E6272226B40071DF15 /* TestLocalizedError.swift */; }; + A9C1719725366F780053BCBD /* WatchHistoricalGlucoseTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9C1719625366F780053BCBD /* WatchHistoricalGlucoseTest.swift */; }; + A9C62D842331700E00535612 /* DiagnosticLog+Subsystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9C62D832331700D00535612 /* DiagnosticLog+Subsystem.swift */; }; + A9C62D882331703100535612 /* Service.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9C62D852331703000535612 /* Service.swift */; }; + A9C62D892331703100535612 /* LoggingServicesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9C62D862331703000535612 /* LoggingServicesManager.swift */; }; + A9C62D8A2331703100535612 /* ServicesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9C62D872331703000535612 /* ServicesManager.swift */; }; + A9C62D8E2331708700535612 /* AuthenticationTableViewCell+NibLoadable.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9C62D8D2331708700535612 /* AuthenticationTableViewCell+NibLoadable.swift */; }; + A9CBE458248AB564008E7BA2 /* DoseStore+SimulatedCoreData.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9CBE457248AB564008E7BA2 /* DoseStore+SimulatedCoreData.swift */; }; + A9CBE45A248ACBE1008E7BA2 /* DosingDecisionStore+SimulatedCoreData.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9CBE459248ACBE1008E7BA2 /* DosingDecisionStore+SimulatedCoreData.swift */; }; + A9CBE45C248ACC03008E7BA2 /* SettingsStore+SimulatedCoreData.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9CBE45B248ACC03008E7BA2 /* SettingsStore+SimulatedCoreData.swift */; }; + A9CE912224CA032E00302A40 /* NSUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430B29892041F54A00BA9F93 /* NSUserDefaults.swift */; }; + A9D5C5B625DC6C6A00534873 /* LoopAppManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9D5C5B525DC6C6A00534873 /* LoopAppManager.swift */; }; + A9DAE7D02332D77F006AE942 /* LoopTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9DAE7CF2332D77F006AE942 /* LoopTests.swift */; }; + A9DCF32A25B0FABF00C89088 /* LoopUIColorPalette+Default.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9DCF2D525B0F3C500C89088 /* LoopUIColorPalette+Default.swift */; }; + A9DF02CB24F72B9E00B7C988 /* CriticalEventLogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9DF02CA24F72B9E00B7C988 /* CriticalEventLogTests.swift */; }; + A9DFAFB324F0415E00950D1E /* CarbBackfillRequestUserInfoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9DFAFB224F0415E00950D1E /* CarbBackfillRequestUserInfoTests.swift */; }; + A9DFAFB524F048A000950D1E /* WatchHistoricalCarbsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9DFAFB424F048A000950D1E /* WatchHistoricalCarbsTests.swift */; }; + A9F5F1F5251050EC00E7C8A4 /* ZipArchiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F5F1F4251050EC00E7C8A4 /* ZipArchiveTests.swift */; }; + A9F66FC3247F451500096EA7 /* UIDevice+Loop.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F66FC2247F451500096EA7 /* UIDevice+Loop.swift */; }; + A9F703732489BC8500C98AD8 /* CarbStore+SimulatedCoreData.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F703722489BC8500C98AD8 /* CarbStore+SimulatedCoreData.swift */; }; + A9F703752489C9A000C98AD8 /* GlucoseStore+SimulatedCoreData.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F703742489C9A000C98AD8 /* GlucoseStore+SimulatedCoreData.swift */; }; + A9F703772489D8AA00C98AD8 /* PersistentDeviceLog+SimulatedCoreData.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F703762489D8AA00C98AD8 /* PersistentDeviceLog+SimulatedCoreData.swift */; }; + A9FB75F1252BE320004C7D3F /* BolusDosingDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9FB75F0252BE320004C7D3F /* BolusDosingDecision.swift */; }; + B4001CEE28CBBC82002FB414 /* AlertManagementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4001CED28CBBC82002FB414 /* AlertManagementView.swift */; }; + B405E35924D2A75B00DD058D /* DerivedAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A966152923EA5A37005D8B29 /* DerivedAssets.xcassets */; }; + B405E35A24D2B1A400DD058D /* HUDAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4F2C15961E09E94E00E160D4 /* HUDAssets.xcassets */; }; + B405E35B24D2E05600DD058D /* HUDAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4F2C15961E09E94E00E160D4 /* HUDAssets.xcassets */; }; + B40D07C7251A89D500C1C6D7 /* GlucoseDisplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = B40D07C6251A89D500C1C6D7 /* GlucoseDisplay.swift */; }; + B42C951424A3C76000857C73 /* CGMStatusHUDViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B42C951324A3C76000857C73 /* CGMStatusHUDViewModel.swift */; }; + B42D124328D371C400E43D22 /* AlertMuter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B42D124228D371C400E43D22 /* AlertMuter.swift */; }; + B43CF07E29434EC4008A520B /* HowMuteAlertWorkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B43CF07D29434EC4008A520B /* HowMuteAlertWorkView.swift */; }; + B43DA44124D9C12100CAFF4E /* DismissibleHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B43DA44024D9C12100CAFF4E /* DismissibleHostingController.swift */; }; + B470F5842AB22B5100049695 /* StatefulPluggable.swift in Sources */ = {isa = PBXBuildFile; fileRef = B470F5832AB22B5100049695 /* StatefulPluggable.swift */; }; + B48B0BAC24900093009A48DE /* PumpStatusHUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B48B0BAB24900093009A48DE /* PumpStatusHUDView.swift */; }; + B490A03F24D0550F00F509FA /* GlucoseRangeCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = B490A03E24D0550F00F509FA /* GlucoseRangeCategory.swift */; }; + B490A04124D0559D00F509FA /* DeviceLifecycleProgressState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B490A04024D0559D00F509FA /* DeviceLifecycleProgressState.swift */; }; + B490A04324D055D900F509FA /* DeviceStatusHighlight.swift in Sources */ = {isa = PBXBuildFile; fileRef = B490A04224D055D900F509FA /* DeviceStatusHighlight.swift */; }; + B491B09E24D0B600004CBE8F /* DerivedAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A966152523EA5A25005D8B29 /* DerivedAssets.xcassets */; }; + B491B0A324D0B66D004CBE8F /* Color.swift in Sources */ = {isa = PBXBuildFile; fileRef = B490A03C24D04F9400F509FA /* Color.swift */; }; + B491B0A424D0B675004CBE8F /* UIColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43BFF0B11E45C18400FF19A9 /* UIColor.swift */; }; + B4AC0D3F24B9005300CDB0A1 /* UIImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437CEEE31CDE5C0A003C8C80 /* UIImage.swift */; }; + B4BC56382518DEA900373647 /* CGMStatusHUDViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4BC56372518DEA900373647 /* CGMStatusHUDViewModelTests.swift */; }; + B4C9859425D5A3BB009FD9CA /* StatusBadgeHUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4C9859325D5A3BB009FD9CA /* StatusBadgeHUDView.swift */; }; + B4CAD8792549D2540057946B /* LoopCompletionFreshnessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4CAD8782549D2540057946B /* LoopCompletionFreshnessTests.swift */; }; + B4D4534128E5CA7900F1A8D9 /* AlertMuterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4D4534028E5CA7900F1A8D9 /* AlertMuterTests.swift */; }; + B4D620D424D9EDB900043B3C /* GuidanceColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4D620D324D9EDB900043B3C /* GuidanceColors.swift */; }; + B4D904412AA8989100CBD826 /* StatefulPluginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4D904402AA8989100CBD826 /* StatefulPluginManager.swift */; }; + B4E202302661063E009421B5 /* AutomaticDosingStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4E2022F2661063E009421B5 /* AutomaticDosingStatus.swift */; }; + B4E96D4B248A6B6E002DABAD /* DeviceStatusHUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4E96D4A248A6B6E002DABAD /* DeviceStatusHUDView.swift */; }; + B4E96D4F248A6E20002DABAD /* CGMStatusHUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4E96D4E248A6E20002DABAD /* CGMStatusHUDView.swift */; }; + B4E96D53248A7386002DABAD /* GlucoseValueHUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4E96D52248A7386002DABAD /* GlucoseValueHUDView.swift */; }; + B4E96D55248A7509002DABAD /* GlucoseTrendHUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4E96D54248A7509002DABAD /* GlucoseTrendHUDView.swift */; }; + B4E96D57248A7B0F002DABAD /* StatusHighlightHUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4E96D56248A7B0F002DABAD /* StatusHighlightHUDView.swift */; }; + B4E96D59248A7F9A002DABAD /* StatusHighlightHUDView.xib in Resources */ = {isa = PBXBuildFile; fileRef = B4E96D58248A7F9A002DABAD /* StatusHighlightHUDView.xib */; }; + B4E96D5B248A8229002DABAD /* StatusBarHUDView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4E96D5A248A8229002DABAD /* StatusBarHUDView.swift */; }; + B4E96D5D248A82A2002DABAD /* StatusBarHUDView.xib in Resources */ = {isa = PBXBuildFile; fileRef = B4E96D5C248A82A2002DABAD /* StatusBarHUDView.xib */; }; + B4F3D25124AF890C0095CE44 /* BluetoothStateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4F3D25024AF890C0095CE44 /* BluetoothStateManager.swift */; }; + B4FEEF7D24B8A71F00A8DF9B /* DeviceDataManager+DeviceStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4FEEF7C24B8A71F00A8DF9B /* DeviceDataManager+DeviceStatus.swift */; }; + B66D1F212E6A5D6500471149 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B66D1F202E6A5D6500471149 /* InfoPlist.xcstrings */; }; + B66D1F232E6A5D6500471149 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B66D1F222E6A5D6500471149 /* InfoPlist.xcstrings */; }; + B66D1F252E6A5D6500471149 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B66D1F242E6A5D6500471149 /* InfoPlist.xcstrings */; }; + B66D1F272E6A5D6500471149 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B66D1F262E6A5D6500471149 /* Localizable.xcstrings */; }; + B66D1F2B2E6A5D6500471149 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B66D1F2A2E6A5D6500471149 /* Localizable.xcstrings */; }; + B66D1F2D2E6A5D6600471149 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B66D1F2C2E6A5D6500471149 /* Localizable.xcstrings */; }; + B66D1F2F2E6A5D6600471149 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B66D1F2E2E6A5D6600471149 /* InfoPlist.xcstrings */; }; + B66D1F312E6A5D6600471149 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B66D1F302E6A5D6600471149 /* InfoPlist.xcstrings */; }; + B66D1F332E6A5D6600471149 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B66D1F322E6A5D6600471149 /* Localizable.xcstrings */; }; + B66D1F352E6A5D6600471149 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B66D1F342E6A5D6600471149 /* Localizable.xcstrings */; }; + B66D1F372E6A5D6600471149 /* ckcomplication.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B66D1F362E6A5D6600471149 /* ckcomplication.xcstrings */; }; + B66D1F392E6A5D6600471149 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B66D1F382E6A5D6600471149 /* InfoPlist.xcstrings */; }; + B66D1F3B2E6A5D6600471149 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B66D1F3A2E6A5D6600471149 /* Localizable.xcstrings */; }; + B66D1F3C2E6A5D6600471149 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B66D1F3A2E6A5D6600471149 /* Localizable.xcstrings */; }; + B66D1F3E2E6A5D6600471149 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B66D1F3D2E6A5D6600471149 /* Localizable.xcstrings */; }; + B66D1F402E6A5D6600471149 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B66D1F3F2E6A5D6600471149 /* InfoPlist.xcstrings */; }; + C110888D2A3913C600BA4898 /* BuildDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = C110888C2A3913C600BA4898 /* BuildDetails.swift */; }; + C11B9D5B286778A800500CF8 /* SwiftCharts in Frameworks */ = {isa = PBXBuildFile; productRef = C11B9D5A286778A800500CF8 /* SwiftCharts */; }; + C11B9D5E286778D000500CF8 /* LoopKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C11B9D5D286778D000500CF8 /* LoopKitUI.framework */; }; + C11B9D62286779C000500CF8 /* MockKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C11B9D60286779C000500CF8 /* MockKit.framework */; }; + C11B9D63286779C000500CF8 /* MockKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = C11B9D60286779C000500CF8 /* MockKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + C11B9D64286779C000500CF8 /* MockKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C11B9D61286779C000500CF8 /* MockKitUI.framework */; }; + C11B9D65286779C000500CF8 /* MockKitUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = C11B9D61286779C000500CF8 /* MockKitUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + C11BD0552523CFED00236B08 /* SimpleBolusViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11BD0542523CFED00236B08 /* SimpleBolusViewModel.swift */; }; + C1201E2C23ECDBD0002DA84A /* WatchContextRequestUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1201E2B23ECDBD0002DA84A /* WatchContextRequestUserInfo.swift */; }; + C1201E2D23ECDF3D002DA84A /* WatchContextRequestUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1201E2B23ECDBD0002DA84A /* WatchContextRequestUserInfo.swift */; }; + C13072BA2A76AF31009A7C58 /* live_capture_predicted_glucose.json in Resources */ = {isa = PBXBuildFile; fileRef = C13072B92A76AF31009A7C58 /* live_capture_predicted_glucose.json */; }; + C13255D6223E7BE2008AF50C /* BolusProgressTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = C1F8B1DB223862D500DD66CF /* BolusProgressTableViewCell.xib */; }; + C13DA2B024F6C7690098BB29 /* UIViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13DA2AF24F6C7690098BB29 /* UIViewController.swift */; }; + C148CEE724FD91BD00711B3B /* DeliveryUncertaintyAlertManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C148CEE624FD91BD00711B3B /* DeliveryUncertaintyAlertManager.swift */; }; + C159C825286785E000A86EC0 /* LoopUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4F75288B1DFE1DC600C322D6 /* LoopUI.framework */; }; + C159C828286785E100A86EC0 /* LoopKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C159C8192867857000A86EC0 /* LoopKitUI.framework */; }; + C159C82A286785E300A86EC0 /* MockKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C159C8212867859800A86EC0 /* MockKitUI.framework */; }; + C159C82D2867876500A86EC0 /* NotificationCenter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4F70C1DD1DE8DCA7006380B7 /* NotificationCenter.framework */; }; + C159C82F286787EF00A86EC0 /* LoopKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C159C82E286787EF00A86EC0 /* LoopKit.framework */; }; + C15A8C492A7305B1009D736B /* SwiftCharts in Frameworks */ = {isa = PBXBuildFile; productRef = C1E3DC4628595FAA00CA19FF /* SwiftCharts */; }; + C165756F2534C468004AE16E /* SimpleBolusViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C165756E2534C468004AE16E /* SimpleBolusViewModelTests.swift */; }; + C16575712538A36B004AE16E /* CGMStalenessMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16575702538A36B004AE16E /* CGMStalenessMonitor.swift */; }; + C16575732538AFF6004AE16E /* CGMStalenessMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16575722538AFF6004AE16E /* CGMStalenessMonitorTests.swift */; }; + C16575752539FD60004AE16E /* LoopCoreConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16575742539FD60004AE16E /* LoopCoreConstants.swift */; }; + C16575762539FEF3004AE16E /* LoopCoreConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16575742539FD60004AE16E /* LoopCoreConstants.swift */; }; + C16B983E26B4893300256B05 /* DoseEnactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16B983D26B4893300256B05 /* DoseEnactor.swift */; }; + C16B984026B4898800256B05 /* DoseEnactorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16B983F26B4898800256B05 /* DoseEnactorTests.swift */; }; + C16DA84222E8E112008624C2 /* PluginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16DA84122E8E112008624C2 /* PluginManager.swift */; }; + C16FC0B02A99392F0025E239 /* live_capture_input.json in Resources */ = {isa = PBXBuildFile; fileRef = C16FC0AF2A99392F0025E239 /* live_capture_input.json */; }; + C1735B1E2A0809830082BB8A /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = C1735B1D2A0809830082BB8A /* ZIPFoundation */; }; + C1742332259BEADC00399C9D /* ManualEntryDoseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1742331259BEADC00399C9D /* ManualEntryDoseView.swift */; }; + C174233C259BEB0F00399C9D /* ManualEntryDoseViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C174233B259BEB0F00399C9D /* ManualEntryDoseViewModel.swift */; }; + C1777A6625A125F100595963 /* ManualEntryDoseViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1777A6525A125F100595963 /* ManualEntryDoseViewModelTests.swift */; }; + C178249A1E1999FA00D9D25C /* CaseCountable.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17824991E1999FA00D9D25C /* CaseCountable.swift */; }; + C17824A01E19CF9800D9D25C /* GlucoseThresholdTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C178249F1E19CF9800D9D25C /* GlucoseThresholdTableViewController.swift */; }; + C17824A51E1AD4D100D9D25C /* ManualBolusRecommendation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17824A41E1AD4D100D9D25C /* ManualBolusRecommendation.swift */; }; + C17DDC9C28AC339E005FBF4C /* PersistedProperty.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DA986B2843B6F9001D04CC /* PersistedProperty.swift */; }; + C17DDC9D28AC33A1005FBF4C /* PersistedProperty.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DA986B2843B6F9001D04CC /* PersistedProperty.swift */; }; + C18913B52524F24C007B0683 /* DeviceDataManager+SimpleBolusViewModelDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C18913B42524F24C007B0683 /* DeviceDataManager+SimpleBolusViewModelDelegate.swift */; }; + C19008FE25225D3900721625 /* SimpleBolusCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C19008FD25225D3900721625 /* SimpleBolusCalculator.swift */; }; + C1900900252271BB00721625 /* SimpleBolusCalculatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C19008FF252271BB00721625 /* SimpleBolusCalculatorTests.swift */; }; + C191D2A125B3ACAA00C26C0B /* DosingStrategySelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C191D2A025B3ACAA00C26C0B /* DosingStrategySelectionView.swift */; }; + C19C8BBA28651DFB0056D5E4 /* TrueTime.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C19C8BB928651DFB0056D5E4 /* TrueTime.framework */; }; + C19C8BBB28651DFB0056D5E4 /* TrueTime.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = C19C8BB928651DFB0056D5E4 /* TrueTime.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + C19C8BBE28651E3D0056D5E4 /* LoopKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43F78D4B1C914197002152D1 /* LoopKit.framework */; }; + C19C8BBF28651E3D0056D5E4 /* LoopKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 43F78D4B1C914197002152D1 /* LoopKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + C19C8BC328651EAE0056D5E4 /* LoopTestingKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C19C8BC228651EAE0056D5E4 /* LoopTestingKit.framework */; }; + C19C8BC428651EAE0056D5E4 /* LoopTestingKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = C19C8BC228651EAE0056D5E4 /* LoopTestingKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + C19C8BCE28651F520056D5E4 /* LoopKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 437AFEE6203688CF008C4892 /* LoopKitUI.framework */; }; + C19C8BCF28651F520056D5E4 /* LoopKitUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 437AFEE6203688CF008C4892 /* LoopKitUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + C19C8C1E28663B040056D5E4 /* LoopKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4344628320A7A3BE00C4BE6F /* LoopKit.framework */; }; + C19C8C1F28663B040056D5E4 /* LoopKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 4344628320A7A3BE00C4BE6F /* LoopKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + C19C8C21286776C20056D5E4 /* LoopKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C19C8C20286776C20056D5E4 /* LoopKit.framework */; }; + C19E96DF23D275F8003F79B0 /* LoopCompletionFreshness.swift in Sources */ = {isa = PBXBuildFile; fileRef = C19E96DD23D2733F003F79B0 /* LoopCompletionFreshness.swift */; }; + C19E96E023D275FA003F79B0 /* LoopCompletionFreshness.swift in Sources */ = {isa = PBXBuildFile; fileRef = C19E96DD23D2733F003F79B0 /* LoopCompletionFreshness.swift */; }; + C19F48742560ABFB003632D7 /* NSBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430DA58D1D4AEC230097D1CA /* NSBundle.swift */; }; + C1AD4200256D61E500164DDD /* Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1AD41FF256D61E500164DDD /* Comparable.swift */; }; + C1AF062329426300002C1B19 /* ManualGlucoseEntryRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1AF062229426300002C1B19 /* ManualGlucoseEntryRow.swift */; }; + C1C660D1252E4DD5009B5C32 /* LoopConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1C660D0252E4DD5009B5C32 /* LoopConstants.swift */; }; + C1CCF1122858FA900035389C /* LoopCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43D9FFCF21EAE05D00AF44BF /* LoopCore.framework */; }; + C1CCF1172858FBAD0035389C /* SwiftCharts in Frameworks */ = {isa = PBXBuildFile; productRef = C1CCF1162858FBAD0035389C /* SwiftCharts */; }; + C1D0B6302986D4D90098D215 /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D0B62F2986D4D90098D215 /* LocalizedString.swift */; }; + C1D0B6312986D4D90098D215 /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D0B62F2986D4D90098D215 /* LocalizedString.swift */; }; + C1D289B522F90A52003FFBD9 /* BasalDeliveryState.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D289B422F90A52003FFBD9 /* BasalDeliveryState.swift */; }; + C1D476B42A8ED179002C1C87 /* LoopAlgorithmTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D476B32A8ED179002C1C87 /* LoopAlgorithmTests.swift */; }; + C1D6EEA02A06C7270047DE5C /* MKRingProgressView in Frameworks */ = {isa = PBXBuildFile; productRef = C1D6EE9F2A06C7270047DE5C /* MKRingProgressView */; }; + C1DE5D23251BFC4D00439E49 /* SimpleBolusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DE5D22251BFC4D00439E49 /* SimpleBolusView.swift */; }; + C1E2773E224177C000354103 /* ClockKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C1E2773D224177C000354103 /* ClockKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; + C1E3862628247C6100F561A4 /* StoredLoopNotRunningNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1E3862428247B7100F561A4 /* StoredLoopNotRunningNotification.swift */; }; + C1E3DC4928595FAA00CA19FF /* SwiftCharts in Embed Frameworks */ = {isa = PBXBuildFile; productRef = C1E3DC4628595FAA00CA19FF /* SwiftCharts */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + C1EE9E812A38D0FB0064784A /* BuildDetails.plist in Resources */ = {isa = PBXBuildFile; fileRef = C1EE9E802A38D0FB0064784A /* BuildDetails.plist */; }; + C1EF747228D6A44A00C8C083 /* CrashRecoveryManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1EF747128D6A44A00C8C083 /* CrashRecoveryManager.swift */; }; + C1F00C60285A802A006302C5 /* SwiftCharts in Frameworks */ = {isa = PBXBuildFile; productRef = C1F00C5F285A802A006302C5 /* SwiftCharts */; }; + C1F00C78285A8256006302C5 /* SwiftCharts in Embed Frameworks */ = {isa = PBXBuildFile; productRef = C1F00C5F285A802A006302C5 /* SwiftCharts */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + C1F2075C26D6F9B0007AB7EB /* AppExpirationAlerter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F2075B26D6F9B0007AB7EB /* AppExpirationAlerter.swift */; }; + C1F7822627CC056900C0919A /* SettingsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F7822527CC056900C0919A /* SettingsManager.swift */; }; + C1F8B243223E73FD00DD66CF /* BolusProgressTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F8B1D122375E4200DD66CF /* BolusProgressTableViewCell.swift */; }; + C1FB428C217806A400FAB378 /* StateColorPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1FB428B217806A300FAB378 /* StateColorPalette.swift */; }; + C1FB428D21791D2500FAB378 /* PumpManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C3B6F620BBCAA30026CAFA /* PumpManager.swift */; }; + C1FB428F217921D600FAB378 /* PumpManagerUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1FB428E217921D600FAB378 /* PumpManagerUI.swift */; }; + C1FB4290217922A100FAB378 /* PumpManagerUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1FB428E217921D600FAB378 /* PumpManagerUI.swift */; }; + DD3DBD292A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD3DBD282A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift */; }; + DDC389F62A2B61750066E2E8 /* ApplicationFactorStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC389F52A2B61750066E2E8 /* ApplicationFactorStrategy.swift */; }; + DDC389F82A2B620B0066E2E8 /* GlucoseBasedApplicationFactorStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC389F72A2B620B0066E2E8 /* GlucoseBasedApplicationFactorStrategy.swift */; }; + DDC389FA2A2B62470066E2E8 /* ConstantApplicationFactorStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC389F92A2B62470066E2E8 /* ConstantApplicationFactorStrategy.swift */; }; + DDC389FC2A2BC6670066E2E8 /* SettingsView+algorithmExperimentsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC389FB2A2BC6670066E2E8 /* SettingsView+algorithmExperimentsSection.swift */; }; + DDC389FE2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC389FD2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift */; }; + E90909D124E34AC500F963D2 /* high_and_rising_with_cob_momentum_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909CC24E34AC500F963D2 /* high_and_rising_with_cob_momentum_effect.json */; }; + E90909D224E34AC500F963D2 /* high_and_rising_with_cob_insulin_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909CD24E34AC500F963D2 /* high_and_rising_with_cob_insulin_effect.json */; }; + E90909D324E34AC500F963D2 /* high_and_rising_with_cob_predicted_glucose.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909CE24E34AC500F963D2 /* high_and_rising_with_cob_predicted_glucose.json */; }; + E90909D424E34AC500F963D2 /* high_and_rising_with_cob_carb_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909CF24E34AC500F963D2 /* high_and_rising_with_cob_carb_effect.json */; }; + E90909D524E34AC500F963D2 /* high_and_rising_with_cob_counteraction_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909D024E34AC500F963D2 /* high_and_rising_with_cob_counteraction_effect.json */; }; + E90909DC24E34F1600F963D2 /* low_and_falling_predicted_glucose.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909D724E34F1500F963D2 /* low_and_falling_predicted_glucose.json */; }; + E90909DD24E34F1600F963D2 /* low_and_falling_carb_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909D824E34F1500F963D2 /* low_and_falling_carb_effect.json */; }; + E90909DE24E34F1600F963D2 /* low_and_falling_counteraction_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909D924E34F1500F963D2 /* low_and_falling_counteraction_effect.json */; }; + E90909DF24E34F1600F963D2 /* low_and_falling_insulin_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909DA24E34F1600F963D2 /* low_and_falling_insulin_effect.json */; }; + E90909E024E34F1600F963D2 /* low_and_falling_momentum_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909DB24E34F1600F963D2 /* low_and_falling_momentum_effect.json */; }; + E90909E724E3530200F963D2 /* low_with_low_treatment_carb_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909E224E3530200F963D2 /* low_with_low_treatment_carb_effect.json */; }; + E90909E824E3530200F963D2 /* low_with_low_treatment_insulin_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909E324E3530200F963D2 /* low_with_low_treatment_insulin_effect.json */; }; + E90909E924E3530200F963D2 /* low_with_low_treatment_predicted_glucose.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909E424E3530200F963D2 /* low_with_low_treatment_predicted_glucose.json */; }; + E90909EA24E3530200F963D2 /* low_with_low_treatment_momentum_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909E524E3530200F963D2 /* low_with_low_treatment_momentum_effect.json */; }; + E90909EB24E3530200F963D2 /* low_with_low_treatment_counteraction_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909E624E3530200F963D2 /* low_with_low_treatment_counteraction_effect.json */; }; + E90909EE24E35B4000F963D2 /* high_and_falling_predicted_glucose.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909ED24E35B4000F963D2 /* high_and_falling_predicted_glucose.json */; }; + E90909F224E35B4D00F963D2 /* high_and_falling_counteraction_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909EF24E35B4C00F963D2 /* high_and_falling_counteraction_effect.json */; }; + E90909F324E35B4D00F963D2 /* high_and_falling_carb_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909F024E35B4C00F963D2 /* high_and_falling_carb_effect.json */; }; + E90909F424E35B4D00F963D2 /* high_and_falling_insulin_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909F124E35B4C00F963D2 /* high_and_falling_insulin_effect.json */; }; + E90909F624E35B7C00F963D2 /* high_and_falling_momentum_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E90909F524E35B7C00F963D2 /* high_and_falling_momentum_effect.json */; }; + E93E865424DB6CBA00FF40C8 /* retrospective_output.json in Resources */ = {isa = PBXBuildFile; fileRef = E93E865324DB6CBA00FF40C8 /* retrospective_output.json */; }; + E93E865624DB731900FF40C8 /* predicted_glucose_without_retrospective.json in Resources */ = {isa = PBXBuildFile; fileRef = E93E865524DB731900FF40C8 /* predicted_glucose_without_retrospective.json */; }; + E93E865824DB75BE00FF40C8 /* predicted_glucose_very_negative.json in Resources */ = {isa = PBXBuildFile; fileRef = E93E865724DB75BD00FF40C8 /* predicted_glucose_very_negative.json */; }; + E93E86A824DDCC4400FF40C8 /* MockDoseStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E93E86A724DDCC4400FF40C8 /* MockDoseStore.swift */; }; + E93E86B024DDE1BD00FF40C8 /* MockGlucoseStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E93E86AF24DDE1BD00FF40C8 /* MockGlucoseStore.swift */; }; + E93E86B224DDE21D00FF40C8 /* MockCarbStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E93E86B124DDE21D00FF40C8 /* MockCarbStore.swift */; }; + E93E86BA24E1FDC400FF40C8 /* flat_and_stable_insulin_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E93E86B424E1FDC400FF40C8 /* flat_and_stable_insulin_effect.json */; }; + E93E86BB24E1FDC400FF40C8 /* flat_and_stable_momentum_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E93E86B524E1FDC400FF40C8 /* flat_and_stable_momentum_effect.json */; }; + E93E86BC24E1FDC400FF40C8 /* flat_and_stable_predicted_glucose.json in Resources */ = {isa = PBXBuildFile; fileRef = E93E86B624E1FDC400FF40C8 /* flat_and_stable_predicted_glucose.json */; }; + E93E86BE24E1FDC400FF40C8 /* flat_and_stable_carb_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E93E86B824E1FDC400FF40C8 /* flat_and_stable_carb_effect.json */; }; + E93E86C324E1FE6100FF40C8 /* flat_and_stable_counteraction_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E93E86C224E1FE6100FF40C8 /* flat_and_stable_counteraction_effect.json */; }; + E93E86CA24E2E02200FF40C8 /* high_and_stable_insulin_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E93E86C524E2E02200FF40C8 /* high_and_stable_insulin_effect.json */; }; + E93E86CB24E2E02200FF40C8 /* high_and_stable_carb_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E93E86C624E2E02200FF40C8 /* high_and_stable_carb_effect.json */; }; + E93E86CC24E2E02200FF40C8 /* high_and_stable_predicted_glucose.json in Resources */ = {isa = PBXBuildFile; fileRef = E93E86C724E2E02200FF40C8 /* high_and_stable_predicted_glucose.json */; }; + E93E86CD24E2E02200FF40C8 /* high_and_stable_counteraction_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E93E86C824E2E02200FF40C8 /* high_and_stable_counteraction_effect.json */; }; + E93E86CE24E2E02200FF40C8 /* high_and_stable_momentum_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E93E86C924E2E02200FF40C8 /* high_and_stable_momentum_effect.json */; }; + E942DE96253BE68F00AC532D /* NSBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430DA58D1D4AEC230097D1CA /* NSBundle.swift */; }; + E942DE9F253BE6A900AC532D /* NSTimeInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439897341CD2F7DE00223065 /* NSTimeInterval.swift */; }; + E942DF34253BF87F00AC532D /* Intents.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = 43785E9B2120E7060057DED1 /* Intents.intentdefinition */; }; + E950CA9129002D9000B5B692 /* LoopDataManagerDosingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E950CA9029002D9000B5B692 /* LoopDataManagerDosingTests.swift */; }; + E95D380124EADE7C005E2F50 /* DoseStoreProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E95D380024EADE7C005E2F50 /* DoseStoreProtocol.swift */; }; + E95D380324EADF36005E2F50 /* CarbStoreProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E95D380224EADF36005E2F50 /* CarbStoreProtocol.swift */; }; + E95D380524EADF78005E2F50 /* GlucoseStoreProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E95D380424EADF78005E2F50 /* GlucoseStoreProtocol.swift */; }; + E98A55ED24EDD6380008715D /* LatestStoredSettingsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E98A55EC24EDD6380008715D /* LatestStoredSettingsProvider.swift */; }; + E98A55EF24EDD6E60008715D /* DosingDecisionStoreProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E98A55EE24EDD6E60008715D /* DosingDecisionStoreProtocol.swift */; }; + E98A55F124EDD85E0008715D /* MockDosingDecisionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E98A55F024EDD85E0008715D /* MockDosingDecisionStore.swift */; }; + E98A55F324EDD9530008715D /* MockSettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E98A55F224EDD9530008715D /* MockSettingsStore.swift */; }; + E98A55F524EEE15A0008715D /* OnOffSelectionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E98A55F424EEE15A0008715D /* OnOffSelectionController.swift */; }; + E98A55F724EEE1E10008715D /* OnOffSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E98A55F624EEE1E10008715D /* OnOffSelectionView.swift */; }; + E98A55F924EEFC200008715D /* OnOffSelectionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E98A55F824EEFC200008715D /* OnOffSelectionViewModel.swift */; }; + E9B07F7F253BBA6500BAD8F8 /* IntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B07F7E253BBA6500BAD8F8 /* IntentHandler.swift */; }; + E9B07F94253BBA6500BAD8F8 /* Loop Intent Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = E9B07F7C253BBA6500BAD8F8 /* Loop Intent Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + E9B07FEE253BBC7100BAD8F8 /* OverrideIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B07FED253BBC7100BAD8F8 /* OverrideIntentHandler.swift */; }; + E9B08016253BBD7300BAD8F8 /* UserDefaults+LoopIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B08015253BBD7300BAD8F8 /* UserDefaults+LoopIntents.swift */; }; + E9B08021253BBDE900BAD8F8 /* IntentExtensionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B08020253BBDE900BAD8F8 /* IntentExtensionInfo.swift */; }; + E9B0802B253BBDFF00BAD8F8 /* IntentExtensionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B08020253BBDE900BAD8F8 /* IntentExtensionInfo.swift */; }; + E9B080B1253BDA6300BAD8F8 /* UserDefaults+LoopIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B08015253BBD7300BAD8F8 /* UserDefaults+LoopIntents.swift */; }; + E9B3552229358C440076AB04 /* MealDetectionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B3552129358C440076AB04 /* MealDetectionManager.swift */; }; + E9B355292935919E0076AB04 /* MissedMealSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B35525293590980076AB04 /* MissedMealSettings.swift */; }; + E9B3552A293591E70076AB04 /* MissedMealNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B3551B292844010076AB04 /* MissedMealNotification.swift */; }; + E9B3552B293591E70076AB04 /* MissedMealNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B3551B292844010076AB04 /* MissedMealNotification.swift */; }; + E9B3552D293592B40076AB04 /* MealDetectionManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B3552C293592B40076AB04 /* MealDetectionManagerTests.swift */; }; + E9B3552F2935968E0076AB04 /* HKHealthStoreMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B3552E2935968E0076AB04 /* HKHealthStoreMock.swift */; }; + E9B35538293706CB0076AB04 /* needs_clamping_counteraction_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E9B35532293706CA0076AB04 /* needs_clamping_counteraction_effect.json */; }; + E9B35539293706CB0076AB04 /* dynamic_autofill_counteraction_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E9B35533293706CA0076AB04 /* dynamic_autofill_counteraction_effect.json */; }; + E9B3553A293706CB0076AB04 /* missed_meal_counteraction_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E9B35534293706CB0076AB04 /* missed_meal_counteraction_effect.json */; }; + E9B3553B293706CB0076AB04 /* noisy_cgm_counteraction_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E9B35535293706CB0076AB04 /* noisy_cgm_counteraction_effect.json */; }; + E9B3553C293706CB0076AB04 /* realistic_report_counteraction_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E9B35536293706CB0076AB04 /* realistic_report_counteraction_effect.json */; }; + E9B3553D293706CB0076AB04 /* long_interval_counteraction_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E9B35537293706CB0076AB04 /* long_interval_counteraction_effect.json */; }; + E9BB27AB23B85C3500FB4987 /* SleepStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9BB27AA23B85C3500FB4987 /* SleepStore.swift */; }; + E9C00EF224C6221B00628F35 /* LoopSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9C00EEF24C620EF00628F35 /* LoopSettings.swift */; }; + E9C00EF324C6222400628F35 /* LoopSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9C00EEF24C620EF00628F35 /* LoopSettings.swift */; }; + E9C00EF524C623EF00628F35 /* LoopSettings+Loop.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9C00EF424C623EF00628F35 /* LoopSettings+Loop.swift */; }; + E9C58A7324DB4A2700487A17 /* LoopDataManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9C58A7124DB489100487A17 /* LoopDataManagerTests.swift */; }; + E9C58A7C24DB529A00487A17 /* momentum_effect_bouncing.json in Resources */ = {isa = PBXBuildFile; fileRef = E9C58A7724DB529A00487A17 /* momentum_effect_bouncing.json */; }; + E9C58A7D24DB529A00487A17 /* basal_profile.json in Resources */ = {isa = PBXBuildFile; fileRef = E9C58A7824DB529A00487A17 /* basal_profile.json */; }; + E9C58A7E24DB529A00487A17 /* dynamic_glucose_effect_partially_observed.json in Resources */ = {isa = PBXBuildFile; fileRef = E9C58A7924DB529A00487A17 /* dynamic_glucose_effect_partially_observed.json */; }; + E9C58A7F24DB529A00487A17 /* counteraction_effect_falling_glucose.json in Resources */ = {isa = PBXBuildFile; fileRef = E9C58A7A24DB529A00487A17 /* counteraction_effect_falling_glucose.json */; }; + E9C58A8024DB529A00487A17 /* insulin_effect.json in Resources */ = {isa = PBXBuildFile; fileRef = E9C58A7B24DB529A00487A17 /* insulin_effect.json */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 1481F9BD28DA26F4004C5AEB /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 43776F841B8022E90074EA36 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 4F75288A1DFE1DC600C322D6; + remoteInfo = LoopUI; + }; + 14B1736728AED9EE006CCD7C /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 43776F841B8022E90074EA36 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 14B1735B28AED9EC006CCD7C; + remoteInfo = SmallStatusWidgetExtension; + }; 43A943801B926B7B0051FA24 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 43776F841B8022E90074EA36 /* Project object */; @@ -203,6 +619,13 @@ remoteGlobalIDString = 43A943711B926B7B0051FA24; remoteInfo = WatchApp; }; + 43D9FFD421EAE05D00AF44BF /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 43776F841B8022E90074EA36 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 43D9FFCE21EAE05D00AF44BF; + remoteInfo = LoopCore; + }; 43E2D9101D20C581004DA55F /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 43776F841B8022E90074EA36 /* Project object */; @@ -224,13 +647,34 @@ remoteGlobalIDString = 4F75288A1DFE1DC600C322D6; remoteInfo = LoopUI; }; - 4F7528981DFE1ED800C322D6 /* PBXContainerItemProxy */ = { + C117ED70232EDB3200DA57CD /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 43776F841B8022E90074EA36 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 43D9001A21EB209400AF44BF; + remoteInfo = "LoopCore-watchOS"; + }; + C11B9D582867781E00500CF8 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 43776F841B8022E90074EA36 /* Project object */; proxyType = 1; remoteGlobalIDString = 4F75288A1DFE1DC600C322D6; remoteInfo = LoopUI; }; + C1CCF1142858FA900035389C /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 43776F841B8022E90074EA36 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 43D9FFCE21EAE05D00AF44BF; + remoteInfo = LoopCore; + }; + E9B07F92253BBA6500BAD8F8 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 43776F841B8022E90074EA36 /* Project object */; + proxyType = 1; + remoteGlobalIDString = E9B07F7B253BBA6500BAD8F8; + remoteInfo = "Loop Intent Extension"; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -262,7 +706,15 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( + C19C8BC428651EAE0056D5E4 /* LoopTestingKit.framework in Embed Frameworks */, 4F2C159A1E0C9E5600E160D4 /* LoopUI.framework in Embed Frameworks */, + C19C8BCF28651F520056D5E4 /* LoopKitUI.framework in Embed Frameworks */, + C11B9D63286779C000500CF8 /* MockKit.framework in Embed Frameworks */, + C19C8BBB28651DFB0056D5E4 /* TrueTime.framework in Embed Frameworks */, + C11B9D65286779C000500CF8 /* MockKitUI.framework in Embed Frameworks */, + C1F00C78285A8256006302C5 /* SwiftCharts in Embed Frameworks */, + C19C8BBF28651E3D0056D5E4 /* LoopKit.framework in Embed Frameworks */, + 43D9FFD721EAE05D00AF44BF /* LoopCore.framework in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -273,116 +725,177 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( + 43C05CB221EBD88A006FB252 /* LoopCore.framework in Embed Frameworks */, + C19C8C1F28663B040056D5E4 /* LoopKit.framework in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; }; - 43E2D8DD1D20C072004DA55F /* CopyFiles */ = { + 4F70C1EC1DE8DCA8006380B7 /* Embed App Extensions */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = ""; - dstSubfolderSpec = 10; + dstSubfolderSpec = 13; files = ( - 4F526D5D1DF0FD6500A04910 /* InsulinKit.framework in CopyFiles */, - 43E2D9171D2226BD004DA55F /* LoopKit.framework in CopyFiles */, + 14B1736928AED9EE006CCD7C /* Loop Widget Extension.appex in Embed App Extensions */, + E9B07F94253BBA6500BAD8F8 /* Loop Intent Extension.appex in Embed App Extensions */, + 4F70C1E81DE8DCA7006380B7 /* Loop Status Extension.appex in Embed App Extensions */, ); + name = "Embed App Extensions"; runOnlyForDeploymentPostprocessing = 0; }; - 4F70C1EC1DE8DCA8006380B7 /* Embed App Extensions */ = { + C1E3DC4828595FAA00CA19FF /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = ""; - dstSubfolderSpec = 13; + dstSubfolderSpec = 10; files = ( - 4F70C1E81DE8DCA7006380B7 /* Loop Status Extension.appex in Embed App Extensions */, + C1E3DC4928595FAA00CA19FF /* SwiftCharts in Embed Frameworks */, ); - name = "Embed App Extensions"; + name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 142CB7582A60BF2E0075748A /* EditMode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EditMode.swift; sourceTree = ""; }; + 142CB75A2A60BFC30075748A /* FavoriteFoodsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FavoriteFoodsView.swift; sourceTree = ""; }; + 1452F4A82A851C9400F8B9E4 /* AddEditFavoriteFoodViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddEditFavoriteFoodViewModel.swift; sourceTree = ""; }; + 1452F4AA2A851EDF00F8B9E4 /* AddEditFavoriteFoodView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddEditFavoriteFoodView.swift; sourceTree = ""; }; + 1452F4AC2A851F8800F8B9E4 /* HowAbsorptionTimeWorksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HowAbsorptionTimeWorksView.swift; sourceTree = ""; }; + 147EFE8D2A8BCC5500272438 /* DefaultAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = DefaultAssets.xcassets; sourceTree = ""; }; + 147EFE8F2A8BCD8000272438 /* DerivedAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = DerivedAssets.xcassets; sourceTree = ""; }; + 147EFE912A8BCD8A00272438 /* DerivedAssetsBase.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = DerivedAssetsBase.xcassets; sourceTree = ""; }; + 149A28BA2A853E5100052EDF /* CarbEntryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbEntryViewModel.swift; sourceTree = ""; }; + 149A28BC2A853E6C00052EDF /* CarbEntryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbEntryView.swift; sourceTree = ""; }; + 149A28E32A8A63A700052EDF /* FavoriteFoodDetailView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FavoriteFoodDetailView.swift; sourceTree = ""; }; + 14B1735C28AED9EC006CCD7C /* Loop Widget Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Loop Widget Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; + 14B1735D28AED9EC006CCD7C /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; + 14B1735F28AED9EC006CCD7C /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; + 14B1736628AED9EE006CCD7C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 14B1736D28AEDA63006CCD7C /* LoopWidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = LoopWidgetExtension.entitlements; sourceTree = ""; }; + 14B1736E28AEDBF6006CCD7C /* BasalView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BasalView.swift; sourceTree = ""; }; + 14B1736F28AEDBF6006CCD7C /* SystemStatusWidget.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SystemStatusWidget.swift; sourceTree = ""; }; + 14B1737028AEDBF6006CCD7C /* GlucoseView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GlucoseView.swift; sourceTree = ""; }; + 14B1737128AEDBF6006CCD7C /* LoopCircleView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoopCircleView.swift; sourceTree = ""; }; + 14D906F32A846510006EB79A /* FavoriteFoodsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteFoodsViewModel.swift; sourceTree = ""; }; + 1D05219A2469E9DF000EBBDE /* StoredAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredAlert.swift; sourceTree = ""; }; + 1D05219C2469F1F5000EBBDE /* AlertStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertStore.swift; sourceTree = ""; }; + 1D080CBC2473214A00356610 /* AlertStore.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = AlertStore.xcdatamodel; sourceTree = ""; }; + 1D12D3B82548EFDD00B53E8B /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; + 1D49795724E7289700948F05 /* ServicesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServicesViewModel.swift; sourceTree = ""; }; + 1D4A3E2B2478628500FD601B /* StoredAlert+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StoredAlert+CoreDataClass.swift"; sourceTree = ""; }; + 1D4A3E2C2478628500FD601B /* StoredAlert+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StoredAlert+CoreDataProperties.swift"; sourceTree = ""; }; + 1D63DEA426E950D400F46FA5 /* SupportManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportManager.swift; sourceTree = ""; }; + 1D6B1B6626866D89009AC446 /* AlertPermissionsChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertPermissionsChecker.swift; sourceTree = ""; }; + 1D70C40026EC0F9D00C62570 /* SupportManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportManagerTests.swift; sourceTree = ""; }; + 1D80313C24746274002810DF /* AlertStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertStoreTests.swift; sourceTree = ""; }; + 1D82E69F25377C6B009131FB /* TrustedTimeChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrustedTimeChecker.swift; sourceTree = ""; }; + 1D8D55BB252274650044DBB6 /* BolusEntryViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusEntryViewModelTests.swift; sourceTree = ""; }; + 1D9650C72523FBA100A1370B /* DeviceDataManager+BolusEntryViewModelDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DeviceDataManager+BolusEntryViewModelDelegate.swift"; sourceTree = ""; }; + 1DA46B5F2492E2E300D71A63 /* NotificationsCriticalAlertPermissionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsCriticalAlertPermissionsView.swift; sourceTree = ""; }; + 1DA649A6244126CD00F61E75 /* UserNotificationAlertScheduler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserNotificationAlertScheduler.swift; sourceTree = ""; }; + 1DA649A8244126DA00F61E75 /* InAppModalAlertScheduler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppModalAlertScheduler.swift; sourceTree = ""; }; + 1DA7A84124476EAD008257F0 /* AlertManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertManagerTests.swift; sourceTree = ""; }; + 1DA7A84324477698008257F0 /* InAppModalAlertSchedulerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppModalAlertSchedulerTests.swift; sourceTree = ""; }; + 1DB1065024467E18005542BD /* AlertManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AlertManager.swift; sourceTree = ""; }; + 1DB1CA4C24A55F0000B3B94C /* Image.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Image.swift; sourceTree = ""; }; + 1DB1CA4E24A56D7600B3B94C /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; + 1DB619AB270BAD3D006C9D07 /* VersionUpdateViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionUpdateViewModel.swift; sourceTree = ""; }; + 1DC63E7325351BDF004605DA /* TrueTime.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = TrueTime.framework; path = Carthage/Build/iOS/TrueTime.framework; sourceTree = ""; }; + 1DE09BA824A3E23F009EE9F9 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + 1DFE9E162447B6270082C280 /* UserNotificationAlertSchedulerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserNotificationAlertSchedulerTests.swift; sourceTree = ""; }; + 3D03C6DA2AACE6AC00FDE5D2 /* hi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hi; path = hi.lproj/Intents.strings; sourceTree = ""; }; + 3ED319862EB659E600820BCF /* BasalViewActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasalViewActivity.swift; sourceTree = ""; }; + 3ED319872EB659E600820BCF /* ChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartView.swift; sourceTree = ""; }; + 3ED319882EB659E600820BCF /* GlucoseLiveActivityConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseLiveActivityConfiguration.swift; sourceTree = ""; }; + 3ED3198D2EB65A2D00820BCF /* ChartAxisGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartAxisGenerator.swift; sourceTree = ""; }; + 3ED3198E2EB65A2D00820BCF /* GlucoseActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseActivityAttributes.swift; sourceTree = ""; }; + 3ED3198F2EB65A2D00820BCF /* LiveActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = ""; }; + 3ED319952EB65A5C00820BCF /* LiveActivityManagementViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManagementViewModel.swift; sourceTree = ""; }; + 3ED319972EB65A6900820BCF /* LiveActivityBottomRowManagerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityBottomRowManagerView.swift; sourceTree = ""; }; + 3ED319982EB65A6900820BCF /* LiveActivityManagementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManagementView.swift; sourceTree = ""; }; + 3ED3199B2EB65A9B00820BCF /* LiveActivitySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivitySettings.swift; sourceTree = ""; }; + 3ED3199E2EB65AFE00820BCF /* LocalizedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedString.swift; sourceTree = ""; }; + 3ED319A02EB65B4100820BCF /* Bootstrap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bootstrap.swift; sourceTree = ""; }; + 3ED319A22EB65DA300820BCF /* LiveActivityManagerProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManagerProxy.swift; sourceTree = ""; }; 4302F4E01D4E9C8900F0FCAF /* TextFieldTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TextFieldTableViewController.swift; sourceTree = ""; }; 4302F4E21D4EA54200F0FCAF /* InsulinDeliveryTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InsulinDeliveryTableViewController.swift; sourceTree = ""; }; - 4302F4E41D4EA75100F0FCAF /* DoseStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DoseStore.swift; sourceTree = ""; }; - 430DA58D1D4AEC230097D1CA /* NSBundle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = NSBundle.swift; path = Common/Extensions/NSBundle.swift; sourceTree = ""; }; - 430DA58F1D4B0E4C0097D1CA /* MySentryPumpStatusMessageBody.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MySentryPumpStatusMessageBody.swift; sourceTree = ""; }; - 4313EDDF1D8A6BF90060FA79 /* ChartContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChartContentView.swift; sourceTree = ""; }; - 4315D2861CA5CC3B00589052 /* CarbEntryEditTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbEntryEditTableViewController.swift; sourceTree = ""; }; - 4315D2891CA5F45E00589052 /* DiagnosticLogger+LoopKit.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "DiagnosticLogger+LoopKit.swift"; sourceTree = ""; }; - 4328E0151CFBE1DA00E199AA /* StatusInterfaceController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusInterfaceController.swift; sourceTree = ""; }; - 4328E0161CFBE1DA00E199AA /* BolusInterfaceController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BolusInterfaceController.swift; sourceTree = ""; }; - 4328E01D1CFBE25F00E199AA /* AddCarbsInterfaceController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddCarbsInterfaceController.swift; sourceTree = ""; }; - 4328E0201CFBE2C500E199AA /* IdentifiableClass.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IdentifiableClass.swift; sourceTree = ""; }; + 430B29892041F54A00BA9F93 /* NSUserDefaults.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSUserDefaults.swift; sourceTree = ""; }; + 430B29922041F5B200BA9F93 /* UserDefaults+Loop.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UserDefaults+Loop.swift"; sourceTree = ""; }; + 430D85881F44037000AF2D4F /* HUDViewTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HUDViewTableViewCell.swift; sourceTree = ""; }; + 430DA58D1D4AEC230097D1CA /* NSBundle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSBundle.swift; sourceTree = ""; }; + 4311FB9A1F37FE1B00D4C0A7 /* TitleSubtitleTextFieldTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TitleSubtitleTextFieldTableViewCell.swift; sourceTree = ""; }; + 431A8C3F1EC6E8AB00823B9C /* CircleMaskView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CircleMaskView.swift; sourceTree = ""; }; + 431E73471FF95A900069B5F7 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = ""; }; + 4326BA631F3A44D9007CCAD4 /* ChartLineModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChartLineModel.swift; sourceTree = ""; }; + 4328E0151CFBE1DA00E199AA /* ActionHUDController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActionHUDController.swift; sourceTree = ""; }; + 4328E01D1CFBE25F00E199AA /* CarbAndBolusFlowController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbAndBolusFlowController.swift; sourceTree = ""; }; 4328E0221CFBE2C500E199AA /* CLKComplicationTemplate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CLKComplicationTemplate.swift; sourceTree = ""; }; - 4328E0231CFBE2C500E199AA /* NSUserDefaults.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSUserDefaults.swift; sourceTree = ""; }; + 4328E0231CFBE2C500E199AA /* NSUserDefaults+WatchApp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSUserDefaults+WatchApp.swift"; sourceTree = ""; }; 4328E0241CFBE2C500E199AA /* UIColor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIColor.swift; sourceTree = ""; }; 4328E0251CFBE2C500E199AA /* WKAlertAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WKAlertAction.swift; sourceTree = ""; }; 4328E02E1CFBF81800E199AA /* WKInterfaceImage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WKInterfaceImage.swift; sourceTree = ""; }; 4328E0311CFC068900E199AA /* WatchContext+LoopKit.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "WatchContext+LoopKit.swift"; sourceTree = ""; }; 4328E0341CFC0AE100E199AA /* WatchDataManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = WatchDataManager.swift; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; - 432E73CA1D24B3D6009AD15D /* RemoteDataManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteDataManager.swift; sourceTree = ""; }; - 4331E0771C85302200FBE832 /* CGPoint.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CGPoint.swift; sourceTree = ""; }; - 4331E0791C85650D00FBE832 /* ChartAxisValueDoubleLog.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChartAxisValueDoubleLog.swift; sourceTree = ""; }; + 432E73CA1D24B3D6009AD15D /* RemoteDataServicesManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteDataServicesManager.swift; sourceTree = ""; }; 4337615E1D52F487004A3647 /* GlucoseHUDView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GlucoseHUDView.swift; sourceTree = ""; }; - 433EA4C11D9F39C900CD78FB /* PumpIDTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PumpIDTableViewController.swift; sourceTree = ""; }; 433EA4C31D9F71C800CD78FB /* CommandResponseViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CommandResponseViewController.swift; sourceTree = ""; }; - 4346D1E61C77F5FE00ABAFE3 /* ChartTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChartTableViewCell.swift; sourceTree = ""; }; - 4346D1EF1C781BEA00ABAFE3 /* SwiftCharts.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftCharts.framework; path = Carthage/Build/iOS/SwiftCharts.framework; sourceTree = ""; }; - 4346D1F51C78501000ABAFE3 /* ChartPoint.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChartPoint.swift; sourceTree = ""; }; - 434AB0B11CBB4C3300422F4A /* RileyLinkBLEKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = RileyLinkBLEKit.framework; path = Carthage/Build/iOS/RileyLinkBLEKit.framework; sourceTree = ""; }; - 434F54561D287FDB002A9274 /* NibLoadable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = NibLoadable.swift; path = Common/Extensions/NibLoadable.swift; sourceTree = ""; }; - 434F54581D28805E002A9274 /* ButtonTableViewCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = ButtonTableViewCell.xib; sourceTree = ""; }; - 434F545A1D2880D4002A9274 /* AuthenticationTableViewCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = AuthenticationTableViewCell.xib; sourceTree = ""; }; - 434F545E1D288345002A9274 /* ShareService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShareService.swift; sourceTree = ""; }; - 434F54601D28859B002A9274 /* ServiceCredential.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ServiceCredential.swift; sourceTree = ""; }; - 434F54621D28DD80002A9274 /* ValidatingIndicatorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ValidatingIndicatorView.swift; sourceTree = ""; }; - 434FB6451D68F1CD007B9C70 /* Amplitude.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Amplitude.framework; path = Carthage/Build/iOS/Amplitude.framework; sourceTree = ""; }; - 434FF1E91CF26C29000DB779 /* IdentifiableClass.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = IdentifiableClass.swift; path = Common/Extensions/IdentifiableClass.swift; sourceTree = ""; }; + 4344628120A7A37E00C4BE6F /* CoreBluetooth.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreBluetooth.framework; path = Platforms/WatchOS.platform/Developer/SDKs/WatchOS.sdk/System/Library/Frameworks/CoreBluetooth.framework; sourceTree = DEVELOPER_DIR; }; + 4344628320A7A3BE00C4BE6F /* LoopKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 4344628420A7A3BE00C4BE6F /* CGMBLEKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = CGMBLEKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 4344629120A7C19800C4BE6F /* ButtonGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonGroup.swift; sourceTree = ""; }; + 4345E3F721F03D2A009E00E5 /* DatesAndNumberCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatesAndNumberCell.swift; sourceTree = ""; }; + 4345E3F921F0473B009E00E5 /* TextCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextCell.swift; sourceTree = ""; }; + 4345E3FD21F04A50009E00E5 /* DateIntervalFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateIntervalFormatter.swift; sourceTree = ""; }; + 4345E40321F68AD9009E00E5 /* TextRowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextRowController.swift; sourceTree = ""; }; + 4345E40521F68E18009E00E5 /* CarbEntryListController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbEntryListController.swift; sourceTree = ""; }; + 434F54561D287FDB002A9274 /* NibLoadable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NibLoadable.swift; sourceTree = ""; }; + 434FF1E91CF26C29000DB779 /* IdentifiableClass.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IdentifiableClass.swift; sourceTree = ""; }; 434FF1ED1CF27EEF000DB779 /* UITableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UITableViewCell.swift; sourceTree = ""; }; - 43523EDA1CC35083001850F1 /* RileyLinkKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = RileyLinkKit.framework; path = Carthage/Build/iOS/RileyLinkKit.framework; sourceTree = ""; }; - 435400301C9F744E00D5819C /* BolusSuggestionUserInfo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BolusSuggestionUserInfo.swift; sourceTree = ""; }; + 43511CED220FC61700566C63 /* HUDRowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HUDRowController.swift; sourceTree = ""; }; + 43517916230A0E1A0072ECC0 /* WKInterfaceLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WKInterfaceLabel.swift; sourceTree = ""; }; 435400331C9F878D00D5819C /* SetBolusUserInfo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SetBolusUserInfo.swift; sourceTree = ""; }; - 43649A621C7A347F00523D7F /* CollectionType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CollectionType.swift; sourceTree = ""; }; 436A0DA41D236A2A00104B24 /* LoopError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoopError.swift; sourceTree = ""; }; - 436A0E7A1D7DE13400D6475D /* NSNumberFormatter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSNumberFormatter.swift; sourceTree = ""; }; - 436FACED1D0BA636004E2427 /* InsulinDataSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InsulinDataSource.swift; sourceTree = ""; }; + 4372E486213C86240068E043 /* SampleValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleValue.swift; sourceTree = ""; }; + 4372E48A213CB5F00068E043 /* Double.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Double.swift; sourceTree = ""; }; + 4372E48F213CFCE70068E043 /* LoopSettingsUserInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopSettingsUserInfo.swift; sourceTree = ""; }; + 4372E495213DCDD30068E043 /* GlucoseChartValueHashable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseChartValueHashable.swift; sourceTree = ""; }; + 4374B5EE209D84BE00D17AA8 /* OSLog.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OSLog.swift; sourceTree = ""; }; + 4374B5F3209D89A900D17AA8 /* TextFieldTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TextFieldTableViewCell.swift; sourceTree = ""; }; 43776F8C1B8022E90074EA36 /* Loop.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Loop.app; sourceTree = BUILT_PRODUCTS_DIR; }; 43776F8F1B8022E90074EA36 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = AppDelegate.swift; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; 43776F961B8022E90074EA36 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; - 43776F981B8022E90074EA36 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 43776F9B1B8022E90074EA36 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; - 437CCAD91D284ADF0075D2C3 /* AuthenticationTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthenticationTableViewCell.swift; sourceTree = ""; }; - 437CCADB1D284B830075D2C3 /* ButtonTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ButtonTableViewCell.swift; sourceTree = ""; }; - 437CCADD1D2858FD0075D2C3 /* AuthenticationViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthenticationViewController.swift; sourceTree = ""; }; - 437CCADF1D285C7B0075D2C3 /* ServiceAuthentication.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ServiceAuthentication.swift; sourceTree = ""; }; - 437CEEBB1CD6DE6A003C8C80 /* BaseHUDView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BaseHUDView.swift; sourceTree = ""; }; + 43785E922120A01B0057DED1 /* NewCarbEntryIntent+Loop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NewCarbEntryIntent+Loop.swift"; sourceTree = ""; }; + 43785E952120E4010057DED1 /* INRelevantShortcutStore+Loop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "INRelevantShortcutStore+Loop.swift"; sourceTree = ""; }; + 43785E9A2120E7060057DED1 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.intentdefinition; name = Base; path = Base.lproj/Intents.intentdefinition; sourceTree = ""; }; + 43785E9F2122774A0057DED1 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Intents.strings; sourceTree = ""; }; + 43785EA12122774B0057DED1 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Intents.strings; sourceTree = ""; }; + 4379CFEF21112CF700AADC79 /* ShareClientUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = ShareClientUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 437AFEE6203688CF008C4892 /* LoopKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 437CEEBD1CD6E0CB003C8C80 /* LoopCompletionHUDView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoopCompletionHUDView.swift; sourceTree = ""; }; 437CEEBF1CD6FCD8003C8C80 /* BasalRateHUDView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BasalRateHUDView.swift; sourceTree = ""; }; - 437CEEC71CD84CBB003C8C80 /* ReservoirVolumeHUDView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReservoirVolumeHUDView.swift; sourceTree = ""; }; - 437CEEC91CD84DB7003C8C80 /* BatteryLevelHUDView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BatteryLevelHUDView.swift; sourceTree = ""; }; 437CEEE31CDE5C0A003C8C80 /* UIImage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIImage.swift; sourceTree = ""; }; 437D9BA11D7B5203007245E8 /* Loop.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Loop.xcconfig; sourceTree = ""; }; 437D9BA21D7BC977007245E8 /* PredictionTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PredictionTableViewController.swift; sourceTree = ""; }; - 43846AD41D8FA67800799272 /* Base.lproj */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Base.lproj; sourceTree = ""; }; - 43846AD81D8FA84B00799272 /* gallery.ckcomplication */ = {isa = PBXFileReference; lastKnownFileType = folder; path = gallery.ckcomplication; sourceTree = ""; }; - 43846ADA1D91057000799272 /* ContextUpdatable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContextUpdatable.swift; sourceTree = ""; }; - 43880F941D9CD54A009061A8 /* ChartPointsScatterDownTrianglesLayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChartPointsScatterDownTrianglesLayer.swift; sourceTree = ""; }; - 438849E91D297CB6003B3F23 /* NightscoutService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NightscoutService.swift; sourceTree = ""; }; - 438849EB1D29EC34003B3F23 /* AmplitudeService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AmplitudeService.swift; sourceTree = ""; }; - 438849ED1D2A1EBB003B3F23 /* MLabService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MLabService.swift; sourceTree = ""; }; - 438A95A71D8B9B24009D12E1 /* xDripG5.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = xDripG5.framework; path = Carthage/Build/iOS/xDripG5.framework; sourceTree = ""; }; + 4389916A1E91B689000EEF90 /* ChartSettings+Loop.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ChartSettings+Loop.swift"; sourceTree = ""; }; + 438A95A71D8B9B24009D12E1 /* CGMBLEKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = CGMBLEKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 438D42F81D7C88BC003244B0 /* PredictionInputEffect.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PredictionInputEffect.swift; sourceTree = ""; }; 438D42FA1D7D11A4003244B0 /* PredictionInputEffectTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PredictionInputEffectTableViewCell.swift; sourceTree = ""; }; 438DADC71CDE8F8B007697A5 /* LoopStateView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoopStateView.swift; sourceTree = ""; }; - 439897341CD2F7DE00223065 /* NSTimeInterval.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = NSTimeInterval.swift; path = Common/Extensions/NSTimeInterval.swift; sourceTree = ""; }; - 439897361CD2F80600223065 /* AnalyticsManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = AnalyticsManager.swift; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; - 4398973A1CD2FC2000223065 /* NSDateFormatter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSDateFormatter.swift; sourceTree = ""; }; + 439706E522D2E84900C81566 /* PredictionSettingTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PredictionSettingTableViewCell.swift; sourceTree = ""; }; + 439897341CD2F7DE00223065 /* NSTimeInterval.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSTimeInterval.swift; sourceTree = ""; }; + 439897361CD2F80600223065 /* AnalyticsServicesManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = AnalyticsServicesManager.swift; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; + 439A7941211F631C0041B75F /* RootNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootNavigationController.swift; sourceTree = ""; }; + 439A7943211FE22F0041B75F /* NSUserActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSUserActivity.swift; sourceTree = ""; }; + 439BED291E76093C00B0AED5 /* CGMManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CGMManager.swift; sourceTree = ""; }; + 43A51E1E1EB6D62A000736CC /* CarbAbsorptionViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbAbsorptionViewController.swift; sourceTree = ""; }; + 43A51E201EB6DBDD000736CC /* LoopChartsTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoopChartsTableViewController.swift; sourceTree = ""; }; 43A567681C94880B00334FAC /* LoopDataManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = LoopDataManager.swift; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; - 43A5676A1C96155700334FAC /* SwitchTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwitchTableViewCell.swift; sourceTree = ""; }; + 43A8EC6E210E622600A81379 /* CGMBLEKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = CGMBLEKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 43A943721B926B7B0051FA24 /* WatchApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = WatchApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 43A943751B926B7B0051FA24 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Interface.storyboard; sourceTree = ""; }; - 43A943771B926B7B0051FA24 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 43A9437E1B926B7B0051FA24 /* WatchApp Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "WatchApp Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; 43A943841B926B7B0051FA24 /* PushNotificationPayload.apns */ = {isa = PBXFileReference; lastKnownFileType = text; path = PushNotificationPayload.apns; sourceTree = ""; }; 43A943871B926B7B0051FA24 /* ExtensionDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionDelegate.swift; sourceTree = ""; }; @@ -390,96 +903,528 @@ 43A9438D1B926B7B0051FA24 /* ComplicationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComplicationController.swift; sourceTree = ""; }; 43A9438F1B926B7B0051FA24 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 43A943911B926B7B0051FA24 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 43B260481ED248FB008CAA77 /* CarbEntryTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbEntryTableViewCell.swift; sourceTree = ""; }; 43B371851CE583890013C5A6 /* BasalStateView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BasalStateView.swift; sourceTree = ""; }; - 43B371871CE597D10013C5A6 /* ShareClient.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ShareClient.framework; path = Carthage/Build/iOS/ShareClient.framework; sourceTree = ""; }; + 43B371871CE597D10013C5A6 /* ShareClient.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = ShareClient.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 43BFF0B11E45C18400FF19A9 /* UIColor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIColor.swift; sourceTree = ""; }; + 43BFF0B31E45C1BE00FF19A9 /* NumberFormatter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NumberFormatter.swift; sourceTree = ""; }; + 43BFF0C31E4659E700FF19A9 /* UIColor+HIG.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIColor+HIG.swift"; sourceTree = ""; }; + 43BFF0CC1E466C8400FF19A9 /* StateColorPalette.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StateColorPalette.swift; sourceTree = ""; }; + 43C05CB021EBBDB9006FB252 /* TimeInRangeLesson.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeInRangeLesson.swift; sourceTree = ""; }; + 43C05CB421EBE274006FB252 /* Date.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Date.swift; sourceTree = ""; }; + 43C05CB721EBEA54006FB252 /* HKUnit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HKUnit.swift; sourceTree = ""; }; + 43C05CBC21EBF77D006FB252 /* LessonsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LessonsViewController.swift; sourceTree = ""; }; + 43C05CBF21EBFFA4006FB252 /* Lesson.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Lesson.swift; sourceTree = ""; }; + 43C05CC121EC06E4006FB252 /* LessonConfigurationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LessonConfigurationViewController.swift; sourceTree = ""; }; + 43C05CC921EC382B006FB252 /* NumberEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NumberEntry.swift; sourceTree = ""; }; 43C094491CACCC73001F6403 /* NotificationManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = ""; }; - 43C246A71D89990F0031F8D1 /* Crypto.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Crypto.framework; path = Carthage/Build/iOS/Crypto.framework; sourceTree = ""; }; - 43C418B41CE0575200405B6A /* ShareGlucose+GlucoseKit.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ShareGlucose+GlucoseKit.swift"; sourceTree = ""; }; - 43C6407B1DA051850093E25D /* InsulinKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = InsulinKit.framework; path = Carthage/Build/iOS/InsulinKit.framework; sourceTree = ""; }; - 43CA93361CB98079000026B5 /* MinimedKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MinimedKit.framework; path = Carthage/Build/iOS/MinimedKit.framework; sourceTree = ""; }; + 43C246A71D89990F0031F8D1 /* Crypto.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Crypto.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 43C2FAE01EB656A500364AFF /* GlucoseEffectVelocity.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GlucoseEffectVelocity.swift; sourceTree = ""; }; + 43C3B6F620BBCAA30026CAFA /* PumpManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpManager.swift; sourceTree = ""; }; + 43C513181E864C4E001547C7 /* GlucoseRangeSchedule.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GlucoseRangeSchedule.swift; sourceTree = ""; }; + 43C5F256222C7B7200905D10 /* TimeComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeComponents.swift; sourceTree = ""; }; + 43C5F259222C921B00905D10 /* OSLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLog.swift; sourceTree = ""; }; + 43C728F4222266F000C62969 /* ModalDayLesson.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModalDayLesson.swift; sourceTree = ""; }; + 43C728F62222700000C62969 /* DateIntervalEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateIntervalEntry.swift; sourceTree = ""; }; + 43C728F8222A448700C62969 /* DayCalculator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DayCalculator.swift; sourceTree = ""; }; + 43C98058212A799E003B5D17 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Intents.strings; sourceTree = ""; }; 43CB2B2A1D924D450079823D /* WCSession.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WCSession.swift; sourceTree = ""; }; 43CE7CDD1CA8B63E003CC1B0 /* Data.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Data.swift; sourceTree = ""; }; + 43D381611EBD9759007F8C8F /* HeaderValuesTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HeaderValuesTableViewCell.swift; sourceTree = ""; }; 43D533BB1CFD1DD7009E3085 /* WatchApp Extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = "WatchApp Extension.entitlements"; sourceTree = ""; }; - 43DBF04B1C93B8D700B3C386 /* BolusViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = BolusViewController.swift; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; + 43D848AF1E7DCBE100DADCBC /* Result.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Result.swift; sourceTree = ""; }; + 43D9002A21EB209400AF44BF /* LoopCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = LoopCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 43D9002C21EB225D00AF44BF /* HealthKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = HealthKit.framework; path = Platforms/WatchOS.platform/Developer/SDKs/WatchOS.sdk/System/Library/Frameworks/HealthKit.framework; sourceTree = DEVELOPER_DIR; }; + 43D9F81721EC51CC000578CD /* DateEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateEntry.swift; sourceTree = ""; }; + 43D9F81921EC593C000578CD /* UITableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITableViewCell.swift; sourceTree = ""; }; + 43D9F81D21EF0609000578CD /* NumberRangeEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NumberRangeEntry.swift; sourceTree = ""; }; + 43D9F81F21EF0906000578CD /* NSNumber.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSNumber.swift; sourceTree = ""; }; + 43D9F82121EF0A7A000578CD /* QuantityRangeEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuantityRangeEntry.swift; sourceTree = ""; }; + 43D9F82321EFF1AB000578CD /* LessonResultsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LessonResultsViewController.swift; sourceTree = ""; }; + 43D9FFA421EA9A0C00AF44BF /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 43D9FFA921EA9A0C00AF44BF /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 43D9FFAB21EA9A0F00AF44BF /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 43D9FFB021EA9A0F00AF44BF /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 43D9FFB521EA9B0100AF44BF /* Learn.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Learn.entitlements; sourceTree = ""; }; + 43D9FFBF21EAB22E00AF44BF /* DataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataManager.swift; sourceTree = ""; }; + 43D9FFCF21EAE05D00AF44BF /* LoopCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = LoopCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 43D9FFD121EAE05D00AF44BF /* LoopCore.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = LoopCore.h; sourceTree = ""; }; + 43D9FFD221EAE05D00AF44BF /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 43DBF0521C93EC8200B3C386 /* DeviceDataManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = DeviceDataManager.swift; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; - 43DBF0581C93F73800B3C386 /* CarbEntryTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbEntryTableViewController.swift; sourceTree = ""; }; - 43DE92501C541832001FFDE1 /* UIColor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIColor.swift; sourceTree = ""; }; - 43DE92581C5479E4001FFDE1 /* CarbEntryUserInfo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbEntryUserInfo.swift; sourceTree = ""; }; - 43DE92601C555C26001FFDE1 /* AbsorptionTimeType+CarbKit.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "AbsorptionTimeType+CarbKit.swift"; sourceTree = ""; }; - 43E2D8C51D204678004DA55F /* KeychainManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeychainManager.swift; sourceTree = ""; }; - 43E2D8C71D208D5B004DA55F /* KeychainManager+Loop.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "KeychainManager+Loop.swift"; sourceTree = ""; }; - 43E2D8C91D20B9E7004DA55F /* KeychainManagerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeychainManagerTests.swift; sourceTree = ""; }; - 43E2D8D11D20BF42004DA55F /* DoseMathTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DoseMathTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 43E2D8D31D20BF42004DA55F /* DoseMathTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoseMathTests.swift; sourceTree = ""; }; - 43E2D8D51D20BF42004DA55F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 43E2D8E11D20C0DB004DA55F /* read_selected_basal_profile.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = read_selected_basal_profile.json; sourceTree = ""; }; - 43E2D8E21D20C0DB004DA55F /* recommend_temp_basal_correct_low_at_min.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = recommend_temp_basal_correct_low_at_min.json; sourceTree = ""; }; - 43E2D8E31D20C0DB004DA55F /* recommend_temp_basal_flat_and_high.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = recommend_temp_basal_flat_and_high.json; sourceTree = ""; }; - 43E2D8E41D20C0DB004DA55F /* recommend_temp_basal_high_and_falling.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = recommend_temp_basal_high_and_falling.json; sourceTree = ""; }; - 43E2D8E51D20C0DB004DA55F /* recommend_temp_basal_high_and_rising.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = recommend_temp_basal_high_and_rising.json; sourceTree = ""; }; - 43E2D8E61D20C0DB004DA55F /* recommend_temp_basal_in_range_and_rising.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = recommend_temp_basal_in_range_and_rising.json; sourceTree = ""; }; - 43E2D8E71D20C0DB004DA55F /* recommend_temp_basal_no_change_glucose.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = recommend_temp_basal_no_change_glucose.json; sourceTree = ""; }; - 43E2D8E81D20C0DB004DA55F /* recommend_temp_basal_start_high_end_in_range.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = recommend_temp_basal_start_high_end_in_range.json; sourceTree = ""; }; - 43E2D8E91D20C0DB004DA55F /* recommend_temp_basal_start_high_end_low.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = recommend_temp_basal_start_high_end_low.json; sourceTree = ""; }; - 43E2D8EA1D20C0DB004DA55F /* recommend_temp_basal_start_low_end_high.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = recommend_temp_basal_start_low_end_high.json; sourceTree = ""; }; - 43E2D8EB1D20C0DB004DA55F /* recommend_temp_basal_start_low_end_in_range.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = recommend_temp_basal_start_low_end_in_range.json; sourceTree = ""; }; + 43DE92581C5479E4001FFDE1 /* PotentialCarbEntryUserInfo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PotentialCarbEntryUserInfo.swift; sourceTree = ""; }; 43E2D90B1D20C581004DA55F /* LoopTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LoopTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 43E2D90F1D20C581004DA55F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 43E3449E1B9D68E900C85C07 /* StatusTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = StatusTableViewController.swift; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; - 43E344A31B9E1B1C00C85C07 /* NSUserDefaults.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSUserDefaults.swift; sourceTree = ""; }; - 43E397A21D56B9E40028E321 /* Glucose.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Glucose.swift; sourceTree = ""; }; - 43EA285E1D50ED3D001BC233 /* GlucoseTrend.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GlucoseTrend.swift; sourceTree = ""; }; - 43EA28611D517E42001BC233 /* SensorDisplayable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SensorDisplayable.swift; sourceTree = ""; }; - 43EB40851C82646A00472A8C /* StatusChartManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusChartManager.swift; sourceTree = ""; }; 43EDEE6B1CF2E12A00393BE3 /* Loop.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = Loop.entitlements; sourceTree = ""; }; - 43F41C321D3A17AA00C11ED6 /* ChartAxisValueDoubleUnit.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChartAxisValueDoubleUnit.swift; sourceTree = ""; }; - 43F41C341D3B623800C11ED6 /* ChartPointsTouchHighlightLayerViewCache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChartPointsTouchHighlightLayerViewCache.swift; sourceTree = ""; }; 43F41C361D3BF32400C11ED6 /* UIAlertController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIAlertController.swift; sourceTree = ""; }; - 43F4EF1C1BA2A57600526CE1 /* DiagnosticLogger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DiagnosticLogger.swift; sourceTree = ""; }; - 43F5173C1D713DB0000FA422 /* RadioSelectionTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RadioSelectionTableViewController.swift; sourceTree = ""; }; 43F5C2C81B929C09003EB13D /* HealthKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = HealthKit.framework; path = System/Library/Frameworks/HealthKit.framework; sourceTree = SDKROOT; }; 43F5C2D41B92A4A6003EB13D /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 43F5C2D61B92A4DC003EB13D /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 43F5C2DA1B92A5E1003EB13D /* SettingsTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = SettingsTableViewController.swift; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; 43F64DD81D9C92C900D24DC6 /* TitleSubtitleTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TitleSubtitleTableViewCell.swift; sourceTree = ""; }; - 43F78D251C8FC000002152D1 /* DoseMath.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DoseMath.swift; sourceTree = ""; }; - 43F78D481C914197002152D1 /* CarbKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CarbKit.framework; path = Carthage/Build/iOS/CarbKit.framework; sourceTree = ""; }; - 43F78D491C914197002152D1 /* GlucoseKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = GlucoseKit.framework; path = Carthage/Build/iOS/GlucoseKit.framework; sourceTree = ""; }; - 43F78D4B1C914197002152D1 /* LoopKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = LoopKit.framework; path = Carthage/Build/iOS/LoopKit.framework; sourceTree = ""; }; - 43FBEDD71D73843700B21F22 /* LevelMaskView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LevelMaskView.swift; sourceTree = ""; }; - 4D3B40021D4A9DFE00BC6334 /* G4ShareSpy.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = G4ShareSpy.framework; path = Carthage/Build/iOS/G4ShareSpy.framework; sourceTree = ""; }; - 4D5B7A4A1D457CCA00796CA9 /* GlucoseG4.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = GlucoseG4.swift; path = Loop/Models/GlucoseG4.swift; sourceTree = SOURCE_ROOT; }; + 43F78D4B1C914197002152D1 /* LoopKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 43F89CA222BDFBBC006BB54E /* UIActivityIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIActivityIndicatorView.swift; sourceTree = ""; }; + 43FCEEA8221A615B0013DD30 /* StatusChartsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusChartsManager.swift; sourceTree = ""; }; + 43FCEEAC221A66780013DD30 /* DateFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateFormatter.swift; sourceTree = ""; }; + 43FCEEB0221A863E0013DD30 /* StatusChartsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusChartsManager.swift; sourceTree = ""; }; + 4D3B40021D4A9DFE00BC6334 /* G4ShareSpy.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = G4ShareSpy.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 4F08DE8E1E7BB871006741EA /* CollectionType+Loop.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "CollectionType+Loop.swift"; sourceTree = ""; }; + 4F11D3BF20DCBEEC006E072C /* GlucoseBackfillRequestUserInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseBackfillRequestUserInfo.swift; sourceTree = ""; }; + 4F11D3C120DD80B3006E072C /* WatchHistoricalGlucose.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchHistoricalGlucose.swift; sourceTree = ""; }; 4F2C15801E0495B200E160D4 /* WatchContext+WatchApp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "WatchContext+WatchApp.swift"; sourceTree = ""; }; 4F2C15921E09BF2C00E160D4 /* HUDView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HUDView.swift; sourceTree = ""; }; 4F2C15941E09BF3C00E160D4 /* HUDView.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = HUDView.xib; sourceTree = ""; }; - 4F2C15961E09E94E00E160D4 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 4F526D5E1DF2459000A04910 /* HKUnit.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = HKUnit.swift; path = Common/Extensions/HKUnit.swift; sourceTree = ""; }; + 4F2C15961E09E94E00E160D4 /* HUDAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = HUDAssets.xcassets; sourceTree = ""; }; + 4F526D5E1DF2459000A04910 /* HKUnit.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HKUnit.swift; sourceTree = ""; }; 4F526D601DF8D9A900A04910 /* NetBasal.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetBasal.swift; sourceTree = ""; }; + 4F6663931E905FD2009E74FC /* ChartColorPalette+Loop.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ChartColorPalette+Loop.swift"; sourceTree = ""; }; 4F70C1DC1DE8DCA7006380B7 /* Loop Status Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Loop Status Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; 4F70C1DD1DE8DCA7006380B7 /* NotificationCenter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = NotificationCenter.framework; path = System/Library/Frameworks/NotificationCenter.framework; sourceTree = SDKROOT; }; - 4F70C1E01DE8DCA7006380B7 /* StatusViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusViewController.swift; sourceTree = ""; }; + 4F70C1E01DE8DCA7006380B7 /* StatusViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = StatusViewController.swift; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; 4F70C1E31DE8DCA7006380B7 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = ""; }; 4F70C1E51DE8DCA7006380B7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 4F70C1FD1DE8E662006380B7 /* Loop Status Extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Loop Status Extension.entitlements"; sourceTree = ""; }; - 4F70C20F1DE8FAC5006380B7 /* StatusExtensionDataManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusExtensionDataManager.swift; sourceTree = ""; }; + 4F70C20F1DE8FAC5006380B7 /* ExtensionDataManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ExtensionDataManager.swift; sourceTree = ""; }; 4F70C2111DE900EA006380B7 /* StatusExtensionContext.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusExtensionContext.swift; sourceTree = ""; }; 4F75288B1DFE1DC600C322D6 /* LoopUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = LoopUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 4F75288D1DFE1DC600C322D6 /* LoopUI.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = LoopUI.h; sourceTree = ""; }; 4F75288E1DFE1DC600C322D6 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 4FC8C8001DEB93E400A1452E /* NSUserDefaults+StatusExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "NSUserDefaults+StatusExtension.swift"; path = "Common/Extensions/NSUserDefaults+StatusExtension.swift"; sourceTree = ""; }; + 4F75F00120FCFE8C00B5570E /* GlucoseChartScene.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseChartScene.swift; sourceTree = ""; }; + 4F7E8AC420E2AB9600AEA65E /* Date.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Date.swift; sourceTree = ""; }; + 4F7E8AC620E2AC0300AEA65E /* WatchPredictedGlucose.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchPredictedGlucose.swift; sourceTree = ""; }; + 4F82654F20E69F9A0031A8F5 /* HUDInterfaceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HUDInterfaceController.swift; sourceTree = ""; }; + 4FC8C8001DEB93E400A1452E /* NSUserDefaults+StatusExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSUserDefaults+StatusExtension.swift"; sourceTree = ""; }; + 4FDDD23620DC51DF00D04B16 /* LoopDataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopDataManager.swift; sourceTree = ""; }; 4FF4D0FF1E18374700846527 /* WatchContext.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WatchContext.swift; sourceTree = ""; }; - C10428961D17BAD400DD539A /* NightscoutUploadKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = NightscoutUploadKit.framework; path = Carthage/Build/iOS/NightscoutUploadKit.framework; sourceTree = ""; }; - C12F21A61DFA79CB00748193 /* recommend_tamp_basal_very_low_end_in_range.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = recommend_tamp_basal_very_low_end_in_range.json; sourceTree = ""; }; - C15713811DAC6983005BC4D2 /* MealBolusNightscoutTreatment.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MealBolusNightscoutTreatment.swift; sourceTree = ""; }; - C17884621D51A7A400405663 /* BatteryIndicator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BatteryIndicator.swift; sourceTree = ""; }; - C18C8C501D5A351900E043FB /* NightscoutDataManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NightscoutDataManager.swift; sourceTree = ""; }; - C1C73EF81DE3D0230022FC89 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/InfoPlist.strings; sourceTree = ""; }; - C1C73F031DE3D0250022FC89 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; - C1C73F091DE3D0260022FC89 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/InfoPlist.strings; sourceTree = ""; }; - C1C73F0E1DE3D0270022FC89 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/InfoPlist.strings; sourceTree = ""; }; + 4FFEDFBE20E5CF22000BFC58 /* ChartHUDController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChartHUDController.swift; sourceTree = ""; }; + 7D23667C21250C7E0028B67D /* LocalizedString.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = LocalizedString.swift; path = LoopUI/Common/LocalizedString.swift; sourceTree = SOURCE_ROOT; }; + 7D9BEEE62335A6B3005DCFD6 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; + 7D9BEEE82335A6B9005DCFD6 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; + 7D9BEEE92335A6BB005DCFD6 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; + 7D9BEEEA2335A6BC005DCFD6 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; + 7D9BEEEB2335A6BD005DCFD6 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; + 7D9BEEEC2335A6BE005DCFD6 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; + 7D9BEEED2335A6BF005DCFD6 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Localizable.strings; sourceTree = ""; }; + 7D9BEEEE2335A6BF005DCFD6 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = ""; }; + 7D9BEEEF2335A6C0005DCFD6 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; + 7D9BEEF02335A6C1005DCFD6 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; + 7D9BEEF42335CF8D005DCFD6 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; + 7D9BEEF62335CF90005DCFD6 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; + 7D9BEEF72335CF91005DCFD6 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; + 7D9BEEF82335CF93005DCFD6 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; + 7D9BEEF92335CF93005DCFD6 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; + 7D9BEEFA2335CF94005DCFD6 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; + 7D9BEEFB2335CF95005DCFD6 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Localizable.strings; sourceTree = ""; }; + 7D9BEEFC2335CF96005DCFD6 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = ""; }; + 7D9BEEFD2335CF97005DCFD6 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; + 7D9BEEFE2335CF97005DCFD6 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; + 7D9BEF002335D67D005DCFD6 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Main.strings; sourceTree = ""; }; + 7D9BEF022335D687005DCFD6 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Main.strings"; sourceTree = ""; }; + 7D9BEF042335D68A005DCFD6 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Main.strings; sourceTree = ""; }; + 7D9BEF062335D68C005DCFD6 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Main.strings; sourceTree = ""; }; + 7D9BEF082335D68D005DCFD6 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Main.strings; sourceTree = ""; }; + 7D9BEF0A2335D68F005DCFD6 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Main.strings; sourceTree = ""; }; + 7D9BEF0C2335D690005DCFD6 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Main.strings; sourceTree = ""; }; + 7D9BEF0E2335D691005DCFD6 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Main.strings; sourceTree = ""; }; + 7D9BEF102335D693005DCFD6 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Main.strings; sourceTree = ""; }; + 7D9BEF122335D694005DCFD6 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Main.strings; sourceTree = ""; }; + 7D9BEF132335EC4B005DCFD6 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Intents.strings; sourceTree = ""; }; + 7D9BEF182335EC4C005DCFD6 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Main.strings; sourceTree = ""; }; + 7D9BEF1A2335EC4C005DCFD6 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; + 7D9BEF282335EC4E005DCFD6 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; + 7D9BEF292335EC58005DCFD6 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Intents.strings"; sourceTree = ""; }; + 7D9BEF2E2335EC59005DCFD6 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Main.strings"; sourceTree = ""; }; + 7D9BEF302335EC59005DCFD6 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = ""; }; + 7D9BEF3E2335EC5A005DCFD6 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = ""; }; + 7D9BEF3F2335EC62005DCFD6 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Intents.strings; sourceTree = ""; }; + 7D9BEF442335EC62005DCFD6 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Main.strings; sourceTree = ""; }; + 7D9BEF462335EC62005DCFD6 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = ""; }; + 7D9BEF542335EC64005DCFD6 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = ""; }; + 7D9BEF552335EC6E005DCFD6 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Intents.strings; sourceTree = ""; }; + 7D9BEF5A2335EC6E005DCFD6 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Main.strings; sourceTree = ""; }; + 7D9BEF5C2335EC6F005DCFD6 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Localizable.strings; sourceTree = ""; }; + 7D9BEF6A2335EC70005DCFD6 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Localizable.strings; sourceTree = ""; }; + 7D9BEF6B2335EC7D005DCFD6 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Intents.strings; sourceTree = ""; }; + 7D9BEF702335EC7D005DCFD6 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Main.strings; sourceTree = ""; }; + 7D9BEF722335EC7D005DCFD6 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = ""; }; + 7D9BEF802335EC7E005DCFD6 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = ""; }; + 7D9BEF812335EC8B005DCFD6 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Intents.strings; sourceTree = ""; }; + 7D9BEF862335EC8B005DCFD6 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Main.strings; sourceTree = ""; }; + 7D9BEF882335EC8C005DCFD6 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Localizable.strings; sourceTree = ""; }; + 7D9BEF962335EC8D005DCFD6 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/Localizable.strings; sourceTree = ""; }; + 7D9BF13A23370E8B005DCFD6 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/Intents.strings; sourceTree = ""; }; + 7D9BF13E23370E8C005DCFD6 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/Main.strings; sourceTree = ""; }; + 7D9BF13F23370E8C005DCFD6 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/Localizable.strings; sourceTree = ""; }; + 7D9BF14623370E8D005DCFD6 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/Localizable.strings; sourceTree = ""; }; + 7E69CFFB2A16A77E00203CBD /* ResetLoopManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetLoopManager.swift; sourceTree = ""; }; + 80F864E52433BF5D0026EC26 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/InfoPlist.strings; sourceTree = ""; }; + 84AA81D22A4A27A3000B658B /* LoopWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopWidgets.swift; sourceTree = ""; }; + 84AA81D52A4A28AF000B658B /* WidgetBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetBackground.swift; sourceTree = ""; }; + 84AA81D72A4A2910000B658B /* StatusWidgetTimelimeEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusWidgetTimelimeEntry.swift; sourceTree = ""; }; + 84AA81DA2A4A2973000B658B /* Date.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Date.swift; sourceTree = ""; }; + 84AA81DC2A4A2999000B658B /* StatusWidgetTimelineProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusWidgetTimelineProvider.swift; sourceTree = ""; }; + 84AA81E22A4A36FB000B658B /* SystemActionLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemActionLink.swift; sourceTree = ""; }; + 84AA81E42A4A3981000B658B /* DeeplinkManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeeplinkManager.swift; sourceTree = ""; }; + 84AA81E62A4A4DEF000B658B /* PumpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpView.swift; sourceTree = ""; }; + 84D2879E2AC756C8007ED283 /* ContentMargin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentMargin.swift; sourceTree = ""; }; + 891B508424342BE1005DA578 /* CarbAndBolusFlowViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbAndBolusFlowViewModel.swift; sourceTree = ""; }; + 892A5D29222EF60A008961AB /* MockKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; name = MockKit.framework; path = Carthage/Build/iOS/MockKit.framework; sourceTree = SOURCE_ROOT; }; + 892A5D2B222EF60A008961AB /* MockKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; name = MockKitUI.framework; path = Carthage/Build/iOS/MockKitUI.framework; sourceTree = SOURCE_ROOT; }; + 892A5D58222F0A27008961AB /* Debug.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Debug.swift; sourceTree = ""; }; + 892A5D5A222F0D7C008961AB /* LoopTestingKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; name = LoopTestingKit.framework; path = Carthage/Build/iOS/LoopTestingKit.framework; sourceTree = SOURCE_ROOT; }; + 892A5D682230C41D008961AB /* RangeReplaceableCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RangeReplaceableCollection.swift; sourceTree = ""; }; + 892FB4CC22040104005293EC /* OverridePresetRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverridePresetRow.swift; sourceTree = ""; }; + 892FB4CE220402C0005293EC /* OverrideSelectionController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverrideSelectionController.swift; sourceTree = ""; }; + 894F6DD2243BCBDB00CCE676 /* Environment+SizeClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Environment+SizeClass.swift"; sourceTree = ""; }; + 894F6DD6243C047300CCE676 /* View+Position.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = "View+Position.swift"; path = "WatchApp Extension/Views/View+Position.swift"; sourceTree = SOURCE_ROOT; }; + 894F6DD8243C060600CCE676 /* ScalablePositionedText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScalablePositionedText.swift; sourceTree = ""; }; + 894F6DDA243C07CF00CCE676 /* GramLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GramLabel.swift; sourceTree = ""; }; + 894F6DDC243C0A2300CCE676 /* CarbAmountLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbAmountLabel.swift; sourceTree = ""; }; + 895788A5242E69A1002CB114 /* AbsorptionTimeSelection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AbsorptionTimeSelection.swift; sourceTree = ""; }; + 895788A6242E69A1002CB114 /* CarbAndBolusFlow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbAndBolusFlow.swift; sourceTree = ""; }; + 895788A7242E69A1002CB114 /* BolusInput.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BolusInput.swift; sourceTree = ""; }; + 895788A9242E69A1002CB114 /* Color.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Color.swift; sourceTree = ""; }; + 895788AA242E69A1002CB114 /* CircularAccessoryButtonStyle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CircularAccessoryButtonStyle.swift; sourceTree = ""; }; + 895788AB242E69A2002CB114 /* ActionButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActionButton.swift; sourceTree = ""; }; + 895FE0942201234000FCF18A /* OverrideSelectionViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideSelectionViewController.swift; sourceTree = ""; }; + 8968B1112408B3520074BB48 /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = ""; }; + 8968B113240C55F10074BB48 /* LoopSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopSettingsTests.swift; sourceTree = ""; }; + 897A5A9524C2175B00C4E71D /* BolusEntryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusEntryView.swift; sourceTree = ""; }; + 897A5A9824C22DE800C4E71D /* BolusEntryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusEntryViewModel.swift; sourceTree = ""; }; + 898ECA5E218ABD17001E9D35 /* GlucoseChartScaler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GlucoseChartScaler.swift; sourceTree = ""; }; + 898ECA5F218ABD17001E9D35 /* GlucoseChartData.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GlucoseChartData.swift; sourceTree = ""; }; + 898ECA62218ABD21001E9D35 /* ComplicationChartManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ComplicationChartManager.swift; sourceTree = ""; }; + 898ECA64218ABD9A001E9D35 /* CGRect.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CGRect.swift; sourceTree = ""; }; + 898ECA66218ABDA8001E9D35 /* WatchApp Extension-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "WatchApp Extension-Bridging-Header.h"; sourceTree = ""; }; + 898ECA67218ABDA8001E9D35 /* CLKTextProvider+Compound.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "CLKTextProvider+Compound.m"; sourceTree = ""; }; + 898ECA68218ABDA9001E9D35 /* CLKTextProvider+Compound.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "CLKTextProvider+Compound.h"; sourceTree = ""; }; + 899433B723FE129700FA4BEA /* OverrideBadgeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverrideBadgeView.swift; sourceTree = ""; }; + 89A1B66D24ABFDF800117AC2 /* SupportedBolusVolumesUserInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportedBolusVolumesUserInfo.swift; sourceTree = ""; }; + 89A605E224327DFE009C1096 /* CarbAmountInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbAmountInput.swift; sourceTree = ""; }; + 89A605E424327F45009C1096 /* DoseVolumeInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoseVolumeInput.swift; sourceTree = ""; }; + 89A605E62432860C009C1096 /* PeriodicPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeriodicPublisher.swift; sourceTree = ""; }; + 89A605E824328862009C1096 /* Checkmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Checkmark.swift; sourceTree = ""; }; + 89A605EA243288E4009C1096 /* TopDownTriangle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopDownTriangle.swift; sourceTree = ""; }; + 89A605EC24328972009C1096 /* BolusArrow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusArrow.swift; sourceTree = ""; }; + 89A605EE2432925D009C1096 /* CompletionCheckmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionCheckmark.swift; sourceTree = ""; }; + 89A605F02432BD18009C1096 /* BolusConfirmationVisual.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusConfirmationVisual.swift; sourceTree = ""; }; + 89ADE13A226BFA0F0067222B /* TestingScenariosManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestingScenariosManager.swift; sourceTree = ""; }; + 89CA2B2F226C0161004D9350 /* DirectoryObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectoryObserver.swift; sourceTree = ""; }; + 89CA2B31226C18B8004D9350 /* TestingScenariosTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestingScenariosTableViewController.swift; sourceTree = ""; }; + 89CA2B3C226E6B13004D9350 /* LocalTestingScenariosManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalTestingScenariosManager.swift; sourceTree = ""; }; + 89CAB36224C8FE95009EE3CE /* PredictedGlucoseChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PredictedGlucoseChartView.swift; sourceTree = ""; }; + 89D1503D24B506EB00EDE253 /* Dictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dictionary.swift; sourceTree = ""; }; + 89D6953D23B6DF8A002B3066 /* PotentialCarbEntryTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PotentialCarbEntryTableViewCell.swift; sourceTree = ""; }; + 89E08FC1242E73DC000D719B /* CarbAmountPositionKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbAmountPositionKey.swift; sourceTree = ""; }; + 89E08FC3242E73F0000D719B /* GramLabelPositionKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GramLabelPositionKey.swift; sourceTree = ""; }; + 89E08FC5242E7506000D719B /* CarbAndDateInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbAndDateInput.swift; sourceTree = ""; }; + 89E08FC7242E76E9000D719B /* AnyTransition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnyTransition.swift; sourceTree = ""; }; + 89E08FC9242E7714000D719B /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = ""; }; + 89E08FCB242E790C000D719B /* Comparable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Comparable.swift; sourceTree = ""; }; + 89E08FCF242E8B2B000D719B /* BolusConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusConfirmationView.swift; sourceTree = ""; }; + 89E267FB2292456700A3F2AF /* FeatureFlags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlags.swift; sourceTree = ""; }; + 89E267FE229267DF00A3F2AF /* Optional.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Optional.swift; sourceTree = ""; }; + 89F9118E24352F1600ECCAF3 /* DigitalCrownRotation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DigitalCrownRotation.swift; sourceTree = ""; }; + 89F9119124358E2B00ECCAF3 /* CarbEntryInputMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbEntryInputMode.swift; sourceTree = ""; }; + 89F9119324358E4500ECCAF3 /* CarbAbsorptionTime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbAbsorptionTime.swift; sourceTree = ""; }; + 89F9119524358E6900ECCAF3 /* BolusPickerValues.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusPickerValues.swift; sourceTree = ""; }; + 89FE21AC24AC57E30033F501 /* Collection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Collection.swift; sourceTree = ""; }; + A900531A28D60862000BC15B /* Loop.shortcut */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop.shortcut; sourceTree = ""; }; + A900531B28D608CA000BC15B /* Cancel Override.shortcut */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Cancel Override.shortcut"; sourceTree = ""; }; + A900531C28D6090D000BC15B /* Loop Remote Overrides.shortcut */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Loop Remote Overrides.shortcut"; sourceTree = ""; }; + A91D2A3E26CF0FF80023B075 /* IconTitleSubtitleTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconTitleSubtitleTableViewCell.swift; sourceTree = ""; }; + A91E4C2024F867A700BE9213 /* StoredAlertTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredAlertTests.swift; sourceTree = ""; }; + A91E4C2224F86F1000BE9213 /* CriticalEventLogExportManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CriticalEventLogExportManagerTests.swift; sourceTree = ""; }; + A9347F2E24E7508A00C99C34 /* WatchHistoricalCarbs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchHistoricalCarbs.swift; sourceTree = ""; }; + A9347F3024E7521800C99C34 /* CarbBackfillRequestUserInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbBackfillRequestUserInfo.swift; sourceTree = ""; }; + A951C5FF23E8AB51003E26DC /* Version.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Version.xcconfig; sourceTree = ""; }; + A963B279252CEBAE0062AA12 /* SetBolusUserInfoTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetBolusUserInfoTests.swift; sourceTree = ""; }; + A966152423EA5A25005D8B29 /* DefaultAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = DefaultAssets.xcassets; sourceTree = ""; }; + A966152523EA5A25005D8B29 /* DerivedAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = DerivedAssets.xcassets; sourceTree = ""; }; + A966152823EA5A37005D8B29 /* DefaultAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = DefaultAssets.xcassets; sourceTree = ""; }; + A966152923EA5A37005D8B29 /* DerivedAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = DerivedAssets.xcassets; sourceTree = ""; }; + A967D94B24F99B9300CDDF8A /* OutputStream.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutputStream.swift; sourceTree = ""; }; + A96DAC232838325900D94E38 /* DiagnosticLog.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DiagnosticLog.swift; sourceTree = ""; }; + A96DAC292838EF8A00D94E38 /* DiagnosticLogTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DiagnosticLogTests.swift; sourceTree = ""; }; + A96DAC2B2838F31200D94E38 /* SharedLogging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedLogging.swift; sourceTree = ""; }; + A977A2F324ACFECF0059C207 /* CriticalEventLogExportManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CriticalEventLogExportManager.swift; sourceTree = ""; }; + A97F250725E056D500F0EE19 /* OnboardingManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingManager.swift; sourceTree = ""; }; + A98556842493F901000FD662 /* AlertStore+SimulatedCoreData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AlertStore+SimulatedCoreData.swift"; sourceTree = ""; }; + A987CD4824A58A0100439ADC /* ZipArchive.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZipArchive.swift; sourceTree = ""; }; + A999D40524663D18004C89D4 /* PumpManagerError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpManagerError.swift; sourceTree = ""; }; + A9A056B224B93C62007CF06D /* CriticalEventLogExportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CriticalEventLogExportView.swift; sourceTree = ""; }; + A9A056B424B94123007CF06D /* CriticalEventLogExportViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CriticalEventLogExportViewModel.swift; sourceTree = ""; }; + A9B607AF247F000F00792BE4 /* UserNotifications+Loop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserNotifications+Loop.swift"; sourceTree = ""; }; + A9B996EF27235191002DC09C /* LoopWarning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopWarning.swift; sourceTree = ""; }; + A9B996F127238705002DC09C /* DosingDecisionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DosingDecisionStore.swift; sourceTree = ""; }; + A9BD28E6272226B40071DF15 /* TestLocalizedError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestLocalizedError.swift; sourceTree = ""; }; + A9C1719625366F780053BCBD /* WatchHistoricalGlucoseTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchHistoricalGlucoseTest.swift; sourceTree = ""; }; + A9C62D832331700D00535612 /* DiagnosticLog+Subsystem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "DiagnosticLog+Subsystem.swift"; sourceTree = ""; }; + A9C62D852331703000535612 /* Service.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Service.swift; sourceTree = ""; }; + A9C62D862331703000535612 /* LoggingServicesManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoggingServicesManager.swift; sourceTree = ""; }; + A9C62D872331703000535612 /* ServicesManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ServicesManager.swift; sourceTree = ""; }; + A9C62D8D2331708700535612 /* AuthenticationTableViewCell+NibLoadable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "AuthenticationTableViewCell+NibLoadable.swift"; sourceTree = ""; }; + A9CBE457248AB564008E7BA2 /* DoseStore+SimulatedCoreData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DoseStore+SimulatedCoreData.swift"; sourceTree = ""; }; + A9CBE459248ACBE1008E7BA2 /* DosingDecisionStore+SimulatedCoreData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DosingDecisionStore+SimulatedCoreData.swift"; sourceTree = ""; }; + A9CBE45B248ACC03008E7BA2 /* SettingsStore+SimulatedCoreData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SettingsStore+SimulatedCoreData.swift"; sourceTree = ""; }; + A9D5C5B525DC6C6A00534873 /* LoopAppManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopAppManager.swift; sourceTree = ""; }; + A9DAE7CF2332D77F006AE942 /* LoopTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoopTests.swift; sourceTree = ""; }; + A9DCF2D525B0F3C500C89088 /* LoopUIColorPalette+Default.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LoopUIColorPalette+Default.swift"; sourceTree = ""; }; + A9DF02CA24F72B9E00B7C988 /* CriticalEventLogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CriticalEventLogTests.swift; sourceTree = ""; }; + A9DFAFB224F0415E00950D1E /* CarbBackfillRequestUserInfoTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbBackfillRequestUserInfoTests.swift; sourceTree = ""; }; + A9DFAFB424F048A000950D1E /* WatchHistoricalCarbsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchHistoricalCarbsTests.swift; sourceTree = ""; }; + A9F5F1F4251050EC00E7C8A4 /* ZipArchiveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZipArchiveTests.swift; sourceTree = ""; }; + A9F66FC2247F451500096EA7 /* UIDevice+Loop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIDevice+Loop.swift"; sourceTree = ""; }; + A9F703722489BC8500C98AD8 /* CarbStore+SimulatedCoreData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CarbStore+SimulatedCoreData.swift"; sourceTree = ""; }; + A9F703742489C9A000C98AD8 /* GlucoseStore+SimulatedCoreData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GlucoseStore+SimulatedCoreData.swift"; sourceTree = ""; }; + A9F703762489D8AA00C98AD8 /* PersistentDeviceLog+SimulatedCoreData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PersistentDeviceLog+SimulatedCoreData.swift"; sourceTree = ""; }; + A9FB75F0252BE320004C7D3F /* BolusDosingDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusDosingDecision.swift; sourceTree = ""; }; + B4001CED28CBBC82002FB414 /* AlertManagementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertManagementView.swift; sourceTree = ""; }; + B40D07C6251A89D500C1C6D7 /* GlucoseDisplay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseDisplay.swift; sourceTree = ""; }; + B42C951324A3C76000857C73 /* CGMStatusHUDViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CGMStatusHUDViewModel.swift; sourceTree = ""; }; + B42D124228D371C400E43D22 /* AlertMuter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AlertMuter.swift; sourceTree = ""; }; + B43CF07D29434EC4008A520B /* HowMuteAlertWorkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HowMuteAlertWorkView.swift; sourceTree = ""; }; + B43DA44024D9C12100CAFF4E /* DismissibleHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DismissibleHostingController.swift; sourceTree = ""; }; + B470F5832AB22B5100049695 /* StatefulPluggable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatefulPluggable.swift; sourceTree = ""; }; + B48B0BAB24900093009A48DE /* PumpStatusHUDView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpStatusHUDView.swift; sourceTree = ""; }; + B490A03C24D04F9400F509FA /* Color.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Color.swift; sourceTree = ""; }; + B490A03E24D0550F00F509FA /* GlucoseRangeCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseRangeCategory.swift; sourceTree = ""; }; + B490A04024D0559D00F509FA /* DeviceLifecycleProgressState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceLifecycleProgressState.swift; sourceTree = ""; }; + B490A04224D055D900F509FA /* DeviceStatusHighlight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStatusHighlight.swift; sourceTree = ""; }; + B4BC56372518DEA900373647 /* CGMStatusHUDViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGMStatusHUDViewModelTests.swift; sourceTree = ""; }; + B4C9859325D5A3BB009FD9CA /* StatusBadgeHUDView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusBadgeHUDView.swift; sourceTree = ""; }; + B4CAD8782549D2540057946B /* LoopCompletionFreshnessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopCompletionFreshnessTests.swift; sourceTree = ""; }; + B4D4534028E5CA7900F1A8D9 /* AlertMuterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertMuterTests.swift; sourceTree = ""; }; + B4D620D324D9EDB900043B3C /* GuidanceColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuidanceColors.swift; sourceTree = ""; }; + B4D904402AA8989100CBD826 /* StatefulPluginManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatefulPluginManager.swift; sourceTree = ""; }; + B4E2022F2661063E009421B5 /* AutomaticDosingStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomaticDosingStatus.swift; sourceTree = ""; }; + B4E96D4A248A6B6E002DABAD /* DeviceStatusHUDView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStatusHUDView.swift; sourceTree = ""; }; + B4E96D4E248A6E20002DABAD /* CGMStatusHUDView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGMStatusHUDView.swift; sourceTree = ""; }; + B4E96D52248A7386002DABAD /* GlucoseValueHUDView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseValueHUDView.swift; sourceTree = ""; }; + B4E96D54248A7509002DABAD /* GlucoseTrendHUDView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseTrendHUDView.swift; sourceTree = ""; }; + B4E96D56248A7B0F002DABAD /* StatusHighlightHUDView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusHighlightHUDView.swift; sourceTree = ""; }; + B4E96D58248A7F9A002DABAD /* StatusHighlightHUDView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = StatusHighlightHUDView.xib; sourceTree = ""; }; + B4E96D5A248A8229002DABAD /* StatusBarHUDView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusBarHUDView.swift; sourceTree = ""; }; + B4E96D5C248A82A2002DABAD /* StatusBarHUDView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = StatusBarHUDView.xib; sourceTree = ""; }; + B4F3D25024AF890C0095CE44 /* BluetoothStateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothStateManager.swift; sourceTree = ""; }; + B4FEEF7C24B8A71F00A8DF9B /* DeviceDataManager+DeviceStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DeviceDataManager+DeviceStatus.swift"; sourceTree = ""; }; + B66D1F202E6A5D6500471149 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; }; + B66D1F222E6A5D6500471149 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; }; + B66D1F242E6A5D6500471149 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; }; + B66D1F262E6A5D6500471149 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; + B66D1F282E6A5D6500471149 /* mul */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; name = mul; path = mul.lproj/MainInterface.xcstrings; sourceTree = ""; }; + B66D1F292E6A5D6500471149 /* mul */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; name = mul; path = mul.lproj/LaunchScreen.xcstrings; sourceTree = ""; }; + B66D1F2A2E6A5D6500471149 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; + B66D1F2C2E6A5D6500471149 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; + B66D1F2E2E6A5D6600471149 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; }; + B66D1F302E6A5D6600471149 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; }; + B66D1F322E6A5D6600471149 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; + B66D1F342E6A5D6600471149 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; + B66D1F362E6A5D6600471149 /* ckcomplication.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = ckcomplication.xcstrings; sourceTree = ""; }; + B66D1F382E6A5D6600471149 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; }; + B66D1F3A2E6A5D6600471149 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; + B66D1F3D2E6A5D6600471149 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; + B66D1F3F2E6A5D6600471149 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; }; + B66D1F412E6A5D6600471149 /* mul */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; name = mul; path = mul.lproj/Interface.xcstrings; sourceTree = ""; }; + B66D1F422E6A5D6600471149 /* mul */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; name = mul; path = mul.lproj/Main.xcstrings; sourceTree = ""; }; + B6F22EF52E95A03600CCA05F /* ce */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ce; path = ce.lproj/Intents.strings; sourceTree = ""; }; + B6F22EF72E95A03800CCA05F /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Intents.strings; sourceTree = ""; }; + B6F22EF92E95A03C00CCA05F /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Intents.strings; sourceTree = ""; }; + C1004DEF2981F5B700B8CF94 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/InfoPlist.strings; sourceTree = ""; }; + C1004DFD2981F67A00B8CF94 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/InfoPlist.strings; sourceTree = ""; }; + C1004E052981F6A100B8CF94 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/InfoPlist.strings; sourceTree = ""; }; + C1004E0D2981F6E200B8CF94 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/InfoPlist.strings; sourceTree = ""; }; + C1004E152981F6F500B8CF94 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/InfoPlist.strings; sourceTree = ""; }; + C1004E1D2981F72D00B8CF94 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/InfoPlist.strings; sourceTree = ""; }; + C1004E2C2981F75B00B8CF94 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/InfoPlist.strings; sourceTree = ""; }; + C1004E302981F77B00B8CF94 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/InfoPlist.strings; sourceTree = ""; }; + C101947127DD473C004E7EB8 /* MockKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MockKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + C1092BFD29F8116700AE3D1C /* apply-info-customizations.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "apply-info-customizations.sh"; sourceTree = ""; }; + C110888C2A3913C600BA4898 /* BuildDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildDetails.swift; sourceTree = ""; }; + C11AA5C7258736CF00BDE12F /* DerivedAssetsBase.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = DerivedAssetsBase.xcassets; sourceTree = ""; }; + C11B9D5D286778D000500CF8 /* LoopKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + C11B9D60286779C000500CF8 /* MockKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MockKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + C11B9D61286779C000500CF8 /* MockKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MockKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + C11BD0542523CFED00236B08 /* SimpleBolusViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleBolusViewModel.swift; sourceTree = ""; }; + C1201E2B23ECDBD0002DA84A /* WatchContextRequestUserInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchContextRequestUserInfo.swift; sourceTree = ""; }; + C12CB9AC23106A3C00F84978 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Intents.strings; sourceTree = ""; }; + C12CB9AE23106A5C00F84978 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Intents.strings; sourceTree = ""; }; + C12CB9B023106A5F00F84978 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Intents.strings; sourceTree = ""; }; + C12CB9B223106A6000F84978 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Intents.strings"; sourceTree = ""; }; + C12CB9B423106A6100F84978 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Intents.strings; sourceTree = ""; }; + C12CB9B623106A6200F84978 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Intents.strings; sourceTree = ""; }; + C12CB9B823106A6300F84978 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Intents.strings; sourceTree = ""; }; + C13072B92A76AF31009A7C58 /* live_capture_predicted_glucose.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = live_capture_predicted_glucose.json; sourceTree = ""; }; + C13DA2AF24F6C7690098BB29 /* UIViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewController.swift; sourceTree = ""; }; + C148CEE624FD91BD00711B3B /* DeliveryUncertaintyAlertManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeliveryUncertaintyAlertManager.swift; sourceTree = ""; }; + C14952142995822A0095AA84 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/InfoPlist.strings; sourceTree = ""; }; + C159C8192867857000A86EC0 /* LoopKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + C159C8212867859800A86EC0 /* MockKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MockKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + C159C82E286787EF00A86EC0 /* LoopKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + C165756E2534C468004AE16E /* SimpleBolusViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleBolusViewModelTests.swift; sourceTree = ""; }; + C16575702538A36B004AE16E /* CGMStalenessMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGMStalenessMonitor.swift; sourceTree = ""; }; + C16575722538AFF6004AE16E /* CGMStalenessMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGMStalenessMonitorTests.swift; sourceTree = ""; }; + C16575742539FD60004AE16E /* LoopCoreConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopCoreConstants.swift; sourceTree = ""; }; + C16B983D26B4893300256B05 /* DoseEnactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoseEnactor.swift; sourceTree = ""; }; + C16B983F26B4898800256B05 /* DoseEnactorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoseEnactorTests.swift; sourceTree = ""; }; + C16DA84122E8E112008624C2 /* PluginManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginManager.swift; sourceTree = ""; }; + C16FC0AF2A99392F0025E239 /* live_capture_input.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = live_capture_input.json; sourceTree = ""; }; + C1742331259BEADC00399C9D /* ManualEntryDoseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualEntryDoseView.swift; sourceTree = ""; }; + C174233B259BEB0F00399C9D /* ManualEntryDoseViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualEntryDoseViewModel.swift; sourceTree = ""; }; + C1750AEB255B013300B8011C /* Minizip.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Minizip.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + C1777A6525A125F100595963 /* ManualEntryDoseViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualEntryDoseViewModelTests.swift; sourceTree = ""; }; + C17824991E1999FA00D9D25C /* CaseCountable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CaseCountable.swift; sourceTree = ""; }; + C178249F1E19CF9800D9D25C /* GlucoseThresholdTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GlucoseThresholdTableViewController.swift; sourceTree = ""; }; + C17824A41E1AD4D100D9D25C /* ManualBolusRecommendation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ManualBolusRecommendation.swift; sourceTree = ""; }; + C1814B85225E507C008D2D8E /* Sequence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sequence.swift; sourceTree = ""; }; + C18913B42524F24C007B0683 /* DeviceDataManager+SimpleBolusViewModelDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DeviceDataManager+SimpleBolusViewModelDelegate.swift"; sourceTree = ""; }; + C18A491222FCC22800FDA733 /* build-derived-assets.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = "build-derived-assets.sh"; sourceTree = ""; }; + C18A491322FCC22900FDA733 /* make_scenario.py */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.python; path = make_scenario.py; sourceTree = ""; }; + C18A491522FCC22900FDA733 /* copy-plugins.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = "copy-plugins.sh"; sourceTree = ""; }; + C19008FD25225D3900721625 /* SimpleBolusCalculator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleBolusCalculator.swift; sourceTree = ""; }; + C19008FF252271BB00721625 /* SimpleBolusCalculatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleBolusCalculatorTests.swift; sourceTree = ""; }; + C191D2A025B3ACAA00C26C0B /* DosingStrategySelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DosingStrategySelectionView.swift; sourceTree = ""; }; + C19C8BB928651DFB0056D5E4 /* TrueTime.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = TrueTime.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + C19C8BC228651EAE0056D5E4 /* LoopTestingKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopTestingKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + C19C8BC728651F0A0056D5E4 /* MockKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MockKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + C19C8C20286776C20056D5E4 /* LoopKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + C19E387B298638CE00851444 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/InfoPlist.strings; sourceTree = ""; }; + C19E96DD23D2733F003F79B0 /* LoopCompletionFreshness.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopCompletionFreshness.swift; sourceTree = ""; }; + C19F496225630504003632D7 /* Minizip.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Minizip.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + C1AD41FF256D61E500164DDD /* Comparable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Comparable.swift; sourceTree = ""; }; + C1AF062229426300002C1B19 /* ManualGlucoseEntryRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualGlucoseEntryRow.swift; sourceTree = ""; }; + C1BCB5AF298309C4001C50FF /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/InfoPlist.strings; sourceTree = ""; }; + C1C247882995823200371B88 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Intents.strings; sourceTree = ""; }; + C1C247892995823200371B88 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = ""; }; + C1C2478B2995823200371B88 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/InfoPlist.strings; sourceTree = ""; }; + C1C3127A297E4BFE00296DA4 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Main.strings; sourceTree = ""; }; + C1C3127C297E4BFE00296DA4 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = ""; }; + C1C3127F297E4C0400296DA4 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Intents.strings; sourceTree = ""; }; + C1C5357529C6346A00E32DF9 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Intents.strings; sourceTree = ""; }; + C1C660D0252E4DD5009B5C32 /* LoopConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopConstants.swift; sourceTree = ""; }; + C1D0B62F2986D4D90098D215 /* LocalizedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedString.swift; sourceTree = ""; }; + C1D197FE232CF92D0096D646 /* capture-build-details.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; path = "capture-build-details.sh"; sourceTree = ""; }; + C1D289B422F90A52003FFBD9 /* BasalDeliveryState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasalDeliveryState.swift; sourceTree = ""; }; + C1D476B32A8ED179002C1C87 /* LoopAlgorithmTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopAlgorithmTests.swift; sourceTree = ""; }; + C1DA986B2843B6F9001D04CC /* PersistedProperty.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistedProperty.swift; sourceTree = ""; }; + C1DE5D22251BFC4D00439E49 /* SimpleBolusView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SimpleBolusView.swift; sourceTree = ""; }; + C1E2773D224177C000354103 /* ClockKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ClockKit.framework; path = Platforms/WatchOS.platform/Developer/SDKs/WatchOS.sdk/System/Library/Frameworks/ClockKit.framework; sourceTree = DEVELOPER_DIR; }; + C1E2774722433D7A00354103 /* MKRingProgressView.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MKRingProgressView.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + C1E3862428247B7100F561A4 /* StoredLoopNotRunningNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredLoopNotRunningNotification.swift; sourceTree = ""; }; + C1E9CB5A295101570022387B /* install-scenarios.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "install-scenarios.sh"; sourceTree = ""; }; + C1EE9E802A38D0FB0064784A /* BuildDetails.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = BuildDetails.plist; sourceTree = ""; }; + C1EF747128D6A44A00C8C083 /* CrashRecoveryManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrashRecoveryManager.swift; sourceTree = ""; }; + C1F2075B26D6F9B0007AB7EB /* AppExpirationAlerter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppExpirationAlerter.swift; sourceTree = ""; }; + C1F48FF62995821600C8BD69 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/InfoPlist.strings; sourceTree = ""; }; + C1F7822527CC056900C0919A /* SettingsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManager.swift; sourceTree = ""; }; + C1F8B1D122375E4200DD66CF /* BolusProgressTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusProgressTableViewCell.swift; sourceTree = ""; }; + C1F8B1DB223862D500DD66CF /* BolusProgressTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = BolusProgressTableViewCell.xib; sourceTree = ""; }; + C1FB428B217806A300FAB378 /* StateColorPalette.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StateColorPalette.swift; sourceTree = ""; }; + C1FB428E217921D600FAB378 /* PumpManagerUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpManagerUI.swift; sourceTree = ""; }; + DD3DBD282A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegralRetrospectiveCorrectionSelectionView.swift; sourceTree = ""; }; + DDC389F52A2B61750066E2E8 /* ApplicationFactorStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationFactorStrategy.swift; sourceTree = ""; }; + DDC389F72A2B620B0066E2E8 /* GlucoseBasedApplicationFactorStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseBasedApplicationFactorStrategy.swift; sourceTree = ""; }; + DDC389F92A2B62470066E2E8 /* ConstantApplicationFactorStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConstantApplicationFactorStrategy.swift; sourceTree = ""; }; + DDC389FB2A2BC6670066E2E8 /* SettingsView+algorithmExperimentsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SettingsView+algorithmExperimentsSection.swift"; sourceTree = ""; }; + DDC389FD2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseBasedApplicationFactorSelectionView.swift; sourceTree = ""; }; + E90909CC24E34AC500F963D2 /* high_and_rising_with_cob_momentum_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = high_and_rising_with_cob_momentum_effect.json; sourceTree = ""; }; + E90909CD24E34AC500F963D2 /* high_and_rising_with_cob_insulin_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = high_and_rising_with_cob_insulin_effect.json; sourceTree = ""; }; + E90909CE24E34AC500F963D2 /* high_and_rising_with_cob_predicted_glucose.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = high_and_rising_with_cob_predicted_glucose.json; sourceTree = ""; }; + E90909CF24E34AC500F963D2 /* high_and_rising_with_cob_carb_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = high_and_rising_with_cob_carb_effect.json; sourceTree = ""; }; + E90909D024E34AC500F963D2 /* high_and_rising_with_cob_counteraction_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = high_and_rising_with_cob_counteraction_effect.json; sourceTree = ""; }; + E90909D724E34F1500F963D2 /* low_and_falling_predicted_glucose.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = low_and_falling_predicted_glucose.json; sourceTree = ""; }; + E90909D824E34F1500F963D2 /* low_and_falling_carb_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = low_and_falling_carb_effect.json; sourceTree = ""; }; + E90909D924E34F1500F963D2 /* low_and_falling_counteraction_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = low_and_falling_counteraction_effect.json; sourceTree = ""; }; + E90909DA24E34F1600F963D2 /* low_and_falling_insulin_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = low_and_falling_insulin_effect.json; sourceTree = ""; }; + E90909DB24E34F1600F963D2 /* low_and_falling_momentum_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = low_and_falling_momentum_effect.json; sourceTree = ""; }; + E90909E224E3530200F963D2 /* low_with_low_treatment_carb_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = low_with_low_treatment_carb_effect.json; sourceTree = ""; }; + E90909E324E3530200F963D2 /* low_with_low_treatment_insulin_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = low_with_low_treatment_insulin_effect.json; sourceTree = ""; }; + E90909E424E3530200F963D2 /* low_with_low_treatment_predicted_glucose.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = low_with_low_treatment_predicted_glucose.json; sourceTree = ""; }; + E90909E524E3530200F963D2 /* low_with_low_treatment_momentum_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = low_with_low_treatment_momentum_effect.json; sourceTree = ""; }; + E90909E624E3530200F963D2 /* low_with_low_treatment_counteraction_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = low_with_low_treatment_counteraction_effect.json; sourceTree = ""; }; + E90909ED24E35B4000F963D2 /* high_and_falling_predicted_glucose.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = high_and_falling_predicted_glucose.json; sourceTree = ""; }; + E90909EF24E35B4C00F963D2 /* high_and_falling_counteraction_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = high_and_falling_counteraction_effect.json; sourceTree = ""; }; + E90909F024E35B4C00F963D2 /* high_and_falling_carb_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = high_and_falling_carb_effect.json; sourceTree = ""; }; + E90909F124E35B4C00F963D2 /* high_and_falling_insulin_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = high_and_falling_insulin_effect.json; sourceTree = ""; }; + E90909F524E35B7C00F963D2 /* high_and_falling_momentum_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = high_and_falling_momentum_effect.json; sourceTree = ""; }; + E93E865324DB6CBA00FF40C8 /* retrospective_output.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = retrospective_output.json; sourceTree = ""; }; + E93E865524DB731900FF40C8 /* predicted_glucose_without_retrospective.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = predicted_glucose_without_retrospective.json; sourceTree = ""; }; + E93E865724DB75BD00FF40C8 /* predicted_glucose_very_negative.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = predicted_glucose_very_negative.json; sourceTree = ""; }; + E93E86A724DDCC4400FF40C8 /* MockDoseStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDoseStore.swift; sourceTree = ""; }; + E93E86AF24DDE1BD00FF40C8 /* MockGlucoseStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockGlucoseStore.swift; sourceTree = ""; }; + E93E86B124DDE21D00FF40C8 /* MockCarbStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockCarbStore.swift; sourceTree = ""; }; + E93E86B424E1FDC400FF40C8 /* flat_and_stable_insulin_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = flat_and_stable_insulin_effect.json; sourceTree = ""; }; + E93E86B524E1FDC400FF40C8 /* flat_and_stable_momentum_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = flat_and_stable_momentum_effect.json; sourceTree = ""; }; + E93E86B624E1FDC400FF40C8 /* flat_and_stable_predicted_glucose.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = flat_and_stable_predicted_glucose.json; sourceTree = ""; }; + E93E86B824E1FDC400FF40C8 /* flat_and_stable_carb_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = flat_and_stable_carb_effect.json; sourceTree = ""; }; + E93E86C224E1FE6100FF40C8 /* flat_and_stable_counteraction_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = flat_and_stable_counteraction_effect.json; sourceTree = ""; }; + E93E86C524E2E02200FF40C8 /* high_and_stable_insulin_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = high_and_stable_insulin_effect.json; sourceTree = ""; }; + E93E86C624E2E02200FF40C8 /* high_and_stable_carb_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = high_and_stable_carb_effect.json; sourceTree = ""; }; + E93E86C724E2E02200FF40C8 /* high_and_stable_predicted_glucose.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = high_and_stable_predicted_glucose.json; sourceTree = ""; }; + E93E86C824E2E02200FF40C8 /* high_and_stable_counteraction_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = high_and_stable_counteraction_effect.json; sourceTree = ""; }; + E93E86C924E2E02200FF40C8 /* high_and_stable_momentum_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = high_and_stable_momentum_effect.json; sourceTree = ""; }; + E942DE6D253BE5E100AC532D /* Loop Intent Extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Loop Intent Extension.entitlements"; sourceTree = ""; }; + E950CA9029002D9000B5B692 /* LoopDataManagerDosingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopDataManagerDosingTests.swift; sourceTree = ""; }; + E95D380024EADE7C005E2F50 /* DoseStoreProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoseStoreProtocol.swift; sourceTree = ""; }; + E95D380224EADF36005E2F50 /* CarbStoreProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbStoreProtocol.swift; sourceTree = ""; }; + E95D380424EADF78005E2F50 /* GlucoseStoreProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseStoreProtocol.swift; sourceTree = ""; }; + E98A55EC24EDD6380008715D /* LatestStoredSettingsProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LatestStoredSettingsProvider.swift; sourceTree = ""; }; + E98A55EE24EDD6E60008715D /* DosingDecisionStoreProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DosingDecisionStoreProtocol.swift; sourceTree = ""; }; + E98A55F024EDD85E0008715D /* MockDosingDecisionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDosingDecisionStore.swift; sourceTree = ""; }; + E98A55F224EDD9530008715D /* MockSettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSettingsStore.swift; sourceTree = ""; }; + E98A55F424EEE15A0008715D /* OnOffSelectionController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnOffSelectionController.swift; sourceTree = ""; }; + E98A55F624EEE1E10008715D /* OnOffSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnOffSelectionView.swift; sourceTree = ""; }; + E98A55F824EEFC200008715D /* OnOffSelectionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnOffSelectionViewModel.swift; sourceTree = ""; }; + E9B07F7C253BBA6500BAD8F8 /* Loop Intent Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Loop Intent Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; + E9B07F7E253BBA6500BAD8F8 /* IntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentHandler.swift; sourceTree = ""; }; + E9B07F80253BBA6500BAD8F8 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + E9B07F86253BBA6500BAD8F8 /* IntentsUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = IntentsUI.framework; path = System/Library/Frameworks/IntentsUI.framework; sourceTree = SDKROOT; }; + E9B07FED253BBC7100BAD8F8 /* OverrideIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverrideIntentHandler.swift; sourceTree = ""; }; + E9B08015253BBD7300BAD8F8 /* UserDefaults+LoopIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserDefaults+LoopIntents.swift"; sourceTree = ""; }; + E9B08020253BBDE900BAD8F8 /* IntentExtensionInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentExtensionInfo.swift; sourceTree = ""; }; + E9B3551B292844010076AB04 /* MissedMealNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MissedMealNotification.swift; sourceTree = ""; }; + E9B3552129358C440076AB04 /* MealDetectionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealDetectionManager.swift; sourceTree = ""; }; + E9B35525293590980076AB04 /* MissedMealSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MissedMealSettings.swift; sourceTree = ""; }; + E9B3552C293592B40076AB04 /* MealDetectionManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealDetectionManagerTests.swift; sourceTree = ""; }; + E9B3552E2935968E0076AB04 /* HKHealthStoreMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HKHealthStoreMock.swift; sourceTree = ""; }; + E9B35532293706CA0076AB04 /* needs_clamping_counteraction_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = needs_clamping_counteraction_effect.json; sourceTree = ""; }; + E9B35533293706CA0076AB04 /* dynamic_autofill_counteraction_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = dynamic_autofill_counteraction_effect.json; sourceTree = ""; }; + E9B35534293706CB0076AB04 /* missed_meal_counteraction_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = missed_meal_counteraction_effect.json; sourceTree = ""; }; + E9B35535293706CB0076AB04 /* noisy_cgm_counteraction_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = noisy_cgm_counteraction_effect.json; sourceTree = ""; }; + E9B35536293706CB0076AB04 /* realistic_report_counteraction_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = realistic_report_counteraction_effect.json; sourceTree = ""; }; + E9B35537293706CB0076AB04 /* long_interval_counteraction_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = long_interval_counteraction_effect.json; sourceTree = ""; }; + E9BB27AA23B85C3500FB4987 /* SleepStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SleepStore.swift; sourceTree = ""; }; + E9C00EEF24C620EF00628F35 /* LoopSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopSettings.swift; sourceTree = ""; }; + E9C00EF424C623EF00628F35 /* LoopSettings+Loop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LoopSettings+Loop.swift"; sourceTree = ""; }; + E9C58A7124DB489100487A17 /* LoopDataManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopDataManagerTests.swift; sourceTree = ""; }; + E9C58A7724DB529A00487A17 /* momentum_effect_bouncing.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = momentum_effect_bouncing.json; sourceTree = ""; }; + E9C58A7824DB529A00487A17 /* basal_profile.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = basal_profile.json; sourceTree = ""; }; + E9C58A7924DB529A00487A17 /* dynamic_glucose_effect_partially_observed.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = dynamic_glucose_effect_partially_observed.json; sourceTree = ""; }; + E9C58A7A24DB529A00487A17 /* counteraction_effect_falling_glucose.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = counteraction_effect_falling_glucose.json; sourceTree = ""; }; + E9C58A7B24DB529A00487A17 /* insulin_effect.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = insulin_effect.json; sourceTree = ""; }; + F5D9C01727DABBE0002E48F6 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Intents.strings; sourceTree = ""; }; + F5D9C01C27DABBE1002E48F6 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Main.strings; sourceTree = ""; }; + F5D9C01E27DABBE2002E48F6 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; + F5D9C02727DABBE4002E48F6 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; + F5E0BDD327E1D71C0033557E /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Intents.strings; sourceTree = ""; }; + F5E0BDD827E1D71E0033557E /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Main.strings; sourceTree = ""; }; + F5E0BDDA27E1D71F0033557E /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = ""; }; + F5E0BDE327E1D7230033557E /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 14B1735928AED9EC006CCD7C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 14B1736028AED9EC006CCD7C /* SwiftUI.framework in Frameworks */, + 14B1735E28AED9EC006CCD7C /* WidgetKit.framework in Frameworks */, + 1419606928D9554E00BA86E0 /* LoopKitUI.framework in Frameworks */, + 1419606A28D955BC00BA86E0 /* MockKitUI.framework in Frameworks */, + 1481F9BB28DA26F4004C5AEB /* LoopUI.framework in Frameworks */, + 1419606428D9550400BA86E0 /* LoopKitUI.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 43105EF81BADC8F9009CD81E /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -491,21 +1436,18 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 4F7528941DFE1E9500C322D6 /* LoopUI.framework in Frameworks */, - 43C6407C1DA051850093E25D /* InsulinKit.framework in Frameworks */, - 438A95A81D8B9B24009D12E1 /* xDripG5.framework in Frameworks */, - 43C246A81D89990F0031F8D1 /* Crypto.framework in Frameworks */, - 434FB6461D68F1CD007B9C70 /* Amplitude.framework in Frameworks */, - C10428971D17BAD400DD539A /* NightscoutUploadKit.framework in Frameworks */, - 43F78D4C1C914197002152D1 /* CarbKit.framework in Frameworks */, - 4D3B40041D4A9E1A00BC6334 /* G4ShareSpy.framework in Frameworks */, - 43F78D4D1C914197002152D1 /* GlucoseKit.framework in Frameworks */, + C1D6EEA02A06C7270047DE5C /* MKRingProgressView in Frameworks */, 43F5C2C91B929C09003EB13D /* HealthKit.framework in Frameworks */, - 43F78D4F1C914197002152D1 /* LoopKit.framework in Frameworks */, - 43CA93371CB98079000026B5 /* MinimedKit.framework in Frameworks */, - 43523EDB1CC35083001850F1 /* RileyLinkKit.framework in Frameworks */, - 43B371881CE597D10013C5A6 /* ShareClient.framework in Frameworks */, - 4346D1F01C781BEA00ABAFE3 /* SwiftCharts.framework in Frameworks */, + 43D9FFD621EAE05D00AF44BF /* LoopCore.framework in Frameworks */, + C11B9D64286779C000500CF8 /* MockKitUI.framework in Frameworks */, + 4F7528941DFE1E9500C322D6 /* LoopUI.framework in Frameworks */, + C11B9D62286779C000500CF8 /* MockKit.framework in Frameworks */, + C19C8BBE28651E3D0056D5E4 /* LoopKit.framework in Frameworks */, + C19C8BBA28651DFB0056D5E4 /* TrueTime.framework in Frameworks */, + C1F00C60285A802A006302C5 /* SwiftCharts in Frameworks */, + C19C8BC328651EAE0056D5E4 /* LoopTestingKit.framework in Frameworks */, + C1735B1E2A0809830082BB8A /* ZIPFoundation in Frameworks */, + C19C8BCE28651F520056D5E4 /* LoopKitUI.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -513,14 +1455,28 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 43D9002F21EB234400AF44BF /* LoopCore.framework in Frameworks */, + C1E2773E224177C000354103 /* ClockKit.framework in Frameworks */, + 4344628220A7A37F00C4BE6F /* CoreBluetooth.framework in Frameworks */, + C19C8C1E28663B040056D5E4 /* LoopKit.framework in Frameworks */, + 4396BD50225159C0005AA4D3 /* HealthKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 43D9002321EB209400AF44BF /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 43D9002D21EB225D00AF44BF /* HealthKit.framework in Frameworks */, + C159C82F286787EF00A86EC0 /* LoopKit.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; - 43E2D8CE1D20BF42004DA55F /* Frameworks */ = { + 43D9FFCC21EAE05D00AF44BF /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 43E2D9191D222759004DA55F /* LoopKit.framework in Frameworks */, + C19C8C21286776C20056D5E4 /* LoopKit.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -528,6 +1484,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + C15A8C492A7305B1009D736B /* SwiftCharts in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -535,12 +1492,25 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 4F7528951DFE1E9B00C322D6 /* LoopUI.framework in Frameworks */, - 4F70C1DE1DE8DCA7006380B7 /* NotificationCenter.framework in Frameworks */, + C159C825286785E000A86EC0 /* LoopUI.framework in Frameworks */, + C1CCF1172858FBAD0035389C /* SwiftCharts in Frameworks */, + C159C828286785E100A86EC0 /* LoopKitUI.framework in Frameworks */, + C159C82A286785E300A86EC0 /* MockKitUI.framework in Frameworks */, + C159C82D2867876500A86EC0 /* NotificationCenter.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; 4F7528871DFE1DC600C322D6 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + C1CCF1122858FA900035389C /* LoopCore.framework in Frameworks */, + C11B9D5B286778A800500CF8 /* SwiftCharts in Frameworks */, + C11B9D5E286778D000500CF8 /* LoopKitUI.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E9B07F79253BBA6500BAD8F8 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( @@ -550,14 +1520,102 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 14B1736128AED9EC006CCD7C /* Loop Widget Extension */ = { + isa = PBXGroup; + children = ( + 147EFE8D2A8BCC5500272438 /* DefaultAssets.xcassets */, + 147EFE8F2A8BCD8000272438 /* DerivedAssets.xcassets */, + 147EFE912A8BCD8A00272438 /* DerivedAssetsBase.xcassets */, + 84AA81D42A4A2813000B658B /* Bootstrap */, + 84AA81D12A4A2778000B658B /* Components */, + 84AA81D92A4A2966000B658B /* Helpers */, + 84AA81DE2A4A2B3D000B658B /* Timeline */, + 84AA81DF2A4A2B7A000B658B /* Widgets */, + 3ED319892EB659E600820BCF /* Live Activity */, + 84AA81D22A4A27A3000B658B /* LoopWidgets.swift */, + ); + path = "Loop Widget Extension"; + sourceTree = ""; + }; + 1DA6499D2441266400F61E75 /* Alerts */ = { + isa = PBXGroup; + children = ( + 1DB1065024467E18005542BD /* AlertManager.swift */, + 1D05219C2469F1F5000EBBDE /* AlertStore.swift */, + 1D080CBB2473214A00356610 /* AlertStore.xcdatamodeld */, + 1DA649A8244126DA00F61E75 /* InAppModalAlertScheduler.swift */, + 1D05219A2469E9DF000EBBDE /* StoredAlert.swift */, + 1D4A3E2B2478628500FD601B /* StoredAlert+CoreDataClass.swift */, + 1D4A3E2C2478628500FD601B /* StoredAlert+CoreDataProperties.swift */, + 1DA649A6244126CD00F61E75 /* UserNotificationAlertScheduler.swift */, + ); + path = Alerts; + sourceTree = ""; + }; + 1DA7A83F24476E8C008257F0 /* Managers */ = { + isa = PBXGroup; + children = ( + 1DA7A84024476E98008257F0 /* Alerts */, + C16575722538AFF6004AE16E /* CGMStalenessMonitorTests.swift */, + A91E4C2224F86F1000BE9213 /* CriticalEventLogExportManagerTests.swift */, + C16B983F26B4898800256B05 /* DoseEnactorTests.swift */, + E9C58A7124DB489100487A17 /* LoopDataManagerTests.swift */, + E950CA9029002D9000B5B692 /* LoopDataManagerDosingTests.swift */, + E9B3552C293592B40076AB04 /* MealDetectionManagerTests.swift */, + 1D70C40026EC0F9D00C62570 /* SupportManagerTests.swift */, + A9F5F1F4251050EC00E7C8A4 /* ZipArchiveTests.swift */, + C1D476B32A8ED179002C1C87 /* LoopAlgorithmTests.swift */, + ); + path = Managers; + sourceTree = ""; + }; + 1DA7A84024476E98008257F0 /* Alerts */ = { + isa = PBXGroup; + children = ( + 1D80313C24746274002810DF /* AlertStoreTests.swift */, + 1DA7A84124476EAD008257F0 /* AlertManagerTests.swift */, + 1DA7A84324477698008257F0 /* InAppModalAlertSchedulerTests.swift */, + 1DFE9E162447B6270082C280 /* UserNotificationAlertSchedulerTests.swift */, + A91E4C2024F867A700BE9213 /* StoredAlertTests.swift */, + B4D4534028E5CA7900F1A8D9 /* AlertMuterTests.swift */, + ); + path = Alerts; + sourceTree = ""; + }; + 3ED319892EB659E600820BCF /* Live Activity */ = { + isa = PBXGroup; + children = ( + 3ED319862EB659E600820BCF /* BasalViewActivity.swift */, + 3ED319872EB659E600820BCF /* ChartView.swift */, + 3ED319882EB659E600820BCF /* GlucoseLiveActivityConfiguration.swift */, + ); + path = "Live Activity"; + sourceTree = ""; + }; + 3ED319902EB65A2D00820BCF /* Live Activity */ = { + isa = PBXGroup; + children = ( + 3ED319A22EB65DA300820BCF /* LiveActivityManagerProxy.swift */, + 3ED3198D2EB65A2D00820BCF /* ChartAxisGenerator.swift */, + 3ED3198E2EB65A2D00820BCF /* GlucoseActivityAttributes.swift */, + 3ED3198F2EB65A2D00820BCF /* LiveActivityManager.swift */, + ); + path = "Live Activity"; + sourceTree = ""; + }; 4328E0121CFBE1B700E199AA /* Controllers */ = { isa = PBXGroup; children = ( - 4328E01D1CFBE25F00E199AA /* AddCarbsInterfaceController.swift */, - 4328E0161CFBE1DA00E199AA /* BolusInterfaceController.swift */, - 43846ADA1D91057000799272 /* ContextUpdatable.swift */, + 4328E0151CFBE1DA00E199AA /* ActionHUDController.swift */, + 4328E01D1CFBE25F00E199AA /* CarbAndBolusFlowController.swift */, + 4345E40521F68E18009E00E5 /* CarbEntryListController.swift */, + 4FFEDFBE20E5CF22000BFC58 /* ChartHUDController.swift */, + 4F82654F20E69F9A0031A8F5 /* HUDInterfaceController.swift */, + 43511CED220FC61700566C63 /* HUDRowController.swift */, 43A943891B926B7B0051FA24 /* NotificationController.swift */, - 4328E0151CFBE1DA00E199AA /* StatusInterfaceController.swift */, + 892FB4CE220402C0005293EC /* OverrideSelectionController.swift */, + 4345E40321F68AD9009E00E5 /* TextRowController.swift */, + E98A55F424EEE15A0008715D /* OnOffSelectionController.swift */, ); path = Controllers; sourceTree = ""; @@ -565,34 +1623,58 @@ 4328E01F1CFBE2B100E199AA /* Extensions */ = { isa = PBXGroup; children = ( + 898ECA68218ABDA9001E9D35 /* CLKTextProvider+Compound.h */, + 898ECA66218ABDA8001E9D35 /* WatchApp Extension-Bridging-Header.h */, + 898ECA67218ABDA8001E9D35 /* CLKTextProvider+Compound.m */, + 4344629120A7C19800C4BE6F /* ButtonGroup.swift */, + 898ECA64218ABD9A001E9D35 /* CGRect.swift */, 4328E0221CFBE2C500E199AA /* CLKComplicationTemplate.swift */, - 4328E0201CFBE2C500E199AA /* IdentifiableClass.swift */, - 4328E0231CFBE2C500E199AA /* NSUserDefaults.swift */, + 89FE21AC24AC57E30033F501 /* Collection.swift */, + 89E08FCB242E790C000D719B /* Comparable.swift */, + 4F7E8AC420E2AB9600AEA65E /* Date.swift */, + 43785E952120E4010057DED1 /* INRelevantShortcutStore+Loop.swift */, + 4328E0231CFBE2C500E199AA /* NSUserDefaults+WatchApp.swift */, 4328E0241CFBE2C500E199AA /* UIColor.swift */, 4F2C15801E0495B200E160D4 /* WatchContext+WatchApp.swift */, 43CB2B2A1D924D450079823D /* WCSession.swift */, 4328E0251CFBE2C500E199AA /* WKAlertAction.swift */, 4328E02E1CFBF81800E199AA /* WKInterfaceImage.swift */, + 43517916230A0E1A0072ECC0 /* WKInterfaceLabel.swift */, ); path = Extensions; sourceTree = ""; }; + 4345E3F621F03C2E009E00E5 /* Display */ = { + isa = PBXGroup; + children = ( + 4345E3F721F03D2A009E00E5 /* DatesAndNumberCell.swift */, + 4345E3F921F0473B009E00E5 /* TextCell.swift */, + ); + path = Display; + sourceTree = ""; + }; 43757D131C06F26C00910CB9 /* Models */ = { isa = PBXGroup; children = ( - 43880F961D9D8052009061A8 /* ServiceAuthentication */, - 43DE92601C555C26001FFDE1 /* AbsorptionTimeType+CarbKit.swift */, - 4331E0791C85650D00FBE832 /* ChartAxisValueDoubleLog.swift */, - 43F41C321D3A17AA00C11ED6 /* ChartAxisValueDoubleUnit.swift */, - 43E397A21D56B9E40028E321 /* Glucose.swift */, - 4D5B7A4A1D457CCA00796CA9 /* GlucoseG4.swift */, - 436FACED1D0BA636004E2427 /* InsulinDataSource.swift */, + DDC389F52A2B61750066E2E8 /* ApplicationFactorStrategy.swift */, + B4E2022F2661063E009421B5 /* AutomaticDosingStatus.swift */, + A9FB75F0252BE320004C7D3F /* BolusDosingDecision.swift */, + DDC389F92A2B62470066E2E8 /* ConstantApplicationFactorStrategy.swift */, + C1EF747128D6A44A00C8C083 /* CrashRecoveryManager.swift */, + DDC389F72A2B620B0066E2E8 /* GlucoseBasedApplicationFactorStrategy.swift */, + B40D07C6251A89D500C1C6D7 /* GlucoseDisplay.swift */, + 43C2FAE01EB656A500364AFF /* GlucoseEffectVelocity.swift */, + C1C660D0252E4DD5009B5C32 /* LoopConstants.swift */, 436A0DA41D236A2A00104B24 /* LoopError.swift */, - 430DA58F1D4B0E4C0097D1CA /* MySentryPumpStatusMessageBody.swift */, + E9C00EF424C623EF00628F35 /* LoopSettings+Loop.swift */, + A9B996EF27235191002DC09C /* LoopWarning.swift */, + C17824A41E1AD4D100D9D25C /* ManualBolusRecommendation.swift */, 4F526D601DF8D9A900A04910 /* NetBasal.swift */, 438D42F81D7C88BC003244B0 /* PredictionInputEffect.swift */, - 43C418B41CE0575200405B6A /* ShareGlucose+GlucoseKit.swift */, + C19008FD25225D3900721625 /* SimpleBolusCalculator.swift */, + C1E3862428247B7100F561A4 /* StoredLoopNotRunningNotification.swift */, 4328E0311CFC068900E199AA /* WatchContext+LoopKit.swift */, + A987CD4824A58A0100439ADC /* ZipArchive.swift */, ); path = Models; sourceTree = ""; @@ -600,17 +1682,23 @@ 43776F831B8022E90074EA36 = { isa = PBXGroup; children = ( + C18A491122FCC20B00FDA733 /* Scripts */, 4FF4D0FA1E1834BD00846527 /* Common */, 43776F8E1B8022E90074EA36 /* Loop */, 4F70C1DF1DE8DCA7006380B7 /* Loop Status Extension */, + 43D9FFD021EAE05D00AF44BF /* LoopCore */, + 4F75288C1DFE1DC600C322D6 /* LoopUI */, 43A943731B926B7B0051FA24 /* WatchApp */, 43A943821B926B7B0051FA24 /* WatchApp Extension */, 43F78D2C1C8FC58F002152D1 /* LoopTests */, - 43E2D8D21D20BF42004DA55F /* DoseMathTests */, - 4F75288C1DFE1DC600C322D6 /* LoopUI */, + 43D9FFA321EA9A0C00AF44BF /* Learn */, + E9B07F7D253BBA6500BAD8F8 /* Loop Intent Extension */, + 14B1736128AED9EC006CCD7C /* Loop Widget Extension */, + A900531928D60852000BC15B /* Shortcuts */, 968DCD53F724DE56FFE51920 /* Frameworks */, 43776F8D1B8022E90074EA36 /* Products */, 437D9BA11D7B5203007245E8 /* Loop.xcconfig */, + A951C5FF23E8AB51003E26DC /* Version.xcconfig */, ); sourceTree = ""; }; @@ -620,10 +1708,13 @@ 43776F8C1B8022E90074EA36 /* Loop.app */, 43A943721B926B7B0051FA24 /* WatchApp.app */, 43A9437E1B926B7B0051FA24 /* WatchApp Extension.appex */, - 43E2D8D11D20BF42004DA55F /* DoseMathTests.xctest */, 43E2D90B1D20C581004DA55F /* LoopTests.xctest */, 4F70C1DC1DE8DCA7006380B7 /* Loop Status Extension.appex */, 4F75288B1DFE1DC600C322D6 /* LoopUI.framework */, + 43D9FFCF21EAE05D00AF44BF /* LoopCore.framework */, + 43D9002A21EB209400AF44BF /* LoopCore.framework */, + E9B07F7C253BBA6500BAD8F8 /* Loop Intent Extension.appex */, + 14B1735C28AED9EC006CCD7C /* Loop Widget Extension.appex */, ); name = Products; sourceTree = ""; @@ -631,44 +1722,37 @@ 43776F8E1B8022E90074EA36 /* Loop */ = { isa = PBXGroup; children = ( - C1C73F0A1DE3D0260022FC89 /* InfoPlist.strings */, - C1C73F041DE3D0250022FC89 /* Localizable.strings */, - 43846AD81D8FA84B00799272 /* gallery.ckcomplication */, + C16DA84022E8E104008624C2 /* Plugins */, + B66D1F322E6A5D6600471149 /* Localizable.xcstrings */, + B66D1F382E6A5D6600471149 /* InfoPlist.xcstrings */, 43EDEE6B1CF2E12A00393BE3 /* Loop.entitlements */, 43F5C2D41B92A4A6003EB13D /* Info.plist */, + C1EE9E802A38D0FB0064784A /* BuildDetails.plist */, 43776F8F1B8022E90074EA36 /* AppDelegate.swift */, - 43776F981B8022E90074EA36 /* Assets.xcassets */, - 43E344A01B9E144300C85C07 /* Extensions */, + 1D12D3B82548EFDD00B53E8B /* main.swift */, 43776F9A1B8022E90074EA36 /* LaunchScreen.storyboard */, 43776F951B8022E90074EA36 /* Main.storyboard */, + A966152423EA5A25005D8B29 /* DefaultAssets.xcassets */, + A966152523EA5A25005D8B29 /* DerivedAssets.xcassets */, + C11AA5C7258736CF00BDE12F /* DerivedAssetsBase.xcassets */, + 43E344A01B9E144300C85C07 /* Extensions */, 43F5C2E41B93C5D4003EB13D /* Managers */, 43757D131C06F26C00910CB9 /* Models */, 43F5C2CE1B92A2A0003EB13D /* View Controllers */, 43F5C2CF1B92A2ED003EB13D /* Views */, + 897A5A9724C22DCE00C4E71D /* View Models */, ); path = Loop; sourceTree = ""; }; - 43880F961D9D8052009061A8 /* ServiceAuthentication */ = { - isa = PBXGroup; - children = ( - 438849EB1D29EC34003B3F23 /* AmplitudeService.swift */, - 438849ED1D2A1EBB003B3F23 /* MLabService.swift */, - 438849E91D297CB6003B3F23 /* NightscoutService.swift */, - 437CCADF1D285C7B0075D2C3 /* ServiceAuthentication.swift */, - 434F54601D28859B002A9274 /* ServiceCredential.swift */, - 434F545E1D288345002A9274 /* ShareService.swift */, - ); - path = ServiceAuthentication; - sourceTree = ""; - }; 43A943731B926B7B0051FA24 /* WatchApp */ = { isa = PBXGroup; children = ( - C1C73F0F1DE3D0270022FC89 /* InfoPlist.strings */, - 43A943771B926B7B0051FA24 /* Assets.xcassets */, + B66D1F222E6A5D6500471149 /* InfoPlist.xcstrings */, 43F5C2D61B92A4DC003EB13D /* Info.plist */, 43A943741B926B7B0051FA24 /* Interface.storyboard */, + A966152823EA5A37005D8B29 /* DefaultAssets.xcassets */, + A966152923EA5A37005D8B29 /* DerivedAssets.xcassets */, ); path = WatchApp; sourceTree = ""; @@ -676,16 +1760,22 @@ 43A943821B926B7B0051FA24 /* WatchApp Extension */ = { isa = PBXGroup; children = ( - C1C73EF91DE3D0230022FC89 /* InfoPlist.strings */, + B66D1F362E6A5D6600471149 /* ckcomplication.xcstrings */, + B66D1F342E6A5D6600471149 /* Localizable.xcstrings */, 43D533BB1CFD1DD7009E3085 /* WatchApp Extension.entitlements */, - 43846AD41D8FA67800799272 /* Base.lproj */, 43A943911B926B7B0051FA24 /* Info.plist */, + B66D1F242E6A5D6500471149 /* InfoPlist.xcstrings */, 43A9438D1B926B7B0051FA24 /* ComplicationController.swift */, 43A943871B926B7B0051FA24 /* ExtensionDelegate.swift */, 43A9438F1B926B7B0051FA24 /* Assets.xcassets */, 4328E0121CFBE1B700E199AA /* Controllers */, 4328E01F1CFBE2B100E199AA /* Extensions */, + 4FE3475F20D5D7FA00A86D03 /* Managers */, + 898ECA5D218ABD17001E9D35 /* Models */, + 4F75F0052100146B00B5570E /* Scenes */, 43A943831B926B7B0051FA24 /* Supporting Files */, + 891B508324342BCA005DA578 /* View Models */, + 895788A3242E6947002CB114 /* Views */, ); path = "WatchApp Extension"; sourceTree = ""; @@ -698,50 +1788,154 @@ name = "Supporting Files"; sourceTree = ""; }; - 43E2D8D21D20BF42004DA55F /* DoseMathTests */ = { + 43C05CB321EBE268006FB252 /* Extensions */ = { isa = PBXGroup; children = ( - 43E2D8E01D20C0CB004DA55F /* Fixtures */, - 43E2D8D31D20BF42004DA55F /* DoseMathTests.swift */, - 43E2D8D51D20BF42004DA55F /* Info.plist */, + 43C05CB421EBE274006FB252 /* Date.swift */, + 4345E3FD21F04A50009E00E5 /* DateIntervalFormatter.swift */, + 43D9F81F21EF0906000578CD /* NSNumber.swift */, + 43C5F259222C921B00905D10 /* OSLog.swift */, + 43D9F81921EC593C000578CD /* UITableViewCell.swift */, + C1814B85225E507C008D2D8E /* Sequence.swift */, ); - path = DoseMathTests; + path = Extensions; sourceTree = ""; }; - 43E2D8E01D20C0CB004DA55F /* Fixtures */ = { + 43C05CBB21EBF743006FB252 /* View Controllers */ = { isa = PBXGroup; children = ( - 43E2D8E11D20C0DB004DA55F /* read_selected_basal_profile.json */, - 43E2D8E21D20C0DB004DA55F /* recommend_temp_basal_correct_low_at_min.json */, - 43E2D8E31D20C0DB004DA55F /* recommend_temp_basal_flat_and_high.json */, - 43E2D8E41D20C0DB004DA55F /* recommend_temp_basal_high_and_falling.json */, - 43E2D8E51D20C0DB004DA55F /* recommend_temp_basal_high_and_rising.json */, - 43E2D8E61D20C0DB004DA55F /* recommend_temp_basal_in_range_and_rising.json */, - 43E2D8E71D20C0DB004DA55F /* recommend_temp_basal_no_change_glucose.json */, - 43E2D8E81D20C0DB004DA55F /* recommend_temp_basal_start_high_end_in_range.json */, - 43E2D8E91D20C0DB004DA55F /* recommend_temp_basal_start_high_end_low.json */, - 43E2D8EA1D20C0DB004DA55F /* recommend_temp_basal_start_low_end_high.json */, - 43E2D8EB1D20C0DB004DA55F /* recommend_temp_basal_start_low_end_in_range.json */, - C12F21A61DFA79CB00748193 /* recommend_tamp_basal_very_low_end_in_range.json */, + 43C05CC121EC06E4006FB252 /* LessonConfigurationViewController.swift */, + 43D9F82321EFF1AB000578CD /* LessonResultsViewController.swift */, + 43C05CBC21EBF77D006FB252 /* LessonsViewController.swift */, ); - path = Fixtures; + path = "View Controllers"; + sourceTree = ""; + }; + 43C05CBE21EBFF66006FB252 /* Lessons */ = { + isa = PBXGroup; + children = ( + 43C728F4222266F000C62969 /* ModalDayLesson.swift */, + 43C05CB021EBBDB9006FB252 /* TimeInRangeLesson.swift */, + ); + path = Lessons; + sourceTree = ""; + }; + 43C05CC321EC0868006FB252 /* Configuration */ = { + isa = PBXGroup; + children = ( + 43D9F81721EC51CC000578CD /* DateEntry.swift */, + 43C05CC921EC382B006FB252 /* NumberEntry.swift */, + 43D9F81D21EF0609000578CD /* NumberRangeEntry.swift */, + 43D9F82121EF0A7A000578CD /* QuantityRangeEntry.swift */, + 43C728F62222700000C62969 /* DateIntervalEntry.swift */, + ); + path = Configuration; + sourceTree = ""; + }; + 43C5F255222C7B6300905D10 /* Models */ = { + isa = PBXGroup; + children = ( + 43C5F256222C7B7200905D10 /* TimeComponents.swift */, + ); + path = Models; + sourceTree = ""; + }; + 43D9FFA321EA9A0C00AF44BF /* Learn */ = { + isa = PBXGroup; + children = ( + 43D9FFA421EA9A0C00AF44BF /* AppDelegate.swift */, + 43C05CBF21EBFFA4006FB252 /* Lesson.swift */, + 43C05CC321EC0868006FB252 /* Configuration */, + 4345E3F621F03C2E009E00E5 /* Display */, + 43C05CB321EBE268006FB252 /* Extensions */, + 43C05CBE21EBFF66006FB252 /* Lessons */, + 43D9FFBE21EAB20B00AF44BF /* Managers */, + 43C5F255222C7B6300905D10 /* Models */, + 43C05CBB21EBF743006FB252 /* View Controllers */, + 43D9FFB521EA9B0100AF44BF /* Learn.entitlements */, + 43D9FFA821EA9A0C00AF44BF /* Main.storyboard */, + 43D9FFAB21EA9A0F00AF44BF /* Assets.xcassets */, + 43D9FFB021EA9A0F00AF44BF /* Info.plist */, + 80F864E42433BF5D0026EC26 /* InfoPlist.strings */, + 7D9BEEE72335A6B3005DCFD6 /* Localizable.strings */, + ); + path = Learn; + sourceTree = ""; + }; + 43D9FFBE21EAB20B00AF44BF /* Managers */ = { + isa = PBXGroup; + children = ( + 43D9FFBF21EAB22E00AF44BF /* DataManager.swift */, + 43C728F8222A448700C62969 /* DayCalculator.swift */, + ); + path = Managers; + sourceTree = ""; + }; + 43D9FFD021EAE05D00AF44BF /* LoopCore */ = { + isa = PBXGroup; + children = ( + 3ED3199B2EB65A9B00820BCF /* LiveActivitySettings.swift */, + C1DA986B2843B6F9001D04CC /* PersistedProperty.swift */, + 43DE92581C5479E4001FFDE1 /* PotentialCarbEntryUserInfo.swift */, + 43C05CB721EBEA54006FB252 /* HKUnit.swift */, + 434FF1E91CF26C29000DB779 /* IdentifiableClass.swift */, + C19E96DD23D2733F003F79B0 /* LoopCompletionFreshness.swift */, + 430B29892041F54A00BA9F93 /* NSUserDefaults.swift */, + 431E73471FF95A900069B5F7 /* PersistenceController.swift */, + 43D848AF1E7DCBE100DADCBC /* Result.swift */, + 43D9FFD121EAE05D00AF44BF /* LoopCore.h */, + 43D9FFD221EAE05D00AF44BF /* Info.plist */, + B66D1F3A2E6A5D6600471149 /* Localizable.xcstrings */, + E9C00EEF24C620EF00628F35 /* LoopSettings.swift */, + C16575742539FD60004AE16E /* LoopCoreConstants.swift */, + E9B3551B292844010076AB04 /* MissedMealNotification.swift */, + C1D0B62F2986D4D90098D215 /* LocalizedString.swift */, + ); + path = LoopCore; sourceTree = ""; }; 43E344A01B9E144300C85C07 /* Extensions */ = { isa = PBXGroup; children = ( - C15713811DAC6983005BC4D2 /* MealBolusNightscoutTreatment.swift */, - C17884621D51A7A400405663 /* BatteryIndicator.swift */, - 4331E0771C85302200FBE832 /* CGPoint.swift */, - 4346D1F51C78501000ABAFE3 /* ChartPoint.swift */, - 43649A621C7A347F00523D7F /* CollectionType.swift */, - 4302F4E41D4EA75100F0FCAF /* DoseStore.swift */, + A98556842493F901000FD662 /* AlertStore+SimulatedCoreData.swift */, + C1D289B422F90A52003FFBD9 /* BasalDeliveryState.swift */, + A9F703722489BC8500C98AD8 /* CarbStore+SimulatedCoreData.swift */, + C17824991E1999FA00D9D25C /* CaseCountable.swift */, + 4F6663931E905FD2009E74FC /* ChartColorPalette+Loop.swift */, + 4389916A1E91B689000EEF90 /* ChartSettings+Loop.swift */, + 4F08DE8E1E7BB871006741EA /* CollectionType+Loop.swift */, 43CE7CDD1CA8B63E003CC1B0 /* Data.swift */, - 4398973A1CD2FC2000223065 /* NSDateFormatter.swift */, - 43E344A31B9E1B1C00C85C07 /* NSUserDefaults.swift */, + 892A5D58222F0A27008961AB /* Debug.swift */, + 1D9650C72523FBA100A1370B /* DeviceDataManager+BolusEntryViewModelDelegate.swift */, + B4FEEF7C24B8A71F00A8DF9B /* DeviceDataManager+DeviceStatus.swift */, + A96DAC232838325900D94E38 /* DiagnosticLog.swift */, + C18913B42524F24C007B0683 /* DeviceDataManager+SimpleBolusViewModelDelegate.swift */, + A9C62D832331700D00535612 /* DiagnosticLog+Subsystem.swift */, + 89D1503D24B506EB00EDE253 /* Dictionary.swift */, + 89CA2B2F226C0161004D9350 /* DirectoryObserver.swift */, + A9CBE457248AB564008E7BA2 /* DoseStore+SimulatedCoreData.swift */, + A9B996F127238705002DC09C /* DosingDecisionStore.swift */, + A9CBE459248ACBE1008E7BA2 /* DosingDecisionStore+SimulatedCoreData.swift */, + 142CB7582A60BF2E0075748A /* EditMode.swift */, + A9F703742489C9A000C98AD8 /* GlucoseStore+SimulatedCoreData.swift */, + A9DCF2D525B0F3C500C89088 /* LoopUIColorPalette+Default.swift */, + 89E267FE229267DF00A3F2AF /* Optional.swift */, + A967D94B24F99B9300CDDF8A /* OutputStream.swift */, + 895FE0942201234000FCF18A /* OverrideSelectionViewController.swift */, + A9F703762489D8AA00C98AD8 /* PersistentDeviceLog+SimulatedCoreData.swift */, + A999D40524663D18004C89D4 /* PumpManagerError.swift */, + 892A5D682230C41D008961AB /* RangeReplaceableCollection.swift */, + A9CBE45B248ACC03008E7BA2 /* SettingsStore+SimulatedCoreData.swift */, + C1FB428B217806A300FAB378 /* StateColorPalette.swift */, + 43F89CA222BDFBBC006BB54E /* UIActivityIndicatorView.swift */, 43F41C361D3BF32400C11ED6 /* UIAlertController.swift */, + A9F66FC2247F451500096EA7 /* UIDevice+Loop.swift */, + 8968B1112408B3520074BB48 /* UIFont.swift */, 437CEEE31CDE5C0A003C8C80 /* UIImage.swift */, 434FF1ED1CF27EEF000DB779 /* UITableViewCell.swift */, + C13DA2AF24F6C7690098BB29 /* UIViewController.swift */, + 430B29922041F5B200BA9F93 /* UserDefaults+Loop.swift */, + A9B607AF247F000F00792BE4 /* UserNotifications+Loop.swift */, ); path = Extensions; sourceTree = ""; @@ -749,17 +1943,15 @@ 43F5C2CE1B92A2A0003EB13D /* View Controllers */ = { isa = PBXGroup; children = ( - 437CCADD1D2858FD0075D2C3 /* AuthenticationViewController.swift */, - 43DBF04B1C93B8D700B3C386 /* BolusViewController.swift */, - 4315D2861CA5CC3B00589052 /* CarbEntryEditTableViewController.swift */, - 43DBF0581C93F73800B3C386 /* CarbEntryTableViewController.swift */, + 43A51E1E1EB6D62A000736CC /* CarbAbsorptionViewController.swift */, + 43A51E201EB6DBDD000736CC /* LoopChartsTableViewController.swift */, 433EA4C31D9F71C800CD78FB /* CommandResponseViewController.swift */, + C178249F1E19CF9800D9D25C /* GlucoseThresholdTableViewController.swift */, 4302F4E21D4EA54200F0FCAF /* InsulinDeliveryTableViewController.swift */, 437D9BA21D7BC977007245E8 /* PredictionTableViewController.swift */, - 433EA4C11D9F39C900CD78FB /* PumpIDTableViewController.swift */, - 43F5173C1D713DB0000FA422 /* RadioSelectionTableViewController.swift */, - 43F5C2DA1B92A5E1003EB13D /* SettingsTableViewController.swift */, + 439A7941211F631C0041B75F /* RootNavigationController.swift */, 43E3449E1B9D68E900C85C07 /* StatusTableViewController.swift */, + 89CA2B31226C18B8004D9350 /* TestingScenariosTableViewController.swift */, 4302F4E01D4E9C8900F0FCAF /* TextFieldTableViewController.swift */, ); path = "View Controllers"; @@ -768,18 +1960,40 @@ 43F5C2CF1B92A2ED003EB13D /* Views */ = { isa = PBXGroup; children = ( - 437CCAD91D284ADF0075D2C3 /* AuthenticationTableViewCell.swift */, - 434F545A1D2880D4002A9274 /* AuthenticationTableViewCell.xib */, - 437CCADB1D284B830075D2C3 /* ButtonTableViewCell.swift */, - 434F54581D28805E002A9274 /* ButtonTableViewCell.xib */, - 4313EDDF1D8A6BF90060FA79 /* ChartContentView.swift */, - 43880F941D9CD54A009061A8 /* ChartPointsScatterDownTrianglesLayer.swift */, - 43F41C341D3B623800C11ED6 /* ChartPointsTouchHighlightLayerViewCache.swift */, - 4346D1E61C77F5FE00ABAFE3 /* ChartTableViewCell.swift */, + 1452F4AA2A851EDF00F8B9E4 /* AddEditFavoriteFoodView.swift */, + B4001CED28CBBC82002FB414 /* AlertManagementView.swift */, + 897A5A9524C2175B00C4E71D /* BolusEntryView.swift */, + C1F8B1D122375E4200DD66CF /* BolusProgressTableViewCell.swift */, + C1F8B1DB223862D500DD66CF /* BolusProgressTableViewCell.xib */, + 43B260481ED248FB008CAA77 /* CarbEntryTableViewCell.swift */, + 149A28BC2A853E6C00052EDF /* CarbEntryView.swift */, + 431A8C3F1EC6E8AB00823B9C /* CircleMaskView.swift */, + A9A056B224B93C62007CF06D /* CriticalEventLogExportView.swift */, + C191D2A025B3ACAA00C26C0B /* DosingStrategySelectionView.swift */, + 149A28E32A8A63A700052EDF /* FavoriteFoodDetailView.swift */, + 142CB75A2A60BFC30075748A /* FavoriteFoodsView.swift */, + 43D381611EBD9759007F8C8F /* HeaderValuesTableViewCell.swift */, + 1452F4AC2A851F8800F8B9E4 /* HowAbsorptionTimeWorksView.swift */, + B43CF07D29434EC4008A520B /* HowMuteAlertWorkView.swift */, + 430D85881F44037000AF2D4F /* HUDViewTableViewCell.swift */, + A91D2A3E26CF0FF80023B075 /* IconTitleSubtitleTableViewCell.swift */, + 3ED319972EB65A6900820BCF /* LiveActivityBottomRowManagerView.swift */, + 3ED319982EB65A6900820BCF /* LiveActivityManagementView.swift */, + C1742331259BEADC00399C9D /* ManualEntryDoseView.swift */, + 1DA46B5F2492E2E300D71A63 /* NotificationsCriticalAlertPermissionsView.swift */, + 899433B723FE129700FA4BEA /* OverrideBadgeView.swift */, + 89D6953D23B6DF8A002B3066 /* PotentialCarbEntryTableViewCell.swift */, + 89CAB36224C8FE95009EE3CE /* PredictedGlucoseChartView.swift */, 438D42FA1D7D11A4003244B0 /* PredictionInputEffectTableViewCell.swift */, - 43A5676A1C96155700334FAC /* SwitchTableViewCell.swift */, + 439706E522D2E84900C81566 /* PredictionSettingTableViewCell.swift */, + 1DE09BA824A3E23F009EE9F9 /* SettingsView.swift */, + DDC389FB2A2BC6670066E2E8 /* SettingsView+algorithmExperimentsSection.swift */, + C1DE5D22251BFC4D00439E49 /* SimpleBolusView.swift */, 43F64DD81D9C92C900D24DC6 /* TitleSubtitleTableViewCell.swift */, - 434F54621D28DD80002A9274 /* ValidatingIndicatorView.swift */, + 4311FB9A1F37FE1B00D4C0A7 /* TitleSubtitleTextFieldTableViewCell.swift */, + C1AF062229426300002C1B19 /* ManualGlucoseEntryRow.swift */, + DDC389FD2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift */, + DD3DBD282A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift */, ); path = Views; sourceTree = ""; @@ -787,20 +2001,43 @@ 43F5C2E41B93C5D4003EB13D /* Managers */ = { isa = PBXGroup; children = ( - 439897361CD2F80600223065 /* AnalyticsManager.swift */, + B42D124228D371C400E43D22 /* AlertMuter.swift */, + 1D6B1B6626866D89009AC446 /* AlertPermissionsChecker.swift */, + 439897361CD2F80600223065 /* AnalyticsServicesManager.swift */, + B4F3D25024AF890C0095CE44 /* BluetoothStateManager.swift */, + 439BED291E76093C00B0AED5 /* CGMManager.swift */, + C16575702538A36B004AE16E /* CGMStalenessMonitor.swift */, + A977A2F324ACFECF0059C207 /* CriticalEventLogExportManager.swift */, + C148CEE624FD91BD00711B3B /* DeliveryUncertaintyAlertManager.swift */, 43DBF0521C93EC8200B3C386 /* DeviceDataManager.swift */, - 43F4EF1C1BA2A57600526CE1 /* DiagnosticLogger.swift */, - 4315D2891CA5F45E00589052 /* DiagnosticLogger+LoopKit.swift */, - 43F78D251C8FC000002152D1 /* DoseMath.swift */, - 43E2D8C51D204678004DA55F /* KeychainManager.swift */, - 43E2D8C71D208D5B004DA55F /* KeychainManager+Loop.swift */, + C16B983D26B4893300256B05 /* DoseEnactor.swift */, + 89CA2B3C226E6B13004D9350 /* LocalTestingScenariosManager.swift */, + A9C62D862331703000535612 /* LoggingServicesManager.swift */, + A9D5C5B525DC6C6A00534873 /* LoopAppManager.swift */, 43A567681C94880B00334FAC /* LoopDataManager.swift */, - C18C8C501D5A351900E043FB /* NightscoutDataManager.swift */, 43C094491CACCC73001F6403 /* NotificationManager.swift */, - 432E73CA1D24B3D6009AD15D /* RemoteDataManager.swift */, - 43EB40851C82646A00472A8C /* StatusChartManager.swift */, - 4F70C20F1DE8FAC5006380B7 /* StatusExtensionDataManager.swift */, + A97F250725E056D500F0EE19 /* OnboardingManager.swift */, + 432E73CA1D24B3D6009AD15D /* RemoteDataServicesManager.swift */, + B4D904402AA8989100CBD826 /* StatefulPluginManager.swift */, + A9C62D852331703000535612 /* Service.swift */, + A9C62D872331703000535612 /* ServicesManager.swift */, + C1F7822527CC056900C0919A /* SettingsManager.swift */, + E9BB27AA23B85C3500FB4987 /* SleepStore.swift */, + B470F5832AB22B5100049695 /* StatefulPluggable.swift */, + 43FCEEA8221A615B0013DD30 /* StatusChartsManager.swift */, + 1D63DEA426E950D400F46FA5 /* SupportManager.swift */, + 4F70C20F1DE8FAC5006380B7 /* ExtensionDataManager.swift */, + 89ADE13A226BFA0F0067222B /* TestingScenariosManager.swift */, + 1D82E69F25377C6B009131FB /* TrustedTimeChecker.swift */, 4328E0341CFC0AE100E199AA /* WatchDataManager.swift */, + 1DA6499D2441266400F61E75 /* Alerts */, + E95D37FF24EADE68005E2F50 /* Store Protocols */, + E9B355232935906B0076AB04 /* Missed Meal Detection */, + 3ED319902EB65A2D00820BCF /* Live Activity */, + C1F2075B26D6F9B0007AB7EB /* AppExpirationAlerter.swift */, + A96DAC2B2838F31200D94E38 /* SharedLogging.swift */, + 7E69CFFB2A16A77E00203CBD /* ResetLoopManager.swift */, + 84AA81E42A4A3981000B658B /* DeeplinkManager.swift */, ); path = Managers; sourceTree = ""; @@ -808,8 +2045,17 @@ 43F78D2C1C8FC58F002152D1 /* LoopTests */ = { isa = PBXGroup; children = ( + E9C58A7624DB510500487A17 /* Fixtures */, + B4CAD8772549D2330057946B /* LoopCore */, + 1DA7A83F24476E8C008257F0 /* Managers */, + A9E6DFED246A0460005B1A1C /* Models */, + B4BC56362518DE8800373647 /* ViewModels */, 43E2D90F1D20C581004DA55F /* Info.plist */, - 43E2D8C91D20B9E7004DA55F /* KeychainManagerTests.swift */, + A9DF02CA24F72B9E00B7C988 /* CriticalEventLogTests.swift */, + A96DAC292838EF8A00D94E38 /* DiagnosticLogTests.swift */, + A9DAE7CF2332D77F006AE942 /* LoopTests.swift */, + 8968B113240C55F10074BB48 /* LoopSettingsTests.swift */, + E93E86AC24DDE02C00FF40C8 /* Mock Stores */, ); path = LoopTests; sourceTree = ""; @@ -817,24 +2063,34 @@ 4F70C1DF1DE8DCA7006380B7 /* Loop Status Extension */ = { isa = PBXGroup; children = ( + B66D1F2A2E6A5D6500471149 /* Localizable.xcstrings */, 4F70C1FD1DE8E662006380B7 /* Loop Status Extension.entitlements */, + 4F70C1E51DE8DCA7006380B7 /* Info.plist */, + B66D1F3F2E6A5D6600471149 /* InfoPlist.xcstrings */, + 43BFF0CC1E466C8400FF19A9 /* StateColorPalette.swift */, + 43FCEEB0221A863E0013DD30 /* StatusChartsManager.swift */, 4F70C1E01DE8DCA7006380B7 /* StatusViewController.swift */, 4F70C1E21DE8DCA7006380B7 /* MainInterface.storyboard */, - 4F70C1E51DE8DCA7006380B7 /* Info.plist */, ); path = "Loop Status Extension"; - sourceTree = SOURCE_ROOT; + sourceTree = ""; }; 4F75288C1DFE1DC600C322D6 /* LoopUI */ = { isa = PBXGroup; children = ( + 7D23667B21250C5A0028B67D /* Common */, + B66D1F3D2E6A5D6600471149 /* Localizable.xcstrings */, + B66D1F202E6A5D6500471149 /* InfoPlist.xcstrings */, + 4FB76FC41E8C576800B39636 /* Extensions */, 4F7528A61DFE20AE00C322D6 /* Models */, - 4F7528A31DFE202B00C322D6 /* Extensions */, + B42C950F24A3C44F00857C73 /* ViewModel */, 4F7528931DFE1E1600C322D6 /* Views */, 4F75288D1DFE1DC600C322D6 /* LoopUI.h */, 4F75288E1DFE1DC600C322D6 /* Info.plist */, 4F2C15941E09BF3C00E160D4 /* HUDView.xib */, - 4F2C15961E09E94E00E160D4 /* Assets.xcassets */, + 4F2C15961E09E94E00E160D4 /* HUDAssets.xcassets */, + B4E96D58248A7F9A002DABAD /* StatusHighlightHUDView.xib */, + B4E96D5C248A82A2002DABAD /* StatusBarHUDView.xib */, ); path = LoopUI; sourceTree = ""; @@ -844,33 +2100,65 @@ children = ( 437CEEBF1CD6FCD8003C8C80 /* BasalRateHUDView.swift */, 43B371851CE583890013C5A6 /* BasalStateView.swift */, - 437CEEC91CD84DB7003C8C80 /* BatteryLevelHUDView.swift */, + B4E96D4E248A6E20002DABAD /* CGMStatusHUDView.swift */, + B4C9859325D5A3BB009FD9CA /* StatusBadgeHUDView.swift */, + B4E96D4A248A6B6E002DABAD /* DeviceStatusHUDView.swift */, 4337615E1D52F487004A3647 /* GlucoseHUDView.swift */, - 437CEEBB1CD6DE6A003C8C80 /* BaseHUDView.swift */, - 43FBEDD71D73843700B21F22 /* LevelMaskView.swift */, + B4E96D54248A7509002DABAD /* GlucoseTrendHUDView.swift */, + B4E96D52248A7386002DABAD /* GlucoseValueHUDView.swift */, + 4F2C15921E09BF2C00E160D4 /* HUDView.swift */, 437CEEBD1CD6E0CB003C8C80 /* LoopCompletionHUDView.swift */, 438DADC71CDE8F8B007697A5 /* LoopStateView.swift */, - 437CEEC71CD84CBB003C8C80 /* ReservoirVolumeHUDView.swift */, - 4F2C15921E09BF2C00E160D4 /* HUDView.swift */, + B48B0BAB24900093009A48DE /* PumpStatusHUDView.swift */, + B4E96D5A248A8229002DABAD /* StatusBarHUDView.swift */, + B4E96D56248A7B0F002DABAD /* StatusHighlightHUDView.swift */, ); path = Views; sourceTree = ""; }; - 4F7528A31DFE202B00C322D6 /* Extensions */ = { + 4F7528A61DFE20AE00C322D6 /* Models */ = { + isa = PBXGroup; + children = ( + 4326BA631F3A44D9007CCAD4 /* ChartLineModel.swift */, + ); + path = Models; + sourceTree = ""; + }; + 4F75F0052100146B00B5570E /* Scenes */ = { + isa = PBXGroup; + children = ( + 4F75F00120FCFE8C00B5570E /* GlucoseChartScene.swift */, + 4372E495213DCDD30068E043 /* GlucoseChartValueHashable.swift */, + ); + path = Scenes; + sourceTree = ""; + }; + 4FB76FC41E8C576800B39636 /* Extensions */ = { isa = PBXGroup; children = ( - 43DE92501C541832001FFDE1 /* UIColor.swift */, - 436A0E7A1D7DE13400D6475D /* NSNumberFormatter.swift */, + A9C62D8D2331708700535612 /* AuthenticationTableViewCell+NibLoadable.swift */, + B490A03C24D04F9400F509FA /* Color.swift */, + 43FCEEAC221A66780013DD30 /* DateFormatter.swift */, + B490A04024D0559D00F509FA /* DeviceLifecycleProgressState.swift */, + B490A04224D055D900F509FA /* DeviceStatusHighlight.swift */, + B43DA44024D9C12100CAFF4E /* DismissibleHostingController.swift */, + B490A03E24D0550F00F509FA /* GlucoseRangeCategory.swift */, + B4D620D324D9EDB900043B3C /* GuidanceColors.swift */, + 1DB1CA4C24A55F0000B3B94C /* Image.swift */, + 434F54561D287FDB002A9274 /* NibLoadable.swift */, + 43BFF0B11E45C18400FF19A9 /* UIColor.swift */, + C1AD41FF256D61E500164DDD /* Comparable.swift */, ); path = Extensions; sourceTree = ""; }; - 4F7528A61DFE20AE00C322D6 /* Models */ = { + 4FE3475F20D5D7FA00A86D03 /* Managers */ = { isa = PBXGroup; children = ( - 43EA28611D517E42001BC233 /* SensorDisplayable.swift */, + 4FDDD23620DC51DF00D04B16 /* LoopDataManager.swift */, + 898ECA62218ABD21001E9D35 /* ComplicationChartManager.swift */, ); - path = Models; + path = Managers; sourceTree = ""; }; 4FF4D0FA1E1834BD00846527 /* Common */ = { @@ -878,151 +2166,657 @@ children = ( 4FF4D0FC1E1834CC00846527 /* Extensions */, 4FF4D0FB1E1834C400846527 /* Models */, + 43785E9B2120E7060057DED1 /* Intents.intentdefinition */, + 89E267FB2292456700A3F2AF /* FeatureFlags.swift */, + 7D9BEEF52335CF8D005DCFD6 /* Localizable.strings */, ); - name = Common; + path = Common; sourceTree = ""; }; 4FF4D0FB1E1834C400846527 /* Models */ = { isa = PBXGroup; children = ( - 435400301C9F744E00D5819C /* BolusSuggestionUserInfo.swift */, - 43DE92581C5479E4001FFDE1 /* CarbEntryUserInfo.swift */, - 43EA285E1D50ED3D001BC233 /* GlucoseTrend.swift */, + 89F9119324358E4500ECCAF3 /* CarbAbsorptionTime.swift */, + A9347F3024E7521800C99C34 /* CarbBackfillRequestUserInfo.swift */, + 4F11D3BF20DCBEEC006E072C /* GlucoseBackfillRequestUserInfo.swift */, + 4372E48F213CFCE70068E043 /* LoopSettingsUserInfo.swift */, + 43C3B6F620BBCAA30026CAFA /* PumpManager.swift */, + C1FB428E217921D600FAB378 /* PumpManagerUI.swift */, 435400331C9F878D00D5819C /* SetBolusUserInfo.swift */, 4F70C2111DE900EA006380B7 /* StatusExtensionContext.swift */, + 89A1B66D24ABFDF800117AC2 /* SupportedBolusVolumesUserInfo.swift */, 4FF4D0FF1E18374700846527 /* WatchContext.swift */, + C1201E2B23ECDBD0002DA84A /* WatchContextRequestUserInfo.swift */, + A9347F2E24E7508A00C99C34 /* WatchHistoricalCarbs.swift */, + 4F11D3C120DD80B3006E072C /* WatchHistoricalGlucose.swift */, + 4F7E8AC620E2AC0300AEA65E /* WatchPredictedGlucose.swift */, + E9B08020253BBDE900BAD8F8 /* IntentExtensionInfo.swift */, + C110888C2A3913C600BA4898 /* BuildDetails.swift */, ); - name = Models; - path = Common/Models; + path = Models; sourceTree = ""; }; 4FF4D0FC1E1834CC00846527 /* Extensions */ = { isa = PBXGroup; children = ( + 4372E48A213CB5F00068E043 /* Double.swift */, 4F526D5E1DF2459000A04910 /* HKUnit.swift */, - 434FF1E91CF26C29000DB779 /* IdentifiableClass.swift */, - 434F54561D287FDB002A9274 /* NibLoadable.swift */, + 43C513181E864C4E001547C7 /* GlucoseRangeSchedule.swift */, + 43785E922120A01B0057DED1 /* NewCarbEntryIntent+Loop.swift */, 430DA58D1D4AEC230097D1CA /* NSBundle.swift */, 439897341CD2F7DE00223065 /* NSTimeInterval.swift */, + 439A7943211FE22F0041B75F /* NSUserActivity.swift */, 4FC8C8001DEB93E400A1452E /* NSUserDefaults+StatusExtension.swift */, + 43BFF0B31E45C1BE00FF19A9 /* NumberFormatter.swift */, + 4374B5EE209D84BE00D17AA8 /* OSLog.swift */, + 4372E486213C86240068E043 /* SampleValue.swift */, + 4374B5F3209D89A900D17AA8 /* TextFieldTableViewCell.swift */, + 43BFF0C31E4659E700FF19A9 /* UIColor+HIG.swift */, + E9B08015253BBD7300BAD8F8 /* UserDefaults+LoopIntents.swift */, ); - name = Extensions; + path = Extensions; sourceTree = ""; }; - 968DCD53F724DE56FFE51920 /* Frameworks */ = { + 7D23667B21250C5A0028B67D /* Common */ = { isa = PBXGroup; children = ( - 43C6407B1DA051850093E25D /* InsulinKit.framework */, - 438A95A71D8B9B24009D12E1 /* xDripG5.framework */, - 434FB6451D68F1CD007B9C70 /* Amplitude.framework */, - 43F78D481C914197002152D1 /* CarbKit.framework */, - 43C246A71D89990F0031F8D1 /* Crypto.framework */, - 4D3B40021D4A9DFE00BC6334 /* G4ShareSpy.framework */, - 43F78D491C914197002152D1 /* GlucoseKit.framework */, - 43F5C2C81B929C09003EB13D /* HealthKit.framework */, - 43F78D4B1C914197002152D1 /* LoopKit.framework */, - 43CA93361CB98079000026B5 /* MinimedKit.framework */, - C10428961D17BAD400DD539A /* NightscoutUploadKit.framework */, - 434AB0B11CBB4C3300422F4A /* RileyLinkBLEKit.framework */, - 43523EDA1CC35083001850F1 /* RileyLinkKit.framework */, - 43B371871CE597D10013C5A6 /* ShareClient.framework */, - 4346D1EF1C781BEA00ABAFE3 /* SwiftCharts.framework */, - 4F70C1DD1DE8DCA7006380B7 /* NotificationCenter.framework */, + 7D23667C21250C7E0028B67D /* LocalizedString.swift */, ); - name = Frameworks; + path = Common; sourceTree = ""; }; -/* End PBXGroup section */ - -/* Begin PBXHeadersBuildPhase section */ - 4F7528881DFE1DC600C322D6 /* Headers */ = { - isa = PBXHeadersBuildPhase; - buildActionMask = 2147483647; - files = ( - 4F2C15851E075B8700E160D4 /* LoopUI.h in Headers */, + 84AA81D12A4A2778000B658B /* Components */ = { + isa = PBXGroup; + children = ( + 14B1736E28AEDBF6006CCD7C /* BasalView.swift */, + 14B1737028AEDBF6006CCD7C /* GlucoseView.swift */, + 14B1737128AEDBF6006CCD7C /* LoopCircleView.swift */, + 84AA81E22A4A36FB000B658B /* SystemActionLink.swift */, + 84AA81E62A4A4DEF000B658B /* PumpView.swift */, ); - runOnlyForDeploymentPostprocessing = 0; + path = Components; + sourceTree = ""; }; -/* End PBXHeadersBuildPhase section */ - -/* Begin PBXNativeTarget section */ - 43776F8B1B8022E90074EA36 /* Loop */ = { - isa = PBXNativeTarget; - buildConfigurationList = 43776FB61B8022E90074EA36 /* Build configuration list for PBXNativeTarget "Loop" */; - buildPhases = ( - 43776F881B8022E90074EA36 /* Sources */, - 43776F891B8022E90074EA36 /* Frameworks */, - 43776F8A1B8022E90074EA36 /* Resources */, - 43A9439C1B926B7B0051FA24 /* Embed Watch Content */, - 43A943AE1B928D400051FA24 /* Embed Frameworks */, - 43EDDBEF1C361BCE007D89B5 /* ShellScript */, - 4F70C1EC1DE8DCA8006380B7 /* Embed App Extensions */, - ); - buildRules = ( - ); - dependencies = ( - 4F7528971DFE1ED400C322D6 /* PBXTargetDependency */, - 43A943931B926B7B0051FA24 /* PBXTargetDependency */, - 4F70C1E71DE8DCA7006380B7 /* PBXTargetDependency */, + 84AA81D42A4A2813000B658B /* Bootstrap */ = { + isa = PBXGroup; + children = ( + 3ED319A02EB65B4100820BCF /* Bootstrap.swift */, + B66D1F262E6A5D6500471149 /* Localizable.xcstrings */, + B66D1F2E2E6A5D6600471149 /* InfoPlist.xcstrings */, + 14B1736D28AEDA63006CCD7C /* LoopWidgetExtension.entitlements */, + 14B1736628AED9EE006CCD7C /* Info.plist */, ); - name = Loop; - productName = Naterade; - productReference = 43776F8C1B8022E90074EA36 /* Loop.app */; - productType = "com.apple.product-type.application"; + path = Bootstrap; + sourceTree = ""; }; - 43A943711B926B7B0051FA24 /* WatchApp */ = { - isa = PBXNativeTarget; - buildConfigurationList = 43A943991B926B7B0051FA24 /* Build configuration list for PBXNativeTarget "WatchApp" */; - buildPhases = ( - 43A943701B926B7B0051FA24 /* Resources */, - 43A943981B926B7B0051FA24 /* Embed App Extensions */, - 43105EF81BADC8F9009CD81E /* Frameworks */, + 84AA81D92A4A2966000B658B /* Helpers */ = { + isa = PBXGroup; + children = ( + 84AA81DA2A4A2973000B658B /* Date.swift */, + 84AA81D52A4A28AF000B658B /* WidgetBackground.swift */, + 3ED3199E2EB65AFE00820BCF /* LocalizedString.swift */, + 84D2879E2AC756C8007ED283 /* ContentMargin.swift */, ); - buildRules = ( + path = Helpers; + sourceTree = ""; + }; + 84AA81DE2A4A2B3D000B658B /* Timeline */ = { + isa = PBXGroup; + children = ( + 84AA81D72A4A2910000B658B /* StatusWidgetTimelimeEntry.swift */, + 84AA81DC2A4A2999000B658B /* StatusWidgetTimelineProvider.swift */, ); - dependencies = ( - 43A943811B926B7B0051FA24 /* PBXTargetDependency */, + path = Timeline; + sourceTree = ""; + }; + 84AA81DF2A4A2B7A000B658B /* Widgets */ = { + isa = PBXGroup; + children = ( + 14B1736F28AEDBF6006CCD7C /* SystemStatusWidget.swift */, ); - name = WatchApp; - productName = WatchApp; - productReference = 43A943721B926B7B0051FA24 /* WatchApp.app */; - productType = "com.apple.product-type.application.watchapp2"; + path = Widgets; + sourceTree = ""; }; - 43A9437D1B926B7B0051FA24 /* WatchApp Extension */ = { - isa = PBXNativeTarget; - buildConfigurationList = 43A943951B926B7B0051FA24 /* Build configuration list for PBXNativeTarget "WatchApp Extension" */; - buildPhases = ( - 43A9437A1B926B7B0051FA24 /* Sources */, - 43A9437B1B926B7B0051FA24 /* Frameworks */, - 43A9437C1B926B7B0051FA24 /* Resources */, - 43C667D71C5577280050C674 /* Embed Frameworks */, + 891B508324342BCA005DA578 /* View Models */ = { + isa = PBXGroup; + children = ( + 891B508424342BE1005DA578 /* CarbAndBolusFlowViewModel.swift */, + E98A55F824EEFC200008715D /* OnOffSelectionViewModel.swift */, ); - buildRules = ( + path = "View Models"; + sourceTree = ""; + }; + 895788A3242E6947002CB114 /* Views */ = { + isa = PBXGroup; + children = ( + 895788B5242E6A25002CB114 /* Carb Entry & Bolus */, + 895788B4242E69C8002CB114 /* Extensions */, + 895788AB242E69A2002CB114 /* ActionButton.swift */, + 895788AA242E69A1002CB114 /* CircularAccessoryButtonStyle.swift */, + 89A605E824328862009C1096 /* Checkmark.swift */, + 89A605EE2432925D009C1096 /* CompletionCheckmark.swift */, + 894F6DD8243C060600CCE676 /* ScalablePositionedText.swift */, + 89A605EA243288E4009C1096 /* TopDownTriangle.swift */, + E98A55F624EEE1E10008715D /* OnOffSelectionView.swift */, ); - dependencies = ( + path = Views; + sourceTree = ""; + }; + 895788B4242E69C8002CB114 /* Extensions */ = { + isa = PBXGroup; + children = ( + 89E08FC7242E76E9000D719B /* AnyTransition.swift */, + 895788A9242E69A1002CB114 /* Color.swift */, + 89F9118E24352F1600ECCAF3 /* DigitalCrownRotation.swift */, + 894F6DD2243BCBDB00CCE676 /* Environment+SizeClass.swift */, + 89A605E62432860C009C1096 /* PeriodicPublisher.swift */, + 89E08FC9242E7714000D719B /* UIFont.swift */, + 894F6DD6243C047300CCE676 /* View+Position.swift */, ); - name = "WatchApp Extension"; + path = Extensions; + sourceTree = ""; + }; + 895788B5242E6A25002CB114 /* Carb Entry & Bolus */ = { + isa = PBXGroup; + children = ( + 895788A5242E69A1002CB114 /* AbsorptionTimeSelection.swift */, + 89A605EC24328972009C1096 /* BolusArrow.swift */, + 89E08FCF242E8B2B000D719B /* BolusConfirmationView.swift */, + 89A605F02432BD18009C1096 /* BolusConfirmationVisual.swift */, + 895788A7242E69A1002CB114 /* BolusInput.swift */, + 89A605E224327DFE009C1096 /* CarbAmountInput.swift */, + 894F6DDC243C0A2300CCE676 /* CarbAmountLabel.swift */, + 895788A6242E69A1002CB114 /* CarbAndBolusFlow.swift */, + 89E08FC5242E7506000D719B /* CarbAndDateInput.swift */, + 89A605E424327F45009C1096 /* DoseVolumeInput.swift */, + 894F6DDA243C07CF00CCE676 /* GramLabel.swift */, + 89F9119024358DED00ECCAF3 /* Models */, + 89E08FC0242E73CA000D719B /* Preference Keys */, + ); + path = "Carb Entry & Bolus"; + sourceTree = ""; + }; + 897A5A9724C22DCE00C4E71D /* View Models */ = { + isa = PBXGroup; + children = ( + 1452F4A82A851C9400F8B9E4 /* AddEditFavoriteFoodViewModel.swift */, + 897A5A9824C22DE800C4E71D /* BolusEntryViewModel.swift */, + 149A28BA2A853E5100052EDF /* CarbEntryViewModel.swift */, + A9A056B424B94123007CF06D /* CriticalEventLogExportViewModel.swift */, + 14D906F32A846510006EB79A /* FavoriteFoodsViewModel.swift */, + C11BD0542523CFED00236B08 /* SimpleBolusViewModel.swift */, + 1DB1CA4E24A56D7600B3B94C /* SettingsViewModel.swift */, + 1D49795724E7289700948F05 /* ServicesViewModel.swift */, + C174233B259BEB0F00399C9D /* ManualEntryDoseViewModel.swift */, + 1DB619AB270BAD3D006C9D07 /* VersionUpdateViewModel.swift */, + 3ED319952EB65A5C00820BCF /* LiveActivityManagementViewModel.swift */, + ); + path = "View Models"; + sourceTree = ""; + }; + 898ECA5D218ABD17001E9D35 /* Models */ = { + isa = PBXGroup; + children = ( + 898ECA5E218ABD17001E9D35 /* GlucoseChartScaler.swift */, + 898ECA5F218ABD17001E9D35 /* GlucoseChartData.swift */, + 892FB4CC22040104005293EC /* OverridePresetRow.swift */, + ); + path = Models; + sourceTree = ""; + }; + 89E08FC0242E73CA000D719B /* Preference Keys */ = { + isa = PBXGroup; + children = ( + 89E08FC1242E73DC000D719B /* CarbAmountPositionKey.swift */, + 89E08FC3242E73F0000D719B /* GramLabelPositionKey.swift */, + ); + path = "Preference Keys"; + sourceTree = ""; + }; + 89F9119024358DED00ECCAF3 /* Models */ = { + isa = PBXGroup; + children = ( + 89F9119524358E6900ECCAF3 /* BolusPickerValues.swift */, + 89F9119124358E2B00ECCAF3 /* CarbEntryInputMode.swift */, + ); + path = Models; + sourceTree = ""; + }; + 968DCD53F724DE56FFE51920 /* Frameworks */ = { + isa = PBXGroup; + children = ( + C159C82E286787EF00A86EC0 /* LoopKit.framework */, + C159C8212867859800A86EC0 /* MockKitUI.framework */, + C159C8192867857000A86EC0 /* LoopKitUI.framework */, + C11B9D60286779C000500CF8 /* MockKit.framework */, + C11B9D61286779C000500CF8 /* MockKitUI.framework */, + C11B9D5D286778D000500CF8 /* LoopKitUI.framework */, + C19C8C20286776C20056D5E4 /* LoopKit.framework */, + C19C8BC728651F0A0056D5E4 /* MockKit.framework */, + C19C8BC228651EAE0056D5E4 /* LoopTestingKit.framework */, + C19C8BB928651DFB0056D5E4 /* TrueTime.framework */, + C101947127DD473C004E7EB8 /* MockKitUI.framework */, + 1DC63E7325351BDF004605DA /* TrueTime.framework */, + 4344628420A7A3BE00C4BE6F /* CGMBLEKit.framework */, + C1750AEB255B013300B8011C /* Minizip.framework */, + C19F496225630504003632D7 /* Minizip.framework */, + 43A8EC6E210E622600A81379 /* CGMBLEKitUI.framework */, + C1E2773D224177C000354103 /* ClockKit.framework */, + 4344628120A7A37E00C4BE6F /* CoreBluetooth.framework */, + 43C246A71D89990F0031F8D1 /* Crypto.framework */, + 4D3B40021D4A9DFE00BC6334 /* G4ShareSpy.framework */, + 43D9002C21EB225D00AF44BF /* HealthKit.framework */, + 43F5C2C81B929C09003EB13D /* HealthKit.framework */, + 4344628320A7A3BE00C4BE6F /* LoopKit.framework */, + 437AFEE6203688CF008C4892 /* LoopKitUI.framework */, + 892A5D5A222F0D7C008961AB /* LoopTestingKit.framework */, + C1E2774722433D7A00354103 /* MKRingProgressView.framework */, + 892A5D29222EF60A008961AB /* MockKit.framework */, + 892A5D2B222EF60A008961AB /* MockKitUI.framework */, + 4F70C1DD1DE8DCA7006380B7 /* NotificationCenter.framework */, + 43B371871CE597D10013C5A6 /* ShareClient.framework */, + 4379CFEF21112CF700AADC79 /* ShareClientUI.framework */, + 438A95A71D8B9B24009D12E1 /* CGMBLEKit.framework */, + 43F78D4B1C914197002152D1 /* LoopKit.framework */, + E9B07F86253BBA6500BAD8F8 /* IntentsUI.framework */, + 14B1735D28AED9EC006CCD7C /* WidgetKit.framework */, + 14B1735F28AED9EC006CCD7C /* SwiftUI.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + A900531928D60852000BC15B /* Shortcuts */ = { + isa = PBXGroup; + children = ( + A900531B28D608CA000BC15B /* Cancel Override.shortcut */, + A900531C28D6090D000BC15B /* Loop Remote Overrides.shortcut */, + A900531A28D60862000BC15B /* Loop.shortcut */, + ); + path = Shortcuts; + sourceTree = ""; + }; + A9E6DFED246A0460005B1A1C /* Models */ = { + isa = PBXGroup; + children = ( + A9DFAFB224F0415E00950D1E /* CarbBackfillRequestUserInfoTests.swift */, + A963B279252CEBAE0062AA12 /* SetBolusUserInfoTests.swift */, + A9DFAFB424F048A000950D1E /* WatchHistoricalCarbsTests.swift */, + C19008FF252271BB00721625 /* SimpleBolusCalculatorTests.swift */, + A9C1719625366F780053BCBD /* WatchHistoricalGlucoseTest.swift */, + A9BD28E6272226B40071DF15 /* TestLocalizedError.swift */, + ); + path = Models; + sourceTree = ""; + }; + B42C950F24A3C44F00857C73 /* ViewModel */ = { + isa = PBXGroup; + children = ( + B42C951324A3C76000857C73 /* CGMStatusHUDViewModel.swift */, + ); + path = ViewModel; + sourceTree = ""; + }; + B4BC56362518DE8800373647 /* ViewModels */ = { + isa = PBXGroup; + children = ( + 1D8D55BB252274650044DBB6 /* BolusEntryViewModelTests.swift */, + B4BC56372518DEA900373647 /* CGMStatusHUDViewModelTests.swift */, + C165756E2534C468004AE16E /* SimpleBolusViewModelTests.swift */, + C1777A6525A125F100595963 /* ManualEntryDoseViewModelTests.swift */, + ); + path = ViewModels; + sourceTree = ""; + }; + B4CAD8772549D2330057946B /* LoopCore */ = { + isa = PBXGroup; + children = ( + B4CAD8782549D2540057946B /* LoopCompletionFreshnessTests.swift */, + ); + path = LoopCore; + sourceTree = ""; + }; + C13072B82A76AF0A009A7C58 /* live_capture */ = { + isa = PBXGroup; + children = ( + C13072B92A76AF31009A7C58 /* live_capture_predicted_glucose.json */, + C16FC0AF2A99392F0025E239 /* live_capture_input.json */, + ); + path = live_capture; + sourceTree = ""; + }; + C16DA84022E8E104008624C2 /* Plugins */ = { + isa = PBXGroup; + children = ( + C16DA84122E8E112008624C2 /* PluginManager.swift */, + ); + path = Plugins; + sourceTree = ""; + }; + C18A491122FCC20B00FDA733 /* Scripts */ = { + isa = PBXGroup; + children = ( + C1D197FE232CF92D0096D646 /* capture-build-details.sh */, + C18A491222FCC22800FDA733 /* build-derived-assets.sh */, + C18A491522FCC22900FDA733 /* copy-plugins.sh */, + C18A491322FCC22900FDA733 /* make_scenario.py */, + C1E9CB5A295101570022387B /* install-scenarios.sh */, + C1092BFD29F8116700AE3D1C /* apply-info-customizations.sh */, + ); + path = Scripts; + sourceTree = ""; + }; + E90909CB24E34A7A00F963D2 /* high_and_rising_with_cob */ = { + isa = PBXGroup; + children = ( + E90909CF24E34AC500F963D2 /* high_and_rising_with_cob_carb_effect.json */, + E90909D024E34AC500F963D2 /* high_and_rising_with_cob_counteraction_effect.json */, + E90909CD24E34AC500F963D2 /* high_and_rising_with_cob_insulin_effect.json */, + E90909CC24E34AC500F963D2 /* high_and_rising_with_cob_momentum_effect.json */, + E90909CE24E34AC500F963D2 /* high_and_rising_with_cob_predicted_glucose.json */, + ); + path = high_and_rising_with_cob; + sourceTree = ""; + }; + E90909D624E34EC200F963D2 /* low_and_falling */ = { + isa = PBXGroup; + children = ( + E90909D824E34F1500F963D2 /* low_and_falling_carb_effect.json */, + E90909D924E34F1500F963D2 /* low_and_falling_counteraction_effect.json */, + E90909DA24E34F1600F963D2 /* low_and_falling_insulin_effect.json */, + E90909DB24E34F1600F963D2 /* low_and_falling_momentum_effect.json */, + E90909D724E34F1500F963D2 /* low_and_falling_predicted_glucose.json */, + ); + path = low_and_falling; + sourceTree = ""; + }; + E90909E124E352C300F963D2 /* low_with_low_treatment */ = { + isa = PBXGroup; + children = ( + E90909E224E3530200F963D2 /* low_with_low_treatment_carb_effect.json */, + E90909E624E3530200F963D2 /* low_with_low_treatment_counteraction_effect.json */, + E90909E324E3530200F963D2 /* low_with_low_treatment_insulin_effect.json */, + E90909E524E3530200F963D2 /* low_with_low_treatment_momentum_effect.json */, + E90909E424E3530200F963D2 /* low_with_low_treatment_predicted_glucose.json */, + ); + path = low_with_low_treatment; + sourceTree = ""; + }; + E90909EC24E35B3400F963D2 /* high_and_falling */ = { + isa = PBXGroup; + children = ( + E90909F524E35B7C00F963D2 /* high_and_falling_momentum_effect.json */, + E90909F024E35B4C00F963D2 /* high_and_falling_carb_effect.json */, + E90909EF24E35B4C00F963D2 /* high_and_falling_counteraction_effect.json */, + E90909F124E35B4C00F963D2 /* high_and_falling_insulin_effect.json */, + E90909ED24E35B4000F963D2 /* high_and_falling_predicted_glucose.json */, + ); + path = high_and_falling; + sourceTree = ""; + }; + E93E86AC24DDE02C00FF40C8 /* Mock Stores */ = { + isa = PBXGroup; + children = ( + E93E86A724DDCC4400FF40C8 /* MockDoseStore.swift */, + E93E86AF24DDE1BD00FF40C8 /* MockGlucoseStore.swift */, + E93E86B124DDE21D00FF40C8 /* MockCarbStore.swift */, + E98A55F024EDD85E0008715D /* MockDosingDecisionStore.swift */, + E98A55F224EDD9530008715D /* MockSettingsStore.swift */, + E9B3552E2935968E0076AB04 /* HKHealthStoreMock.swift */, + ); + path = "Mock Stores"; + sourceTree = ""; + }; + E93E86B324E1FD8700FF40C8 /* flat_and_stable */ = { + isa = PBXGroup; + children = ( + E93E86C224E1FE6100FF40C8 /* flat_and_stable_counteraction_effect.json */, + E93E86B824E1FDC400FF40C8 /* flat_and_stable_carb_effect.json */, + E93E86B424E1FDC400FF40C8 /* flat_and_stable_insulin_effect.json */, + E93E86B524E1FDC400FF40C8 /* flat_and_stable_momentum_effect.json */, + E93E86B624E1FDC400FF40C8 /* flat_and_stable_predicted_glucose.json */, + ); + path = flat_and_stable; + sourceTree = ""; + }; + E93E86C424E2DF6700FF40C8 /* high_and_stable */ = { + isa = PBXGroup; + children = ( + E93E86C624E2E02200FF40C8 /* high_and_stable_carb_effect.json */, + E93E86C824E2E02200FF40C8 /* high_and_stable_counteraction_effect.json */, + E93E86C524E2E02200FF40C8 /* high_and_stable_insulin_effect.json */, + E93E86C924E2E02200FF40C8 /* high_and_stable_momentum_effect.json */, + E93E86C724E2E02200FF40C8 /* high_and_stable_predicted_glucose.json */, + ); + path = high_and_stable; + sourceTree = ""; + }; + E95D37FF24EADE68005E2F50 /* Store Protocols */ = { + isa = PBXGroup; + children = ( + E95D380024EADE7C005E2F50 /* DoseStoreProtocol.swift */, + E95D380224EADF36005E2F50 /* CarbStoreProtocol.swift */, + E95D380424EADF78005E2F50 /* GlucoseStoreProtocol.swift */, + E98A55EC24EDD6380008715D /* LatestStoredSettingsProvider.swift */, + E98A55EE24EDD6E60008715D /* DosingDecisionStoreProtocol.swift */, + ); + path = "Store Protocols"; + sourceTree = ""; + }; + E9B07F7D253BBA6500BAD8F8 /* Loop Intent Extension */ = { + isa = PBXGroup; + children = ( + E942DE6D253BE5E100AC532D /* Loop Intent Extension.entitlements */, + E9B07F7E253BBA6500BAD8F8 /* IntentHandler.swift */, + E9B07F80253BBA6500BAD8F8 /* Info.plist */, + B66D1F2C2E6A5D6500471149 /* Localizable.xcstrings */, + B66D1F302E6A5D6600471149 /* InfoPlist.xcstrings */, + E9B07FED253BBC7100BAD8F8 /* OverrideIntentHandler.swift */, + ); + path = "Loop Intent Extension"; + sourceTree = ""; + }; + E9B355232935906B0076AB04 /* Missed Meal Detection */ = { + isa = PBXGroup; + children = ( + E9B3552129358C440076AB04 /* MealDetectionManager.swift */, + E9B35525293590980076AB04 /* MissedMealSettings.swift */, + ); + path = "Missed Meal Detection"; + sourceTree = ""; + }; + E9B355312937068A0076AB04 /* meal_detection */ = { + isa = PBXGroup; + children = ( + E9B35533293706CA0076AB04 /* dynamic_autofill_counteraction_effect.json */, + E9B35532293706CA0076AB04 /* needs_clamping_counteraction_effect.json */, + E9B35534293706CB0076AB04 /* missed_meal_counteraction_effect.json */, + E9B35535293706CB0076AB04 /* noisy_cgm_counteraction_effect.json */, + E9B35537293706CB0076AB04 /* long_interval_counteraction_effect.json */, + E9B35536293706CB0076AB04 /* realistic_report_counteraction_effect.json */, + ); + path = meal_detection; + sourceTree = ""; + }; + E9C58A7624DB510500487A17 /* Fixtures */ = { + isa = PBXGroup; + children = ( + C13072B82A76AF0A009A7C58 /* live_capture */, + E9B355312937068A0076AB04 /* meal_detection */, + E90909EC24E35B3400F963D2 /* high_and_falling */, + E90909E124E352C300F963D2 /* low_with_low_treatment */, + E90909D624E34EC200F963D2 /* low_and_falling */, + E90909CB24E34A7A00F963D2 /* high_and_rising_with_cob */, + E93E86C424E2DF6700FF40C8 /* high_and_stable */, + E93E86B324E1FD8700FF40C8 /* flat_and_stable */, + E93E865724DB75BD00FF40C8 /* predicted_glucose_very_negative.json */, + E93E865524DB731900FF40C8 /* predicted_glucose_without_retrospective.json */, + E9C58A7824DB529A00487A17 /* basal_profile.json */, + E93E865324DB6CBA00FF40C8 /* retrospective_output.json */, + E9C58A7A24DB529A00487A17 /* counteraction_effect_falling_glucose.json */, + E9C58A7924DB529A00487A17 /* dynamic_glucose_effect_partially_observed.json */, + E9C58A7B24DB529A00487A17 /* insulin_effect.json */, + E9C58A7724DB529A00487A17 /* momentum_effect_bouncing.json */, + ); + path = Fixtures; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXHeadersBuildPhase section */ + 43D9001D21EB209400AF44BF /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + 43D9001E21EB209400AF44BF /* LoopCore.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 43D9FFCA21EAE05D00AF44BF /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + 43D9FFD321EAE05D00AF44BF /* LoopCore.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 4F7528881DFE1DC600C322D6 /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + 4F2C15851E075B8700E160D4 /* LoopUI.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXHeadersBuildPhase section */ + +/* Begin PBXNativeTarget section */ + 14B1735B28AED9EC006CCD7C /* Loop Widget Extension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 14B1736C28AED9EE006CCD7C /* Build configuration list for PBXNativeTarget "Loop Widget Extension" */; + buildPhases = ( + 14B1735828AED9EC006CCD7C /* Sources */, + 14B1735928AED9EC006CCD7C /* Frameworks */, + 14B1735A28AED9EC006CCD7C /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 1481F9BE28DA26F4004C5AEB /* PBXTargetDependency */, + ); + name = "Loop Widget Extension"; + productName = SmallStatusWidgetExtension; + productReference = 14B1735C28AED9EC006CCD7C /* Loop Widget Extension.appex */; + productType = "com.apple.product-type.app-extension"; + }; + 43776F8B1B8022E90074EA36 /* Loop */ = { + isa = PBXNativeTarget; + buildConfigurationList = 43776FB61B8022E90074EA36 /* Build configuration list for PBXNativeTarget "Loop" */; + buildPhases = ( + C1D1405722FB66DF00DA6242 /* Build Derived Assets */, + 43776F881B8022E90074EA36 /* Sources */, + 43776F891B8022E90074EA36 /* Frameworks */, + 43776F8A1B8022E90074EA36 /* Resources */, + 43A9439C1B926B7B0051FA24 /* Embed Watch Content */, + 43A943AE1B928D400051FA24 /* Embed Frameworks */, + C113F4472951352C00758735 /* Install Scenarios */, + C16DA84322E8E5FF008624C2 /* Install Plugins */, + C1D19800232CFA2A0096D646 /* Capture Build Details */, + C1092BFE29F88F0600AE3D1C /* Apply Info Customizations */, + 4F70C1EC1DE8DCA8006380B7 /* Embed App Extensions */, + ); + buildRules = ( + ); + dependencies = ( + 4F7528971DFE1ED400C322D6 /* PBXTargetDependency */, + 43A943931B926B7B0051FA24 /* PBXTargetDependency */, + 4F70C1E71DE8DCA7006380B7 /* PBXTargetDependency */, + 43D9FFD521EAE05D00AF44BF /* PBXTargetDependency */, + E9B07F93253BBA6500BAD8F8 /* PBXTargetDependency */, + 14B1736828AED9EE006CCD7C /* PBXTargetDependency */, + ); + name = Loop; + packageProductDependencies = ( + C1F00C5F285A802A006302C5 /* SwiftCharts */, + C1D6EE9F2A06C7270047DE5C /* MKRingProgressView */, + C1735B1D2A0809830082BB8A /* ZIPFoundation */, + ); + productName = Loop; + productReference = 43776F8C1B8022E90074EA36 /* Loop.app */; + productType = "com.apple.product-type.application"; + }; + 43A943711B926B7B0051FA24 /* WatchApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = 43A943991B926B7B0051FA24 /* Build configuration list for PBXNativeTarget "WatchApp" */; + buildPhases = ( + 43A943701B926B7B0051FA24 /* Resources */, + 43A943981B926B7B0051FA24 /* Embed App Extensions */, + 43105EF81BADC8F9009CD81E /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 43A943811B926B7B0051FA24 /* PBXTargetDependency */, + ); + name = WatchApp; + productName = WatchApp; + productReference = 43A943721B926B7B0051FA24 /* WatchApp.app */; + productType = "com.apple.product-type.application.watchapp2"; + }; + 43A9437D1B926B7B0051FA24 /* WatchApp Extension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 43A943951B926B7B0051FA24 /* Build configuration list for PBXNativeTarget "WatchApp Extension" */; + buildPhases = ( + C1E9CB59294E67060022387B /* Build Derived Assets */, + 43A9437A1B926B7B0051FA24 /* Sources */, + 43A9437B1B926B7B0051FA24 /* Frameworks */, + 43A9437C1B926B7B0051FA24 /* Resources */, + 43C667D71C5577280050C674 /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + C117ED71232EDB3200DA57CD /* PBXTargetDependency */, + ); + name = "WatchApp Extension"; productName = "WatchApp Extension"; productReference = 43A9437E1B926B7B0051FA24 /* WatchApp Extension.appex */; productType = "com.apple.product-type.watchkit2-extension"; }; - 43E2D8D01D20BF42004DA55F /* DoseMathTests */ = { + 43D9001A21EB209400AF44BF /* LoopCore-watchOS */ = { isa = PBXNativeTarget; - buildConfigurationList = 43E2D8D61D20BF42004DA55F /* Build configuration list for PBXNativeTarget "DoseMathTests" */; + buildConfigurationList = 43D9002721EB209400AF44BF /* Build configuration list for PBXNativeTarget "LoopCore-watchOS" */; buildPhases = ( - 43E2D8CD1D20BF42004DA55F /* Sources */, - 43E2D8CE1D20BF42004DA55F /* Frameworks */, - 43E2D8CF1D20BF42004DA55F /* Resources */, - 43E2D8DD1D20C072004DA55F /* CopyFiles */, + 43D9001D21EB209400AF44BF /* Headers */, + 43D9001F21EB209400AF44BF /* Sources */, + 43D9002321EB209400AF44BF /* Frameworks */, + 43D9002621EB209400AF44BF /* Resources */, ); buildRules = ( ); dependencies = ( ); - name = DoseMathTests; - productName = DoseMathTests; - productReference = 43E2D8D11D20BF42004DA55F /* DoseMathTests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; + name = "LoopCore-watchOS"; + productName = LoopCore; + productReference = 43D9002A21EB209400AF44BF /* LoopCore.framework */; + productType = "com.apple.product-type.framework"; + }; + 43D9FFCE21EAE05D00AF44BF /* LoopCore */ = { + isa = PBXNativeTarget; + buildConfigurationList = 43D9FFD821EAE05D00AF44BF /* Build configuration list for PBXNativeTarget "LoopCore" */; + buildPhases = ( + 43D9FFCA21EAE05D00AF44BF /* Headers */, + 43D9FFCB21EAE05D00AF44BF /* Sources */, + 43D9FFCC21EAE05D00AF44BF /* Frameworks */, + 43D9FFCD21EAE05D00AF44BF /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = LoopCore; + productName = LoopCore; + productReference = 43D9FFCF21EAE05D00AF44BF /* LoopCore.framework */; + productType = "com.apple.product-type.framework"; }; 43E2D90A1D20C581004DA55F /* LoopTests */ = { isa = PBXNativeTarget; @@ -1031,6 +2825,7 @@ 43E2D9071D20C581004DA55F /* Sources */, 43E2D9081D20C581004DA55F /* Frameworks */, 43E2D9091D20C581004DA55F /* Resources */, + C1E3DC4828595FAA00CA19FF /* Embed Frameworks */, ); buildRules = ( ); @@ -1038,6 +2833,9 @@ 43E2D9111D20C581004DA55F /* PBXTargetDependency */, ); name = LoopTests; + packageProductDependencies = ( + C1E3DC4628595FAA00CA19FF /* SwiftCharts */, + ); productName = LoopTests; productReference = 43E2D90B1D20C581004DA55F /* LoopTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; @@ -1053,9 +2851,12 @@ buildRules = ( ); dependencies = ( - 4F7528991DFE1ED800C322D6 /* PBXTargetDependency */, + C11B9D592867781E00500CF8 /* PBXTargetDependency */, ); name = "Loop Status Extension"; + packageProductDependencies = ( + C1CCF1162858FBAD0035389C /* SwiftCharts */, + ); productName = "Loop Status Extension"; productReference = 4F70C1DC1DE8DCA7006380B7 /* Loop Status Extension.appex */; productType = "com.apple.product-type.app-extension"; @@ -1064,33 +2865,58 @@ isa = PBXNativeTarget; buildConfigurationList = 4F7528921DFE1DC600C322D6 /* Build configuration list for PBXNativeTarget "LoopUI" */; buildPhases = ( + 4F7528881DFE1DC600C322D6 /* Headers */, 4F7528861DFE1DC600C322D6 /* Sources */, 4F7528871DFE1DC600C322D6 /* Frameworks */, - 4F7528881DFE1DC600C322D6 /* Headers */, 4F7528891DFE1DC600C322D6 /* Resources */, ); buildRules = ( ); dependencies = ( + C1CCF1152858FA900035389C /* PBXTargetDependency */, ); name = LoopUI; + packageProductDependencies = ( + C11B9D5A286778A800500CF8 /* SwiftCharts */, + ); productName = LoopUI; productReference = 4F75288B1DFE1DC600C322D6 /* LoopUI.framework */; productType = "com.apple.product-type.framework"; }; + E9B07F7B253BBA6500BAD8F8 /* Loop Intent Extension */ = { + isa = PBXNativeTarget; + buildConfigurationList = E9B07F9A253BBA6500BAD8F8 /* Build configuration list for PBXNativeTarget "Loop Intent Extension" */; + buildPhases = ( + E9B07F78253BBA6500BAD8F8 /* Sources */, + E9B07F79253BBA6500BAD8F8 /* Frameworks */, + E9B07F7A253BBA6500BAD8F8 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = "Loop Intent Extension"; + productName = "Loop Intent Extension"; + productReference = E9B07F7C253BBA6500BAD8F8 /* Loop Intent Extension.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ 43776F841B8022E90074EA36 /* Project object */ = { isa = PBXProject; attributes = { - LastSwiftUpdateCheck = 0730; - LastUpgradeCheck = 0810; + LastSwiftUpdateCheck = 1340; + LastUpgradeCheck = 1010; ORGANIZATIONNAME = "LoopKit Authors"; TargetAttributes = { + 14B1735B28AED9EC006CCD7C = { + CreatedOnToolsVersion = 13.4.1; + }; 43776F8B1B8022E90074EA36 = { CreatedOnToolsVersion = 7.0; - LastSwiftMigration = 0800; + LastSwiftMigration = 1020; + ProvisioningStyle = Automatic; SystemCapabilities = { com.apple.ApplicationGroups.iOS = { enabled = 1; @@ -1104,11 +2930,15 @@ com.apple.Keychain = { enabled = 0; }; + com.apple.Siri = { + enabled = 1; + }; }; }; 43A943711B926B7B0051FA24 = { CreatedOnToolsVersion = 7.0; LastSwiftMigration = 0800; + ProvisioningStyle = Automatic; SystemCapabilities = { com.apple.ApplicationGroups.iOS = { enabled = 0; @@ -1120,7 +2950,8 @@ }; 43A9437D1B926B7B0051FA24 = { CreatedOnToolsVersion = 7.0; - LastSwiftMigration = 0800; + LastSwiftMigration = 1020; + ProvisioningStyle = Automatic; SystemCapabilities = { com.apple.ApplicationGroups.iOS = { enabled = 0; @@ -1128,22 +2959,35 @@ com.apple.HealthKit = { enabled = 0; }; + com.apple.HealthKit.watchos = { + enabled = 1; + }; com.apple.Keychain = { enabled = 0; }; + com.apple.Siri = { + enabled = 1; + }; }; }; - 43E2D8D01D20BF42004DA55F = { - CreatedOnToolsVersion = 7.3.1; - LastSwiftMigration = 0800; + 43D9001A21EB209400AF44BF = { + LastSwiftMigration = 1020; + ProvisioningStyle = Automatic; + }; + 43D9FFCE21EAE05D00AF44BF = { + CreatedOnToolsVersion = 10.1; + LastSwiftMigration = 1020; + ProvisioningStyle = Automatic; }; 43E2D90A1D20C581004DA55F = { CreatedOnToolsVersion = 7.3.1; LastSwiftMigration = 0800; + ProvisioningStyle = Automatic; TestTargetID = 43776F8B1B8022E90074EA36; }; 4F70C1DB1DE8DCA7006380B7 = { CreatedOnToolsVersion = 8.1; + LastSwiftMigration = 1020; ProvisioningStyle = Automatic; SystemCapabilities = { com.apple.ApplicationGroups.iOS = { @@ -1153,19 +2997,53 @@ }; 4F75288A1DFE1DC600C322D6 = { CreatedOnToolsVersion = 8.1; + LastSwiftMigration = 1020; + ProvisioningStyle = Automatic; + }; + E9B07F7B253BBA6500BAD8F8 = { ProvisioningStyle = Automatic; }; }; }; buildConfigurationList = 43776F871B8022E90074EA36 /* Build configuration list for PBXProject "Loop" */; compatibilityVersion = "Xcode 8.0"; - developmentRegion = English; + developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( en, Base, + fr, + de, + "zh-Hans", + it, + nl, + nb, + es, + pl, + ru, + ja, + "pt-BR", + vi, + da, + sv, + fi, + ro, + tr, + he, + ar, + sk, + cs, + hi, + ce, + hu, + uk, ); mainGroup = 43776F831B8022E90074EA36; + packageReferences = ( + C1CCF10B2858F4F70035389C /* XCRemoteSwiftPackageReference "SwiftCharts" */, + C1D6EE9E2A06C7270047DE5C /* XCRemoteSwiftPackageReference "MKRingProgressView" */, + C1735B1C2A0809830082BB8A /* XCRemoteSwiftPackageReference "ZIPFoundation" */, + ); productRefGroup = 43776F8D1B8022E90074EA36 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -1174,25 +3052,42 @@ 4F70C1DB1DE8DCA7006380B7 /* Loop Status Extension */, 43A943711B926B7B0051FA24 /* WatchApp */, 43A9437D1B926B7B0051FA24 /* WatchApp Extension */, + 14B1735B28AED9EC006CCD7C /* Loop Widget Extension */, + E9B07F7B253BBA6500BAD8F8 /* Loop Intent Extension */, + 43D9FFCE21EAE05D00AF44BF /* LoopCore */, + 43D9001A21EB209400AF44BF /* LoopCore-watchOS */, 4F75288A1DFE1DC600C322D6 /* LoopUI */, - 43E2D8D01D20BF42004DA55F /* DoseMathTests */, 43E2D90A1D20C581004DA55F /* LoopTests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 14B1735A28AED9EC006CCD7C /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B66D1F2F2E6A5D6600471149 /* InfoPlist.xcstrings in Resources */, + 147EFE8E2A8BCC5500272438 /* DefaultAssets.xcassets in Resources */, + B66D1F272E6A5D6500471149 /* Localizable.xcstrings in Resources */, + 147EFE902A8BCD8000272438 /* DerivedAssets.xcassets in Resources */, + 147EFE922A8BCD8A00272438 /* DerivedAssetsBase.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 43776F8A1B8022E90074EA36 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - C1C73F081DE3D0260022FC89 /* InfoPlist.strings in Resources */, - 43776F991B8022E90074EA36 /* Assets.xcassets in Resources */, - 434F54591D28805E002A9274 /* ButtonTableViewCell.xib in Resources */, - C1C73F021DE3D0250022FC89 /* Localizable.strings in Resources */, + B66D1F392E6A5D6600471149 /* InfoPlist.xcstrings in Resources */, + C13255D6223E7BE2008AF50C /* BolusProgressTableViewCell.xib in Resources */, + C1EE9E812A38D0FB0064784A /* BuildDetails.plist in Resources */, + 43FCBBC21E51710B00343C1B /* LaunchScreen.storyboard in Resources */, + B405E35A24D2B1A400DD058D /* HUDAssets.xcassets in Resources */, + A966152623EA5A26005D8B29 /* DefaultAssets.xcassets in Resources */, + A966152723EA5A26005D8B29 /* DerivedAssets.xcassets in Resources */, + B66D1F332E6A5D6600471149 /* Localizable.xcstrings in Resources */, 43776F971B8022E90074EA36 /* Main.storyboard in Resources */, - 43846AD91D8FA84B00799272 /* gallery.ckcomplication in Resources */, - 434F545B1D2880D4002A9274 /* AuthenticationTableViewCell.xib in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1200,9 +3095,10 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 43A943781B926B7B0051FA24 /* Assets.xcassets in Resources */, - C1C73F0D1DE3D0270022FC89 /* InfoPlist.strings in Resources */, + A966152B23EA5A37005D8B29 /* DerivedAssets.xcassets in Resources */, + B66D1F232E6A5D6500471149 /* InfoPlist.xcstrings in Resources */, 43A943761B926B7B0051FA24 /* Interface.storyboard in Resources */, + A966152A23EA5A37005D8B29 /* DefaultAssets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1210,28 +3106,27 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 43846AD51D8FA67800799272 /* Base.lproj in Resources */, - C1C73EF71DE3D0230022FC89 /* InfoPlist.strings in Resources */, + B66D1F252E6A5D6500471149 /* InfoPlist.xcstrings in Resources */, + B66D1F372E6A5D6600471149 /* ckcomplication.xcstrings in Resources */, 43A943901B926B7B0051FA24 /* Assets.xcassets in Resources */, + B66D1F352E6A5D6600471149 /* Localizable.xcstrings in Resources */, + B405E35924D2A75B00DD058D /* DerivedAssets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 43D9002621EB209400AF44BF /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B66D1F3C2E6A5D6600471149 /* Localizable.xcstrings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; - 43E2D8CF1D20BF42004DA55F /* Resources */ = { + 43D9FFCD21EAE05D00AF44BF /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 43E2D8F21D20C0DB004DA55F /* recommend_temp_basal_no_change_glucose.json in Resources */, - 43E2D8F61D20C0DB004DA55F /* recommend_temp_basal_start_low_end_in_range.json in Resources */, - 43E2D8F41D20C0DB004DA55F /* recommend_temp_basal_start_high_end_low.json in Resources */, - 43E2D8EF1D20C0DB004DA55F /* recommend_temp_basal_high_and_falling.json in Resources */, - 43E2D8ED1D20C0DB004DA55F /* recommend_temp_basal_correct_low_at_min.json in Resources */, - 43E2D8F01D20C0DB004DA55F /* recommend_temp_basal_high_and_rising.json in Resources */, - C12F21A71DFA79CB00748193 /* recommend_tamp_basal_very_low_end_in_range.json in Resources */, - 43E2D8F11D20C0DB004DA55F /* recommend_temp_basal_in_range_and_rising.json in Resources */, - 43E2D8EE1D20C0DB004DA55F /* recommend_temp_basal_flat_and_high.json in Resources */, - 43E2D8F31D20C0DB004DA55F /* recommend_temp_basal_start_high_end_in_range.json in Resources */, - 43E2D8F51D20C0DB004DA55F /* recommend_temp_basal_start_low_end_high.json in Resources */, - 43E2D8EC1D20C0DB004DA55F /* read_selected_basal_profile.json in Resources */, + B66D1F3B2E6A5D6600471149 /* Localizable.xcstrings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1239,6 +3134,52 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + E93E86CE24E2E02200FF40C8 /* high_and_stable_momentum_effect.json in Resources */, + C13072BA2A76AF31009A7C58 /* live_capture_predicted_glucose.json in Resources */, + E93E865424DB6CBA00FF40C8 /* retrospective_output.json in Resources */, + E9C58A7F24DB529A00487A17 /* counteraction_effect_falling_glucose.json in Resources */, + E93E865624DB731900FF40C8 /* predicted_glucose_without_retrospective.json in Resources */, + E9B3553D293706CB0076AB04 /* long_interval_counteraction_effect.json in Resources */, + E9B3553A293706CB0076AB04 /* missed_meal_counteraction_effect.json in Resources */, + E9B35538293706CB0076AB04 /* needs_clamping_counteraction_effect.json in Resources */, + E90909D424E34AC500F963D2 /* high_and_rising_with_cob_carb_effect.json in Resources */, + E93E86CC24E2E02200FF40C8 /* high_and_stable_predicted_glucose.json in Resources */, + E90909DC24E34F1600F963D2 /* low_and_falling_predicted_glucose.json in Resources */, + E90909DE24E34F1600F963D2 /* low_and_falling_counteraction_effect.json in Resources */, + E90909D224E34AC500F963D2 /* high_and_rising_with_cob_insulin_effect.json in Resources */, + E90909F224E35B4D00F963D2 /* high_and_falling_counteraction_effect.json in Resources */, + E90909EE24E35B4000F963D2 /* high_and_falling_predicted_glucose.json in Resources */, + E90909DD24E34F1600F963D2 /* low_and_falling_carb_effect.json in Resources */, + E90909E924E3530200F963D2 /* low_with_low_treatment_predicted_glucose.json in Resources */, + E90909D124E34AC500F963D2 /* high_and_rising_with_cob_momentum_effect.json in Resources */, + E9C58A8024DB529A00487A17 /* insulin_effect.json in Resources */, + E90909DF24E34F1600F963D2 /* low_and_falling_insulin_effect.json in Resources */, + C16FC0B02A99392F0025E239 /* live_capture_input.json in Resources */, + E93E86BE24E1FDC400FF40C8 /* flat_and_stable_carb_effect.json in Resources */, + E90909E824E3530200F963D2 /* low_with_low_treatment_insulin_effect.json in Resources */, + E9B3553B293706CB0076AB04 /* noisy_cgm_counteraction_effect.json in Resources */, + E9B3553C293706CB0076AB04 /* realistic_report_counteraction_effect.json in Resources */, + E93E865824DB75BE00FF40C8 /* predicted_glucose_very_negative.json in Resources */, + E93E86BC24E1FDC400FF40C8 /* flat_and_stable_predicted_glucose.json in Resources */, + E90909EB24E3530200F963D2 /* low_with_low_treatment_counteraction_effect.json in Resources */, + E90909F424E35B4D00F963D2 /* high_and_falling_insulin_effect.json in Resources */, + E9B35539293706CB0076AB04 /* dynamic_autofill_counteraction_effect.json in Resources */, + E90909F624E35B7C00F963D2 /* high_and_falling_momentum_effect.json in Resources */, + E93E86BA24E1FDC400FF40C8 /* flat_and_stable_insulin_effect.json in Resources */, + E90909E724E3530200F963D2 /* low_with_low_treatment_carb_effect.json in Resources */, + E90909EA24E3530200F963D2 /* low_with_low_treatment_momentum_effect.json in Resources */, + E90909D324E34AC500F963D2 /* high_and_rising_with_cob_predicted_glucose.json in Resources */, + E90909D524E34AC500F963D2 /* high_and_rising_with_cob_counteraction_effect.json in Resources */, + E9C58A7D24DB529A00487A17 /* basal_profile.json in Resources */, + E93E86CD24E2E02200FF40C8 /* high_and_stable_counteraction_effect.json in Resources */, + E93E86C324E1FE6100FF40C8 /* flat_and_stable_counteraction_effect.json in Resources */, + E9C58A7E24DB529A00487A17 /* dynamic_glucose_effect_partially_observed.json in Resources */, + E90909F324E35B4D00F963D2 /* high_and_falling_carb_effect.json in Resources */, + E93E86CA24E2E02200FF40C8 /* high_and_stable_insulin_effect.json in Resources */, + E93E86BB24E1FDC400FF40C8 /* flat_and_stable_momentum_effect.json in Resources */, + E93E86CB24E2E02200FF40C8 /* high_and_stable_carb_effect.json in Resources */, + E9C58A7C24DB529A00487A17 /* momentum_effect_bouncing.json in Resources */, + E90909E024E34F1600F963D2 /* low_and_falling_momentum_effect.json in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1246,8 +3187,11 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 4F70C1FC1DE8E5FB006380B7 /* Assets.xcassets in Resources */, + B491B09E24D0B600004CBE8F /* DerivedAssets.xcassets in Resources */, + B66D1F402E6A5D6600471149 /* InfoPlist.xcstrings in Resources */, 4F70C1E41DE8DCA7006380B7 /* MainInterface.storyboard in Resources */, + B66D1F2B2E6A5D6500471149 /* Localizable.xcstrings in Resources */, + B405E35B24D2E05600DD058D /* HUDAssets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1255,125 +3199,383 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 4F2C15971E09E94E00E160D4 /* Assets.xcassets in Resources */, + 4F2C15971E09E94E00E160D4 /* HUDAssets.xcassets in Resources */, + B66D1F212E6A5D6500471149 /* InfoPlist.xcstrings in Resources */, + B66D1F3E2E6A5D6600471149 /* Localizable.xcstrings in Resources */, 4F2C15951E09BF3C00E160D4 /* HUDView.xib in Resources */, + B4E96D5D248A82A2002DABAD /* StatusBarHUDView.xib in Resources */, + B4E96D59248A7F9A002DABAD /* StatusHighlightHUDView.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E9B07F7A253BBA6500BAD8F8 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B66D1F312E6A5D6600471149 /* InfoPlist.xcstrings in Resources */, + B66D1F2D2E6A5D6600471149 /* Localizable.xcstrings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 43EDDBEF1C361BCE007D89B5 /* ShellScript */ = { + C1092BFE29F88F0600AE3D1C /* Apply Info Customizations */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "$(SRCROOT)/../InfoCustomizations", + ); + name = "Apply Info Customizations"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${SRCROOT}/Scripts/apply-info-customizations.sh\"\n"; + }; + C113F4472951352C00758735 /* Install Scenarios */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Install Scenarios"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# Type a script or drag a script file from your workspace to insert its path.\n\"${SRCROOT}/Scripts/install-scenarios.sh\"\n"; + }; + C16DA84322E8E5FF008624C2 /* Install Plugins */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); + inputFileListPaths = ( + ); inputPaths = ( - "$(SRCROOT)/Carthage/Build/iOS/xDripG5.framework", - "$(SRCROOT)/Carthage/Build/iOS/CarbKit.framework", - "$(SRCROOT)/Carthage/Build/iOS/GlucoseKit.framework", - "$(SRCROOT)/Carthage/Build/iOS/InsulinKit.framework", - "$(SRCROOT)/Carthage/Build/iOS/LoopKit.framework", - "$(SRCROOT)/Carthage/Build/iOS/SwiftCharts.framework", - "$(SRCROOT)/Carthage/Build/iOS/MinimedKit.framework", - "$(SRCROOT)/Carthage/Build/iOS/RileyLinkBLEKit.framework", - "$(SRCROOT)/Carthage/Build/iOS/RileyLinkKit.framework", - "$(SRCROOT)/Carthage/Build/iOS/Amplitude.framework", - "$(SRCROOT)/Carthage/Build/iOS/ShareClient.framework", - "$(SRCROOT)/Carthage/Build/iOS/NightscoutUploadKit.framework", - "$(SRCROOT)/Carthage/Build/iOS/Crypto.framework", - "$(SRCROOT)/Carthage/Build/iOS/G4ShareSpy.framework", + ); + name = "Install Plugins"; + outputFileListPaths = ( ); outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "# /usr/local/bin/carthage build --platform \"$PLATFORM_NAME\" \"$SRCROOT\"\n/usr/local/bin/carthage copy-frameworks"; + shellScript = "\"${SRCROOT}/Scripts/copy-plugins.sh\"\n"; + }; + C1D1405722FB66DF00DA6242 /* Build Derived Assets */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Build Derived Assets"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${SRCROOT}/Scripts/build-derived-assets.sh\" \"${SRCROOT}/Loop\"\n"; + }; + C1D19800232CFA2A0096D646 /* Capture Build Details */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Capture Build Details"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${SRCROOT}/Scripts/capture-build-details.sh\"\n"; + }; + C1E9CB59294E67060022387B /* Build Derived Assets */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Build Derived Assets"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${SRCROOT}/Scripts/build-derived-assets.sh\" \"${SRCROOT}/WatchApp\"\n"; }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 14B1735828AED9EC006CCD7C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 14B1738128AEDC70006CCD7C /* StatusExtensionContext.swift in Sources */, + 14B1737628AEDC6C006CCD7C /* HKUnit.swift in Sources */, + 14B1737728AEDC6C006CCD7C /* NSBundle.swift in Sources */, + 84AA81D62A4A28AF000B658B /* WidgetBackground.swift in Sources */, + 84AA81D82A4A2910000B658B /* StatusWidgetTimelimeEntry.swift in Sources */, + 84AA81D32A4A27A3000B658B /* LoopWidgets.swift in Sources */, + 84AA81E32A4A36FB000B658B /* SystemActionLink.swift in Sources */, + 14B1737828AEDC6C006CCD7C /* NSTimeInterval.swift in Sources */, + 14B1737928AEDC6C006CCD7C /* NSUserDefaults+StatusExtension.swift in Sources */, + 14B1737A28AEDC6C006CCD7C /* NumberFormatter.swift in Sources */, + 14B1737B28AEDC6C006CCD7C /* OSLog.swift in Sources */, + 14B1737C28AEDC6C006CCD7C /* PumpManager.swift in Sources */, + 3ED319942EB65A3E00820BCF /* GlucoseActivityAttributes.swift in Sources */, + 14B1737D28AEDC6C006CCD7C /* PumpManagerUI.swift in Sources */, + 3ED319A12EB65B4100820BCF /* Bootstrap.swift in Sources */, + 14B1737E28AEDC6C006CCD7C /* FeatureFlags.swift in Sources */, + 84AA81E72A4A4DEF000B658B /* PumpView.swift in Sources */, + 14B1737F28AEDC6C006CCD7C /* PluginManager.swift in Sources */, + 3ED3198A2EB659E600820BCF /* GlucoseLiveActivityConfiguration.swift in Sources */, + 3ED3198B2EB659E600820BCF /* ChartView.swift in Sources */, + 3ED3199F2EB65AFE00820BCF /* LocalizedString.swift in Sources */, + 3ED3198C2EB659E600820BCF /* BasalViewActivity.swift in Sources */, + 14B1738028AEDC6C006CCD7C /* Debug.swift in Sources */, + 84AA81DB2A4A2973000B658B /* Date.swift in Sources */, + 14B1737228AEDBF6006CCD7C /* BasalView.swift in Sources */, + 14B1737428AEDBF6006CCD7C /* GlucoseView.swift in Sources */, + 14B1737328AEDBF6006CCD7C /* SystemStatusWidget.swift in Sources */, + 84AA81DD2A4A2999000B658B /* StatusWidgetTimelineProvider.swift in Sources */, + 14B1737528AEDBF6006CCD7C /* LoopCircleView.swift in Sources */, + 84D2879F2AC756C8007ED283 /* ContentMargin.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 43776F881B8022E90074EA36 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + C17824A51E1AD4D100D9D25C /* ManualBolusRecommendation.swift in Sources */, + 897A5A9624C2175B00C4E71D /* BolusEntryView.swift in Sources */, 4F70C2131DE90339006380B7 /* StatusExtensionContext.swift in Sources */, - 434F54571D287FDB002A9274 /* NibLoadable.swift in Sources */, + A9A056B324B93C62007CF06D /* CriticalEventLogExportView.swift in Sources */, + 43C05CC521EC29E3006FB252 /* TextFieldTableViewCell.swift in Sources */, 4FF4D1001E18374700846527 /* WatchContext.swift in Sources */, - 4315D28A1CA5F45E00589052 /* DiagnosticLogger+LoopKit.swift in Sources */, - 43C418B51CE0575200405B6A /* ShareGlucose+GlucoseKit.swift in Sources */, + C1D289B522F90A52003FFBD9 /* BasalDeliveryState.swift in Sources */, 4F2C15821E074FC600E160D4 /* NSTimeInterval.swift in Sources */, - 430DA58E1D4AEC230097D1CA /* NSBundle.swift in Sources */, + 4311FB9B1F37FE1B00D4C0A7 /* TitleSubtitleTextFieldTableViewCell.swift in Sources */, + 3ED319992EB65A6900820BCF /* LiveActivityManagementView.swift in Sources */, + 3ED3199A2EB65A6900820BCF /* LiveActivityBottomRowManagerView.swift in Sources */, + C1F2075C26D6F9B0007AB7EB /* AppExpirationAlerter.swift in Sources */, + B4FEEF7D24B8A71F00A8DF9B /* DeviceDataManager+DeviceStatus.swift in Sources */, + 142CB7592A60BF2E0075748A /* EditMode.swift in Sources */, + E95D380324EADF36005E2F50 /* CarbStoreProtocol.swift in Sources */, + B470F5842AB22B5100049695 /* StatefulPluggable.swift in Sources */, + E98A55ED24EDD6380008715D /* LatestStoredSettingsProvider.swift in Sources */, + C1FB428F217921D600FAB378 /* PumpManagerUI.swift in Sources */, + A9B996F227238705002DC09C /* DosingDecisionStore.swift in Sources */, + 43C513191E864C4E001547C7 /* GlucoseRangeSchedule.swift in Sources */, + B4D904412AA8989100CBD826 /* StatefulPluginManager.swift in Sources */, + E9B355292935919E0076AB04 /* MissedMealSettings.swift in Sources */, + 43A51E1F1EB6D62A000736CC /* CarbAbsorptionViewController.swift in Sources */, 43776F901B8022E90074EA36 /* AppDelegate.swift in Sources */, - 437CCADA1D284ADF0075D2C3 /* AuthenticationTableViewCell.swift in Sources */, + 4372E48B213CB5F00068E043 /* Double.swift in Sources */, + 430B29932041F5B300BA9F93 /* UserDefaults+Loop.swift in Sources */, 43CE7CDE1CA8B63E003CC1B0 /* Data.swift in Sources */, - 43F41C331D3A17AA00C11ED6 /* ChartAxisValueDoubleUnit.swift in Sources */, - 43F5C2DB1B92A5E1003EB13D /* SettingsTableViewController.swift in Sources */, - 4313EDE01D8A6BF90060FA79 /* ChartContentView.swift in Sources */, - 434FF1EA1CF26C29000DB779 /* IdentifiableClass.swift in Sources */, - 437CCADE1D2858FD0075D2C3 /* AuthenticationViewController.swift in Sources */, - 43A5676B1C96155700334FAC /* SwitchTableViewCell.swift in Sources */, + E9BB27AB23B85C3500FB4987 /* SleepStore.swift in Sources */, + C1F7822627CC056900C0919A /* SettingsManager.swift in Sources */, + 1452F4AD2A851F8800F8B9E4 /* HowAbsorptionTimeWorksView.swift in Sources */, + C16575712538A36B004AE16E /* CGMStalenessMonitor.swift in Sources */, + 1D080CBD2473214A00356610 /* AlertStore.xcdatamodeld in Sources */, + C11BD0552523CFED00236B08 /* SimpleBolusViewModel.swift in Sources */, + 149A28BB2A853E5100052EDF /* CarbEntryViewModel.swift in Sources */, + C19008FE25225D3900721625 /* SimpleBolusCalculator.swift in Sources */, + C1F8B243223E73FD00DD66CF /* BolusProgressTableViewCell.swift in Sources */, + 89D6953E23B6DF8A002B3066 /* PotentialCarbEntryTableViewCell.swift in Sources */, + 89CA2B30226C0161004D9350 /* DirectoryObserver.swift in Sources */, + 1DA649A7244126CD00F61E75 /* UserNotificationAlertScheduler.swift in Sources */, + 439A7942211F631C0041B75F /* RootNavigationController.swift in Sources */, + 149A28BD2A853E6C00052EDF /* CarbEntryView.swift in Sources */, + 4F11D3C020DCBEEC006E072C /* GlucoseBackfillRequestUserInfo.swift in Sources */, + 89E267FC2292456700A3F2AF /* FeatureFlags.swift in Sources */, 43A567691C94880B00334FAC /* LoopDataManager.swift in Sources */, - 43E397A31D56B9E40028E321 /* Glucose.swift in Sources */, + 1DA649A9244126DA00F61E75 /* InAppModalAlertScheduler.swift in Sources */, + 43B260491ED248FB008CAA77 /* CarbEntryTableViewCell.swift in Sources */, + 142CB75B2A60BFC30075748A /* FavoriteFoodsView.swift in Sources */, + A9D5C5B625DC6C6A00534873 /* LoopAppManager.swift in Sources */, 4302F4E11D4E9C8900F0FCAF /* TextFieldTableViewController.swift in Sources */, - 43E344A41B9E1B1C00C85C07 /* NSUserDefaults.swift in Sources */, - 43649A631C7A347F00523D7F /* CollectionType.swift in Sources */, + 1452F4AB2A851EDF00F8B9E4 /* AddEditFavoriteFoodView.swift in Sources */, + C1742332259BEADC00399C9D /* ManualEntryDoseView.swift in Sources */, 43F64DD91D9C92C900D24DC6 /* TitleSubtitleTableViewCell.swift in Sources */, - C15713821DAC6983005BC4D2 /* MealBolusNightscoutTreatment.swift in Sources */, - 435400321C9F745500D5819C /* BolusSuggestionUserInfo.swift in Sources */, + 43FCEEA9221A615B0013DD30 /* StatusChartsManager.swift in Sources */, + A9F703752489C9A000C98AD8 /* GlucoseStore+SimulatedCoreData.swift in Sources */, + E95D380524EADF78005E2F50 /* GlucoseStoreProtocol.swift in Sources */, 43E3449F1B9D68E900C85C07 /* StatusTableViewController.swift in Sources */, + B42D124328D371C400E43D22 /* AlertMuter.swift in Sources */, + A96DAC242838325900D94E38 /* DiagnosticLog.swift in Sources */, + A9CBE45A248ACBE1008E7BA2 /* DosingDecisionStore+SimulatedCoreData.swift in Sources */, + A9C62D8A2331703100535612 /* ServicesManager.swift in Sources */, 43DBF0531C93EC8200B3C386 /* DeviceDataManager.swift in Sources */, - 43E2D8C81D208D5B004DA55F /* KeychainManager+Loop.swift in Sources */, - 4346D1E71C77F5FE00ABAFE3 /* ChartTableViewCell.swift in Sources */, + A9347F2F24E7508A00C99C34 /* WatchHistoricalCarbs.swift in Sources */, + A9B996F027235191002DC09C /* LoopWarning.swift in Sources */, + C17824A01E19CF9800D9D25C /* GlucoseThresholdTableViewController.swift in Sources */, + 4372E487213C86240068E043 /* SampleValue.swift in Sources */, 437CEEE41CDE5C0A003C8C80 /* UIImage.swift in Sources */, - 43DBF0591C93F73800B3C386 /* CarbEntryTableViewController.swift in Sources */, - 43F41C351D3B623800C11ED6 /* ChartPointsTouchHighlightLayerViewCache.swift in Sources */, - 43EB40861C82646A00472A8C /* StatusChartManager.swift in Sources */, - C17884631D51A7A400405663 /* BatteryIndicator.swift in Sources */, + C1201E2C23ECDBD0002DA84A /* WatchContextRequestUserInfo.swift in Sources */, + 1D49795824E7289700948F05 /* ServicesViewModel.swift in Sources */, + 1D4A3E2D2478628500FD601B /* StoredAlert+CoreDataClass.swift in Sources */, + DDC389FA2A2B62470066E2E8 /* ConstantApplicationFactorStrategy.swift in Sources */, + B4E202302661063E009421B5 /* AutomaticDosingStatus.swift in Sources */, + C191D2A125B3ACAA00C26C0B /* DosingStrategySelectionView.swift in Sources */, + A977A2F424ACFECF0059C207 /* CriticalEventLogExportManager.swift in Sources */, + 89CA2B32226C18B8004D9350 /* TestingScenariosTableViewController.swift in Sources */, + 43E93FB71E469A5100EAB8DB /* HKUnit.swift in Sources */, + 43C05CAF21EB2C24006FB252 /* NSBundle.swift in Sources */, + A91D2A3F26CF0FF80023B075 /* IconTitleSubtitleTableViewCell.swift in Sources */, + A967D94C24F99B9300CDDF8A /* OutputStream.swift in Sources */, + 1DB1065124467E18005542BD /* AlertManager.swift in Sources */, + 1D9650C82523FBA100A1370B /* DeviceDataManager+BolusEntryViewModelDelegate.swift in Sources */, 43C0944A1CACCC73001F6403 /* NotificationManager.swift in Sources */, + 149A28E42A8A63A700052EDF /* FavoriteFoodDetailView.swift in Sources */, + 1DDE274024AEA4F200796622 /* NotificationsCriticalAlertPermissionsView.swift in Sources */, + A9A056B524B94123007CF06D /* CriticalEventLogExportViewModel.swift in Sources */, 434FF1EE1CF27EEF000DB779 /* UITableViewCell.swift in Sources */, - C18C8C511D5A351900E043FB /* NightscoutDataManager.swift in Sources */, - 438849EA1D297CB6003B3F23 /* NightscoutService.swift in Sources */, - 437CCADC1D284B830075D2C3 /* ButtonTableViewCell.swift in Sources */, - 4315D2871CA5CC3B00589052 /* CarbEntryEditTableViewController.swift in Sources */, - 43F5173D1D713DB0000FA422 /* RadioSelectionTableViewController.swift in Sources */, - 4331E0781C85302200FBE832 /* CGPoint.swift in Sources */, - 43DBF04C1C93B8D700B3C386 /* BolusViewController.swift in Sources */, + 439BED2A1E76093C00B0AED5 /* CGMManager.swift in Sources */, + C16B983E26B4893300256B05 /* DoseEnactor.swift in Sources */, + 3ED319962EB65A5C00820BCF /* LiveActivityManagementViewModel.swift in Sources */, + E98A55EF24EDD6E60008715D /* DosingDecisionStoreProtocol.swift in Sources */, + B4001CEE28CBBC82002FB414 /* AlertManagementView.swift in Sources */, + E9C00EF524C623EF00628F35 /* LoopSettings+Loop.swift in Sources */, + 4389916B1E91B689000EEF90 /* ChartSettings+Loop.swift in Sources */, + C178249A1E1999FA00D9D25C /* CaseCountable.swift in Sources */, + B4F3D25124AF890C0095CE44 /* BluetoothStateManager.swift in Sources */, + 1DDE273D24AEA4B000796622 /* SettingsViewModel.swift in Sources */, + DD3DBD292A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift in Sources */, + A9347F3124E7521800C99C34 /* CarbBackfillRequestUserInfo.swift in Sources */, + A9CBE458248AB564008E7BA2 /* DoseStore+SimulatedCoreData.swift in Sources */, + 897A5A9924C22DE800C4E71D /* BolusEntryViewModel.swift in Sources */, + 4374B5EF209D84BF00D17AA8 /* OSLog.swift in Sources */, + 1DB619AC270BAD3D006C9D07 /* VersionUpdateViewModel.swift in Sources */, + A9C62D882331703100535612 /* Service.swift in Sources */, + 89CAB36324C8FE96009EE3CE /* PredictedGlucoseChartView.swift in Sources */, + DDC389F82A2B620B0066E2E8 /* GlucoseBasedApplicationFactorStrategy.swift in Sources */, + 4F6663941E905FD2009E74FC /* ChartColorPalette+Loop.swift in Sources */, + A9F703732489BC8500C98AD8 /* CarbStore+SimulatedCoreData.swift in Sources */, 4328E0351CFC0AE100E199AA /* WatchDataManager.swift in Sources */, + 4345E3FC21F04911009E00E5 /* UIColor+HIG.swift in Sources */, + 1D4A3E2E2478628500FD601B /* StoredAlert+CoreDataProperties.swift in Sources */, + E95D380124EADE7C005E2F50 /* DoseStoreProtocol.swift in Sources */, + 43D381621EBD9759007F8C8F /* HeaderValuesTableViewCell.swift in Sources */, + C13DA2B024F6C7690098BB29 /* UIViewController.swift in Sources */, + A9C62D892331703100535612 /* LoggingServicesManager.swift in Sources */, + 89E267FF229267DF00A3F2AF /* Optional.swift in Sources */, + 43785E982120E7060057DED1 /* Intents.intentdefinition in Sources */, + 1D82E6A025377C6B009131FB /* TrustedTimeChecker.swift in Sources */, + A98556852493F901000FD662 /* AlertStore+SimulatedCoreData.swift in Sources */, + 899433B823FE129800FA4BEA /* OverrideBadgeView.swift in Sources */, + 89D1503E24B506EB00EDE253 /* Dictionary.swift in Sources */, + C110888D2A3913C600BA4898 /* BuildDetails.swift in Sources */, + A96DAC2C2838F31200D94E38 /* SharedLogging.swift in Sources */, 4302F4E31D4EA54200F0FCAF /* InsulinDeliveryTableViewController.swift in Sources */, - 437CCAE01D285C7B0075D2C3 /* ServiceAuthentication.swift in Sources */, + 1D63DEA526E950D400F46FA5 /* SupportManager.swift in Sources */, 4FC8C8011DEB93E400A1452E /* NSUserDefaults+StatusExtension.swift in Sources */, - 4302F4E51D4EA75100F0FCAF /* DoseStore.swift in Sources */, - 430DA5901D4B0E4C0097D1CA /* MySentryPumpStatusMessageBody.swift in Sources */, - 4D5B7A4B1D457CCA00796CA9 /* GlucoseG4.swift in Sources */, - 438849EC1D29EC34003B3F23 /* AmplitudeService.swift in Sources */, + 43E93FB61E469A4000EAB8DB /* NumberFormatter.swift in Sources */, + C1FB428C217806A400FAB378 /* StateColorPalette.swift in Sources */, + B43CF07E29434EC4008A520B /* HowMuteAlertWorkView.swift in Sources */, + 84AA81E52A4A3981000B658B /* DeeplinkManager.swift in Sources */, + 1D6B1B6726866D89009AC446 /* AlertPermissionsChecker.swift in Sources */, + 4F08DE8F1E7BB871006741EA /* CollectionType+Loop.swift in Sources */, + A9F703772489D8AA00C98AD8 /* PersistentDeviceLog+SimulatedCoreData.swift in Sources */, + E9B080B1253BDA6300BAD8F8 /* UserDefaults+LoopIntents.swift in Sources */, + C1AF062329426300002C1B19 /* ManualGlucoseEntryRow.swift in Sources */, + C148CEE724FD91BD00711B3B /* DeliveryUncertaintyAlertManager.swift in Sources */, + DDC389FC2A2BC6670066E2E8 /* SettingsView+algorithmExperimentsSection.swift in Sources */, + 1D12D3B92548EFDD00B53E8B /* main.swift in Sources */, 435400341C9F878D00D5819C /* SetBolusUserInfo.swift in Sources */, + A9DCF32A25B0FABF00C89088 /* LoopUIColorPalette+Default.swift in Sources */, + 1DDE273E24AEA4B000796622 /* SettingsView.swift in Sources */, + A9B607B0247F000F00792BE4 /* UserNotifications+Loop.swift in Sources */, + 43F89CA322BDFBBD006BB54E /* UIActivityIndicatorView.swift in Sources */, + A999D40624663D18004C89D4 /* PumpManagerError.swift in Sources */, 437D9BA31D7BC977007245E8 /* PredictionTableViewController.swift in Sources */, + A987CD4924A58A0100439ADC /* ZipArchive.swift in Sources */, 43F41C371D3BF32400C11ED6 /* UIAlertController.swift in Sources */, + A9CBE45C248ACC03008E7BA2 /* SettingsStore+SimulatedCoreData.swift in Sources */, 433EA4C41D9F71C800CD78FB /* CommandResponseViewController.swift in Sources */, - 434F545F1D288345002A9274 /* ShareService.swift in Sources */, + E9B3552229358C440076AB04 /* MealDetectionManager.swift in Sources */, + C16DA84222E8E112008624C2 /* PluginManager.swift in Sources */, + 43785E932120A01B0057DED1 /* NewCarbEntryIntent+Loop.swift in Sources */, + 439A7944211FE22F0041B75F /* NSUserActivity.swift in Sources */, 4328E0331CFC091100E199AA /* WatchContext+LoopKit.swift in Sources */, 4F526D611DF8D9A900A04910 /* NetBasal.swift in Sources */, - 4398973B1CD2FC2000223065 /* NSDateFormatter.swift in Sources */, + C1DE5D23251BFC4D00439E49 /* SimpleBolusView.swift in Sources */, + 89ADE13B226BFA0F0067222B /* TestingScenariosManager.swift in Sources */, + 4F7E8ACB20E2ACB500AEA65E /* WatchPredictedGlucose.swift in Sources */, 436A0DA51D236A2A00104B24 /* LoopError.swift in Sources */, - 43E2D8C61D204678004DA55F /* KeychainManager.swift in Sources */, - 433EA4C21D9F39C900CD78FB /* PumpIDTableViewController.swift in Sources */, - 43F78D261C8FC000002152D1 /* DoseMath.swift in Sources */, + 4F11D3C220DD80B3006E072C /* WatchHistoricalGlucose.swift in Sources */, + 4372E490213CFCE70068E043 /* LoopSettingsUserInfo.swift in Sources */, + C174233C259BEB0F00399C9D /* ManualEntryDoseViewModel.swift in Sources */, + 89CA2B3D226E6B13004D9350 /* LocalTestingScenariosManager.swift in Sources */, + 1D05219B2469E9DF000EBBDE /* StoredAlert.swift in Sources */, + E9B0802B253BBDFF00BAD8F8 /* IntentExtensionInfo.swift in Sources */, + C1E3862628247C6100F561A4 /* StoredLoopNotRunningNotification.swift in Sources */, + A97F250825E056D500F0EE19 /* OnboardingManager.swift in Sources */, 438D42F91D7C88BC003244B0 /* PredictionInputEffect.swift in Sources */, - 4331E07A1C85650D00FBE832 /* ChartAxisValueDoubleLog.swift in Sources */, - 434F54611D28859B002A9274 /* ServiceCredential.swift in Sources */, - 4F70C2101DE8FAC5006380B7 /* StatusExtensionDataManager.swift in Sources */, - 436FACEE1D0BA636004E2427 /* InsulinDataSource.swift in Sources */, - 439897371CD2F80600223065 /* AnalyticsManager.swift in Sources */, - 4346D1F61C78501000ABAFE3 /* ChartPoint.swift in Sources */, - 438849EE1D2A1EBB003B3F23 /* MLabService.swift in Sources */, + 892A5D692230C41D008961AB /* RangeReplaceableCollection.swift in Sources */, + DDC389F62A2B61750066E2E8 /* ApplicationFactorStrategy.swift in Sources */, + 4F70C2101DE8FAC5006380B7 /* ExtensionDataManager.swift in Sources */, + 43DFB62320D4CAE7008A7BAE /* PumpManager.swift in Sources */, + A9FB75F1252BE320004C7D3F /* BolusDosingDecision.swift in Sources */, + 892A5D59222F0A27008961AB /* Debug.swift in Sources */, + 431A8C401EC6E8AB00823B9C /* CircleMaskView.swift in Sources */, + 1D05219D2469F1F5000EBBDE /* AlertStore.swift in Sources */, + 439897371CD2F80600223065 /* AnalyticsServicesManager.swift in Sources */, + 14D906F42A846510006EB79A /* FavoriteFoodsViewModel.swift in Sources */, + 3ED319912EB65A2D00820BCF /* GlucoseActivityAttributes.swift in Sources */, + 3ED319922EB65A2D00820BCF /* LiveActivityManager.swift in Sources */, + 3ED319932EB65A2D00820BCF /* ChartAxisGenerator.swift in Sources */, + DDC389FE2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift in Sources */, + A9C62D842331700E00535612 /* DiagnosticLog+Subsystem.swift in Sources */, + 3ED319A32EB65DA800820BCF /* LiveActivityManagerProxy.swift in Sources */, + 895FE0952201234000FCF18A /* OverrideSelectionViewController.swift in Sources */, + C1EF747228D6A44A00C8C083 /* CrashRecoveryManager.swift in Sources */, + A9F66FC3247F451500096EA7 /* UIDevice+Loop.swift in Sources */, + 439706E622D2E84900C81566 /* PredictionSettingTableViewCell.swift in Sources */, + 430D85891F44037000AF2D4F /* HUDViewTableViewCell.swift in Sources */, + 43A51E211EB6DBDD000736CC /* LoopChartsTableViewController.swift in Sources */, + 8968B1122408B3520074BB48 /* UIFont.swift in Sources */, + 1452F4A92A851C9400F8B9E4 /* AddEditFavoriteFoodViewModel.swift in Sources */, 438D42FB1D7D11A4003244B0 /* PredictionInputEffectTableViewCell.swift in Sources */, - 43880F951D9CD54A009061A8 /* ChartPointsScatterDownTrianglesLayer.swift in Sources */, - 43F4EF1D1BA2A57600526CE1 /* DiagnosticLogger.swift in Sources */, - 432E73CB1D24B3D6009AD15D /* RemoteDataManager.swift in Sources */, - 43DE92591C5479E4001FFDE1 /* CarbEntryUserInfo.swift in Sources */, - 434F54631D28DD80002A9274 /* ValidatingIndicatorView.swift in Sources */, - 43DE92611C555C26001FFDE1 /* AbsorptionTimeType+CarbKit.swift in Sources */, + 89A1B66E24ABFDF800117AC2 /* SupportedBolusVolumesUserInfo.swift in Sources */, + C1C660D1252E4DD5009B5C32 /* LoopConstants.swift in Sources */, + 432E73CB1D24B3D6009AD15D /* RemoteDataServicesManager.swift in Sources */, + C18913B52524F24C007B0683 /* DeviceDataManager+SimpleBolusViewModelDelegate.swift in Sources */, + 7E69CFFC2A16A77E00203CBD /* ResetLoopManager.swift in Sources */, + B40D07C7251A89D500C1C6D7 /* GlucoseDisplay.swift in Sources */, + 43C2FAE11EB656A500364AFF /* GlucoseEffectVelocity.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1381,37 +3583,137 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 4F2C15751E0209FA00E160D4 /* GlucoseTrend.swift in Sources */, + 894F6DDD243C0A2300CCE676 /* CarbAmountLabel.swift in Sources */, + 89A605E524327F45009C1096 /* DoseVolumeInput.swift in Sources */, + 4372E488213C862B0068E043 /* SampleValue.swift in Sources */, + 89A605EB243288E4009C1096 /* TopDownTriangle.swift in Sources */, 4F2C15741E0209F500E160D4 /* NSTimeInterval.swift in Sources */, + 89F9119224358E2B00ECCAF3 /* CarbEntryInputMode.swift in Sources */, 4FF4D1011E18375000846527 /* WatchContext.swift in Sources */, - 435400311C9F744E00D5819C /* BolusSuggestionUserInfo.swift in Sources */, + 89FE21AD24AC57E30033F501 /* Collection.swift in Sources */, + 898ECA63218ABD21001E9D35 /* ComplicationChartManager.swift in Sources */, 43A9438A1B926B7B0051FA24 /* NotificationController.swift in Sources */, + 439A7945211FE23A0041B75F /* NSUserActivity.swift in Sources */, 43A943881B926B7B0051FA24 /* ExtensionDelegate.swift in Sources */, - 4328E0291CFBE2C500E199AA /* NSUserDefaults.swift in Sources */, + 43511CEE220FC61700566C63 /* HUDRowController.swift in Sources */, + 1D3F0F7526D59B6C004A5960 /* Debug.swift in Sources */, + 892FB4CD22040104005293EC /* OverridePresetRow.swift in Sources */, + 4F75F00220FCFE8C00B5570E /* GlucoseChartScene.swift in Sources */, + 89E26800229267DF00A3F2AF /* Optional.swift in Sources */, 4328E02F1CFBF81800E199AA /* WKInterfaceImage.swift in Sources */, + 89F9118F24352F1600ECCAF3 /* DigitalCrownRotation.swift in Sources */, 4F2C15811E0495B200E160D4 /* WatchContext+WatchApp.swift in Sources */, + 4372E496213DCDD30068E043 /* GlucoseChartValueHashable.swift in Sources */, + 89E08FC6242E7506000D719B /* CarbAndDateInput.swift in Sources */, + 89E08FC8242E76E9000D719B /* AnyTransition.swift in Sources */, + 89A605E324327DFE009C1096 /* CarbAmountInput.swift in Sources */, + 898ECA61218ABD17001E9D35 /* GlucoseChartData.swift in Sources */, + 4344629820A8B2D700C4BE6F /* OSLog.swift in Sources */, 4328E02A1CFBE2C500E199AA /* UIColor.swift in Sources */, - 4328E01B1CFBE1DA00E199AA /* BolusInterfaceController.swift in Sources */, + 4372E484213A63FB0068E043 /* ChartHUDController.swift in Sources */, + 895788AF242E69A2002CB114 /* BolusInput.swift in Sources */, + 894F6DDB243C07CF00CCE676 /* GramLabel.swift in Sources */, + 4345E40621F68E18009E00E5 /* CarbEntryListController.swift in Sources */, + 4FDDD23720DC51DF00D04B16 /* LoopDataManager.swift in Sources */, + 89E267FD2292456700A3F2AF /* FeatureFlags.swift in Sources */, + 898ECA60218ABD17001E9D35 /* GlucoseChartScaler.swift in Sources */, + 894F6DD9243C060600CCE676 /* ScalablePositionedText.swift in Sources */, + 89E08FC4242E73F0000D719B /* GramLabelPositionKey.swift in Sources */, + 4F82655020E69F9A0031A8F5 /* HUDInterfaceController.swift in Sources */, + 4372E492213D956C0068E043 /* GlucoseRangeSchedule.swift in Sources */, + A9347F3324E7522900C99C34 /* WatchHistoricalCarbs.swift in Sources */, + 895788AD242E69A2002CB114 /* AbsorptionTimeSelection.swift in Sources */, + 89A605EF2432925D009C1096 /* CompletionCheckmark.swift in Sources */, + 89F9119624358E6900ECCAF3 /* BolusPickerValues.swift in Sources */, 4328E02B1CFBE2C500E199AA /* WKAlertAction.swift in Sources */, + 4F7E8AC720E2AC0300AEA65E /* WatchPredictedGlucose.swift in Sources */, + 4F7E8AC520E2AB9600AEA65E /* Date.swift in Sources */, + 89F9119424358E4500ECCAF3 /* CarbAbsorptionTime.swift in Sources */, + 895788B1242E69A2002CB114 /* Color.swift in Sources */, + 89E08FC2242E73DC000D719B /* CarbAmountPositionKey.swift in Sources */, + 4F11D3C420DD881A006E072C /* WatchHistoricalGlucose.swift in Sources */, + E98A55F724EEE1E10008715D /* OnOffSelectionView.swift in Sources */, + 89E08FCA242E7714000D719B /* UIFont.swift in Sources */, 4328E0281CFBE2C500E199AA /* CLKComplicationTemplate.swift in Sources */, - 4328E01E1CFBE25F00E199AA /* AddCarbsInterfaceController.swift in Sources */, - 43846ADB1D91057000799272 /* ContextUpdatable.swift in Sources */, - 4328E0261CFBE2C500E199AA /* IdentifiableClass.swift in Sources */, + 4328E01E1CFBE25F00E199AA /* CarbAndBolusFlowController.swift in Sources */, + 89E08FCC242E790C000D719B /* Comparable.swift in Sources */, + 432CF87520D8AC950066B889 /* NSUserDefaults+WatchApp.swift in Sources */, + 89A1B66F24ABFDF800117AC2 /* SupportedBolusVolumesUserInfo.swift in Sources */, + 43027F0F1DFE0EC900C51989 /* HKUnit.swift in Sources */, + 4344629220A7C19800C4BE6F /* ButtonGroup.swift in Sources */, + 89A605E924328862009C1096 /* Checkmark.swift in Sources */, + 891B508524342BE1005DA578 /* CarbAndBolusFlowViewModel.swift in Sources */, + 894F6DD7243C047300CCE676 /* View+Position.swift in Sources */, + 898ECA69218ABDA9001E9D35 /* CLKTextProvider+Compound.m in Sources */, + 4372E48C213CB6750068E043 /* Double.swift in Sources */, + 89A605ED24328972009C1096 /* BolusArrow.swift in Sources */, + E98A55F924EEFC200008715D /* OnOffSelectionViewModel.swift in Sources */, + 892FB4CF220402C0005293EC /* OverrideSelectionController.swift in Sources */, + 89E08FD0242E8B2B000D719B /* BolusConfirmationView.swift in Sources */, + 43785E972120E4500057DED1 /* INRelevantShortcutStore+Loop.swift in Sources */, + 89A605E72432860C009C1096 /* PeriodicPublisher.swift in Sources */, + 895788AE242E69A2002CB114 /* CarbAndBolusFlow.swift in Sources */, + 89A605F12432BD18009C1096 /* BolusConfirmationVisual.swift in Sources */, + 898ECA65218ABD9B001E9D35 /* CGRect.swift in Sources */, 43CB2B2B1D924D450079823D /* WCSession.swift in Sources */, - 43DE925A1C5479E4001FFDE1 /* CarbEntryUserInfo.swift in Sources */, + 4372E491213D05F90068E043 /* LoopSettingsUserInfo.swift in Sources */, + 4345E40421F68AD9009E00E5 /* TextRowController.swift in Sources */, + 43BFF0B51E45C1E700FF19A9 /* NumberFormatter.swift in Sources */, + C1201E2D23ECDF3D002DA84A /* WatchContextRequestUserInfo.swift in Sources */, 43A9438E1B926B7B0051FA24 /* ComplicationController.swift in Sources */, - 4328E01A1CFBE1DA00E199AA /* StatusInterfaceController.swift in Sources */, + 43517917230A0E1A0072ECC0 /* WKInterfaceLabel.swift in Sources */, + A9347F3224E7522400C99C34 /* CarbBackfillRequestUserInfo.swift in Sources */, + 895788B3242E69A2002CB114 /* ActionButton.swift in Sources */, + 894F6DD3243BCBDB00CCE676 /* Environment+SizeClass.swift in Sources */, + E98A55F524EEE15A0008715D /* OnOffSelectionController.swift in Sources */, + 4328E01A1CFBE1DA00E199AA /* ActionHUDController.swift in Sources */, + 4F11D3C320DD84DB006E072C /* GlucoseBackfillRequestUserInfo.swift in Sources */, 435400351C9F878D00D5819C /* SetBolusUserInfo.swift in Sources */, + 895788B2242E69A2002CB114 /* CircularAccessoryButtonStyle.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; - 43E2D8CD1D20BF42004DA55F /* Sources */ = { + 43D9001F21EB209400AF44BF /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 43E2D8DC1D20C049004DA55F /* DoseMath.swift in Sources */, - 43E2D8DB1D20C03B004DA55F /* NSTimeInterval.swift in Sources */, - 43E2D8D41D20BF42004DA55F /* DoseMathTests.swift in Sources */, + E9C00EF324C6222400628F35 /* LoopSettings.swift in Sources */, + C1D0B6312986D4D90098D215 /* LocalizedString.swift in Sources */, + 43C05CB821EBEA54006FB252 /* HKUnit.swift in Sources */, + 4345E3F421F036FC009E00E5 /* Result.swift in Sources */, + C19E96E023D275FA003F79B0 /* LoopCompletionFreshness.swift in Sources */, + 43D9002021EB209400AF44BF /* NSTimeInterval.swift in Sources */, + C16575762539FEF3004AE16E /* LoopCoreConstants.swift in Sources */, + C17DDC9D28AC33A1005FBF4C /* PersistedProperty.swift in Sources */, + 43C05CA921EB2B26006FB252 /* PersistenceController.swift in Sources */, + A9CE912224CA032E00302A40 /* NSUserDefaults.swift in Sources */, + 3ED3199D2EB65A9B00820BCF /* LiveActivitySettings.swift in Sources */, + 43C05CAB21EB2B4A006FB252 /* NSBundle.swift in Sources */, + 43C05CC721EC2ABC006FB252 /* IdentifiableClass.swift in Sources */, + E9B3552B293591E70076AB04 /* MissedMealNotification.swift in Sources */, + 4345E40221F67300009E00E5 /* PotentialCarbEntryUserInfo.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 43D9FFCB21EAE05D00AF44BF /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E9C00EF224C6221B00628F35 /* LoopSettings.swift in Sources */, + C1D0B6302986D4D90098D215 /* LocalizedString.swift in Sources */, + 43C05CB921EBEA54006FB252 /* HKUnit.swift in Sources */, + 4345E3F521F036FC009E00E5 /* Result.swift in Sources */, + C19E96DF23D275F8003F79B0 /* LoopCompletionFreshness.swift in Sources */, + 43D9FFFB21EAF3D300AF44BF /* NSTimeInterval.swift in Sources */, + C16575752539FD60004AE16E /* LoopCoreConstants.swift in Sources */, + C17DDC9C28AC339E005FBF4C /* PersistedProperty.swift in Sources */, + 43C05CA821EB2B26006FB252 /* PersistenceController.swift in Sources */, + 43C05CAA21EB2B49006FB252 /* NSBundle.swift in Sources */, + 3ED3199C2EB65A9B00820BCF /* LiveActivitySettings.swift in Sources */, + 43C05CC821EC2ABC006FB252 /* IdentifiableClass.swift in Sources */, + 43C05CAD21EB2BBF006FB252 /* NSUserDefaults.swift in Sources */, + E9B3552A293591E70076AB04 /* MissedMealNotification.swift in Sources */, + 4345E40121F67300009E00E5 /* PotentialCarbEntryUserInfo.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1419,7 +3721,43 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 43E2D9151D20C5A2004DA55F /* KeychainManagerTests.swift in Sources */, + A9DF02CB24F72B9E00B7C988 /* CriticalEventLogTests.swift in Sources */, + 1D80313D24746274002810DF /* AlertStoreTests.swift in Sources */, + C1777A6625A125F100595963 /* ManualEntryDoseViewModelTests.swift in Sources */, + C16B984026B4898800256B05 /* DoseEnactorTests.swift in Sources */, + A9A63F8E246B271600588D5B /* NSTimeInterval.swift in Sources */, + A9DFAFB324F0415E00950D1E /* CarbBackfillRequestUserInfoTests.swift in Sources */, + A963B27A252CEBAE0062AA12 /* SetBolusUserInfoTests.swift in Sources */, + A9DFAFB524F048A000950D1E /* WatchHistoricalCarbsTests.swift in Sources */, + C16575732538AFF6004AE16E /* CGMStalenessMonitorTests.swift in Sources */, + 1DA7A84424477698008257F0 /* InAppModalAlertSchedulerTests.swift in Sources */, + 1D70C40126EC0F9D00C62570 /* SupportManagerTests.swift in Sources */, + E93E86A824DDCC4400FF40C8 /* MockDoseStore.swift in Sources */, + B4D4534128E5CA7900F1A8D9 /* AlertMuterTests.swift in Sources */, + E98A55F124EDD85E0008715D /* MockDosingDecisionStore.swift in Sources */, + C1D476B42A8ED179002C1C87 /* LoopAlgorithmTests.swift in Sources */, + 8968B114240C55F10074BB48 /* LoopSettingsTests.swift in Sources */, + A9BD28E7272226B40071DF15 /* TestLocalizedError.swift in Sources */, + A9F5F1F5251050EC00E7C8A4 /* ZipArchiveTests.swift in Sources */, + E9B3552D293592B40076AB04 /* MealDetectionManagerTests.swift in Sources */, + E950CA9129002D9000B5B692 /* LoopDataManagerDosingTests.swift in Sources */, + B4CAD8792549D2540057946B /* LoopCompletionFreshnessTests.swift in Sources */, + 1D8D55BC252274650044DBB6 /* BolusEntryViewModelTests.swift in Sources */, + A91E4C2124F867A700BE9213 /* StoredAlertTests.swift in Sources */, + 1DA7A84224476EAD008257F0 /* AlertManagerTests.swift in Sources */, + A91E4C2324F86F1000BE9213 /* CriticalEventLogExportManagerTests.swift in Sources */, + E9C58A7324DB4A2700487A17 /* LoopDataManagerTests.swift in Sources */, + E98A55F324EDD9530008715D /* MockSettingsStore.swift in Sources */, + C165756F2534C468004AE16E /* SimpleBolusViewModelTests.swift in Sources */, + A96DAC2A2838EF8A00D94E38 /* DiagnosticLogTests.swift in Sources */, + A9DAE7D02332D77F006AE942 /* LoopTests.swift in Sources */, + E93E86B024DDE1BD00FF40C8 /* MockGlucoseStore.swift in Sources */, + 1DFE9E172447B6270082C280 /* UserNotificationAlertSchedulerTests.swift in Sources */, + E9B3552F2935968E0076AB04 /* HKHealthStoreMock.swift in Sources */, + B4BC56382518DEA900373647 /* CGMStatusHUDViewModelTests.swift in Sources */, + C1900900252271BB00721625 /* SimpleBolusCalculatorTests.swift in Sources */, + A9C1719725366F780053BCBD /* WatchHistoricalGlucoseTest.swift in Sources */, + E93E86B224DDE21D00FF40C8 /* MockCarbStore.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1427,11 +3765,22 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 43FCEEB1221A863E0013DD30 /* StatusChartsManager.swift in Sources */, + 43C05CAC21EB2B8B006FB252 /* NSBundle.swift in Sources */, + 4FAC02541E22F6B20087A773 /* NSTimeInterval.swift in Sources */, 4F2C15831E0757E600E160D4 /* HKUnit.swift in Sources */, - 4F526D621DF9D95200A04910 /* NSBundle.swift in Sources */, + C1FB4290217922A100FAB378 /* PumpManagerUI.swift in Sources */, + 1D4990E824A25931005CC357 /* FeatureFlags.swift in Sources */, + A90EF53C25DEF06200F32D61 /* PluginManager.swift in Sources */, + C1FB428D21791D2500FAB378 /* PumpManager.swift in Sources */, + 43E93FB51E4675E800EAB8DB /* NumberFormatter.swift in Sources */, + 4345E3FB21F04911009E00E5 /* UIColor+HIG.swift in Sources */, + 43BFF0CD1E466C8400FF19A9 /* StateColorPalette.swift in Sources */, 4FC8C8021DEB943800A1452E /* NSUserDefaults+StatusExtension.swift in Sources */, 4F70C2121DE900EA006380B7 /* StatusExtensionContext.swift in Sources */, + 1D3F0F7626D59DCD004A5960 /* Debug.swift in Sources */, 4F70C1E11DE8DCA7006380B7 /* StatusViewController.swift in Sources */, + A90EF54425DEF0A000F32D61 /* OSLog.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1439,30 +3788,73 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 4FF4D0F91E17268800846527 /* IdentifiableClass.swift in Sources */, + 7D23667D21250C7E0028B67D /* LocalizedString.swift in Sources */, 4FF4D0F81E1725B000846527 /* NibLoadable.swift in Sources */, + 4326BA641F3A44D9007CCAD4 /* ChartLineModel.swift in Sources */, + 4374B5F0209D857E00D17AA8 /* OSLog.swift in Sources */, + B4E96D4F248A6E20002DABAD /* CGMStatusHUDView.swift in Sources */, + B4E96D4B248A6B6E002DABAD /* DeviceStatusHUDView.swift in Sources */, 4F7528AA1DFE215100C322D6 /* HKUnit.swift in Sources */, - 4F7528A91DFE212600C322D6 /* GlucoseTrend.swift in Sources */, - 4F7528A71DFE20CE00C322D6 /* SensorDisplayable.swift in Sources */, - 4F7528A81DFE20CE00C322D6 /* NSNumberFormatter.swift in Sources */, + B490A03F24D0550F00F509FA /* GlucoseRangeCategory.swift in Sources */, 4F2C15931E09BF2C00E160D4 /* HUDView.swift in Sources */, + 43BFF0B71E45C20C00FF19A9 /* NumberFormatter.swift in Sources */, + B43DA44124D9C12100CAFF4E /* DismissibleHostingController.swift in Sources */, 4F7528A51DFE208C00C322D6 /* NSTimeInterval.swift in Sources */, - 4F7528A41DFE204900C322D6 /* UIColor.swift in Sources */, + A9C62D8E2331708700535612 /* AuthenticationTableViewCell+NibLoadable.swift in Sources */, + B490A04324D055D900F509FA /* DeviceStatusHighlight.swift in Sources */, + B4AC0D3F24B9005300CDB0A1 /* UIImage.swift in Sources */, + B4E96D5B248A8229002DABAD /* StatusBarHUDView.swift in Sources */, 4F7528A11DFE200B00C322D6 /* BasalStateView.swift in Sources */, - 4F7528A21DFE200B00C322D6 /* LevelMaskView.swift in Sources */, + 43BFF0C61E465A4400FF19A9 /* UIColor+HIG.swift in Sources */, 4F7528A01DFE1F9D00C322D6 /* LoopStateView.swift in Sources */, + B491B0A324D0B66D004CBE8F /* Color.swift in Sources */, + B4D620D424D9EDB900043B3C /* GuidanceColors.swift in Sources */, + B48B0BAC24900093009A48DE /* PumpStatusHUDView.swift in Sources */, + B4C9859425D5A3BB009FD9CA /* StatusBadgeHUDView.swift in Sources */, + B491B0A424D0B675004CBE8F /* UIColor.swift in Sources */, + C1AD4200256D61E500164DDD /* Comparable.swift in Sources */, + 43FCEEAD221A66780013DD30 /* DateFormatter.swift in Sources */, + 1DB1CA4D24A55F0000B3B94C /* Image.swift in Sources */, + B4E96D55248A7509002DABAD /* GlucoseTrendHUDView.swift in Sources */, + C19F48742560ABFB003632D7 /* NSBundle.swift in Sources */, 4F75289A1DFE1F6000C322D6 /* BasalRateHUDView.swift in Sources */, - 4F75289B1DFE1F6000C322D6 /* BatteryLevelHUDView.swift in Sources */, + B490A04124D0559D00F509FA /* DeviceLifecycleProgressState.swift in Sources */, 4F75289C1DFE1F6000C322D6 /* GlucoseHUDView.swift in Sources */, - 4F75289D1DFE1F6000C322D6 /* BaseHUDView.swift in Sources */, + B4E96D53248A7386002DABAD /* GlucoseValueHUDView.swift in Sources */, + B4E96D57248A7B0F002DABAD /* StatusHighlightHUDView.swift in Sources */, + B42C951424A3C76000857C73 /* CGMStatusHUDViewModel.swift in Sources */, 4F75289E1DFE1F6000C322D6 /* LoopCompletionHUDView.swift in Sources */, - 4F75289F1DFE1F6000C322D6 /* ReservoirVolumeHUDView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E9B07F78253BBA6500BAD8F8 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E942DE96253BE68F00AC532D /* NSBundle.swift in Sources */, + E9B08021253BBDE900BAD8F8 /* IntentExtensionInfo.swift in Sources */, + E9B07FEE253BBC7100BAD8F8 /* OverrideIntentHandler.swift in Sources */, + E942DE9F253BE6A900AC532D /* NSTimeInterval.swift in Sources */, + E9B08016253BBD7300BAD8F8 /* UserDefaults+LoopIntents.swift in Sources */, + 1D3F0F7726D59DCE004A5960 /* Debug.swift in Sources */, + E942DF34253BF87F00AC532D /* Intents.intentdefinition in Sources */, + E9B07F7F253BBA6500BAD8F8 /* IntentHandler.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 1481F9BE28DA26F4004C5AEB /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 4F75288A1DFE1DC600C322D6 /* LoopUI */; + targetProxy = 1481F9BD28DA26F4004C5AEB /* PBXContainerItemProxy */; + }; + 14B1736828AED9EE006CCD7C /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 14B1735B28AED9EC006CCD7C /* Loop Widget Extension */; + targetProxy = 14B1736728AED9EE006CCD7C /* PBXContainerItemProxy */; + }; 43A943811B926B7B0051FA24 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 43A9437D1B926B7B0051FA24 /* WatchApp Extension */; @@ -1473,6 +3865,11 @@ target = 43A943711B926B7B0051FA24 /* WatchApp */; targetProxy = 43A943921B926B7B0051FA24 /* PBXContainerItemProxy */; }; + 43D9FFD521EAE05D00AF44BF /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 43D9FFCE21EAE05D00AF44BF /* LoopCore */; + targetProxy = 43D9FFD421EAE05D00AF44BF /* PBXContainerItemProxy */; + }; 43E2D9111D20C581004DA55F /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 43776F8B1B8022E90074EA36 /* Loop */; @@ -1488,10 +3885,25 @@ target = 4F75288A1DFE1DC600C322D6 /* LoopUI */; targetProxy = 4F7528961DFE1ED400C322D6 /* PBXContainerItemProxy */; }; - 4F7528991DFE1ED800C322D6 /* PBXTargetDependency */ = { + C117ED71232EDB3200DA57CD /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 43D9001A21EB209400AF44BF /* LoopCore-watchOS */; + targetProxy = C117ED70232EDB3200DA57CD /* PBXContainerItemProxy */; + }; + C11B9D592867781E00500CF8 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 4F75288A1DFE1DC600C322D6 /* LoopUI */; - targetProxy = 4F7528981DFE1ED800C322D6 /* PBXContainerItemProxy */; + targetProxy = C11B9D582867781E00500CF8 /* PBXContainerItemProxy */; + }; + C1CCF1152858FA900035389C /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 43D9FFCE21EAE05D00AF44BF /* LoopCore */; + targetProxy = C1CCF1142858FA900035389C /* PBXContainerItemProxy */; + }; + E9B07F93253BBA6500BAD8F8 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = E9B07F7B253BBA6500BAD8F8 /* Loop Intent Extension */; + targetProxy = E9B07F92253BBA6500BAD8F8 /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ @@ -1500,6 +3912,7 @@ isa = PBXVariantGroup; children = ( 43776F961B8022E90074EA36 /* Base */, + B66D1F422E6A5D6600471149 /* mul */, ); name = Main.storyboard; sourceTree = ""; @@ -1508,54 +3921,162 @@ isa = PBXVariantGroup; children = ( 43776F9B1B8022E90074EA36 /* Base */, + B66D1F292E6A5D6500471149 /* mul */, ); name = LaunchScreen.storyboard; sourceTree = ""; }; + 43785E9B2120E7060057DED1 /* Intents.intentdefinition */ = { + isa = PBXVariantGroup; + children = ( + 43785E9A2120E7060057DED1 /* Base */, + 43785E9F2122774A0057DED1 /* es */, + 43785EA12122774B0057DED1 /* ru */, + 43C98058212A799E003B5D17 /* en */, + C12CB9AC23106A3C00F84978 /* it */, + C12CB9AE23106A5C00F84978 /* fr */, + C12CB9B023106A5F00F84978 /* de */, + C12CB9B223106A6000F84978 /* zh-Hans */, + C12CB9B423106A6100F84978 /* nl */, + C12CB9B623106A6200F84978 /* nb */, + C12CB9B823106A6300F84978 /* pl */, + 7D9BEF132335EC4B005DCFD6 /* ja */, + 7D9BEF292335EC58005DCFD6 /* pt-BR */, + 7D9BEF3F2335EC62005DCFD6 /* vi */, + 7D9BEF552335EC6E005DCFD6 /* da */, + 7D9BEF6B2335EC7D005DCFD6 /* sv */, + 7D9BEF812335EC8B005DCFD6 /* fi */, + 7D9BF13A23370E8B005DCFD6 /* ro */, + F5D9C01727DABBE0002E48F6 /* tr */, + F5E0BDD327E1D71C0033557E /* he */, + C1C3127F297E4C0400296DA4 /* ar */, + C1C247882995823200371B88 /* sk */, + C1C5357529C6346A00E32DF9 /* cs */, + 3D03C6DA2AACE6AC00FDE5D2 /* hi */, + B6F22EF52E95A03600CCA05F /* ce */, + B6F22EF72E95A03800CCA05F /* hu */, + B6F22EF92E95A03C00CCA05F /* uk */, + ); + name = Intents.intentdefinition; + sourceTree = ""; + }; 43A943741B926B7B0051FA24 /* Interface.storyboard */ = { isa = PBXVariantGroup; children = ( 43A943751B926B7B0051FA24 /* Base */, + B66D1F412E6A5D6600471149 /* mul */, ); name = Interface.storyboard; sourceTree = ""; }; - 4F70C1E21DE8DCA7006380B7 /* MainInterface.storyboard */ = { + 43D9FFA821EA9A0C00AF44BF /* Main.storyboard */ = { isa = PBXVariantGroup; children = ( - 4F70C1E31DE8DCA7006380B7 /* Base */, + 43D9FFA921EA9A0C00AF44BF /* Base */, + 7D9BEF002335D67D005DCFD6 /* en */, + 7D9BEF022335D687005DCFD6 /* zh-Hans */, + 7D9BEF042335D68A005DCFD6 /* nl */, + 7D9BEF062335D68C005DCFD6 /* fr */, + 7D9BEF082335D68D005DCFD6 /* de */, + 7D9BEF0A2335D68F005DCFD6 /* it */, + 7D9BEF0C2335D690005DCFD6 /* nb */, + 7D9BEF0E2335D691005DCFD6 /* pl */, + 7D9BEF102335D693005DCFD6 /* ru */, + 7D9BEF122335D694005DCFD6 /* es */, + 7D9BEF182335EC4C005DCFD6 /* ja */, + 7D9BEF2E2335EC59005DCFD6 /* pt-BR */, + 7D9BEF442335EC62005DCFD6 /* vi */, + 7D9BEF5A2335EC6E005DCFD6 /* da */, + 7D9BEF702335EC7D005DCFD6 /* sv */, + 7D9BEF862335EC8B005DCFD6 /* fi */, + 7D9BF13E23370E8C005DCFD6 /* ro */, + F5D9C01C27DABBE1002E48F6 /* tr */, + F5E0BDD827E1D71E0033557E /* he */, + C1C3127A297E4BFE00296DA4 /* ar */, ); - name = MainInterface.storyboard; + name = Main.storyboard; sourceTree = ""; }; - C1C73EF91DE3D0230022FC89 /* InfoPlist.strings */ = { + 4F70C1E21DE8DCA7006380B7 /* MainInterface.storyboard */ = { isa = PBXVariantGroup; children = ( - C1C73EF81DE3D0230022FC89 /* it */, + 4F70C1E31DE8DCA7006380B7 /* Base */, + B66D1F282E6A5D6500471149 /* mul */, ); - name = InfoPlist.strings; + name = MainInterface.storyboard; sourceTree = ""; }; - C1C73F041DE3D0250022FC89 /* Localizable.strings */ = { + 7D9BEEE72335A6B3005DCFD6 /* Localizable.strings */ = { isa = PBXVariantGroup; children = ( - C1C73F031DE3D0250022FC89 /* it */, + 7D9BEEE62335A6B3005DCFD6 /* en */, + 7D9BEEE82335A6B9005DCFD6 /* zh-Hans */, + 7D9BEEE92335A6BB005DCFD6 /* nl */, + 7D9BEEEA2335A6BC005DCFD6 /* fr */, + 7D9BEEEB2335A6BD005DCFD6 /* de */, + 7D9BEEEC2335A6BE005DCFD6 /* it */, + 7D9BEEED2335A6BF005DCFD6 /* nb */, + 7D9BEEEE2335A6BF005DCFD6 /* pl */, + 7D9BEEEF2335A6C0005DCFD6 /* ru */, + 7D9BEEF02335A6C1005DCFD6 /* es */, + 7D9BEF282335EC4E005DCFD6 /* ja */, + 7D9BEF3E2335EC5A005DCFD6 /* pt-BR */, + 7D9BEF542335EC64005DCFD6 /* vi */, + 7D9BEF6A2335EC70005DCFD6 /* da */, + 7D9BEF802335EC7E005DCFD6 /* sv */, + 7D9BEF962335EC8D005DCFD6 /* fi */, + 7D9BF14623370E8D005DCFD6 /* ro */, + F5D9C02727DABBE4002E48F6 /* tr */, + F5E0BDE327E1D7230033557E /* he */, ); name = Localizable.strings; sourceTree = ""; }; - C1C73F0A1DE3D0260022FC89 /* InfoPlist.strings */ = { + 7D9BEEF52335CF8D005DCFD6 /* Localizable.strings */ = { isa = PBXVariantGroup; children = ( - C1C73F091DE3D0260022FC89 /* it */, + 7D9BEEF42335CF8D005DCFD6 /* en */, + 7D9BEEF62335CF90005DCFD6 /* zh-Hans */, + 7D9BEEF72335CF91005DCFD6 /* nl */, + 7D9BEEF82335CF93005DCFD6 /* fr */, + 7D9BEEF92335CF93005DCFD6 /* de */, + 7D9BEEFA2335CF94005DCFD6 /* it */, + 7D9BEEFB2335CF95005DCFD6 /* nb */, + 7D9BEEFC2335CF96005DCFD6 /* pl */, + 7D9BEEFD2335CF97005DCFD6 /* ru */, + 7D9BEEFE2335CF97005DCFD6 /* es */, + 7D9BEF1A2335EC4C005DCFD6 /* ja */, + 7D9BEF302335EC59005DCFD6 /* pt-BR */, + 7D9BEF462335EC62005DCFD6 /* vi */, + 7D9BEF5C2335EC6F005DCFD6 /* da */, + 7D9BEF722335EC7D005DCFD6 /* sv */, + 7D9BEF882335EC8C005DCFD6 /* fi */, + 7D9BF13F23370E8C005DCFD6 /* ro */, + F5D9C01E27DABBE2002E48F6 /* tr */, + F5E0BDDA27E1D71F0033557E /* he */, + C1C3127C297E4BFE00296DA4 /* ar */, + C1C247892995823200371B88 /* sk */, ); - name = InfoPlist.strings; + name = Localizable.strings; sourceTree = ""; }; - C1C73F0F1DE3D0270022FC89 /* InfoPlist.strings */ = { + 80F864E42433BF5D0026EC26 /* InfoPlist.strings */ = { isa = PBXVariantGroup; children = ( - C1C73F0E1DE3D0270022FC89 /* it */, + 80F864E52433BF5D0026EC26 /* fi */, + C1004DEF2981F5B700B8CF94 /* da */, + C1004DFD2981F67A00B8CF94 /* sv */, + C1004E052981F6A100B8CF94 /* ro */, + C1004E0D2981F6E200B8CF94 /* nl */, + C1004E152981F6F500B8CF94 /* nb */, + C1004E1D2981F72D00B8CF94 /* fr */, + C1004E2C2981F75B00B8CF94 /* es */, + C1004E302981F77B00B8CF94 /* de */, + C1BCB5AF298309C4001C50FF /* it */, + C19E387B298638CE00851444 /* tr */, + C1F48FF62995821600C8BD69 /* pl */, + C14952142995822A0095AA84 /* ru */, + C1C2478B2995823200371B88 /* sk */, ); name = InfoPlist.strings; sourceTree = ""; @@ -1563,57 +4084,216 @@ /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ + 14B1736A28AED9EE006CCD7C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CODE_SIGN_ENTITLEMENTS = "Loop Widget Extension/Bootstrap/LoopWidgetExtension.entitlements"; + CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_DEBUG)"; + CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; + DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; + GCC_DYNAMIC_NO_PIC = NO; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = "Loop Widget Extension/Bootstrap/Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = "Loop Widgets"; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 LoopKit Authors. All rights reserved."; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).LoopWidgetExtension"; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_WIDGET_EXTENSION_DEBUG)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + 14B1736B28AED9EE006CCD7C /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CODE_SIGN_ENTITLEMENTS = "Loop Widget Extension/Bootstrap/LoopWidgetExtension.entitlements"; + CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_RELEASE)"; + CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = "Loop Widget Extension/Bootstrap/Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = "Loop Widgets"; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 LoopKit Authors. All rights reserved."; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).LoopWidgetExtension"; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_WIDGET_EXTENSION_RELEASE)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; 43776FB41B8022E90074EA36 /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = 437D9BA11D7B5203007245E8 /* Loop.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; APP_GROUP_IDENTIFIER = "group.$(MAIN_APP_BUNDLE_IDENTIFIER)Group"; + CLANG_ANALYZER_GCD_PERFORMANCE = YES; + CLANG_ANALYZER_LOCALIZABILITY_EMPTY_CONTEXT = YES; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_ANALYZER_SECURITY_FLOATLOOPCOUNTER = YES; + CLANG_ANALYZER_SECURITY_INSECUREAPI_RAND = YES; + CLANG_ANALYZER_SECURITY_INSECUREAPI_STRCPY = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_ASSIGN_ENUM = YES; + CLANG_WARN_ATOMIC_IMPLICIT_SEQ_CST = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES_ERROR; + CLANG_WARN_BOOL_CONVERSION = YES_ERROR; + CLANG_WARN_COMMA = YES_ERROR; + CLANG_WARN_CONSTANT_CONVERSION = YES_ERROR; + CLANG_WARN_CXX0X_EXTENSIONS = YES; + CLANG_WARN_DELETE_NON_VIRTUAL_DTOR = YES_ERROR; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_ENUM_CONVERSION = YES_ERROR; + CLANG_WARN_FLOAT_CONVERSION = YES_ERROR; + CLANG_WARN_IMPLICIT_SIGN_CONVERSION = YES_ERROR; CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_INT_CONVERSION = YES_ERROR; + CLANG_WARN_MISSING_NOESCAPE = YES_ERROR; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES_ERROR; + CLANG_WARN_OBJC_EXPLICIT_OWNERSHIP_TYPE = YES; + CLANG_WARN_OBJC_IMPLICIT_ATOMIC_PROPERTIES = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_INTERFACE_IVARS = YES_ERROR; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES_ERROR; + CLANG_WARN_OBJC_MISSING_PROPERTY_SYNTHESIS = YES; + CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES_AGGRESSIVE; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_PRAGMA_PACK = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SEMICOLON_BEFORE_METHOD_BODY = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES_ERROR; + CLANG_WARN_SUSPICIOUS_IMPLICIT_CONVERSION = YES_ERROR; CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES_AGGRESSIVE; + CLANG_WARN_VEXING_PARSE = YES_ERROR; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - CODE_SIGN_IDENTITY = "iPhone Developer: loudnate@gmail.com (XZN842LDLT)"; + CLANG_WARN__EXIT_TIME_DESTRUCTORS = YES; + CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 31; DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = ""; + DYLIB_COMPATIBILITY_VERSION = 1; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_DYNAMIC_NO_PIC = NO; + FRAMEWORK_SEARCH_PATHS = "$(inherited)"; + GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_OPTIMIZATION_LEVEL = 0; GCC_PREPROCESSOR_DEFINITIONS = ( "DEBUG=1", "$(inherited)", ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_TREAT_IMPLICIT_FUNCTION_DECLARATIONS_AS_ERRORS = YES; + GCC_TREAT_INCOMPATIBLE_POINTER_TYPE_WARNINGS_AS_ERRORS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES_ERROR; + GCC_WARN_ABOUT_MISSING_FIELD_INITIALIZERS = YES; + GCC_WARN_ABOUT_MISSING_NEWLINE = YES; + GCC_WARN_ABOUT_MISSING_PROTOTYPES = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_FOUR_CHARACTER_CONSTANTS = YES; + GCC_WARN_HIDDEN_VIRTUAL_FUNCTIONS = YES; + GCC_WARN_INITIALIZER_NOT_FULLY_BRACKETED = YES; + GCC_WARN_NON_VIRTUAL_DESTRUCTOR = YES; + GCC_WARN_SHADOW = YES; + GCC_WARN_SIGN_COMPARE = YES; + GCC_WARN_STRICT_SELECTOR_MATCH = YES; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNKNOWN_PRAGMAS = YES; GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_LABEL = YES; + GCC_WARN_UNUSED_PARAMETER = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 10.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + LOCALIZED_STRING_MACRO_NAMES = ( + NSLocalizedString, + CFLocalizedString, + LocalizedString, + ); MAIN_APP_BUNDLE_IDENTIFIER = "$(inherited).Loop"; - MTL_ENABLE_DEBUG_INFO = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; + PROVISIONING_PROFILE = ""; + PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; + SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 3.0; + SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; WARNING_CFLAGS = "-Wall"; + WATCHOS_DEPLOYMENT_TARGET = 7.1; }; name = Debug; }; @@ -1623,45 +4303,109 @@ buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; APP_GROUP_IDENTIFIER = "group.$(MAIN_APP_BUNDLE_IDENTIFIER)Group"; + CLANG_ANALYZER_GCD_PERFORMANCE = YES; + CLANG_ANALYZER_LOCALIZABILITY_EMPTY_CONTEXT = YES; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_ANALYZER_SECURITY_FLOATLOOPCOUNTER = YES; + CLANG_ANALYZER_SECURITY_INSECUREAPI_RAND = YES; + CLANG_ANALYZER_SECURITY_INSECUREAPI_STRCPY = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_ASSIGN_ENUM = YES; + CLANG_WARN_ATOMIC_IMPLICIT_SEQ_CST = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES_ERROR; + CLANG_WARN_BOOL_CONVERSION = YES_ERROR; + CLANG_WARN_COMMA = YES_ERROR; + CLANG_WARN_CONSTANT_CONVERSION = YES_ERROR; + CLANG_WARN_CXX0X_EXTENSIONS = YES; + CLANG_WARN_DELETE_NON_VIRTUAL_DTOR = YES_ERROR; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_ENUM_CONVERSION = YES_ERROR; + CLANG_WARN_FLOAT_CONVERSION = YES_ERROR; + CLANG_WARN_IMPLICIT_SIGN_CONVERSION = YES_ERROR; CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_INT_CONVERSION = YES_ERROR; + CLANG_WARN_MISSING_NOESCAPE = YES_ERROR; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES_ERROR; + CLANG_WARN_OBJC_EXPLICIT_OWNERSHIP_TYPE = YES; + CLANG_WARN_OBJC_IMPLICIT_ATOMIC_PROPERTIES = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_INTERFACE_IVARS = YES_ERROR; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES_ERROR; + CLANG_WARN_OBJC_MISSING_PROPERTY_SYNTHESIS = YES; + CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES_AGGRESSIVE; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_PRAGMA_PACK = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SEMICOLON_BEFORE_METHOD_BODY = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES_ERROR; + CLANG_WARN_SUSPICIOUS_IMPLICIT_CONVERSION = YES_ERROR; CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES_AGGRESSIVE; + CLANG_WARN_VEXING_PARSE = YES_ERROR; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - CODE_SIGN_IDENTITY = "iPhone Developer: loudnate@gmail.com (XZN842LDLT)"; + CLANG_WARN__EXIT_TIME_DESTRUCTORS = YES; + CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 31; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = ""; + DYLIB_COMPATIBILITY_VERSION = 1; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu99; + FRAMEWORK_SEARCH_PATHS = "$(inherited)"; + GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_TREAT_IMPLICIT_FUNCTION_DECLARATIONS_AS_ERRORS = YES; + GCC_TREAT_INCOMPATIBLE_POINTER_TYPE_WARNINGS_AS_ERRORS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES_ERROR; + GCC_WARN_ABOUT_MISSING_FIELD_INITIALIZERS = YES; + GCC_WARN_ABOUT_MISSING_NEWLINE = YES; + GCC_WARN_ABOUT_MISSING_PROTOTYPES = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_FOUR_CHARACTER_CONSTANTS = YES; + GCC_WARN_HIDDEN_VIRTUAL_FUNCTIONS = YES; + GCC_WARN_INITIALIZER_NOT_FULLY_BRACKETED = YES; + GCC_WARN_NON_VIRTUAL_DESTRUCTOR = YES; + GCC_WARN_SHADOW = YES; + GCC_WARN_SIGN_COMPARE = YES; + GCC_WARN_STRICT_SELECTOR_MATCH = YES; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNKNOWN_PRAGMAS = YES; GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_LABEL = YES; + GCC_WARN_UNUSED_PARAMETER = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 10.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + LOCALIZED_STRING_MACRO_NAMES = ( + NSLocalizedString, + CFLocalizedString, + LocalizedString, + ); MAIN_APP_BUNDLE_IDENTIFIER = "$(inherited).Loop"; MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PROVISIONING_PROFILE = ""; + PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = iphoneos; - SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; - SWIFT_VERSION = 3.0; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; + VERSIONING_SYSTEM = "apple-generic"; WARNING_CFLAGS = "-Wall"; + WATCHOS_DEPLOYMENT_TARGET = 7.1; }; name = Release; }; @@ -1669,20 +4413,29 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_ENTITLEMENTS = Loop/Loop.entitlements; - CODE_SIGN_IDENTITY = "iPhone Developer"; - DEVELOPMENT_TEAM = ""; - FRAMEWORK_SEARCH_PATHS = ( + ASSETCATALOG_COMPILER_APPICON_NAME = "$(APPICON_NAME)"; + CODE_SIGN_ENTITLEMENTS = "$(LOOP_ENTITLEMENTS)"; + CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_DEBUG)"; + CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; + FRAMEWORK_SEARCH_PATHS = "$(inherited)"; + INFOPLIST_FILE = Loop/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", - "$(PROJECT_DIR)/Carthage/Build/iOS", + "@executable_path/Frameworks", ); - INFOPLIST_FILE = Loop/Info.plist; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - "OTHER_SWIFT_FLAGS[sdk=iphonesimulator*]" = "-D IOS_SIMULATOR"; + OTHER_LDFLAGS = ""; + "OTHER_SWIFT_FLAGS[arch=*]" = "-DDEBUG"; + "OTHER_SWIFT_FLAGS[sdk=iphonesimulator*]" = "-D IOS_SIMULATOR -D DEBUG"; PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE = ""; + PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_DEBUG)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + TARGETED_DEVICE_FAMILY = 1; }; name = Debug; }; @@ -1690,35 +4443,52 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_ENTITLEMENTS = Loop/Loop.entitlements; - CODE_SIGN_IDENTITY = "iPhone Developer"; - DEVELOPMENT_TEAM = ""; - FRAMEWORK_SEARCH_PATHS = ( + ASSETCATALOG_COMPILER_APPICON_NAME = "$(APPICON_NAME)"; + CODE_SIGN_ENTITLEMENTS = "$(LOOP_ENTITLEMENTS)"; + CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_RELEASE)"; + CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; + DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; + FRAMEWORK_SEARCH_PATHS = "$(inherited)"; + INFOPLIST_FILE = Loop/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", - "$(PROJECT_DIR)/Carthage/Build/iOS", + "@executable_path/Frameworks", ); - INFOPLIST_FILE = Loop/Info.plist; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + OTHER_LDFLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER)"; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE = ""; + PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_RELEASE)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + TARGETED_DEVICE_FAMILY = 1; }; name = Release; }; 43A943961B926B7B0051FA24 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - CODE_SIGN_IDENTITY = "iPhone Developer"; - "CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = ""; + ASSETCATALOG_COMPILER_COMPLICATION_NAME = Complication; + CODE_SIGN_ENTITLEMENTS = "WatchApp Extension/WatchApp Extension.entitlements"; + CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_DEBUG)"; + CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; + DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; + FRAMEWORK_SEARCH_PATHS = ""; INFOPLIST_FILE = "WatchApp Extension/Info.plist"; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).watchkitapp.watchkitextension"; - PRODUCT_NAME = "${TARGET_NAME}"; - PROVISIONING_PROFILE = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).LoopWatch.watchkitextension"; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_WATCHAPP_EXTENSION_DEBUG)"; SDKROOT = watchos; SKIP_INSTALL = YES; + SWIFT_INSTALL_OBJC_HEADER = NO; + SWIFT_OBJC_BRIDGING_HEADER = "WatchApp Extension/Extensions/WatchApp Extension-Bridging-Header.h"; + SWIFT_PRECOMPILE_BRIDGING_HEADER = NO; TARGETED_DEVICE_FAMILY = 4; }; name = Debug; @@ -1726,16 +4496,26 @@ 43A943971B926B7B0051FA24 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - CODE_SIGN_IDENTITY = "iPhone Developer"; - "CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = ""; + ASSETCATALOG_COMPILER_COMPLICATION_NAME = Complication; + CODE_SIGN_ENTITLEMENTS = "WatchApp Extension/WatchApp Extension.entitlements"; + CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_RELEASE)"; + CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; + DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; + FRAMEWORK_SEARCH_PATHS = ""; INFOPLIST_FILE = "WatchApp Extension/Info.plist"; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).watchkitapp.watchkitextension"; - PRODUCT_NAME = "${TARGET_NAME}"; - PROVISIONING_PROFILE = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).LoopWatch.watchkitextension"; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_WATCHAPP_EXTENSION_RELEASE)"; SDKROOT = watchos; SKIP_INSTALL = YES; + SWIFT_INSTALL_OBJC_HEADER = NO; + SWIFT_OBJC_BRIDGING_HEADER = "WatchApp Extension/Extensions/WatchApp Extension-Bridging-Header.h"; + SWIFT_PRECOMPILE_BRIDGING_HEADER = NO; TARGETED_DEVICE_FAMILY = 4; }; name = Release; @@ -1744,16 +4524,20 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_IDENTITY = "iPhone Developer"; - "CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = ""; + ASSETCATALOG_COMPILER_APPICON_NAME = "$(APPICON_NAME)"; + CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_DEBUG)"; + CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; + DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; + FRAMEWORK_SEARCH_PATHS = ""; IBSC_MODULE = WatchApp_Extension; INFOPLIST_FILE = WatchApp/Info.plist; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).watchkitapp"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).LoopWatch"; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE = ""; + PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_WATCHAPP_DEBUG)"; SDKROOT = watchos; SKIP_INSTALL = YES; TARGETED_DEVICE_FAMILY = 4; @@ -1764,53 +4548,129 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_IDENTITY = "iPhone Developer"; - "CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = ""; + ASSETCATALOG_COMPILER_APPICON_NAME = "$(APPICON_NAME)"; + CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_RELEASE)"; + CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; + DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; + FRAMEWORK_SEARCH_PATHS = ""; IBSC_MODULE = WatchApp_Extension; INFOPLIST_FILE = WatchApp/Info.plist; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).watchkitapp"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).LoopWatch"; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE = ""; + PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_WATCHAPP_RELEASE)"; SDKROOT = watchos; SKIP_INSTALL = YES; TARGETED_DEVICE_FAMILY = 4; }; name = Release; }; - 43E2D8D71D20BF42004DA55F /* Debug */ = { + 43D9002821EB209400AF44BF /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - CLANG_ANALYZER_NONNULL = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = ""; - FRAMEWORK_SEARCH_PATHS = ( + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + DEFINES_MODULE = YES; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + FRAMEWORK_SEARCH_PATHS = ""; + INFOPLIST_FILE = LoopCore/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_NO_PIE = NO; + LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", - "$(PROJECT_DIR)/Carthage/Build/iOS", + "@executable_path/Frameworks", + "@loader_path/Frameworks", ); - INFOPLIST_FILE = DoseMathTests/Info.plist; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @loader_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = com.loudnate.DoseMathTests; - PRODUCT_NAME = "$(TARGET_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = com.loopkit.LoopCore; + PRODUCT_NAME = LoopCore; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_INSTALL_OBJC_HEADER = NO; + TARGETED_DEVICE_FAMILY = 4; }; name = Debug; }; - 43E2D8D81D20BF42004DA55F /* Release */ = { + 43D9002921EB209400AF44BF /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - CLANG_ANALYZER_NONNULL = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = ""; - FRAMEWORK_SEARCH_PATHS = ( + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + DEFINES_MODULE = YES; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + FRAMEWORK_SEARCH_PATHS = ""; + INFOPLIST_FILE = LoopCore/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_NO_PIE = NO; + LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", - "$(PROJECT_DIR)/Carthage/Build/iOS", + "@executable_path/Frameworks", + "@loader_path/Frameworks", ); - INFOPLIST_FILE = DoseMathTests/Info.plist; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @loader_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = com.loudnate.DoseMathTests; - PRODUCT_NAME = "$(TARGET_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = com.loopkit.LoopCore; + PRODUCT_NAME = LoopCore; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_INSTALL_OBJC_HEADER = NO; + TARGETED_DEVICE_FAMILY = 4; + }; + name = Release; + }; + 43D9FFD921EAE05D00AF44BF /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + DEFINES_MODULE = YES; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + INFOPLIST_FILE = LoopCore/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.loopkit.LoopCore; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_INSTALL_OBJC_HEADER = NO; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 43D9FFDA21EAE05D00AF44BF /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + DEFINES_MODULE = YES; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + INFOPLIST_FILE = LoopCore/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.loopkit.LoopCore; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_INSTALL_OBJC_HEADER = NO; + TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; @@ -1818,13 +4678,17 @@ isa = XCBuildConfiguration; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; - CLANG_ANALYZER_NONNULL = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; INFOPLIST_FILE = LoopTests/Info.plist; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = com.loudnate.LoopTests; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.loopkit.LoopTests; PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_INSTALL_OBJC_HEADER = NO; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Loop.app/Loop"; }; name = Debug; @@ -1833,13 +4697,18 @@ isa = XCBuildConfiguration; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; - CLANG_ANALYZER_NONNULL = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; INFOPLIST_FILE = LoopTests/Info.plist; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = com.loudnate.LoopTests; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + ONLY_ACTIVE_ARCH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.loopkit.LoopTests; PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_INSTALL_OBJC_HEADER = NO; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Loop.app/Loop"; }; name = Release; @@ -1847,47 +4716,52 @@ 4F70C1E91DE8DCA8006380B7 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - CLANG_ANALYZER_NONNULL = YES; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_SUSPICIOUS_MOVES = YES; CODE_SIGN_ENTITLEMENTS = "Loop Status Extension/Loop Status Extension.entitlements"; - CODE_SIGN_IDENTITY = "iPhone Developer"; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = ""; - FRAMEWORK_SEARCH_PATHS = ( + CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_DEBUG)"; + CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; + DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = "Loop Status Extension/Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", - "$(PROJECT_DIR)/Carthage/Build/iOS", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", ); - INFOPLIST_FILE = "Loop Status Extension/Info.plist"; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).statuswidget"; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_STATUS_EXTENSION_DEBUG)"; SKIP_INSTALL = YES; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_VERSION = 3.0; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + TARGETED_DEVICE_FAMILY = 1; }; name = Debug; }; 4F70C1EA1DE8DCA8006380B7 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - CLANG_ANALYZER_NONNULL = YES; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_SUSPICIOUS_MOVES = YES; CODE_SIGN_ENTITLEMENTS = "Loop Status Extension/Loop Status Extension.entitlements"; - CODE_SIGN_IDENTITY = "iPhone Developer"; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = ""; - FRAMEWORK_SEARCH_PATHS = ( + CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_RELEASE)"; + CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; + DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = "Loop Status Extension/Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", - "$(PROJECT_DIR)/Carthage/Build/iOS", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", ); - INFOPLIST_FILE = "Loop Status Extension/Info.plist"; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).statuswidget"; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_STATUS_EXTENSION_RELEASE)"; SKIP_INSTALL = YES; - SWIFT_VERSION = 3.0; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + TARGETED_DEVICE_FAMILY = 1; }; name = Release; }; @@ -1895,26 +4769,24 @@ isa = XCBuildConfiguration; buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_SUSPICIOUS_MOVES = YES; - CODE_SIGN_IDENTITY = ""; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = LoopUI/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = com.loudnate.Loop.LoopUI; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).LoopUI"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_VERSION = 3.0; - VERSIONING_SYSTEM = "apple-generic"; - VERSION_INFO_PREFIX = ""; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_INSTALL_OBJC_HEADER = NO; + TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; @@ -1922,31 +4794,93 @@ isa = XCBuildConfiguration; buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_SUSPICIOUS_MOVES = YES; - CODE_SIGN_IDENTITY = ""; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = LoopUI/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - PRODUCT_BUNDLE_IDENTIFIER = com.loudnate.Loop.LoopUI; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).LoopUI"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; - SWIFT_VERSION = 3.0; - VERSIONING_SYSTEM = "apple-generic"; - VERSION_INFO_PREFIX = ""; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_INSTALL_OBJC_HEADER = NO; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + E9B07F95253BBA6500BAD8F8 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = "Loop Intent Extension/Loop Intent Extension.entitlements"; + CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_DEBUG)"; + CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; + DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = "$(inherited)"; + INFOPLIST_FILE = "Loop Intent Extension/Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).Loop-Intent-Extension"; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_INTENT_EXTENSION_DEBUG)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + E9B07F96253BBA6500BAD8F8 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = "Loop Intent Extension/Loop Intent Extension.entitlements"; + CODE_SIGN_IDENTITY = "$(LOOP_CODE_SIGN_IDENTITY_RELEASE)"; + CODE_SIGN_STYLE = "$(LOOP_CODE_SIGN_STYLE)"; + DEVELOPMENT_TEAM = "$(LOOP_DEVELOPMENT_TEAM)"; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = "$(inherited)"; + INFOPLIST_FILE = "Loop Intent Extension/Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).Loop-Intent-Extension"; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "$(LOOP_PROVISIONING_PROFILE_SPECIFIER_INTENT_EXTENSION_RELEASE)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + TARGETED_DEVICE_FAMILY = 1; }; name = Release; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 14B1736C28AED9EE006CCD7C /* Build configuration list for PBXNativeTarget "Loop Widget Extension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 14B1736A28AED9EE006CCD7C /* Debug */, + 14B1736B28AED9EE006CCD7C /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 43776F871B8022E90074EA36 /* Build configuration list for PBXProject "Loop" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -1983,11 +4917,20 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 43E2D8D61D20BF42004DA55F /* Build configuration list for PBXNativeTarget "DoseMathTests" */ = { + 43D9002721EB209400AF44BF /* Build configuration list for PBXNativeTarget "LoopCore-watchOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 43D9002821EB209400AF44BF /* Debug */, + 43D9002921EB209400AF44BF /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 43D9FFD821EAE05D00AF44BF /* Build configuration list for PBXNativeTarget "LoopCore" */ = { isa = XCConfigurationList; buildConfigurations = ( - 43E2D8D71D20BF42004DA55F /* Debug */, - 43E2D8D81D20BF42004DA55F /* Release */, + 43D9FFD921EAE05D00AF44BF /* Debug */, + 43D9FFDA21EAE05D00AF44BF /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; @@ -2019,7 +4962,89 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + E9B07F9A253BBA6500BAD8F8 /* Build configuration list for PBXNativeTarget "Loop Intent Extension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E9B07F95253BBA6500BAD8F8 /* Debug */, + E9B07F96253BBA6500BAD8F8 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + C1735B1C2A0809830082BB8A /* XCRemoteSwiftPackageReference "ZIPFoundation" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/LoopKit/ZIPFoundation.git"; + requirement = { + branch = "stream-entry"; + kind = branch; + }; + }; + C1CCF10B2858F4F70035389C /* XCRemoteSwiftPackageReference "SwiftCharts" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/ivanschuetz/SwiftCharts"; + requirement = { + branch = master; + kind = branch; + }; + }; + C1D6EE9E2A06C7270047DE5C /* XCRemoteSwiftPackageReference "MKRingProgressView" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/maxkonovalov/MKRingProgressView.git"; + requirement = { + branch = master; + kind = branch; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + C11B9D5A286778A800500CF8 /* SwiftCharts */ = { + isa = XCSwiftPackageProductDependency; + package = C1CCF10B2858F4F70035389C /* XCRemoteSwiftPackageReference "SwiftCharts" */; + productName = SwiftCharts; + }; + C1735B1D2A0809830082BB8A /* ZIPFoundation */ = { + isa = XCSwiftPackageProductDependency; + package = C1735B1C2A0809830082BB8A /* XCRemoteSwiftPackageReference "ZIPFoundation" */; + productName = ZIPFoundation; + }; + C1CCF1162858FBAD0035389C /* SwiftCharts */ = { + isa = XCSwiftPackageProductDependency; + package = C1CCF10B2858F4F70035389C /* XCRemoteSwiftPackageReference "SwiftCharts" */; + productName = SwiftCharts; + }; + C1D6EE9F2A06C7270047DE5C /* MKRingProgressView */ = { + isa = XCSwiftPackageProductDependency; + package = C1D6EE9E2A06C7270047DE5C /* XCRemoteSwiftPackageReference "MKRingProgressView" */; + productName = MKRingProgressView; + }; + C1E3DC4628595FAA00CA19FF /* SwiftCharts */ = { + isa = XCSwiftPackageProductDependency; + package = C1CCF10B2858F4F70035389C /* XCRemoteSwiftPackageReference "SwiftCharts" */; + productName = SwiftCharts; + }; + C1F00C5F285A802A006302C5 /* SwiftCharts */ = { + isa = XCSwiftPackageProductDependency; + package = C1CCF10B2858F4F70035389C /* XCRemoteSwiftPackageReference "SwiftCharts" */; + productName = SwiftCharts; + }; +/* End XCSwiftPackageProductDependency section */ + +/* Begin XCVersionGroup section */ + 1D080CBB2473214A00356610 /* AlertStore.xcdatamodeld */ = { + isa = XCVersionGroup; + children = ( + 1D080CBC2473214A00356610 /* AlertStore.xcdatamodel */, + ); + currentVersion = 1D080CBC2473214A00356610 /* AlertStore.xcdatamodel */; + path = AlertStore.xcdatamodeld; + sourceTree = ""; + versionGroupType = wrapper.xcdatamodel; + }; +/* End XCVersionGroup section */ }; rootObject = 43776F841B8022E90074EA36 /* Project object */; } diff --git a/Loop.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Loop.xcodeproj/project.xcworkspace/contents.xcworkspacedata index 1bd3f8ef6f..919434a625 100644 --- a/Loop.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ b/Loop.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -2,6 +2,6 @@ + location = "self:"> diff --git a/Loop.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Loop.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/Loop.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Loop.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/Loop.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000000..08de0be8d3 --- /dev/null +++ b/Loop.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded + + + diff --git a/Loop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Loop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000000..3ae53addaa --- /dev/null +++ b/Loop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,33 @@ +{ + "originHash" : "c4cada90348e9cd61ccdc1fdd95d021f037913f127c30ed5678702f19c1b75db", + "pins" : [ + { + "identity" : "mkringprogressview", + "kind" : "remoteSourceControl", + "location" : "https://github.com/maxkonovalov/MKRingProgressView.git", + "state" : { + "branch" : "master", + "revision" : "660888aab1d2ab0ed7eb9eb53caec12af4955fa7" + } + }, + { + "identity" : "swiftcharts", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ivanschuetz/SwiftCharts", + "state" : { + "branch" : "master", + "revision" : "c354c1945bb35a1f01b665b22474f6db28cba4a2" + } + }, + { + "identity" : "zipfoundation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/LoopKit/ZIPFoundation.git", + "state" : { + "branch" : "stream-entry", + "revision" : "ad465ee2545392153a64c0976d6e59227d0c1c70" + } + } + ], + "version" : 3 +} diff --git a/Loop.xcodeproj/xcshareddata/xcschemes/Complication - WatchApp.xcscheme b/Loop.xcodeproj/xcshareddata/xcschemes/Complication - WatchApp.xcscheme deleted file mode 100644 index bf7d22a5ca..0000000000 --- a/Loop.xcodeproj/xcshareddata/xcschemes/Complication - WatchApp.xcscheme +++ /dev/null @@ -1,153 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Loop.xcodeproj/xcshareddata/xcschemes/DoseMathTests.xcscheme b/Loop.xcodeproj/xcshareddata/xcschemes/DoseMathTests.xcscheme index 876de8c727..a56f874c88 100644 --- a/Loop.xcodeproj/xcshareddata/xcschemes/DoseMathTests.xcscheme +++ b/Loop.xcodeproj/xcshareddata/xcschemes/DoseMathTests.xcscheme @@ -1,10 +1,10 @@ + buildImplicitDependencies = "NO"> - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Loop.xcodeproj/xcshareddata/xcschemes/Loop Status Extension.xcscheme b/Loop.xcodeproj/xcshareddata/xcschemes/Loop Status Extension.xcscheme index fb1515b9d7..09e7a0cd02 100644 --- a/Loop.xcodeproj/xcshareddata/xcschemes/Loop Status Extension.xcscheme +++ b/Loop.xcodeproj/xcshareddata/xcschemes/Loop Status Extension.xcscheme @@ -1,11 +1,11 @@ + buildImplicitDependencies = "NO"> - - - - + + - - diff --git a/Loop.xcodeproj/xcshareddata/xcschemes/Loop.xcscheme b/Loop.xcodeproj/xcshareddata/xcschemes/Loop.xcscheme index 119c1f3e7b..f89444d5b7 100644 --- a/Loop.xcodeproj/xcshareddata/xcschemes/Loop.xcscheme +++ b/Loop.xcodeproj/xcshareddata/xcschemes/Loop.xcscheme @@ -1,11 +1,25 @@ + parallelizeBuildables = "NO" + buildImplicitDependencies = "NO"> + + + + + + + + @@ -39,18 +62,17 @@ ReferencedContainer = "container:Loop.xcodeproj"> + + + + - - - - - - - - + buildImplicitDependencies = "NO"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Loop.xcodeproj/xcshareddata/xcschemes/SmallStatusWidgetExtension.xcscheme b/Loop.xcodeproj/xcshareddata/xcschemes/SmallStatusWidgetExtension.xcscheme new file mode 100644 index 0000000000..35903ab2e5 --- /dev/null +++ b/Loop.xcodeproj/xcshareddata/xcschemes/SmallStatusWidgetExtension.xcscheme @@ -0,0 +1,114 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Loop.xcodeproj/xcshareddata/xcschemes/WatchApp.xcscheme b/Loop.xcodeproj/xcshareddata/xcschemes/WatchApp.xcscheme index 81d0b69195..6ab6be0246 100644 --- a/Loop.xcodeproj/xcshareddata/xcschemes/WatchApp.xcscheme +++ b/Loop.xcodeproj/xcshareddata/xcschemes/WatchApp.xcscheme @@ -1,10 +1,10 @@ + buildImplicitDependencies = "NO"> + + + + @@ -67,17 +76,6 @@ - - - - - - - + - - - - - - - + - + - - - - - + diff --git a/Loop/AppDelegate.swift b/Loop/AppDelegate.swift index f2f0868f3c..ebb05d5c12 100644 --- a/Loop/AppDelegate.swift +++ b/Loop/AppDelegate.swift @@ -7,95 +7,100 @@ // import UIKit -import UserNotifications -import CarbKit -import InsulinKit - -@UIApplicationMain -final class AppDelegate: UIResponder, UIApplicationDelegate { +import LoopKit +final class AppDelegate: UIResponder, UIApplicationDelegate, WindowProvider { var window: UIWindow? - private(set) lazy var dataManager = DeviceDataManager() + private let loopAppManager = LoopAppManager() + private let log = DiagnosticLog(category: "AppDelegate") - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { - window?.tintColor = UIColor.tintColor + // MARK: - UIApplicationDelegate - Initialization - NotificationManager.authorize(delegate: self) + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + log.default("%{public}@ with launchOptions: %{public}@", #function, String(describing: launchOptions)) - AnalyticsManager.sharedManager.application(application, didFinishLaunchingWithOptions: launchOptions) + setenv("CFNETWORK_DIAGNOSTICS", "3", 1) - if let navVC = window?.rootViewController as? UINavigationController, - let statusVC = navVC.viewControllers.first as? StatusTableViewController { - statusVC.dataManager = dataManager - } + log.default("lastPathComponent = %{public}@", String(describing: Bundle.main.appStoreReceiptURL?.lastPathComponent)) + + loopAppManager.initialize(windowProvider: self, launchOptions: launchOptions) + loopAppManager.launch() + return loopAppManager.isLaunchComplete + } + + // MARK: - UIApplicationDelegate - Life Cycle + + func applicationDidBecomeActive(_ application: UIApplication) { + log.default(#function) - return true + loopAppManager.didBecomeActive() } func applicationWillResignActive(_ application: UIApplication) { - // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. - // Use this method to pause ongoing tasks, disable timers, and throttle down OpenGL ES frame rates. Games should use this method to pause the game. + log.default(#function) } func applicationDidEnterBackground(_ application: UIApplication) { - // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. - // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. + log.default(#function) } func applicationWillEnterForeground(_ application: UIApplication) { - // Called as part of the transition from the background to the inactive state; here you can undo many of the changes made on entering the background. + log.default(#function) + + loopAppManager.askUserToConfirmLoopReset() } - func applicationDidBecomeActive(_ application: UIApplication) { - // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. - - dataManager.transmitter?.resumeScanning() + func applicationWillTerminate(_ application: UIApplication) { + log.default(#function) } - func applicationWillTerminate(_ application: UIApplication) { - // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. + // MARK: - UIApplicationDelegate - Environment + + func applicationProtectedDataDidBecomeAvailable(_ application: UIApplication) { + DispatchQueue.main.async { + if self.loopAppManager.isLaunchPending { + self.loopAppManager.launch() + } + } } - func applicationShouldRequestHealthAuthorization(_ application: UIApplication) { + // MARK: - UIApplicationDelegate - Remote Notification + + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + log.default(#function) + loopAppManager.remoteNotificationRegistrationDidFinish(.success(deviceToken)) } - // MARK: - 3D Touch + func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { + log.error("%{public}@ with error: %{public}@", #function, String(describing: error)) + loopAppManager.remoteNotificationRegistrationDidFinish(.failure(error)) + } + + func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + log.default(#function) - func application(_ application: UIApplication, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) { - completionHandler(false) + completionHandler(loopAppManager.handleRemoteNotification(userInfo as? [String: AnyObject]) ? .noData : .failed) + } + + // MARK: - UIApplicationDelegate - Deeplinking + + func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool { + loopAppManager.handle(url) } -} + // MARK: - UIApplicationDelegate - Continuity -extension AppDelegate: UNUserNotificationCenterDelegate { - func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { - switch response.actionIdentifier { - case NotificationManager.Action.RetryBolus.rawValue: - if let units = response.notification.request.content.userInfo[NotificationManager.UserInfoKey.BolusAmount.rawValue] as? Double, - let startDate = response.notification.request.content.userInfo[NotificationManager.UserInfoKey.BolusStartDate.rawValue] as? Date, - startDate.timeIntervalSinceNow >= TimeInterval(minutes: -5) - { - AnalyticsManager.sharedManager.didRetryBolus() - - dataManager.enactBolus(units: units) { (error) in - if error != nil { - NotificationManager.sendBolusFailureNotificationForAmount(units, atStartDate: startDate) - } - - completionHandler() - } - return - } - default: - break - } - - completionHandler() + func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { + log.default(#function) + + return loopAppManager.userActivity(userActivity, restorationHandler: restorationHandler) } - func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { - completionHandler([.badge, .sound, .alert]) + // MARK: - UIApplicationDelegate - Interface + + func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { + return loopAppManager.supportedInterfaceOrientations } } diff --git a/Loop/Assets.xcassets/AppIcon.appiconset/Contents.json b/Loop/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index ec38b33a9d..0000000000 --- a/Loop/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,110 +0,0 @@ -{ - "images" : [ - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-20@2x.png", - "scale" : "2x" - }, - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-20@3x.png", - "scale" : "3x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-29@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-29@3x.png", - "scale" : "3x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-40@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-40@3x.png", - "scale" : "3x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-60@2x.png", - "scale" : "2x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-60@3x.png", - "scale" : "3x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-20.png", - "scale" : "1x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-20@2x-1.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-29.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-29@2x-1.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-40.png", - "scale" : "1x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-40@2x-1.png", - "scale" : "2x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-76.png", - "scale" : "1x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-76@2x.png", - "scale" : "2x" - }, - { - "size" : "83.5x83.5", - "idiom" : "ipad", - "filename" : "Icon-83.5@2x.png", - "scale" : "2x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/Loop/Assets.xcassets/AppIcon.appiconset/Icon-20.png b/Loop/Assets.xcassets/AppIcon.appiconset/Icon-20.png deleted file mode 100644 index f2da6f6d70..0000000000 Binary files a/Loop/Assets.xcassets/AppIcon.appiconset/Icon-20.png and /dev/null differ diff --git a/Loop/Assets.xcassets/AppIcon.appiconset/Icon-20@2x-1.png b/Loop/Assets.xcassets/AppIcon.appiconset/Icon-20@2x-1.png deleted file mode 100644 index 7bf573b324..0000000000 Binary files a/Loop/Assets.xcassets/AppIcon.appiconset/Icon-20@2x-1.png and /dev/null differ diff --git a/Loop/Assets.xcassets/AppIcon.appiconset/Icon-20@2x.png b/Loop/Assets.xcassets/AppIcon.appiconset/Icon-20@2x.png deleted file mode 100644 index 7bf573b324..0000000000 Binary files a/Loop/Assets.xcassets/AppIcon.appiconset/Icon-20@2x.png and /dev/null differ diff --git a/Loop/Assets.xcassets/AppIcon.appiconset/Icon-20@3x.png b/Loop/Assets.xcassets/AppIcon.appiconset/Icon-20@3x.png deleted file mode 100644 index e6454a2e21..0000000000 Binary files a/Loop/Assets.xcassets/AppIcon.appiconset/Icon-20@3x.png and /dev/null differ diff --git a/Loop/Assets.xcassets/AppIcon.appiconset/Icon-29.png b/Loop/Assets.xcassets/AppIcon.appiconset/Icon-29.png deleted file mode 100644 index 804594487c..0000000000 Binary files a/Loop/Assets.xcassets/AppIcon.appiconset/Icon-29.png and /dev/null differ diff --git a/Loop/Assets.xcassets/AppIcon.appiconset/Icon-29@2x-1.png b/Loop/Assets.xcassets/AppIcon.appiconset/Icon-29@2x-1.png deleted file mode 100644 index 0be6ed649f..0000000000 Binary files a/Loop/Assets.xcassets/AppIcon.appiconset/Icon-29@2x-1.png and /dev/null differ diff --git a/Loop/Assets.xcassets/AppIcon.appiconset/Icon-29@2x.png b/Loop/Assets.xcassets/AppIcon.appiconset/Icon-29@2x.png deleted file mode 100644 index 0be6ed649f..0000000000 Binary files a/Loop/Assets.xcassets/AppIcon.appiconset/Icon-29@2x.png and /dev/null differ diff --git a/Loop/Assets.xcassets/AppIcon.appiconset/Icon-29@3x.png b/Loop/Assets.xcassets/AppIcon.appiconset/Icon-29@3x.png deleted file mode 100644 index 7390ffa97a..0000000000 Binary files a/Loop/Assets.xcassets/AppIcon.appiconset/Icon-29@3x.png and /dev/null differ diff --git a/Loop/Assets.xcassets/AppIcon.appiconset/Icon-40.png b/Loop/Assets.xcassets/AppIcon.appiconset/Icon-40.png deleted file mode 100644 index 294f7ba752..0000000000 Binary files a/Loop/Assets.xcassets/AppIcon.appiconset/Icon-40.png and /dev/null differ diff --git a/Loop/Assets.xcassets/AppIcon.appiconset/Icon-40@2x-1.png b/Loop/Assets.xcassets/AppIcon.appiconset/Icon-40@2x-1.png deleted file mode 100644 index 1df0f29b6a..0000000000 Binary files a/Loop/Assets.xcassets/AppIcon.appiconset/Icon-40@2x-1.png and /dev/null differ diff --git a/Loop/Assets.xcassets/AppIcon.appiconset/Icon-40@2x.png b/Loop/Assets.xcassets/AppIcon.appiconset/Icon-40@2x.png deleted file mode 100644 index 1df0f29b6a..0000000000 Binary files a/Loop/Assets.xcassets/AppIcon.appiconset/Icon-40@2x.png and /dev/null differ diff --git a/Loop/Assets.xcassets/AppIcon.appiconset/Icon-40@3x.png b/Loop/Assets.xcassets/AppIcon.appiconset/Icon-40@3x.png deleted file mode 100644 index b4e9fbb066..0000000000 Binary files a/Loop/Assets.xcassets/AppIcon.appiconset/Icon-40@3x.png and /dev/null differ diff --git a/Loop/Assets.xcassets/AppIcon.appiconset/Icon-60@2x.png b/Loop/Assets.xcassets/AppIcon.appiconset/Icon-60@2x.png deleted file mode 100644 index cf2432256f..0000000000 Binary files a/Loop/Assets.xcassets/AppIcon.appiconset/Icon-60@2x.png and /dev/null differ diff --git a/Loop/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png b/Loop/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png deleted file mode 100644 index b6e68e9df7..0000000000 Binary files a/Loop/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png and /dev/null differ diff --git a/Loop/Assets.xcassets/AppIcon.appiconset/Icon-76.png b/Loop/Assets.xcassets/AppIcon.appiconset/Icon-76.png deleted file mode 100644 index ff0d7ee4a1..0000000000 Binary files a/Loop/Assets.xcassets/AppIcon.appiconset/Icon-76.png and /dev/null differ diff --git a/Loop/Assets.xcassets/AppIcon.appiconset/Icon-76@2x.png b/Loop/Assets.xcassets/AppIcon.appiconset/Icon-76@2x.png deleted file mode 100644 index 2e05d19a84..0000000000 Binary files a/Loop/Assets.xcassets/AppIcon.appiconset/Icon-76@2x.png and /dev/null differ diff --git a/Loop/Assets.xcassets/AppIcon.appiconset/Icon-83.5@2x.png b/Loop/Assets.xcassets/AppIcon.appiconset/Icon-83.5@2x.png deleted file mode 100644 index dbba465aed..0000000000 Binary files a/Loop/Assets.xcassets/AppIcon.appiconset/Icon-83.5@2x.png and /dev/null differ diff --git a/Loop/Assets.xcassets/bolus.imageset/Contents.json b/Loop/Assets.xcassets/bolus.imageset/Contents.json deleted file mode 100644 index 2b1e3ca6c9..0000000000 --- a/Loop/Assets.xcassets/bolus.imageset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "filename" : "bolus.pdf" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/Loop/Assets.xcassets/carbs.imageset/Contents.json b/Loop/Assets.xcassets/carbs.imageset/Contents.json deleted file mode 100644 index 1e4fc587fc..0000000000 --- a/Loop/Assets.xcassets/carbs.imageset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "filename" : "carbs.pdf" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/Loop/Assets.xcassets/carbs.imageset/carbs.pdf b/Loop/Assets.xcassets/carbs.imageset/carbs.pdf deleted file mode 100644 index 327ec631b5..0000000000 Binary files a/Loop/Assets.xcassets/carbs.imageset/carbs.pdf and /dev/null differ diff --git a/Loop/Assets.xcassets/workout-selected.imageset/Contents.json b/Loop/Assets.xcassets/workout-selected.imageset/Contents.json deleted file mode 100644 index 3f89e16b44..0000000000 --- a/Loop/Assets.xcassets/workout-selected.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "filename" : "workout-selected.pdf" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - }, - "properties" : { - "template-rendering-intent" : "template" - } -} \ No newline at end of file diff --git a/Loop/Assets.xcassets/workout.imageset/Contents.json b/Loop/Assets.xcassets/workout.imageset/Contents.json deleted file mode 100644 index 2abbf97ee1..0000000000 --- a/Loop/Assets.xcassets/workout.imageset/Contents.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "filename" : "workout.pdf" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - }, - "properties" : { - "template-rendering-intent" : "template" - } -} \ No newline at end of file diff --git a/Loop/Base.lproj/LaunchScreen.storyboard b/Loop/Base.lproj/LaunchScreen.storyboard index 9e724c0695..03664e6dbd 100644 --- a/Loop/Base.lproj/LaunchScreen.storyboard +++ b/Loop/Base.lproj/LaunchScreen.storyboard @@ -1,22 +1,23 @@ - - + + + - + - + + + - - @@ -36,9 +37,9 @@ - + - + diff --git a/Loop/Base.lproj/Main.storyboard b/Loop/Base.lproj/Main.storyboard index 09ca7666af..dac14ecfb4 100644 --- a/Loop/Base.lproj/Main.storyboard +++ b/Loop/Base.lproj/Main.storyboard @@ -1,123 +1,14 @@ - - - - + + - - + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -125,30 +16,30 @@ - + - - + + - + - - - + + - - + - + @@ -206,43 +97,37 @@ - - + + - + - - - - - + + - - - @@ -260,50 +145,361 @@ - + - + - - + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - + - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + - + @@ -311,16 +507,15 @@ - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + + + + + + - + + + + + + + @@ -395,285 +653,59 @@ - - - - - - - - - - - - - - - - - - - - - - - - - + - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - - + + - - - - - + + + + - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - + - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - + - - - - + + + + + + + + + + + + + diff --git a/Loop/BuildDetails.plist b/Loop/BuildDetails.plist new file mode 100644 index 0000000000..fe23d977d4 --- /dev/null +++ b/Loop/BuildDetails.plist @@ -0,0 +1,12 @@ + + + + + + diff --git a/Loop/DefaultAssets.xcassets/Contents.json b/Loop/DefaultAssets.xcassets/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/Loop/DefaultAssets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop/DefaultAssets.xcassets/Favorite Foods Icon.imageset/Contents.json b/Loop/DefaultAssets.xcassets/Favorite Foods Icon.imageset/Contents.json new file mode 100644 index 0000000000..e910256d55 --- /dev/null +++ b/Loop/DefaultAssets.xcassets/Favorite Foods Icon.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Favorite Foods Icon.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Favorite Foods Icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Favorite Foods Icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop/DefaultAssets.xcassets/Favorite Foods Icon.imageset/Favorite Foods Icon.png b/Loop/DefaultAssets.xcassets/Favorite Foods Icon.imageset/Favorite Foods Icon.png new file mode 100644 index 0000000000..794ae3f5a7 Binary files /dev/null and b/Loop/DefaultAssets.xcassets/Favorite Foods Icon.imageset/Favorite Foods Icon.png differ diff --git a/Loop/DefaultAssets.xcassets/Favorite Foods Icon.imageset/Favorite Foods Icon@2x.png b/Loop/DefaultAssets.xcassets/Favorite Foods Icon.imageset/Favorite Foods Icon@2x.png new file mode 100644 index 0000000000..3220f834e2 Binary files /dev/null and b/Loop/DefaultAssets.xcassets/Favorite Foods Icon.imageset/Favorite Foods Icon@2x.png differ diff --git a/Loop/DefaultAssets.xcassets/Favorite Foods Icon.imageset/Favorite Foods Icon@3x.png b/Loop/DefaultAssets.xcassets/Favorite Foods Icon.imageset/Favorite Foods Icon@3x.png new file mode 100644 index 0000000000..0ace52c65e Binary files /dev/null and b/Loop/DefaultAssets.xcassets/Favorite Foods Icon.imageset/Favorite Foods Icon@3x.png differ diff --git a/Loop/DefaultAssets.xcassets/Open Loop.imageset/Contents.json b/Loop/DefaultAssets.xcassets/Open Loop.imageset/Contents.json new file mode 100644 index 0000000000..c1751ee8b5 --- /dev/null +++ b/Loop/DefaultAssets.xcassets/Open Loop.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Open Loop.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop/DefaultAssets.xcassets/Open Loop.imageset/Open Loop.pdf b/Loop/DefaultAssets.xcassets/Open Loop.imageset/Open Loop.pdf new file mode 100644 index 0000000000..9cd901667d Binary files /dev/null and b/Loop/DefaultAssets.xcassets/Open Loop.imageset/Open Loop.pdf differ diff --git a/Loop/DefaultAssets.xcassets/Oval Selection.imageset/Contents.json b/Loop/DefaultAssets.xcassets/Oval Selection.imageset/Contents.json new file mode 100644 index 0000000000..7bff2dbe3a --- /dev/null +++ b/Loop/DefaultAssets.xcassets/Oval Selection.imageset/Contents.json @@ -0,0 +1,53 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "Oval Selection.pdf", + "resizing" : { + "mode" : "9-part", + "center" : { + "mode" : "tile", + "width" : 1, + "height" : 1 + }, + "cap-insets" : { + "bottom" : 10, + "top" : 10, + "right" : 10, + "left" : 10 + } + } + }, + { + "idiom" : "universal", + "filename" : "Oval Selection Dark.pdf", + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "resizing" : { + "mode" : "9-part", + "center" : { + "mode" : "tile", + "width" : 1, + "height" : 1 + }, + "cap-insets" : { + "bottom" : 10, + "top" : 10, + "right" : 10, + "left" : 10 + } + } + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + }, + "properties" : { + "preserves-vector-representation" : true + } +} \ No newline at end of file diff --git a/Loop/DefaultAssets.xcassets/Oval Selection.imageset/Oval Selection Dark.pdf b/Loop/DefaultAssets.xcassets/Oval Selection.imageset/Oval Selection Dark.pdf new file mode 100644 index 0000000000..a67042ecbf Binary files /dev/null and b/Loop/DefaultAssets.xcassets/Oval Selection.imageset/Oval Selection Dark.pdf differ diff --git a/Loop/DefaultAssets.xcassets/Oval Selection.imageset/Oval Selection.pdf b/Loop/DefaultAssets.xcassets/Oval Selection.imageset/Oval Selection.pdf new file mode 100644 index 0000000000..13056f028e Binary files /dev/null and b/Loop/DefaultAssets.xcassets/Oval Selection.imageset/Oval Selection.pdf differ diff --git a/Loop/DefaultAssets.xcassets/Therapy Icon.imageset/Contents.json b/Loop/DefaultAssets.xcassets/Therapy Icon.imageset/Contents.json new file mode 100644 index 0000000000..9fd8b28ea4 --- /dev/null +++ b/Loop/DefaultAssets.xcassets/Therapy Icon.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Therapy Icon 1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Therapy Icon 2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Therapy Icon 3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop/DefaultAssets.xcassets/Therapy Icon.imageset/Therapy Icon 1x.png b/Loop/DefaultAssets.xcassets/Therapy Icon.imageset/Therapy Icon 1x.png new file mode 100644 index 0000000000..4741ef6526 Binary files /dev/null and b/Loop/DefaultAssets.xcassets/Therapy Icon.imageset/Therapy Icon 1x.png differ diff --git a/Loop/DefaultAssets.xcassets/Therapy Icon.imageset/Therapy Icon 2x.png b/Loop/DefaultAssets.xcassets/Therapy Icon.imageset/Therapy Icon 2x.png new file mode 100644 index 0000000000..2d03df71b8 Binary files /dev/null and b/Loop/DefaultAssets.xcassets/Therapy Icon.imageset/Therapy Icon 2x.png differ diff --git a/Loop/DefaultAssets.xcassets/Therapy Icon.imageset/Therapy Icon 3x.png b/Loop/DefaultAssets.xcassets/Therapy Icon.imageset/Therapy Icon 3x.png new file mode 100644 index 0000000000..e40e455546 Binary files /dev/null and b/Loop/DefaultAssets.xcassets/Therapy Icon.imageset/Therapy Icon 3x.png differ diff --git a/Loop/DefaultAssets.xcassets/Uploading.imageset/Contents.json b/Loop/DefaultAssets.xcassets/Uploading.imageset/Contents.json new file mode 100644 index 0000000000..d55824da08 --- /dev/null +++ b/Loop/DefaultAssets.xcassets/Uploading.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "Uploading.pdf" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + }, + "properties" : { + "preserves-vector-representation" : true + } +} \ No newline at end of file diff --git a/Loop/DefaultAssets.xcassets/Uploading.imageset/Uploading.pdf b/Loop/DefaultAssets.xcassets/Uploading.imageset/Uploading.pdf new file mode 100644 index 0000000000..d14e9d070d Binary files /dev/null and b/Loop/DefaultAssets.xcassets/Uploading.imageset/Uploading.pdf differ diff --git a/Loop/DefaultAssets.xcassets/drop.circle.imageset/Contents.json b/Loop/DefaultAssets.xcassets/drop.circle.imageset/Contents.json new file mode 100644 index 0000000000..03cc97f1ea --- /dev/null +++ b/Loop/DefaultAssets.xcassets/drop.circle.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "drop.circle.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Loop/DefaultAssets.xcassets/drop.circle.imageset/drop.circle.pdf b/Loop/DefaultAssets.xcassets/drop.circle.imageset/drop.circle.pdf new file mode 100644 index 0000000000..20d71e0a7e Binary files /dev/null and b/Loop/DefaultAssets.xcassets/drop.circle.imageset/drop.circle.pdf differ diff --git a/Loop/DefaultAssets.xcassets/hardware.imageset/Contents.json b/Loop/DefaultAssets.xcassets/hardware.imageset/Contents.json new file mode 100644 index 0000000000..579e60790c --- /dev/null +++ b/Loop/DefaultAssets.xcassets/hardware.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Group 3403.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop/DefaultAssets.xcassets/hardware.imageset/Group 3403.pdf b/Loop/DefaultAssets.xcassets/hardware.imageset/Group 3403.pdf new file mode 100644 index 0000000000..14057221ed Binary files /dev/null and b/Loop/DefaultAssets.xcassets/hardware.imageset/Group 3403.pdf differ diff --git a/Loop/DefaultAssets.xcassets/notification-permissions-on.imageset/Contents.json b/Loop/DefaultAssets.xcassets/notification-permissions-on.imageset/Contents.json new file mode 100644 index 0000000000..af599e5617 --- /dev/null +++ b/Loop/DefaultAssets.xcassets/notification-permissions-on.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "notification-permissions-on.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop/DefaultAssets.xcassets/notification-permissions-on.imageset/notification-permissions-on.png b/Loop/DefaultAssets.xcassets/notification-permissions-on.imageset/notification-permissions-on.png new file mode 100644 index 0000000000..9d9804f4a9 Binary files /dev/null and b/Loop/DefaultAssets.xcassets/notification-permissions-on.imageset/notification-permissions-on.png differ diff --git a/Loop/DefaultAssets.xcassets/phone.imageset/Contents.json b/Loop/DefaultAssets.xcassets/phone.imageset/Contents.json new file mode 100644 index 0000000000..507753a905 --- /dev/null +++ b/Loop/DefaultAssets.xcassets/phone.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Group 3405.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop/DefaultAssets.xcassets/phone.imageset/Group 3405.pdf b/Loop/DefaultAssets.xcassets/phone.imageset/Group 3405.pdf new file mode 100644 index 0000000000..fc12ec3959 Binary files /dev/null and b/Loop/DefaultAssets.xcassets/phone.imageset/Group 3405.pdf differ diff --git a/Loop/DerivedAssets.xcassets/Contents.json b/Loop/DerivedAssets.xcassets/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/Loop/DerivedAssets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/AppStore-1024pt@1x.png b/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/AppStore-1024pt@1x.png new file mode 100644 index 0000000000..189337f741 Binary files /dev/null and b/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/AppStore-1024pt@1x.png differ diff --git a/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Contents.json b/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..8bf119b6a8 --- /dev/null +++ b/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,116 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-Notification-40.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-Small-60.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-Small@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-Small@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-Small-40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-Small-60@2x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-Notification-20.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-Notification-42.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-Small.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-Small@2x-1.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-Notification-41.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-Small-40@2x-1.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-76.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "AppStore-1024pt@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-60@2x.png b/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-60@2x.png new file mode 100644 index 0000000000..754271234a Binary files /dev/null and b/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-60@2x.png differ diff --git a/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-60@3x.png b/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-60@3x.png new file mode 100644 index 0000000000..b08ae598c6 Binary files /dev/null and b/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-60@3x.png differ diff --git a/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-76.png b/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-76.png new file mode 100644 index 0000000000..d916527459 Binary files /dev/null and b/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-76.png differ diff --git a/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-76@2x.png b/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-76@2x.png new file mode 100644 index 0000000000..266aa5829a Binary files /dev/null and b/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-76@2x.png differ diff --git a/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-83.5@2x.png b/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-83.5@2x.png new file mode 100644 index 0000000000..b2df83a839 Binary files /dev/null and b/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-83.5@2x.png differ diff --git a/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-Notification-20.png b/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-Notification-20.png new file mode 100644 index 0000000000..f70accfa03 Binary files /dev/null and b/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-Notification-20.png differ diff --git a/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-Notification-40.png b/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-Notification-40.png new file mode 100644 index 0000000000..b3792921d7 Binary files /dev/null and b/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-Notification-40.png differ diff --git a/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-Notification-41.png b/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-Notification-41.png new file mode 100644 index 0000000000..b3792921d7 Binary files /dev/null and b/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-Notification-41.png differ diff --git a/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-Notification-42.png b/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-Notification-42.png new file mode 100644 index 0000000000..b3792921d7 Binary files /dev/null and b/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-Notification-42.png differ diff --git a/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-Small-40@2x-1.png b/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-Small-40@2x-1.png new file mode 100644 index 0000000000..b9b45b2865 Binary files /dev/null and b/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-Small-40@2x-1.png differ diff --git a/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-Small-40@2x.png b/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-Small-40@2x.png new file mode 100644 index 0000000000..b9b45b2865 Binary files /dev/null and b/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-Small-40@2x.png differ diff --git a/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-Small-60.png b/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-Small-60.png new file mode 100644 index 0000000000..0ad6187e0b Binary files /dev/null and b/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-Small-60.png differ diff --git a/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-Small-60@2x.png b/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-Small-60@2x.png new file mode 100644 index 0000000000..754271234a Binary files /dev/null and b/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-Small-60@2x.png differ diff --git a/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-Small.png b/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-Small.png new file mode 100644 index 0000000000..ec13a4cb23 Binary files /dev/null and b/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-Small.png differ diff --git a/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-Small@2x-1.png b/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-Small@2x-1.png new file mode 100644 index 0000000000..02e28324c5 Binary files /dev/null and b/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-Small@2x-1.png differ diff --git a/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-Small@2x.png b/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-Small@2x.png new file mode 100644 index 0000000000..02e28324c5 Binary files /dev/null and b/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-Small@2x.png differ diff --git a/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-Small@3x.png b/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-Small@3x.png new file mode 100644 index 0000000000..3659954b6e Binary files /dev/null and b/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-Small@3x.png differ diff --git a/Loop/DerivedAssetsBase.xcassets/Contents.json b/Loop/DerivedAssetsBase.xcassets/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/Loop/DerivedAssetsBase.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop/DerivedAssetsBase.xcassets/Pre-Meal Selected.imageset/Contents.json b/Loop/DerivedAssetsBase.xcassets/Pre-Meal Selected.imageset/Contents.json new file mode 100644 index 0000000000..09aed6fbc2 --- /dev/null +++ b/Loop/DerivedAssetsBase.xcassets/Pre-Meal Selected.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "Pre-Meal Selected.pdf" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + }, + "properties" : { + "preserves-vector-representation" : true + } +} \ No newline at end of file diff --git a/Loop/DerivedAssetsBase.xcassets/Pre-Meal Selected.imageset/Pre-Meal Selected.pdf b/Loop/DerivedAssetsBase.xcassets/Pre-Meal Selected.imageset/Pre-Meal Selected.pdf new file mode 100644 index 0000000000..8c285da0df Binary files /dev/null and b/Loop/DerivedAssetsBase.xcassets/Pre-Meal Selected.imageset/Pre-Meal Selected.pdf differ diff --git a/Loop/DerivedAssetsBase.xcassets/Pre-Meal-symbol.symbolset/Contents.json b/Loop/DerivedAssetsBase.xcassets/Pre-Meal-symbol.symbolset/Contents.json new file mode 100644 index 0000000000..b4d613d12f --- /dev/null +++ b/Loop/DerivedAssetsBase.xcassets/Pre-Meal-symbol.symbolset/Contents.json @@ -0,0 +1,12 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "symbols" : [ + { + "filename" : "premeal.DIY.svg", + "idiom" : "universal" + } + ] +} diff --git a/Loop/DerivedAssetsBase.xcassets/Pre-Meal-symbol.symbolset/premeal.DIY.svg b/Loop/DerivedAssetsBase.xcassets/Pre-Meal-symbol.symbolset/premeal.DIY.svg new file mode 100644 index 0000000000..22863b71b7 --- /dev/null +++ b/Loop/DerivedAssetsBase.xcassets/Pre-Meal-symbol.symbolset/premeal.DIY.svg @@ -0,0 +1,341 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Loop/DerivedAssetsBase.xcassets/Pre-Meal.imageset/Contents.json b/Loop/DerivedAssetsBase.xcassets/Pre-Meal.imageset/Contents.json new file mode 100644 index 0000000000..85a31aeae3 --- /dev/null +++ b/Loop/DerivedAssetsBase.xcassets/Pre-Meal.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "Pre-Meal.pdf" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + }, + "properties" : { + "preserves-vector-representation" : true + } +} \ No newline at end of file diff --git a/Loop/DerivedAssetsBase.xcassets/Pre-Meal.imageset/Pre-Meal.pdf b/Loop/DerivedAssetsBase.xcassets/Pre-Meal.imageset/Pre-Meal.pdf new file mode 100644 index 0000000000..0992a4a286 Binary files /dev/null and b/Loop/DerivedAssetsBase.xcassets/Pre-Meal.imageset/Pre-Meal.pdf differ diff --git a/Loop/DerivedAssetsBase.xcassets/accent.colorset/Contents.json b/Loop/DerivedAssetsBase.xcassets/accent.colorset/Contents.json new file mode 100644 index 0000000000..9b711cb95b --- /dev/null +++ b/Loop/DerivedAssetsBase.xcassets/accent.colorset/Contents.json @@ -0,0 +1,15 @@ +{ + "colors" : [ + { + "color" : { + "platform" : "ios", + "reference" : "systemBlueColor" + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop/DerivedAssetsBase.xcassets/bolus.imageset/Contents.json b/Loop/DerivedAssetsBase.xcassets/bolus.imageset/Contents.json new file mode 100644 index 0000000000..cf4c428346 --- /dev/null +++ b/Loop/DerivedAssetsBase.xcassets/bolus.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "bolus.pdf" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + }, + "properties" : { + "preserves-vector-representation" : true + } +} \ No newline at end of file diff --git a/Loop/DerivedAssetsBase.xcassets/bolus.imageset/bolus.pdf b/Loop/DerivedAssetsBase.xcassets/bolus.imageset/bolus.pdf new file mode 100644 index 0000000000..c9e69b5cfc Binary files /dev/null and b/Loop/DerivedAssetsBase.xcassets/bolus.imageset/bolus.pdf differ diff --git a/Loop/DerivedAssetsBase.xcassets/carbs.colorset/Contents.json b/Loop/DerivedAssetsBase.xcassets/carbs.colorset/Contents.json new file mode 100644 index 0000000000..5d023a96b0 --- /dev/null +++ b/Loop/DerivedAssetsBase.xcassets/carbs.colorset/Contents.json @@ -0,0 +1,68 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.220", + "green" : "0.855", + "red" : "0.388" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.200", + "green" : "0.894", + "red" : "0.349" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "contrast", + "value" : "high" + } + ], + "color" : { + "platform" : "ios", + "reference" : "systemGreenColor" + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + }, + { + "appearance" : "contrast", + "value" : "high" + } + ], + "color" : { + "platform" : "ios", + "reference" : "systemGreenColor" + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop/DerivedAssetsBase.xcassets/carbs.imageset/Contents.json b/Loop/DerivedAssetsBase.xcassets/carbs.imageset/Contents.json new file mode 100644 index 0000000000..1b039e67a1 --- /dev/null +++ b/Loop/DerivedAssetsBase.xcassets/carbs.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "Meal.pdf" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + }, + "properties" : { + "preserves-vector-representation" : true + } +} \ No newline at end of file diff --git a/Loop/DerivedAssetsBase.xcassets/carbs.imageset/Meal.pdf b/Loop/DerivedAssetsBase.xcassets/carbs.imageset/Meal.pdf new file mode 100644 index 0000000000..5e1c948dad Binary files /dev/null and b/Loop/DerivedAssetsBase.xcassets/carbs.imageset/Meal.pdf differ diff --git a/Loop/DerivedAssetsBase.xcassets/fresh.colorset/Contents.json b/Loop/DerivedAssetsBase.xcassets/fresh.colorset/Contents.json new file mode 100644 index 0000000000..def0a38a93 --- /dev/null +++ b/Loop/DerivedAssetsBase.xcassets/fresh.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "100", + "green" : "217", + "red" : "76" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop/DerivedAssetsBase.xcassets/glucose.colorset/Contents.json b/Loop/DerivedAssetsBase.xcassets/glucose.colorset/Contents.json new file mode 100644 index 0000000000..4061e0f057 --- /dev/null +++ b/Loop/DerivedAssetsBase.xcassets/glucose.colorset/Contents.json @@ -0,0 +1,68 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "0.690", + "red" : "0.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "0.729", + "red" : "0.390" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "contrast", + "value" : "high" + } + ], + "color" : { + "platform" : "ios", + "reference" : "systemBlueColor" + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + }, + { + "appearance" : "contrast", + "value" : "high" + } + ], + "color" : { + "platform" : "ios", + "reference" : "systemBlueColor" + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop/DerivedAssetsBase.xcassets/insulin.colorset/Contents.json b/Loop/DerivedAssetsBase.xcassets/insulin.colorset/Contents.json new file mode 100644 index 0000000000..b431287b2a --- /dev/null +++ b/Loop/DerivedAssetsBase.xcassets/insulin.colorset/Contents.json @@ -0,0 +1,15 @@ +{ + "colors" : [ + { + "color" : { + "platform" : "ios", + "reference" : "systemOrangeColor" + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop/Assets.xcassets/settings.imageset/Contents.json b/Loop/DerivedAssetsBase.xcassets/settings.imageset/Contents.json similarity index 100% rename from Loop/Assets.xcassets/settings.imageset/Contents.json rename to Loop/DerivedAssetsBase.xcassets/settings.imageset/Contents.json diff --git a/Loop/Assets.xcassets/settings.imageset/settings.png b/Loop/DerivedAssetsBase.xcassets/settings.imageset/settings.png similarity index 100% rename from Loop/Assets.xcassets/settings.imageset/settings.png rename to Loop/DerivedAssetsBase.xcassets/settings.imageset/settings.png diff --git a/Loop/Assets.xcassets/settings.imageset/settings@2x.png b/Loop/DerivedAssetsBase.xcassets/settings.imageset/settings@2x.png similarity index 100% rename from Loop/Assets.xcassets/settings.imageset/settings@2x.png rename to Loop/DerivedAssetsBase.xcassets/settings.imageset/settings@2x.png diff --git a/Loop/Assets.xcassets/settings.imageset/settings@3x.png b/Loop/DerivedAssetsBase.xcassets/settings.imageset/settings@3x.png similarity index 100% rename from Loop/Assets.xcassets/settings.imageset/settings@3x.png rename to Loop/DerivedAssetsBase.xcassets/settings.imageset/settings@3x.png diff --git a/Loop/Assets.xcassets/settings.imageset/settings_compact@2x.png b/Loop/DerivedAssetsBase.xcassets/settings.imageset/settings_compact@2x.png similarity index 100% rename from Loop/Assets.xcassets/settings.imageset/settings_compact@2x.png rename to Loop/DerivedAssetsBase.xcassets/settings.imageset/settings_compact@2x.png diff --git a/Loop/Assets.xcassets/settings.imageset/settings_compact@3x.png b/Loop/DerivedAssetsBase.xcassets/settings.imageset/settings_compact@3x.png similarity index 100% rename from Loop/Assets.xcassets/settings.imageset/settings_compact@3x.png rename to Loop/DerivedAssetsBase.xcassets/settings.imageset/settings_compact@3x.png diff --git a/Loop/DerivedAssetsBase.xcassets/warning.colorset/Contents.json b/Loop/DerivedAssetsBase.xcassets/warning.colorset/Contents.json new file mode 100644 index 0000000000..c26d0f7a13 --- /dev/null +++ b/Loop/DerivedAssetsBase.xcassets/warning.colorset/Contents.json @@ -0,0 +1,33 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0.269", + "green" : "0.763", + "red" : "0.917" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "platform" : "ios", + "reference" : "systemYellowColor" + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Loop/DerivedAssetsBase.xcassets/workout-selected.imageset/Contents.json b/Loop/DerivedAssetsBase.xcassets/workout-selected.imageset/Contents.json new file mode 100644 index 0000000000..07a9fb7036 --- /dev/null +++ b/Loop/DerivedAssetsBase.xcassets/workout-selected.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "workout-selected.pdf" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + }, + "properties" : { + "template-rendering-intent" : "template", + "preserves-vector-representation" : true + } +} \ No newline at end of file diff --git a/Loop/Assets.xcassets/workout-selected.imageset/workout-selected.pdf b/Loop/DerivedAssetsBase.xcassets/workout-selected.imageset/workout-selected.pdf similarity index 100% rename from Loop/Assets.xcassets/workout-selected.imageset/workout-selected.pdf rename to Loop/DerivedAssetsBase.xcassets/workout-selected.imageset/workout-selected.pdf diff --git a/Loop/DerivedAssetsBase.xcassets/workout-symbol.symbolset/Contents.json b/Loop/DerivedAssetsBase.xcassets/workout-symbol.symbolset/Contents.json new file mode 100644 index 0000000000..fea0fb11b6 --- /dev/null +++ b/Loop/DerivedAssetsBase.xcassets/workout-symbol.symbolset/Contents.json @@ -0,0 +1,12 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "symbols" : [ + { + "filename" : "heart.pulse.svg", + "idiom" : "universal" + } + ] +} diff --git a/Loop/DerivedAssetsBase.xcassets/workout-symbol.symbolset/heart.pulse.svg b/Loop/DerivedAssetsBase.xcassets/workout-symbol.symbolset/heart.pulse.svg new file mode 100644 index 0000000000..2439f1cc36 --- /dev/null +++ b/Loop/DerivedAssetsBase.xcassets/workout-symbol.symbolset/heart.pulse.svg @@ -0,0 +1,239 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Loop/DerivedAssetsBase.xcassets/workout.imageset/Contents.json b/Loop/DerivedAssetsBase.xcassets/workout.imageset/Contents.json new file mode 100644 index 0000000000..6255f94f8b --- /dev/null +++ b/Loop/DerivedAssetsBase.xcassets/workout.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "workout.pdf" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + }, + "properties" : { + "template-rendering-intent" : "template", + "preserves-vector-representation" : true + } +} \ No newline at end of file diff --git a/Loop/DerivedAssetsBase.xcassets/workout.imageset/workout.pdf b/Loop/DerivedAssetsBase.xcassets/workout.imageset/workout.pdf new file mode 100644 index 0000000000..251c7b3b3c Binary files /dev/null and b/Loop/DerivedAssetsBase.xcassets/workout.imageset/workout.pdf differ diff --git a/Loop/Extensions/AlertStore+SimulatedCoreData.swift b/Loop/Extensions/AlertStore+SimulatedCoreData.swift new file mode 100644 index 0000000000..2634bc3274 --- /dev/null +++ b/Loop/Extensions/AlertStore+SimulatedCoreData.swift @@ -0,0 +1,64 @@ +// +// AlertStore+SimulatedCoreData.swift +// Loop +// +// Created by Darin Krauss on 6/12/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit + +// MARK: - Simulated Core Data + +extension AlertStore { + private var historicalEndDate: Date { Date(timeIntervalSinceNow: -.hours(24)) } + + private var simulatedPerDay: Int { 12 } + private var simulatedLimit: Int { 10000 } + + func generateSimulatedHistoricalStoredAlerts(completion: @escaping (Error?) -> Void) { + var startDate = Calendar.current.startOfDay(for: expireDate) + let endDate = Calendar.current.startOfDay(for: historicalEndDate) + var simulated = [DatedAlert]() + + while startDate < endDate { + for index in 0..= simulatedLimit { + if let error = addAlerts(alerts: simulated) { + completion(error) + return + } + simulated = [] + } + + startDate = Calendar.current.date(byAdding: .day, value: 1, to: startDate)! + } + + completion(addAlerts(alerts: simulated)) + } + + func purgeHistoricalStoredAlerts(completion: @escaping (Error?) -> Void) { + purge(before: historicalEndDate, completion: completion) + } +} + +fileprivate extension AlertStore.DatedAlert { + static func simulated(date: Date) -> AlertStore.DatedAlert { + let alert = Alert(identifier: Alert.Identifier(managerIdentifier: "simulatedManagerIdentifier", + alertIdentifier: "simulatedAlertIdentifier"), + foregroundContent: Alert.Content(title: "Simulated Alert Foreground Title", + body: "The body of a foreground simulated alert approximates an actual alert body.", + acknowledgeActionButtonLabel: "Acknowledged"), + backgroundContent: Alert.Content(title: "Simulated Alert Background Title", + body: "The body of a background simulated alert approximates an actual alert body.", + acknowledgeActionButtonLabel: "Acknowledged"), + trigger: .delayed(interval: 60), + sound: .sound(name: "simulated"), + metadata: Alert.Metadata(dict: ["simulated": true])) + return AlertStore.DatedAlert(date: date, alert: alert, syncIdentifier: UUID()) + } +} diff --git a/Loop/Extensions/BasalDeliveryState.swift b/Loop/Extensions/BasalDeliveryState.swift new file mode 100644 index 0000000000..7aef479ccf --- /dev/null +++ b/Loop/Extensions/BasalDeliveryState.swift @@ -0,0 +1,50 @@ +// +// BasalDeliveryState.swift +// Loop +// +// Created by Pete Schwamb on 8/5/19. +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import LoopKit +import LoopCore + +extension PumpManagerStatus.BasalDeliveryState { + func getNetBasal(basalSchedule: BasalRateSchedule, settings: LoopSettings) -> NetBasal? { + func scheduledBasal(for date: Date) -> AbsoluteScheduleValue? { + return basalSchedule.between(start: date, end: date).first + } + + switch self { + case .tempBasal(let dose): + if let scheduledBasal = scheduledBasal(for: dose.startDate) { + return NetBasal( + lastTempBasal: dose, + maxBasal: settings.maximumBasalRatePerHour, + scheduledBasal: scheduledBasal + ) + } else { + return nil + } + case .suspended(let date): + if let scheduledBasal = scheduledBasal(for: date) { + return NetBasal( + suspendedAt: date, + maxBasal: settings.maximumBasalRatePerHour, + scheduledBasal: scheduledBasal + ) + } else { + return nil + } + case .active(let date): + if scheduledBasal(for: date) != nil { + return NetBasal(scheduledRateStartedAt: date) + } else { + return nil + } + default: + return nil + } + } +} + diff --git a/Loop/Extensions/BatteryIndicator.swift b/Loop/Extensions/BatteryIndicator.swift deleted file mode 100644 index 0a50c7b4b3..0000000000 --- a/Loop/Extensions/BatteryIndicator.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// BatteryIndicator.swift -// Loop -// -// Created by Pete Schwamb on 8/2/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import NightscoutUploadKit -import MinimedKit - - -// TODO: Remove this when made public in NightscoutUploadKit - -extension BatteryIndicator { - init?(batteryStatus: MinimedKit.BatteryStatus) { - switch batteryStatus { - case .low: - self = .low - case .normal: - self = .normal - default: - return nil - } - } -} diff --git a/Loop/Extensions/CGPoint.swift b/Loop/Extensions/CGPoint.swift deleted file mode 100644 index aa7e24da91..0000000000 --- a/Loop/Extensions/CGPoint.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// CGPoint.swift -// Naterade -// -// Created by Nathan Racklyeft on 2/29/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import UIKit - - -extension CGPoint { - /** - Rounds the coordinates to whole-pixel values - - - parameter scale: The display scale to use. Defaults to the main screen scale. - */ - mutating func makeIntegralInPlaceWithDisplayScale(_ scale: CGFloat = 0) { - var scale = scale - - // It's possible for scale values retrieved from traitCollection objects to be 0. - if scale == 0 { - scale = UIScreen.main.scale - } - x = round(x * scale) / scale - y = round(y * scale) / scale - } -} diff --git a/Loop/Extensions/CarbStore+SimulatedCoreData.swift b/Loop/Extensions/CarbStore+SimulatedCoreData.swift new file mode 100644 index 0000000000..3ddcc23c83 --- /dev/null +++ b/Loop/Extensions/CarbStore+SimulatedCoreData.swift @@ -0,0 +1,71 @@ +// +// CarbStore+SimulatedCoreData.swift +// Loop +// +// Created by Darin Krauss on 6/4/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import Foundation +import HealthKit +import LoopKit + +// MARK: - Simulated Core Data + +extension CarbStore { + private var historicalEndDate: Date { Date(timeIntervalSinceNow: -.hours(24)) } + + private var simulatedPerDay: Int { 10 } + private var simulatedLimit: Int { 10000 } + + func generateSimulatedHistoricalCarbObjects(completion: @escaping (Error?) -> Void) { + var startDate = Calendar.current.startOfDay(for: earliestCacheDate) + let endDate = Calendar.current.startOfDay(for: historicalEndDate) + var simulated = [NewCarbEntry]() + + while startDate < endDate { + for index in 0..= simulatedLimit { + if let error = addSimulatedHistoricalCarbObjects(entries: simulated) { + completion(error) + return + } + simulated = [] + } + + startDate = Calendar.current.date(byAdding: .day, value: 1, to: startDate)! + } + + completion(addSimulatedHistoricalCarbObjects(entries: simulated)) + } + + private func addSimulatedHistoricalCarbObjects(entries: [NewCarbEntry]) -> Error? { + var addError: Error? + let semaphore = DispatchSemaphore(value: 0) + addNewCarbEntries(entries: entries) { error in + addError = error + semaphore.signal() + } + semaphore.wait() + return addError + } + + func purgeHistoricalCarbObjects(completion: @escaping (Error?) -> Void) { + purgeCachedCarbObjectsUnconditionally(before: historicalEndDate, completion: completion) + } +} + +fileprivate extension NewCarbEntry { + static func simulated(startDate: Date, grams: Double, absorptionTime: TimeInterval) -> NewCarbEntry { + return NewCarbEntry(date: startDate, + quantity: HKQuantity(unit: .gram(), doubleValue: grams), + startDate: startDate, + foodType: "Simulated", + absorptionTime: absorptionTime) + } +} diff --git a/Loop/Extensions/CaseCountable.swift b/Loop/Extensions/CaseCountable.swift new file mode 100644 index 0000000000..75166df062 --- /dev/null +++ b/Loop/Extensions/CaseCountable.swift @@ -0,0 +1,17 @@ +// +// CaseCountable.swift +// Loop +// +// Created by Pete Schwamb on 1/1/17. +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +protocol CaseCountable: RawRepresentable {} + +extension CaseCountable where RawValue == Int { + static var count: Int { + var i: RawValue = 0 + while let new = Self(rawValue: i) { i = new.rawValue.advanced(by: 1) } + return i + } +} diff --git a/Loop/Extensions/ChartColorPalette+Loop.swift b/Loop/Extensions/ChartColorPalette+Loop.swift new file mode 100644 index 0000000000..5da14fde31 --- /dev/null +++ b/Loop/Extensions/ChartColorPalette+Loop.swift @@ -0,0 +1,17 @@ +// +// ChartColorPalette+Loop.swift +// Loop +// +// Created by Bharat Mediratta on 4/1/17. +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +import LoopUI +import LoopKitUI + + +extension ChartColorPalette { + static var primary: ChartColorPalette { + return ChartColorPalette(axisLine: .axisLineColor, axisLabel: .axisLabelColor, grid: .gridColor, glucoseTint: .glucoseTintColor, insulinTint: .insulinTintColor, carbTint: .carbTintColor) + } +} diff --git a/Loop/Extensions/ChartPoint.swift b/Loop/Extensions/ChartPoint.swift deleted file mode 100644 index 3bb5ba888b..0000000000 --- a/Loop/Extensions/ChartPoint.swift +++ /dev/null @@ -1,103 +0,0 @@ -// -// ChartPoint.swift -// Naterade -// -// Created by Nathan Racklyeft on 2/19/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import Foundation -import LoopKit -import SwiftCharts - - -extension ChartPoint { - static func pointsForGlucoseRangeSchedule(_ glucoseRangeSchedule: GlucoseRangeSchedule, xAxisValues: [ChartAxisValue]) -> [ChartPoint] { - let targetRanges = glucoseRangeSchedule.between( - start: ChartAxisValueDate.dateFromScalar(xAxisValues.first!.scalar), - end: ChartAxisValueDate.dateFromScalar(xAxisValues.last!.scalar) - ) - let dateFormatter = DateFormatter() - - var maxPoints: [ChartPoint] = [] - var minPoints: [ChartPoint] = [] - - for (index, range) in targetRanges.enumerated() { - var startDate = ChartAxisValueDate(date: range.startDate, formatter: dateFormatter) - var endDate: ChartAxisValueDate - - if index == targetRanges.startIndex, let firstDate = xAxisValues.first as? ChartAxisValueDate { - startDate = firstDate - } - - if index == targetRanges.endIndex - 1, let lastDate = xAxisValues.last as? ChartAxisValueDate { - endDate = lastDate - } else { - endDate = ChartAxisValueDate(date: targetRanges[index + 1].startDate, formatter: dateFormatter) - } - - let minValue = ChartAxisValueDouble(range.value.minValue) - let maxValue = ChartAxisValueDouble(range.value.maxValue) - - maxPoints += [ - ChartPoint(x: startDate, y: maxValue), - ChartPoint(x: endDate, y: maxValue) - ] - - minPoints += [ - ChartPoint(x: startDate, y: minValue), - ChartPoint(x: endDate, y: minValue) - ] - } - - return maxPoints + minPoints.reversed() - } - - static func pointsForGlucoseRangeScheduleOverrideDuration(_ override: AbsoluteScheduleValue, xAxisValues: [ChartAxisValue]) -> [ChartPoint] { - let startDate = Date() - - guard override.endDate.timeIntervalSince(startDate) > 0, - let lastXAxisValue = xAxisValues.last as? ChartAxisValueDate - else { - return [] - } - - let dateFormatter = DateFormatter() - let startDateAxisValue = ChartAxisValueDate(date: startDate, formatter: dateFormatter) - let endDateAxisValue = ChartAxisValueDate(date: min(lastXAxisValue.date, override.endDate), formatter: dateFormatter) - let minValue = ChartAxisValueDouble(override.value.minValue) - let maxValue = ChartAxisValueDouble(override.value.maxValue) - - return [ - ChartPoint(x: startDateAxisValue, y: maxValue), - ChartPoint(x: endDateAxisValue, y: maxValue), - ChartPoint(x: endDateAxisValue, y: minValue), - ChartPoint(x: startDateAxisValue, y: minValue) - ] - } - - static func pointsForGlucoseRangeScheduleOverride(_ override: AbsoluteScheduleValue, xAxisValues: [ChartAxisValue]) -> [ChartPoint] { - let startDate = Date() - - guard override.endDate.timeIntervalSince(startDate) > 0, - let lastXAxisValue = xAxisValues.last as? ChartAxisValueDate - else { - return [] - } - - let dateFormatter = DateFormatter() - let startDateAxisValue = ChartAxisValueDate(date: startDate, formatter: dateFormatter) - let endDateAxisValue = ChartAxisValueDate(date: lastXAxisValue.date, formatter: dateFormatter) - let minValue = ChartAxisValueDouble(override.value.minValue) - let maxValue = ChartAxisValueDouble(override.value.maxValue) - - return [ - ChartPoint(x: startDateAxisValue, y: maxValue), - ChartPoint(x: endDateAxisValue, y: maxValue), - ChartPoint(x: endDateAxisValue, y: minValue), - ChartPoint(x: startDateAxisValue, y: minValue) - ] - } -} - - diff --git a/Loop/Extensions/ChartSettings+Loop.swift b/Loop/Extensions/ChartSettings+Loop.swift new file mode 100644 index 0000000000..731a67f126 --- /dev/null +++ b/Loop/Extensions/ChartSettings+Loop.swift @@ -0,0 +1,21 @@ +// +// ChartSettings+Loop.swift +// Loop +// +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +import SwiftCharts + +extension ChartSettings { + static var `default`: ChartSettings { + var settings = ChartSettings() + settings.top = 12 + settings.bottom = 0 + settings.trailing = 8 + settings.axisTitleLabelsToLabelsSpacing = 0 + settings.labelsToAxisSpacingX = 6 + settings.clipInnerFrame = false + return settings + } +} diff --git a/Loop/Extensions/CollectionType+Loop.swift b/Loop/Extensions/CollectionType+Loop.swift new file mode 100644 index 0000000000..1ca70b1ff9 --- /dev/null +++ b/Loop/Extensions/CollectionType+Loop.swift @@ -0,0 +1,68 @@ +// +// CollectionType.swift +// Naterade +// +// Created by Nathan Racklyeft on 2/21/16. +// Copyright © 2016 Nathan Racklyeft. All rights reserved. +// + +import Foundation +import LoopKit + + +public extension Sequence where Element: TimelineValue { + /// Returns the closest element index in the sorted sequence prior to the specified date + /// + /// - parameter date: The date to use in the search + /// + /// - returns: The closest index, if any exist before the specified date + func closestIndex(priorTo date: Date) -> Int? { + var closestIndex: Int? + + for (index, value) in self.enumerated() { + if value.startDate <= date { + closestIndex = index + } else { + break + } + } + + return closestIndex + } +} + +// Source: https://github.com/apple/swift/blob/master/stdlib/public/core/CollectionAlgorithms.swift#L476 +extension Collection { + /// Returns the index of the first element in the collection that matches + /// the predicate. + /// + /// The collection must already be partitioned according to the predicate. + /// That is, there should be an index `i` where for every element in + /// `collection[.. Bool + ) rethrows -> Index { + var n = count + var l = startIndex + + while n > 0 { + let half = n / 2 + let mid = index(l, offsetBy: half) + if try predicate(self[mid]) { + n = half + } else { + l = index(after: mid) + n -= half + 1 + } + } + return l + } +} diff --git a/Loop/Extensions/CollectionType.swift b/Loop/Extensions/CollectionType.swift deleted file mode 100644 index 39359a1af4..0000000000 --- a/Loop/Extensions/CollectionType.swift +++ /dev/null @@ -1,95 +0,0 @@ -// -// CollectionType.swift -// Naterade -// -// Created by Nathan Racklyeft on 2/21/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import Foundation -import LoopKit - - -extension BidirectionalCollection where Index: Strideable, Iterator.Element: Comparable, Index.Stride == Int { - - /** - Returns the insertion index of a new value in a sorted collection - - Based on some helpful responses found at [StackOverflow](http://stackoverflow.com/a/33674192) - - - parameter value: The value to insert - - - returns: The appropriate insertion index, between `startIndex` and `endIndex` - */ - func findInsertionIndex(for value: Iterator.Element) -> Index { - var low = startIndex - var high = endIndex - - while low != high { - let mid = low.advanced(by: low.distance(to: high) / 2) - - if self[mid] < value { - low = mid.advanced(by: 1) - } else { - high = mid - } - } - - return low - } -} - - -extension BidirectionalCollection where Index: Strideable, Iterator.Element: Strideable, Index.Stride == Int { - /** - Returns the index of the closest element to a specified value in a sorted collection - - - parameter value: The value to match - - - returns: The index of the closest element, or nil if the collection is empty - */ - func findClosestElementIndex(matching value: Iterator.Element) -> Index? { - let upperBound = findInsertionIndex(for: value) - - if upperBound == startIndex { - if upperBound == endIndex { - return nil - } - return upperBound - } - - let lowerBound = upperBound.advanced(by: -1) - - if upperBound == endIndex { - return lowerBound - } - - if value.distance(to: self[upperBound]) < self[lowerBound].distance(to: value) { - return upperBound - } - - return lowerBound - } -} - - -public extension Sequence where Iterator.Element: TimelineValue { - /// Returns the closest element index in the sorted sequence prior to the specified date - /// - /// - parameter date: The date to use in the search - /// - /// - returns: The closest index, if any exist before the specified date - func closestIndexPriorToDate(_ date: Date) -> Int? { - var closestIndex: Int? - - for (index, value) in self.enumerated() { - if value.startDate <= date { - closestIndex = index - } else { - break - } - } - - return closestIndex - } -} diff --git a/Loop/Extensions/Data.swift b/Loop/Extensions/Data.swift index d8c34bc481..2a2fd05918 100644 --- a/Loop/Extensions/Data.swift +++ b/Loop/Extensions/Data.swift @@ -11,12 +11,6 @@ import Foundation extension Data { var hexadecimalString: String { - let string = NSMutableString(capacity: count * 2) - - for byte in self { - string.appendFormat("%02x", byte) - } - - return string as String + return map { String(format: "%02hhx", $0) }.joined() } } diff --git a/Loop/Extensions/Debug.swift b/Loop/Extensions/Debug.swift new file mode 100644 index 0000000000..8f0501eff3 --- /dev/null +++ b/Loop/Extensions/Debug.swift @@ -0,0 +1,21 @@ +// +// Debug.swift +// Loop +// +// Created by Michael Pangburn on 3/5/19. +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +var debugEnabled: Bool { + #if DEBUG || IOS_SIMULATOR || targetEnvironment(simulator) + return true + #else + return false + #endif +} + +func assertDebugOnly(file: StaticString = #file, line: UInt = #line) { + guard debugEnabled else { + fatalError("\(file):\(line) should never be invoked in release builds", file: file, line: line) + } +} diff --git a/Loop/Extensions/DeviceDataManager+BolusEntryViewModelDelegate.swift b/Loop/Extensions/DeviceDataManager+BolusEntryViewModelDelegate.swift new file mode 100644 index 0000000000..25173f92d8 --- /dev/null +++ b/Loop/Extensions/DeviceDataManager+BolusEntryViewModelDelegate.swift @@ -0,0 +1,97 @@ +// +// DeviceDataManager+BolusEntryViewModelDelegate.swift +// Loop +// +// Created by Rick Pasetto on 9/29/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import HealthKit +import LoopCore +import LoopKit + +extension DeviceDataManager: CarbEntryViewModelDelegate { + var defaultAbsorptionTimes: LoopKit.CarbStore.DefaultAbsorptionTimes { + return carbStore.defaultAbsorptionTimes + } +} + +extension DeviceDataManager: BolusEntryViewModelDelegate, ManualDoseViewModelDelegate { + + func addManuallyEnteredDose(startDate: Date, units: Double, insulinType: InsulinType?) { + loopManager.addManuallyEnteredDose(startDate: startDate, units: units, insulinType: insulinType) + } + + func withLoopState(do block: @escaping (LoopState) -> Void) { + loopManager.getLoopState { block($1) } + } + + func saveGlucose(sample: NewGlucoseSample) async -> StoredGlucoseSample? { + return await withCheckedContinuation { continuation in + loopManager.addGlucoseSamples([sample]) { result in + switch result { + case .success(let samples): + continuation.resume(returning: samples.first) + case .failure: + continuation.resume(returning: nil) + } + } + } + } + + func addCarbEntry(_ carbEntry: NewCarbEntry, replacing replacingEntry: StoredCarbEntry?, completion: @escaping (Result) -> Void) { + loopManager.addCarbEntry(carbEntry, replacing: replacingEntry, completion: completion) + } + + func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) { + loopManager.storeManualBolusDosingDecision(bolusDosingDecision, withDate: date) + } + + func getGlucoseSamples(start: Date?, end: Date?, completion: @escaping (Swift.Result<[StoredGlucoseSample], Error>) -> Void) { + glucoseStore.getGlucoseSamples(start: start, end: end, completion: completion) + } + + func insulinOnBoard(at date: Date, completion: @escaping (DoseStoreResult) -> Void) { + doseStore.insulinOnBoard(at: date, completion: completion) + } + + func carbsOnBoard(at date: Date, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (CarbStoreResult) -> Void) { + carbStore.carbsOnBoard(at: date, effectVelocities: effectVelocities, completion: completion) + } + + func ensureCurrentPumpData(completion: @escaping (Date?) -> Void) { + pumpManager?.ensureCurrentPumpData(completion: completion) + } + + var mostRecentGlucoseDataDate: Date? { + return glucoseStore.latestGlucose?.startDate + } + + var mostRecentPumpDataDate: Date? { + return doseStore.lastAddedPumpData + } + + var isPumpConfigured: Bool { + return pumpManager != nil + } + + var preferredGlucoseUnit: HKUnit { + return displayGlucosePreference.unit + } + + var pumpInsulinType: InsulinType? { + return pumpManager?.status.insulinType + } + + func insulinActivityDuration(for type: InsulinType?) -> TimeInterval { + return doseStore.insulinModelProvider.model(for: type).effectDuration + } + + var settings: LoopSettings { + return loopManager.settings + } + + func updateRemoteRecommendation() { + loopManager.updateRemoteRecommendation() + } +} diff --git a/Loop/Extensions/DeviceDataManager+DeviceStatus.swift b/Loop/Extensions/DeviceDataManager+DeviceStatus.swift new file mode 100644 index 0000000000..fbcf52b983 --- /dev/null +++ b/Loop/Extensions/DeviceDataManager+DeviceStatus.swift @@ -0,0 +1,156 @@ +// +// DeviceDataManager+DeviceStatus.swift +// Loop +// +// Created by Nathaniel Hamming on 2020-07-10. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import LoopKit +import LoopKitUI +import LoopCore + +extension DeviceDataManager { + var cgmStatusHighlight: DeviceStatusHighlight? { + let bluetoothState = bluetoothProvider.bluetoothState + if bluetoothState == .unsupported || bluetoothState == .unauthorized { + return BluetoothState.unavailableHighlight + } else if bluetoothState == .poweredOff { + return BluetoothState.offHighlight + } else if cgmManager == nil { + return DeviceDataManager.addCGMStatusHighlight + } else { + return (cgmManager as? CGMManagerUI)?.cgmStatusHighlight + } + } + + var cgmStatusBadge: DeviceStatusBadge? { + return (cgmManager as? CGMManagerUI)?.cgmStatusBadge + } + + var cgmLifecycleProgress: DeviceLifecycleProgress? { + return (cgmManager as? CGMManagerUI)?.cgmLifecycleProgress + } + + var pumpStatusHighlight: DeviceStatusHighlight? { + let bluetoothState = bluetoothProvider.bluetoothState + if bluetoothState == .unsupported || bluetoothState == .unauthorized || bluetoothState == .poweredOff { + return BluetoothState.enableHighlight + } else if let onboardingManager = onboardingManager, !onboardingManager.isComplete, pumpManager?.isOnboarded != true { + return DeviceDataManager.resumeOnboardingStatusHighlight + } else if pumpManager == nil { + return DeviceDataManager.addPumpStatusHighlight + } else { + return pumpManager?.pumpStatusHighlight + } + } + + var pumpStatusBadge: DeviceStatusBadge? { + return pumpManager?.pumpStatusBadge + } + + var pumpLifecycleProgress: DeviceLifecycleProgress? { + return pumpManager?.pumpLifecycleProgress + } + + static var resumeOnboardingStatusHighlight: ResumeOnboardingStatusHighlight { + return ResumeOnboardingStatusHighlight() + } + + struct ResumeOnboardingStatusHighlight: DeviceStatusHighlight { + var localizedMessage: String = NSLocalizedString("Complete Setup", comment: "Title text for button to complete setup") + var imageName: String = "exclamationmark.circle.fill" + var state: DeviceStatusHighlightState = .warning + } + + static var addCGMStatusHighlight: AddDeviceStatusHighlight { + return AddDeviceStatusHighlight(localizedMessage: NSLocalizedString("Add CGM", comment: "Title text for button to set up a CGM"), + state: .critical) + } + + static var addPumpStatusHighlight: AddDeviceStatusHighlight { + return AddDeviceStatusHighlight(localizedMessage: NSLocalizedString("Add Pump", comment: "Title text for button to set up a Pump"), + state: .critical) + } + + struct AddDeviceStatusHighlight: DeviceStatusHighlight { + var localizedMessage: String + var imageName: String = "plus.circle" + var state: DeviceStatusHighlightState + } + + func didTapOnCGMStatus(_ view: BaseHUDView? = nil) -> HUDTapAction? { + if let action = bluetoothProvider.bluetoothState.action { + return action + } else if let url = cgmManager?.appURL, + UIApplication.shared.canOpenURL(url) + { + return .openAppURL(url) + } else if let cgmManagerUI = (cgmManager as? CGMManagerUI) { + return .presentViewController(cgmManagerUI.settingsViewController(bluetoothProvider: bluetoothProvider, displayGlucosePreference: displayGlucosePreference, colorPalette: .default, allowDebugFeatures: FeatureFlags.allowDebugFeatures)) + } else { + return .setupNewCGM + } + } + + func didTapOnPumpStatus(_ view: BaseHUDView? = nil) -> HUDTapAction? { + if let action = bluetoothProvider.bluetoothState.action { + return action + } else if let onboardingManager = onboardingManager, !onboardingManager.isComplete, pumpManager?.isOnboarded != true { + onboardingManager.resume() + return .takeNoAction + } else if let pumpManagerHUDProvider = pumpManagerHUDProvider, + let view = view, + let action = pumpManagerHUDProvider.didTapOnHUDView(view, allowDebugFeatures: FeatureFlags.allowDebugFeatures) + { + return action + } else if let pumpManager = pumpManager { + return .presentViewController(pumpManager.settingsViewController(bluetoothProvider: bluetoothProvider, colorPalette: .default, allowDebugFeatures: FeatureFlags.allowDebugFeatures, allowedInsulinTypes: allowedInsulinTypes)) + } else { + return .setupNewPump + } + } + + var isGlucoseValueStale: Bool { + guard let latestGlucoseDataDate = glucoseStore.latestGlucose?.startDate else { return true } + + return Date().timeIntervalSince(latestGlucoseDataDate) > LoopCoreConstants.inputDataRecencyInterval + } +} + +// MARK: - BluetoothState + +fileprivate extension BluetoothState { + struct Highlight: DeviceStatusHighlight { + var localizedMessage: String + var imageName: String = "bluetooth.disabled" + var state: DeviceStatusHighlightState = .critical + + init(localizedMessage: String) { + self.localizedMessage = localizedMessage + } + } + + static var offHighlight: Highlight { + return Highlight(localizedMessage: NSLocalizedString("Bluetooth\nOff", comment: "Message to the user to that the bluetooth is off")) + } + + static var enableHighlight: Highlight { + return Highlight(localizedMessage: NSLocalizedString("Enable\nBluetooth", comment: "Message to the user to enable bluetooth")) + } + + static var unavailableHighlight: Highlight { + return Highlight(localizedMessage: NSLocalizedString("Bluetooth\nUnavailable", comment: "Message to the user that bluetooth is unavailable to the app")) + } + + var action: HUDTapAction? { + switch self { + case .unauthorized: + return .openAppURL(URL(string: UIApplication.openSettingsURLString)!) + case .poweredOff: + return .takeNoAction + default: + return nil + } + } +} diff --git a/Loop/Extensions/DeviceDataManager+SimpleBolusViewModelDelegate.swift b/Loop/Extensions/DeviceDataManager+SimpleBolusViewModelDelegate.swift new file mode 100644 index 0000000000..4192700ef4 --- /dev/null +++ b/Loop/Extensions/DeviceDataManager+SimpleBolusViewModelDelegate.swift @@ -0,0 +1,33 @@ +// +// DeviceDataManager+SimpleBolusViewModelDelegate.swift +// Loop +// +// Created by Pete Schwamb on 9/30/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import HealthKit +import LoopCore +import LoopKit + +extension DeviceDataManager: SimpleBolusViewModelDelegate { + func addGlucose(_ samples: [NewGlucoseSample], completion: @escaping (Swift.Result<[StoredGlucoseSample], Error>) -> Void) { + loopManager.addGlucoseSamples(samples, completion: completion) + } + + func enactBolus(units: Double, activationType: BolusActivationType) { + enactBolus(units: units, activationType: activationType) { (_) in } + } + + func computeSimpleBolusRecommendation(at date: Date, mealCarbs: HKQuantity?, manualGlucose: HKQuantity?) -> BolusDosingDecision? { + return loopManager.generateSimpleBolusRecommendation(at: date, mealCarbs: mealCarbs, manualGlucose: manualGlucose) + } + + var maximumBolus: Double { + return loopManager.settings.maximumBolus! + } + + var suspendThreshold: HKQuantity { + return loopManager.settings.suspendThreshold!.quantity + } +} diff --git a/Loop/Extensions/DiagnosticLog+Subsystem.swift b/Loop/Extensions/DiagnosticLog+Subsystem.swift new file mode 100644 index 0000000000..28944703d5 --- /dev/null +++ b/Loop/Extensions/DiagnosticLog+Subsystem.swift @@ -0,0 +1,17 @@ +// +// DiagnosticLog+Subsystem.swift +// Loop +// +// Created by Darin Krauss on 6/12/19. +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import LoopKit + +extension DiagnosticLog { + + convenience init(category: String) { + self.init(subsystem: "com.loopkit.Loop", category: category) + } + +} diff --git a/Loop/Extensions/DiagnosticLog.swift b/Loop/Extensions/DiagnosticLog.swift new file mode 100644 index 0000000000..92b1128430 --- /dev/null +++ b/Loop/Extensions/DiagnosticLog.swift @@ -0,0 +1,65 @@ +// +// DiagnosticLog.swift +// LoopKit +// +// Created by Darin Krauss on 6/12/19. +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import os.log + +public class DiagnosticLog { + + private let subsystem: String + + private let category: String + + private let log: OSLog + + public init(subsystem: String, category: String) { + self.subsystem = subsystem + self.category = category + self.log = OSLog(subsystem: subsystem, category: category) + } + + public func debug(_ message: StaticString, _ args: CVarArg...) { + log(message, type: .debug, args) + } + + public func info(_ message: StaticString, _ args: CVarArg...) { + log(message, type: .info, args) + } + + public func `default`(_ message: StaticString, _ args: CVarArg...) { + log(message, type: .default, args) + } + + public func error(_ message: StaticString, _ args: CVarArg...) { + log(message, type: .error, args) + } + + private func log(_ message: StaticString, type: OSLogType, _ args: [CVarArg]) { + switch args.count { + case 0: + os_log(message, log: log, type: type) + case 1: + os_log(message, log: log, type: type, args[0]) + case 2: + os_log(message, log: log, type: type, args[0], args[1]) + case 3: + os_log(message, log: log, type: type, args[0], args[1], args[2]) + case 4: + os_log(message, log: log, type: type, args[0], args[1], args[2], args[3]) + case 5: + os_log(message, log: log, type: type, args[0], args[1], args[2], args[3], args[4]) + default: + os_log(message, log: log, type: type, args) + } + + guard let sharedLogging = SharedLogging.instance else { + return + } + sharedLogging.log(message, subsystem: subsystem, category: category, type: type, args) + } + +} diff --git a/Loop/Extensions/Dictionary.swift b/Loop/Extensions/Dictionary.swift new file mode 100644 index 0000000000..ae508f5106 --- /dev/null +++ b/Loop/Extensions/Dictionary.swift @@ -0,0 +1,17 @@ +// +// Dictionary.swift +// Loop +// +// Created by Michael Pangburn on 7/7/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +extension Dictionary { + func compactMapValuesWithKeys(_ transform: (Element) throws -> NewValue?) rethrows -> [Key: NewValue] { + try reduce(into: [:]) { result, element in + if let newValue = try transform(element) { + result[element.key] = newValue + } + } + } +} diff --git a/Loop/Extensions/DirectoryObserver.swift b/Loop/Extensions/DirectoryObserver.swift new file mode 100644 index 0000000000..f5a384d1a7 --- /dev/null +++ b/Loop/Extensions/DirectoryObserver.swift @@ -0,0 +1,40 @@ +// +// DirectoryObserver.swift +// Loop +// +// Created by Michael Pangburn on 4/20/19. +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import Foundation + + +protocol DirectoryObserver {} +typealias DirectoryObservationToken = AnyObject + +extension DirectoryObserver { + func observeDirectory(at url: URL, updatingWith notifyOfUpdates: @escaping () -> Void) -> DirectoryObservationToken? { + return DirectoryObservation(url: url, updatingWith: notifyOfUpdates) + } +} + +private final class DirectoryObservation { + private let fileDescriptor: CInt + private let source: DispatchSourceFileSystemObject + + fileprivate init?(url: URL, updatingWith notifyOfUpdates: @escaping () -> Void) { + fileDescriptor = open(url.path, O_EVTONLY) + guard fileDescriptor != -1 else { + assertionFailure("Unable to open url: \(url)") + return nil + } + source = DispatchSource.makeFileSystemObjectSource(fileDescriptor: fileDescriptor, eventMask: .all) + source.setEventHandler(handler: notifyOfUpdates) + source.activate() + } + + deinit { + source.cancel() + close(fileDescriptor) + } +} diff --git a/Loop/Extensions/DoseStore+SimulatedCoreData.swift b/Loop/Extensions/DoseStore+SimulatedCoreData.swift new file mode 100644 index 0000000000..6036f7d08c --- /dev/null +++ b/Loop/Extensions/DoseStore+SimulatedCoreData.swift @@ -0,0 +1,166 @@ +// +// DoseStore+SimulatedCoreData.swift +// Loop +// +// Created by Darin Krauss on 6/5/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import Foundation +import HealthKit +import LoopKit + +// MARK: - Simulated Core Data + +extension DoseStore { + private var historicalEndDate: Date { Date(timeIntervalSinceNow: -.hours(24)) } + + private var simulatedBolusPerDay: Int { 8 } + private var simulatedBasalStartDateInterval: TimeInterval { .minutes(5) } + private var simulatedOtherPerDay: Int { 1 } + private var simulatedLimit: Int { 10000 } + + func generateSimulatedHistoricalPumpEvents(completion: @escaping (Error?) -> Void) { + var startDate = Calendar.current.startOfDay(for: cacheStartDate) + let endDate = Calendar.current.startOfDay(for: historicalEndDate) + var index = 0 + var simulated = [PersistedPumpEvent]() + var suspendedAt: Date? + + while startDate < endDate { + + let basalEvent: PersistedPumpEvent? + + // Suspends last for 30m + if let suspendedTime = suspendedAt, startDate.timeIntervalSince(suspendedTime) >= .minutes(30) { + basalEvent = PersistedPumpEvent.simulatedResume(date: startDate) + suspendedAt = nil + } else if Double.random(in: 0...1) > 0.98 { // 2% chance of this being a suspend + basalEvent = PersistedPumpEvent.simulatedSuspend(date: startDate) + suspendedAt = startDate + } else if Double.random(in: 0...1) < 0.98 { // 98% chance of a successful basal + let rate = [0, 0.5, 1, 1.5, 2, 6].randomElement()! + basalEvent = PersistedPumpEvent.simulatedTempBasal(date: startDate, duration: .minutes(5), rate: rate, scheduledRate: 1) + } else { + basalEvent = nil + } + + if let basalEvent = basalEvent { + simulated.append(basalEvent) + } + + if Double.random(in: 0...1) > 0.98 { // 2% chance of some other event + let eventDate = startDate.addingTimeInterval(.minutes(1)) + simulated.append([ + PersistedPumpEvent.simulatedAlarm(date: eventDate), + PersistedPumpEvent.simulatedAlarmClear(date: eventDate), + PersistedPumpEvent.simulatedRewind(date: eventDate), + PersistedPumpEvent.simulatedPrime(date: eventDate) + ].randomElement()!) + } + + if Double.random(in: 0...1) < 0.27 { // Aim for roughly 8 per day (chance = 8/288) + let eventDate = startDate.addingTimeInterval(.minutes(2)) + let amount = [0, 1.5, 2, 3.5, 5, 6].randomElement()! + simulated.append(PersistedPumpEvent.simulatedBolus(date: eventDate, amount: amount)) + } + + + // Process about a day's worth at a time + if simulated.count >= 300 { + if let error = addPumpEvents(events: simulated) { + completion(error) + return + } + simulated = [] + } + + index += 1 + startDate = startDate.addingTimeInterval(simulatedBasalStartDateInterval) + } + + completion(addPumpEvents(events: simulated)) + } + + func purgeHistoricalPumpEvents(completion: @escaping (Error?) -> Void) { + purgePumpEventObjects(before: historicalEndDate, completion: completion) + } +} + +fileprivate extension PersistedPumpEvent { + static func simulatedAlarm(date: Date) -> PersistedPumpEvent { + return simulated(date: date, type: .alarm, alarmType: .other("Simulated Other Alarm")) + } + + static func simulatedAlarmClear(date: Date) -> PersistedPumpEvent { + return simulated(date: date, type: .alarmClear) + } + + static func simulatedBasal(date: Date, duration: TimeInterval, rate: Double) -> PersistedPumpEvent { + return simulated(dose: DoseEntry(type: .basal, + startDate: date, + endDate: date.addingTimeInterval(duration), + value: rate, + unit: .unitsPerHour, + deliveredUnits: rate * duration / .hours(1))) + } + + static func simulatedBolus(date: Date, amount: Double) -> PersistedPumpEvent { + return simulated(dose: DoseEntry(type: .bolus, + startDate: date, + endDate: date.addingTimeInterval(.minutes(1)), + value: amount, + unit: .units)) + } + + static func simulatedPrime(date: Date) -> PersistedPumpEvent { + return simulated(date: date, type: .prime) + } + + static func simulatedResume(date: Date) -> PersistedPumpEvent { + return simulated(dose: DoseEntry(resumeDate: date)) + } + + static func simulatedRewind(date: Date) -> PersistedPumpEvent { + return simulated(date: date, type: .rewind) + } + + static func simulatedSuspend(date: Date) -> PersistedPumpEvent { + return simulated(dose: DoseEntry(suspendDate: date)) + } + + static func simulatedTempBasal(date: Date, duration: TimeInterval, rate: Double, scheduledRate: Double) -> PersistedPumpEvent { + return simulated(dose: DoseEntry(type: .tempBasal, + startDate: date, + endDate: date.addingTimeInterval(duration), + value: rate, + unit: .unitsPerHour, + deliveredUnits: rate * duration / .hours(1), + scheduledBasalRate: HKQuantity(unit: .internationalUnitsPerHour, doubleValue: scheduledRate))) + } + + private static func simulated(date: Date, type: PumpEventType, alarmType: PumpAlarmType? = nil) -> PersistedPumpEvent { + return PersistedPumpEvent(date: date, + persistedDate: date, + dose: nil, + isUploaded: false, + objectIDURL: URL(string: "x-coredata:///PumpEvent/\(UUID().uuidString)")!, + raw: Data(UUID().uuidString.utf8), + title: UUID().uuidString, + type: type, + automatic: nil, + alarmType: alarmType) + } + + private static func simulated(dose: DoseEntry) -> PersistedPumpEvent { + return PersistedPumpEvent(date: dose.startDate, + persistedDate: dose.startDate, + dose: dose, + isUploaded: false, + objectIDURL: URL(string: "x-coredata:///PumpEvent/\(UUID().uuidString)")!, + raw: Data(UUID().uuidString.utf8), + title: String(describing: dose), + type: dose.type.pumpEventType, + automatic: nil) + } +} diff --git a/Loop/Extensions/DoseStore.swift b/Loop/Extensions/DoseStore.swift deleted file mode 100644 index 65e01ac834..0000000000 --- a/Loop/Extensions/DoseStore.swift +++ /dev/null @@ -1,66 +0,0 @@ -// -// DoseStore.swift -// Loop -// -// Created by Nate Racklyeft on 7/31/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import InsulinKit -import MinimedKit - - -// Bridges support for MinimedKit data types -extension DoseStore { - /** - Adds and persists new pump events. - */ - func add(_ pumpEvents: [TimestampedHistoryEvent], completionHandler: @escaping (_ error: DoseStore.DoseStoreError?) -> Void) { - var events: [NewPumpEvent] = [] - var lastTempBasalAmount: DoseEntry? - var title: String - - for event in pumpEvents { - var dose: DoseEntry? - - switch event.pumpEvent { - case let bolus as BolusNormalPumpEvent: - let unit: DoseUnit - - switch bolus.type { - case .Normal: - unit = .units - case .Square: - unit = .unitsPerHour - } - - dose = DoseEntry(type: .bolus, startDate: event.date, endDate: event.date.addingTimeInterval(bolus.duration), value: bolus.amount, unit: unit) - case is SuspendPumpEvent: - dose = DoseEntry(suspendDate: event.date) - case is ResumePumpEvent: - dose = DoseEntry(resumeDate: event.date) - case let temp as TempBasalPumpEvent: - if case .Absolute = temp.rateType { - lastTempBasalAmount = DoseEntry(type: .tempBasal, startDate: event.date, value: temp.rate, unit: .unitsPerHour) - } - case let temp as TempBasalDurationPumpEvent: - if let amount = lastTempBasalAmount, amount.startDate == event.date { - dose = DoseEntry( - type: .tempBasal, - startDate: event.date, - endDate: event.date.addingTimeInterval(TimeInterval(minutes: Double(temp.duration))), - value: amount.value, - unit: amount.unit - ) - } - default: - break - } - - title = String(describing: event.pumpEvent) - events.append(NewPumpEvent(date: event.date, dose: dose, isMutable: event.isMutable(), raw: event.pumpEvent.rawData, title: title)) - } - - addPumpEvents(events, completionHandler: completionHandler) - } -} diff --git a/Loop/Extensions/DosingDecisionStore+SimulatedCoreData.swift b/Loop/Extensions/DosingDecisionStore+SimulatedCoreData.swift new file mode 100644 index 0000000000..53f81c5209 --- /dev/null +++ b/Loop/Extensions/DosingDecisionStore+SimulatedCoreData.swift @@ -0,0 +1,204 @@ +// +// DosingDecisionStore+SimulatedCoreData.swift +// Loop +// +// Created by Darin Krauss on 6/5/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import Foundation +import HealthKit +import LoopKit + +// MARK: - Simulated Core Data + +extension DosingDecisionStore { + private var historicalEndDate: Date { Date(timeIntervalSinceNow: -.hours(24)) } + + private var simulatedStartDateInterval: TimeInterval { .minutes(5) } + private var simulatedLimit: Int { 10000 } + + func generateSimulatedHistoricalDosingDecisionObjects(completion: @escaping (Error?) -> Void) { + var startDate = Calendar.current.startOfDay(for: expireDate) + let endDate = Calendar.current.startOfDay(for: historicalEndDate) + var simulated = [StoredDosingDecision]() + + while startDate < endDate { + simulated.append(StoredDosingDecision.simulated(date: startDate)) + + if simulated.count >= simulatedLimit { + if let error = addSimulatedHistoricalDosingDecisionObjects(dosingDecisions: simulated) { + completion(error) + return + } + simulated = [] + } + + startDate = startDate.addingTimeInterval(simulatedStartDateInterval) + } + + completion(addSimulatedHistoricalDosingDecisionObjects(dosingDecisions: simulated)) + } + + private func addSimulatedHistoricalDosingDecisionObjects(dosingDecisions: [StoredDosingDecision]) -> Error? { + var addError: Error? + let semaphore = DispatchSemaphore(value: 0) + addStoredDosingDecisions(dosingDecisions: dosingDecisions) { error in + addError = error + semaphore.signal() + } + semaphore.wait() + return addError + } + + func purgeHistoricalDosingDecisionObjects(completion: @escaping (Error?) -> Void) { + purgeDosingDecisions(before: historicalEndDate, completion: completion) + } +} + +fileprivate extension StoredDosingDecision { + static func simulated(date: Date) -> StoredDosingDecision { + let controllerTimeZone = TimeZone(identifier: "America/Los_Angeles")! + let scheduleTimeZone = TimeZone(secondsFromGMT: TimeZone(identifier: "America/Phoenix")!.secondsFromGMT())! + let reason = "simulatedCoreData" + let settings = StoredDosingDecision.Settings(syncIdentifier: UUID(uuidString: "18CF3948-0B3D-4B12-8BFE-14986B0E6784")!) + let scheduleOverride = TemporaryScheduleOverride(context: .preMeal, + settings: TemporaryScheduleOverrideSettings(unit: .milligramsPerDeciliter, + targetRange: DoubleRange(minValue: 80.0, + maxValue: 90.0), + insulinNeedsScaleFactor: 1.5), + startDate: date.addingTimeInterval(-.hours(0.5)), + duration: .finite(.hours(1)), + enactTrigger: .local, + syncIdentifier: UUID()) + let controllerStatus = StoredDosingDecision.ControllerStatus(batteryState: .charging, + batteryLevel: 0.5) + let pumpManagerStatus = PumpManagerStatus(timeZone: scheduleTimeZone, + device: HKDevice(name: "Pump Name", + manufacturer: "Pump Manufacturer", + model: "Pump Model", + hardwareVersion: "Pump Hardware Version", + firmwareVersion: "Pump Firmware Version", + softwareVersion: "Pump Software Version", + localIdentifier: "Pump Local Identifier", + udiDeviceIdentifier: "Pump UDI Device Identifier"), + pumpBatteryChargeRemaining: 0.75, + basalDeliveryState: .initiatingTempBasal, + bolusState: .noBolus, + insulinType: .novolog) + let cgmManagerStatus = CGMManagerStatus(hasValidSensorSession: true, + lastCommunicationDate: date.addingTimeInterval(-.minutes(1)), + device: HKDevice(name: "CGM Name", + manufacturer: "CGM Manufacturer", + model: "CGM Model", + hardwareVersion: "CGM Hardware Version", + firmwareVersion: "CGM Firmware Version", + softwareVersion: "CGM Software Version", + localIdentifier: "CGM Local Identifier", + udiDeviceIdentifier: "CGM UDI Device Identifier")) + let lastReservoirValue = StoredDosingDecision.LastReservoirValue(startDate: date.addingTimeInterval(-.minutes(1)), + unitVolume: 113.3) + var historicalGlucose = [HistoricalGlucoseValue]() + for minutes in stride(from: -120.0, to: 0.0, by: 5.0) { + historicalGlucose.append(HistoricalGlucoseValue(startDate: date.addingTimeInterval(.minutes(minutes)), + quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 125 + minutes / 5))) + } + let originalCarbEntry = StoredCarbEntry(startDate: date.addingTimeInterval(-.minutes(15)), + quantity: HKQuantity(unit: .gram(), doubleValue: 15), + uuid: UUID(uuidString: "C86DEB61-68E9-464E-9DD5-96A9CB445FD3")!, + provenanceIdentifier: Bundle.main.bundleIdentifier!, + syncIdentifier: "2B03D96C-6F5D-4140-99CD-80C3E64D6010", + syncVersion: 1, + foodType: "Simulated", + absorptionTime: .hours(3), + createdByCurrentApp: true, + userCreatedDate: date.addingTimeInterval(-.minutes(15)), + userUpdatedDate: date.addingTimeInterval(-.minutes(1))) + let carbEntry = StoredCarbEntry(startDate: date.addingTimeInterval(-.minutes(1)), + quantity: HKQuantity(unit: .gram(), doubleValue: 25), + uuid: UUID(uuidString: "71B699D7-0E8F-4B13-B7A1-E7751EB78E74")!, + provenanceIdentifier: Bundle.main.bundleIdentifier!, + syncIdentifier: "2B03D96C-6F5D-4140-99CD-80C3E64D6010", + syncVersion: 2, + foodType: "Simulated", + absorptionTime: .hours(5), + createdByCurrentApp: true, + userCreatedDate: date.addingTimeInterval(-.minutes(1)), + userUpdatedDate: nil) + let manualGlucoseSample = StoredGlucoseSample(uuid: UUID(uuidString: "71B699D7-0E8F-4B13-B7A1-E7751EB78E74")!, + provenanceIdentifier: Bundle.main.bundleIdentifier!, + syncIdentifier: "2A67A303-1234-4CB8-8263-79498265368E", + syncVersion: 1, + startDate: date.addingTimeInterval(-.minutes(1)), + quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 123.45), + condition: nil, + trend: .up, + trendRate: HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: 3.4), + isDisplayOnly: false, + wasUserEntered: true, + device: HKDevice(name: "Device Name", + manufacturer: "Device Manufacturer", + model: "Device Model", + hardwareVersion: "Device Hardware Version", + firmwareVersion: "Device Firmware Version", + softwareVersion: "Device Software Version", + localIdentifier: "Device Local Identifier", + udiDeviceIdentifier: "Device UDI Device Identifier"), + healthKitEligibleDate: nil) + let carbsOnBoard = CarbValue(startDate: date, + endDate: date.addingTimeInterval(.minutes(5)), + value: 45.5) + let insulinOnBoard = InsulinValue(startDate: date, value: 1.5) + let glucoseTargetRangeSchedule = GlucoseRangeSchedule(rangeSchedule: DailyQuantitySchedule(unit: .milligramsPerDeciliter, + dailyItems: [RepeatingScheduleValue(startTime: .hours(0), value: DoubleRange(minValue: 100.0, maxValue: 110.0)), + RepeatingScheduleValue(startTime: .hours(8), value: DoubleRange(minValue: 95.0, maxValue: 105.0)), + RepeatingScheduleValue(startTime: .hours(10), value: DoubleRange(minValue: 90.0, maxValue: 100.0)), + RepeatingScheduleValue(startTime: .hours(12), value: DoubleRange(minValue: 95.0, maxValue: 105.0)), + RepeatingScheduleValue(startTime: .hours(14), value: DoubleRange(minValue: 95.0, maxValue: 105.0)), + RepeatingScheduleValue(startTime: .hours(16), value: DoubleRange(minValue: 100.0, maxValue: 110.0)), + RepeatingScheduleValue(startTime: .hours(18), value: DoubleRange(minValue: 90.0, maxValue: 100.0)), + RepeatingScheduleValue(startTime: .hours(21), value: DoubleRange(minValue: 110.0, maxValue: 120.0))], + timeZone: scheduleTimeZone)!) + var predictedGlucose = [PredictedGlucoseValue]() + for minutes in stride(from: 5.0, to: 360.0, by: 5.0) { + predictedGlucose.append(PredictedGlucoseValue(startDate: date.addingTimeInterval(.minutes(minutes)), + quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 125 + minutes / 5))) + } + let automaticDoseRecommendation = AutomaticDoseRecommendation(basalAdjustment: TempBasalRecommendation(unitsPerHour: 0.75, + duration: .minutes(30)), + bolusUnits: 1.25) + let manualBolusRecommendation = ManualBolusRecommendationWithDate(recommendation: ManualBolusRecommendation(amount: 0.2, + pendingInsulin: 0.75, + notice: .predictedGlucoseBelowTarget(minGlucose: PredictedGlucoseValue(startDate: date.addingTimeInterval(.minutes(30)), + quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 95.0)))), + date: date.addingTimeInterval(-.minutes(1))) + let manualBolusRequested = 0.5 + let warnings: [Issue] = [Issue(id: "one"), + Issue(id: "two", details: ["size": "small"])] + let errors: [Issue] = [Issue(id: "alpha"), + Issue(id: "bravo", details: ["size": "tiny"])] + + return StoredDosingDecision(date: date, + controllerTimeZone: controllerTimeZone, + reason: reason, + settings: settings, + scheduleOverride: scheduleOverride, + controllerStatus: controllerStatus, + pumpManagerStatus: pumpManagerStatus, + cgmManagerStatus: cgmManagerStatus, + lastReservoirValue: lastReservoirValue, + historicalGlucose: historicalGlucose, + originalCarbEntry: originalCarbEntry, + carbEntry: carbEntry, + manualGlucoseSample: manualGlucoseSample, + carbsOnBoard: carbsOnBoard, + insulinOnBoard: insulinOnBoard, + glucoseTargetRangeSchedule: glucoseTargetRangeSchedule, + predictedGlucose: predictedGlucose, + automaticDoseRecommendation: automaticDoseRecommendation, + manualBolusRecommendation: manualBolusRecommendation, + manualBolusRequested: manualBolusRequested, + warnings: warnings, + errors: errors) + } +} diff --git a/Loop/Extensions/DosingDecisionStore.swift b/Loop/Extensions/DosingDecisionStore.swift new file mode 100644 index 0000000000..74d113dada --- /dev/null +++ b/Loop/Extensions/DosingDecisionStore.swift @@ -0,0 +1,44 @@ +// +// DosingDecisionStore.swift +// Loop +// +// Created by Darin Krauss on 10/22/21. +// Copyright © 2021 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit + +extension StoredDosingDecision { + mutating func appendWarning(_ warning: LoopWarning) { warnings.append(warning.issue) } + mutating func appendWarnings(_ warnings: [LoopWarning]) { warnings.forEach{ appendWarning($0) } } + + mutating func appendError(_ error: LoopError) { errors.append(error.issue) } + mutating func appendErrors(_ errors: [LoopError]) { errors.forEach{ appendError($0) } } +} + +enum StoredDosingDecisionIssue { + static func description(for error: Error?) -> String? { + guard let error = error else { + return nil + } + if let localizedError = error as? LocalizedError { + return localizedError.errorDescription ?? String(describing: error) + } else { + return String(describing: error) + } + } + + static func description(for date: Date?) -> String? { + guard let date = date else { + return nil + } + return Self.dateFormatter.string(from: date) + } + + static var dateFormatter: ISO8601DateFormatter = { + let dateFormatter = ISO8601DateFormatter() + dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return dateFormatter + }() +} diff --git a/Loop/Extensions/EditMode.swift b/Loop/Extensions/EditMode.swift new file mode 100644 index 0000000000..b1ff303a43 --- /dev/null +++ b/Loop/Extensions/EditMode.swift @@ -0,0 +1,19 @@ +// +// EditMode.swift +// Loop +// +// Created by Noah Brauner on 7/13/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +extension EditMode { + var title: String { + self == .active ? "Done" : "Edit" + } + + mutating func toggle() { + self = self == .active ? .inactive : .active + } +} diff --git a/Loop/Extensions/GlucoseStore+SimulatedCoreData.swift b/Loop/Extensions/GlucoseStore+SimulatedCoreData.swift new file mode 100644 index 0000000000..e5cc830a70 --- /dev/null +++ b/Loop/Extensions/GlucoseStore+SimulatedCoreData.swift @@ -0,0 +1,101 @@ +// +// GlucoseStore+SimulatedCoreData.swift +// Loop +// +// Created by Darin Krauss on 6/4/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import Foundation +import HealthKit +import LoopKit + +// MARK: - Simulated Core Data + +extension GlucoseStore { + private var historicalEndDate: Date { Date(timeIntervalSinceNow: -.hours(24)) } + + private var simulatedStartDateInterval: TimeInterval { .minutes(5) } + private var simulatedValueBase: Double { 110 } + private var simulatedValueAmplitude: Double { 40 } + private var simulatedValueIncrement: Double { 2.0 * .pi / 72.0 } // 6 hour period + private var simulatedLimit: Int { 10000 } + + func generateSimulatedHistoricalGlucoseObjects(completion: @escaping (Error?) -> Void) { + var startDate = Calendar.current.startOfDay(for: earliestCacheDate) + let endDate = Calendar.current.startOfDay(for: historicalEndDate) + var value = 0.0 + var simulated = [NewGlucoseSample]() + + while startDate < endDate { + let previous = simulatedValueBase + simulatedValueAmplitude * sin(value - simulatedValueIncrement) + let new = simulatedValueBase + simulatedValueAmplitude * sin(value) + let trendRateValue = new - previous + let trend: GlucoseTrend? = { + switch trendRateValue { + case -0.01...0.01: + return .flat + case -2 ..< -0.01: + return .down + case -5 ..< -2: + return .downDown + case -Double.greatestFiniteMagnitude ..< -5: + return .downDownDown + case 0.01...2: + return .up + case 2...5: + return .upUp + case 5...Double.greatestFiniteMagnitude: + return .upUpUp + default: + return nil + } + }() + simulated.append(NewGlucoseSample.simulated(date: startDate, + quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: new), + trend: trend, + trendRate: HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: trendRateValue))) + + if simulated.count >= simulatedLimit { + if let error = addSimulatedHistoricalGlucoseObjects(samples: simulated) { + completion(error) + return + } + simulated = [] + } + + value += simulatedValueIncrement + startDate = startDate.addingTimeInterval(simulatedStartDateInterval) + } + + completion(addSimulatedHistoricalGlucoseObjects(samples: simulated)) + } + + private func addSimulatedHistoricalGlucoseObjects(samples: [NewGlucoseSample]) -> Error? { + var addError: Error? + let semaphore = DispatchSemaphore(value: 0) + addNewGlucoseSamples(samples: samples) { error in + addError = error + semaphore.signal() + } + semaphore.wait() + return addError + } + + func purgeHistoricalGlucoseObjects(completion: @escaping (Error?) -> Void) { + purgeCachedGlucoseObjects(before: historicalEndDate, completion: completion) + } +} + +fileprivate extension NewGlucoseSample { + static func simulated(date: Date, quantity: HKQuantity, trend: GlucoseTrend?, trendRate: HKQuantity?) -> NewGlucoseSample { + return NewGlucoseSample(date: date, + quantity: quantity, + condition: nil, + trend: trend, + trendRate: trendRate, + isDisplayOnly: false, + wasUserEntered: false, + syncIdentifier: UUID().uuidString) + } +} diff --git a/Loop/Extensions/LoopUIColorPalette+Default.swift b/Loop/Extensions/LoopUIColorPalette+Default.swift new file mode 100644 index 0000000000..39e4132e97 --- /dev/null +++ b/Loop/Extensions/LoopUIColorPalette+Default.swift @@ -0,0 +1,20 @@ +// +// LoopUIColorPalette+Default.swift +// LoopUI +// +// Created by Darin Krauss on 1/14/21. +// Copyright © 2021 LoopKit Authors. All rights reserved. +// + +import LoopKitUI + +extension LoopUIColorPalette { + public static var `default`: LoopUIColorPalette { + return LoopUIColorPalette(guidanceColors: .default, + carbTintColor: .carbTintColor, + glucoseTintColor: .glucoseTintColor, + insulinTintColor: .insulinTintColor, + loopStatusColorPalette: .loopStatus, + chartColorPalette: .primary) + } +} diff --git a/Loop/Extensions/MealBolusNightscoutTreatment.swift b/Loop/Extensions/MealBolusNightscoutTreatment.swift deleted file mode 100644 index 43b1dda056..0000000000 --- a/Loop/Extensions/MealBolusNightscoutTreatment.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// NightscoutTreatment.swift -// Loop -// -// Created by Pete Schwamb on 10/7/16. -// Copyright © 2016 LoopKit Authors. All rights reserved. -// - -import Foundation -import NightscoutUploadKit -import CarbKit -import HealthKit - -extension MealBolusNightscoutTreatment { - public convenience init(carbEntry: CarbEntry) { - let carbGrams = carbEntry.quantity.doubleValue(for: HKUnit.gram()) - self.init(timestamp: carbEntry.startDate, enteredBy: "loop://\(UIDevice.current.name)", id: carbEntry.externalId, carbs: lround(carbGrams), absorptionTime: carbEntry.absorptionTime) - } -} diff --git a/Loop/Extensions/NSDateFormatter.swift b/Loop/Extensions/NSDateFormatter.swift deleted file mode 100644 index f353a4ddb5..0000000000 --- a/Loop/Extensions/NSDateFormatter.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// NSDateFormatter.swift -// Naterade -// -// Created by Nathan Racklyeft on 11/25/15. -// Copyright © 2015 Nathan Racklyeft. All rights reserved. -// - -import Foundation - - -extension DateFormatter { - static func ISO8601StrictDateFormatter() -> Self { - let dateFormatter = self.init() - - dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ" - dateFormatter.locale = Locale(identifier: "en_US_POSIX") - - return dateFormatter - } -} diff --git a/Loop/Extensions/NSUserDefaults.swift b/Loop/Extensions/NSUserDefaults.swift deleted file mode 100644 index cc13e51eb5..0000000000 --- a/Loop/Extensions/NSUserDefaults.swift +++ /dev/null @@ -1,248 +0,0 @@ -// -// NSUserDefaults.swift -// Naterade -// -// Created by Nathan Racklyeft on 8/30/15. -// Copyright © 2015 Nathan Racklyeft. All rights reserved. -// - -import Foundation -import LoopKit -import MinimedKit - -extension UserDefaults { - - private enum Key: String { - case BasalRateSchedule = "com.loudnate.Naterade.BasalRateSchedule" - case CarbRatioSchedule = "com.loudnate.Naterade.CarbRatioSchedule" - case ConnectedPeripheralIDs = "com.loudnate.Naterade.ConnectedPeripheralIDs" - case DosingEnabled = "com.loudnate.Naterade.DosingEnabled" - case InsulinActionDuration = "com.loudnate.Naterade.InsulinActionDuration" - case InsulinSensitivitySchedule = "com.loudnate.Naterade.InsulinSensitivitySchedule" - case G4ReceiverEnabled = "com.loudnate.Loop.G4ReceiverEnabled" - case G5TransmitterID = "com.loudnate.Naterade.TransmitterID" - case GlucoseTargetRangeSchedule = "com.loudnate.Naterade.GlucoseTargetRangeSchedule" - case MaximumBasalRatePerHour = "com.loudnate.Naterade.MaximumBasalRatePerHour" - case MaximumBolus = "com.loudnate.Naterade.MaximumBolus" - case PreferredInsulinDataSource = "com.loudnate.Loop.PreferredInsulinDataSource" - case PumpID = "com.loudnate.Naterade.PumpID" - case PumpModelNumber = "com.loudnate.Naterade.PumpModelNumber" - case PumpRegion = "com.loopkit.Loop.PumpRegion" - case PumpTimeZone = "com.loudnate.Naterade.PumpTimeZone" - case RetrospectiveCorrectionEnabled = "com.loudnate.Loop.RetrospectiveCorrectionEnabled" - case BatteryChemistry = "com.loopkit.Loop.BatteryChemistry" - } - - var basalRateSchedule: BasalRateSchedule? { - get { - if let rawValue = dictionary(forKey: Key.BasalRateSchedule.rawValue) { - return BasalRateSchedule(rawValue: rawValue) - } else { - return nil - } - } - set { - set(newValue?.rawValue, forKey: Key.BasalRateSchedule.rawValue) - } - } - - var carbRatioSchedule: CarbRatioSchedule? { - get { - if let rawValue = dictionary(forKey: Key.CarbRatioSchedule.rawValue) { - return CarbRatioSchedule(rawValue: rawValue) - } else { - return nil - } - } - set { - set(newValue?.rawValue, forKey: Key.CarbRatioSchedule.rawValue) - } - } - - var connectedPeripheralIDs: [String] { - get { - return array(forKey: Key.ConnectedPeripheralIDs.rawValue) as? [String] ?? [] - } - set { - set(newValue, forKey: Key.ConnectedPeripheralIDs.rawValue) - } - } - - var dosingEnabled: Bool { - get { - return bool(forKey: Key.DosingEnabled.rawValue) - } - set { - set(newValue, forKey: Key.DosingEnabled.rawValue) - } - } - - var insulinActionDuration: TimeInterval? { - get { - let value = double(forKey: Key.InsulinActionDuration.rawValue) - - return value > 0 ? value : nil - } - set { - if let insulinActionDuration = newValue { - set(insulinActionDuration, forKey: Key.InsulinActionDuration.rawValue) - } else { - removeObject(forKey: Key.InsulinActionDuration.rawValue) - } - } - } - - var insulinSensitivitySchedule: InsulinSensitivitySchedule? { - get { - if let rawValue = dictionary(forKey: Key.InsulinSensitivitySchedule.rawValue) { - return InsulinSensitivitySchedule(rawValue: rawValue) - } else { - return nil - } - } - set { - set(newValue?.rawValue, forKey: Key.InsulinSensitivitySchedule.rawValue) - } - } - - var glucoseTargetRangeSchedule: GlucoseRangeSchedule? { - get { - if let rawValue = dictionary(forKey: Key.GlucoseTargetRangeSchedule.rawValue) { - return GlucoseRangeSchedule(rawValue: rawValue) - } else { - return nil - } - } - set { - set(newValue?.rawValue, forKey: Key.GlucoseTargetRangeSchedule.rawValue) - } - } - - var maximumBasalRatePerHour: Double? { - get { - let value = double(forKey: Key.MaximumBasalRatePerHour.rawValue) - - return value > 0 ? value : nil - } - set { - if let maximumBasalRatePerHour = newValue { - set(maximumBasalRatePerHour, forKey: Key.MaximumBasalRatePerHour.rawValue) - } else { - removeObject(forKey: Key.MaximumBasalRatePerHour.rawValue) - } - } - } - - var maximumBolus: Double? { - get { - let value = double(forKey: Key.MaximumBolus.rawValue) - - return value > 0 ? value : nil - } - set { - if let maximumBolus = newValue { - set(maximumBolus, forKey: Key.MaximumBolus.rawValue) - } else { - removeObject(forKey: Key.MaximumBolus.rawValue) - } - } - } - - var preferredInsulinDataSource: InsulinDataSource? { - get { - return InsulinDataSource(rawValue: integer(forKey: Key.PreferredInsulinDataSource.rawValue)) - } - set { - if let preferredInsulinDataSource = newValue { - set(preferredInsulinDataSource.rawValue, forKey: Key.PreferredInsulinDataSource.rawValue) - } else { - removeObject(forKey: Key.PreferredInsulinDataSource.rawValue) - } - } - } - - var pumpID: String? { - get { - return string(forKey: Key.PumpID.rawValue) - } - set { - set(newValue, forKey: Key.PumpID.rawValue) - } - } - - var pumpModelNumber: String? { - get { - return string(forKey: Key.PumpModelNumber.rawValue) - } - set { - set(newValue, forKey: Key.PumpModelNumber.rawValue) - } - } - - var pumpRegion: PumpRegion? { - get { - // Defaults to 0 / northAmerica - return PumpRegion(rawValue: integer(forKey: Key.PumpRegion.rawValue)) - } - set { - set(newValue?.rawValue, forKey: Key.PumpRegion.rawValue) - } - } - - var pumpTimeZone: TimeZone? { - get { - if let offset = object(forKey: Key.PumpTimeZone.rawValue) as? NSNumber { - return TimeZone(secondsFromGMT: offset.intValue) - } else { - return nil - } - } set { - if let value = newValue { - set(NSNumber(value: value.secondsFromGMT() as Int), forKey: Key.PumpTimeZone.rawValue) - } else { - removeObject(forKey: Key.PumpTimeZone.rawValue) - } - } - } - - var receiverEnabled: Bool { - get { - return bool(forKey: Key.G4ReceiverEnabled.rawValue) - } - set { - set(newValue, forKey: Key.G4ReceiverEnabled.rawValue) - } - } - - var retrospectiveCorrectionEnabled: Bool { - get { - return bool(forKey: Key.RetrospectiveCorrectionEnabled.rawValue) - } - set { - set(newValue, forKey: Key.RetrospectiveCorrectionEnabled.rawValue) - } - } - - var transmitterID: String? { - get { - return string(forKey: Key.G5TransmitterID.rawValue) - } - set { - set(newValue, forKey: Key.G5TransmitterID.rawValue) - } - } - - var batteryChemistry: BatteryChemistryType? { - get { - return BatteryChemistryType(rawValue: integer(forKey: Key.BatteryChemistry.rawValue)) - } - set { - if let batteryChemistry = newValue { - set(batteryChemistry.rawValue, forKey: Key.BatteryChemistry.rawValue) - } else { - removeObject(forKey: Key.BatteryChemistry.rawValue) - } - } - } - -} diff --git a/Loop/Extensions/Optional.swift b/Loop/Extensions/Optional.swift new file mode 100644 index 0000000000..9bd4e4432e --- /dev/null +++ b/Loop/Extensions/Optional.swift @@ -0,0 +1,14 @@ +// +// Optional.swift +// Loop +// +// Created by Michael Pangburn on 5/19/19. +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +extension Optional { + /// Returns `nil` if the value is `nil` or if it fails the predicate. + func filter(_ shouldKeep: (Wrapped) throws -> Bool) rethrows -> Optional { + return try flatMap { try shouldKeep($0) ? $0 : nil } + } +} diff --git a/Loop/Extensions/OutputStream.swift b/Loop/Extensions/OutputStream.swift new file mode 100644 index 0000000000..11a1c3a85f --- /dev/null +++ b/Loop/Extensions/OutputStream.swift @@ -0,0 +1,40 @@ +// +// OutputStream.swift +// Loop +// +// Created by Darin Krauss on 8/28/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import Foundation + +extension OutputStream { + func write(_ string: String) throws { + if let streamError = streamError { + throw streamError + } + let bytes = [UInt8](string.utf8) + write(bytes, maxLength: bytes.count) + if let streamError = streamError { + throw streamError + } + } + + func write(_ data: Data) throws { + if let streamError = streamError { + throw streamError + } + if data.isEmpty { + return + } + _ = data.withUnsafeBytes { (unsafeRawBuffer: UnsafeRawBufferPointer) -> UInt8 in + if let unsafe = unsafeRawBuffer.baseAddress?.assumingMemoryBound(to: UInt8.self) { + write(unsafe, maxLength: unsafeRawBuffer.count) + } + return 0 + } + if let streamError = streamError { + throw streamError + } + } +} diff --git a/Loop/Extensions/OverrideSelectionViewController.swift b/Loop/Extensions/OverrideSelectionViewController.swift new file mode 100644 index 0000000000..bbe072b813 --- /dev/null +++ b/Loop/Extensions/OverrideSelectionViewController.swift @@ -0,0 +1,13 @@ +// +// OverrideSelectionViewController.swift +// Loop +// +// Created by Michael Pangburn on 1/27/19. +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import LoopKitUI +import LoopCore + + +extension OverrideSelectionViewController: IdentifiableClass { } diff --git a/Loop/Extensions/PersistentDeviceLog+SimulatedCoreData.swift b/Loop/Extensions/PersistentDeviceLog+SimulatedCoreData.swift new file mode 100644 index 0000000000..d39848337d --- /dev/null +++ b/Loop/Extensions/PersistentDeviceLog+SimulatedCoreData.swift @@ -0,0 +1,57 @@ +// +// PersistentDeviceLog+SimulatedCoreData.swift +// Loop +// +// Created by Darin Krauss on 6/4/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit + +// MARK: - Simulated Core Data + +extension PersistentDeviceLog { + private var historicalEndDate: Date { Date(timeIntervalSinceNow: -.hours(24)) } + + private var simulatedPerHour: Int { 250 } + private var simulatedLimit: Int { 10000 } + + func generateSimulatedHistoricalDeviceLogEntries(completion: @escaping (Error?) -> Void) { + var startDate = Calendar.current.startOfDay(for: earliestLogEntryDate) + let endDate = Calendar.current.startOfDay(for: historicalEndDate) + var simulated = [StoredDeviceLogEntry]() + + while startDate < endDate { + for index in 0..= simulatedLimit { + if let error = addStoredDeviceLogEntries(entries: simulated) { + completion(error) + return + } + simulated = [] + } + + startDate = Calendar.current.date(byAdding: .hour, value: 1, to: startDate)! + } + + completion(addStoredDeviceLogEntries(entries: simulated)) + } + + func purgeHistoricalDeviceLogEntries(completion: @escaping (Error?) -> Void) { + purgeLogEntries(before: historicalEndDate, completion: completion) + } +} + +fileprivate extension StoredDeviceLogEntry { + static func simulated(timestamp: Date) -> StoredDeviceLogEntry { + return StoredDeviceLogEntry(type: .connection, + managerIdentifier: "SimulatedMId", + deviceIdentifier: "SimulatedDId", + message: "This is an simulated message for the PersistentDeviceLog. In an analysis performed on June 1, 2020, the current average length of these messages is about 225 characters. This string should also be approximately that length.", + timestamp: timestamp) + } +} diff --git a/Loop/Extensions/PumpManagerError.swift b/Loop/Extensions/PumpManagerError.swift new file mode 100644 index 0000000000..79c6c4e87a --- /dev/null +++ b/Loop/Extensions/PumpManagerError.swift @@ -0,0 +1,40 @@ +// +// PumpManagerError.swift +// Loop +// +// Created by Darin Krauss on 5/8/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import LoopKit + +extension PumpManagerError { + var issueId: String { + switch self { + case .configuration: + return "configuration" + case .connection: + return "connection" + case .communication: + return "communication" + case .deviceState: + return "deviceState" + case .uncertainDelivery: + return "uncertainDelivery" + } + } + + var issueDetails: [String: String] { + var details = ["detail": issueId] + switch self { + case .configuration(let localizedError), + .connection(let localizedError), + .communication(let localizedError), + .deviceState(let localizedError): + details["error"] = StoredDosingDecisionIssue.description(for: localizedError) + default: + break + } + return details + } +} diff --git a/Loop/Extensions/RangeReplaceableCollection.swift b/Loop/Extensions/RangeReplaceableCollection.swift new file mode 100644 index 0000000000..b27cf69f7a --- /dev/null +++ b/Loop/Extensions/RangeReplaceableCollection.swift @@ -0,0 +1,20 @@ +// +// RangeReplaceableCollection.swift +// Loop +// +// Created by Michael Pangburn on 3/6/19. +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +extension RangeReplaceableCollection where Element: Equatable { + /// Returns `true` if the element was removed, or `false` if it is not present in the collection. + @discardableResult + mutating func remove(_ element: Element) -> Bool { + guard let index = self.firstIndex(of: element) else { + return false + } + + remove(at: index) + return true + } +} diff --git a/Loop/Extensions/SettingsStore+SimulatedCoreData.swift b/Loop/Extensions/SettingsStore+SimulatedCoreData.swift new file mode 100644 index 0000000000..80c990bb38 --- /dev/null +++ b/Loop/Extensions/SettingsStore+SimulatedCoreData.swift @@ -0,0 +1,175 @@ +// +// SettingsStore+SimulatedCoreData.swift +// Loop +// +// Created by Darin Krauss on 6/5/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import Foundation +import HealthKit +import LoopKit + +// MARK: - Simulated Core Data + +extension SettingsStore { + private var historicalEndDate: Date { Date(timeIntervalSinceNow: -.hours(24)) } + + private var simulatedPerDay: Int { 2 } + private var simulatedLimit: Int { 10000 } + + func generateSimulatedHistoricalSettingsObjects(completion: @escaping (Error?) -> Void) { + var startDate = Calendar.current.startOfDay(for: expireDate) + let endDate = Calendar.current.startOfDay(for: historicalEndDate) + var simulated = [StoredSettings]() + + while startDate < endDate { + for index in 0..= simulatedLimit { + if let error = addSimulatedHistoricalSettingsObjects(settings: simulated) { + completion(error) + return + } + simulated = [] + } + + startDate = Calendar.current.date(byAdding: .day, value: 1, to: startDate)! + } + + completion(addSimulatedHistoricalSettingsObjects(settings: simulated)) + } + + private func addSimulatedHistoricalSettingsObjects(settings: [StoredSettings]) -> Error? { + var addError: Error? + let semaphore = DispatchSemaphore(value: 0) + addStoredSettings(settings: settings) { error in + addError = error + semaphore.signal() + } + semaphore.wait() + return addError + } + + func purgeHistoricalSettingsObjects(completion: @escaping (Error?) -> Void) { + purgeSettings(before: historicalEndDate, completion: completion) + } +} + +fileprivate extension StoredSettings { + static func simulated(date: Date) -> StoredSettings { + let controllerTimeZone = TimeZone(identifier: "America/Los_Angeles")! + let scheduleTimeZone = TimeZone(secondsFromGMT: TimeZone(identifier: "America/Phoenix")!.secondsFromGMT())! + let glucoseTargetRangeSchedule = GlucoseRangeSchedule(rangeSchedule: DailyQuantitySchedule(unit: .milligramsPerDeciliter, + dailyItems: [RepeatingScheduleValue(startTime: .hours(0), value: DoubleRange(minValue: 100.0, maxValue: 110.0)), + RepeatingScheduleValue(startTime: .hours(8), value: DoubleRange(minValue: 95.0, maxValue: 105.0)), + RepeatingScheduleValue(startTime: .hours(10), value: DoubleRange(minValue: 90.0, maxValue: 100.0)), + RepeatingScheduleValue(startTime: .hours(12), value: DoubleRange(minValue: 95.0, maxValue: 105.0)), + RepeatingScheduleValue(startTime: .hours(14), value: DoubleRange(minValue: 95.0, maxValue: 105.0)), + RepeatingScheduleValue(startTime: .hours(16), value: DoubleRange(minValue: 100.0, maxValue: 110.0)), + RepeatingScheduleValue(startTime: .hours(18), value: DoubleRange(minValue: 90.0, maxValue: 100.0)), + RepeatingScheduleValue(startTime: .hours(21), value: DoubleRange(minValue: 110.0, maxValue: 120.0))], + timeZone: scheduleTimeZone)!, + override: GlucoseRangeSchedule.Override(value: DoubleRange(minValue: 80.0, maxValue: 90.0), + start: date.addingTimeInterval(-.minutes(30)), + end: date.addingTimeInterval(.minutes(30)))) + let preMealOverride = TemporaryScheduleOverride(context: .preMeal, + settings: TemporaryScheduleOverrideSettings(unit: .milligramsPerDeciliter, + targetRange: DoubleRange(minValue: 80.0, maxValue: 90.0), + insulinNeedsScaleFactor: 0.5), + startDate: date.addingTimeInterval(-.minutes(30)), + duration: .finite(.minutes(60)), + enactTrigger: .local, + syncIdentifier: UUID()) + let basalRateSchedule = BasalRateSchedule(dailyItems: [RepeatingScheduleValue(startTime: .hours(0), value: 1.0), + RepeatingScheduleValue(startTime: .hours(8), value: 1.125), + RepeatingScheduleValue(startTime: .hours(10), value: 1.25), + RepeatingScheduleValue(startTime: .hours(12), value: 1.5), + RepeatingScheduleValue(startTime: .hours(14), value: 1.25), + RepeatingScheduleValue(startTime: .hours(16), value: 1.5), + RepeatingScheduleValue(startTime: .hours(18), value: 1.25), + RepeatingScheduleValue(startTime: .hours(21), value: 1.0)], + timeZone: scheduleTimeZone) + let insulinSensitivitySchedule = InsulinSensitivitySchedule(unit: .milligramsPerDeciliter, + dailyItems: [RepeatingScheduleValue(startTime: .hours(0), value: 45.0), + RepeatingScheduleValue(startTime: .hours(8), value: 40.0), + RepeatingScheduleValue(startTime: .hours(10), value: 35.0), + RepeatingScheduleValue(startTime: .hours(12), value: 30.0), + RepeatingScheduleValue(startTime: .hours(14), value: 35.0), + RepeatingScheduleValue(startTime: .hours(16), value: 40.0), + RepeatingScheduleValue(startTime: .hours(18), value: 45.0), + RepeatingScheduleValue(startTime: .hours(21), value: 50.0)], + timeZone: scheduleTimeZone) + let carbRatioSchedule = CarbRatioSchedule(unit: .gram(), + dailyItems: [RepeatingScheduleValue(startTime: .hours(0), value: 10.0), + RepeatingScheduleValue(startTime: .hours(8), value: 12.0), + RepeatingScheduleValue(startTime: .hours(10), value: 9.0), + RepeatingScheduleValue(startTime: .hours(12), value: 10.0), + RepeatingScheduleValue(startTime: .hours(14), value: 11.0), + RepeatingScheduleValue(startTime: .hours(16), value: 12.0), + RepeatingScheduleValue(startTime: .hours(18), value: 8.0), + RepeatingScheduleValue(startTime: .hours(21), value: 10.0)], + timeZone: scheduleTimeZone) + let notificationSettings = NotificationSettings(authorizationStatus: .authorized, + soundSetting: .enabled, + badgeSetting: .enabled, + alertSetting: .enabled, + notificationCenterSetting: .enabled, + lockScreenSetting: .enabled, + carPlaySetting: .enabled, + alertStyle: .banner, + showPreviewsSetting: .always, + criticalAlertSetting: .enabled, + providesAppNotificationSettings: true, + announcementSetting: .enabled, + timeSensitiveSetting: .enabled, + scheduledDeliverySetting: .disabled, + temporaryMuteAlertsSetting: .disabled) + let controllerDevice = StoredSettings.ControllerDevice(name: "Controller Name", + systemName: "Controller System Name", + systemVersion: "Controller System Version", + model: "Controller Model", + modelIdentifier: "Controller Model Identifier") + let cgmDevice = HKDevice(name: "CGM Name", + manufacturer: "CGM Manufacturer", + model: "CGM Model", + hardwareVersion: "CGM Hardware Version", + firmwareVersion: "CGM Firmware Version", + softwareVersion: "CGM Software Version", + localIdentifier: "CGM Local Identifier", + udiDeviceIdentifier: "CGM UDI Device Identifier") + let pumpDevice = HKDevice(name: "Pump Name", + manufacturer: "Pump Manufacturer", + model: "Pump Model", + hardwareVersion: "Pump Hardware Version", + firmwareVersion: "Pump Firmware Version", + softwareVersion: "Pump Software Version", + localIdentifier: "Pump Local Identifier", + udiDeviceIdentifier: "Pump UDI Device Identifier") + return StoredSettings(date: date, + controllerTimeZone: controllerTimeZone, + dosingEnabled: true, + glucoseTargetRangeSchedule: glucoseTargetRangeSchedule, + preMealTargetRange: DoubleRange(minValue: 80.0, maxValue: 90.0).quantityRange(for: .milligramsPerDeciliter), + workoutTargetRange: DoubleRange(minValue: 150.0, maxValue: 160.0).quantityRange(for: .milligramsPerDeciliter), + overridePresets: nil, + scheduleOverride: nil, + preMealOverride: preMealOverride, + maximumBasalRatePerHour: 3.5, + maximumBolus: 10.0, + suspendThreshold: GlucoseThreshold(unit: .milligramsPerDeciliter, value: 75.0), + deviceToken: UUID().uuidString, + insulinType: .humalog, + defaultRapidActingModel: StoredInsulinModel(ExponentialInsulinModelPreset.rapidActingAdult), + basalRateSchedule: basalRateSchedule, + insulinSensitivitySchedule: insulinSensitivitySchedule, + carbRatioSchedule: carbRatioSchedule, + notificationSettings: notificationSettings, + controllerDevice: controllerDevice, + cgmDevice: cgmDevice, + pumpDevice: pumpDevice, + bloodGlucoseUnit: .milligramsPerDeciliter) + } +} diff --git a/Loop/Extensions/StateColorPalette.swift b/Loop/Extensions/StateColorPalette.swift new file mode 100644 index 0000000000..e6f18b436a --- /dev/null +++ b/Loop/Extensions/StateColorPalette.swift @@ -0,0 +1,17 @@ +// +// StateColorPalette.swift +// Loop +// +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +import LoopUI +import LoopKitUI + +extension StateColorPalette { + static let loopStatus = StateColorPalette(unknown: .unknownColor, normal: .freshColor, warning: .agingColor, error: .staleColor) + + static let cgmStatus = loopStatus + + static let pumpStatus = StateColorPalette(unknown: .unknownColor, normal: .pumpStatusNormal, warning: .agingColor, error: .staleColor) +} diff --git a/Loop/Extensions/UIActivityIndicatorView.swift b/Loop/Extensions/UIActivityIndicatorView.swift new file mode 100644 index 0000000000..d947d0eff3 --- /dev/null +++ b/Loop/Extensions/UIActivityIndicatorView.swift @@ -0,0 +1,15 @@ +// +// UIActivityIndicatorView.swift +// LoopKitUI +// +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import UIKit + + +extension UIActivityIndicatorView.Style { + static var `default`: UIActivityIndicatorView.Style { + return .medium + } +} diff --git a/Loop/Extensions/UIAlertController.swift b/Loop/Extensions/UIAlertController.swift index 0dd971ea6c..3b83aa11d8 100644 --- a/Loop/Extensions/UIAlertController.swift +++ b/Loop/Extensions/UIAlertController.swift @@ -7,6 +7,8 @@ // import UIKit +import LoopKit +import LoopKitUI extension UIAlertController { @@ -14,11 +16,11 @@ extension UIAlertController { Initializes an ActionSheet-styled controller for selecting a workout duration - parameter handler: A closure to execute when the sheet is dismissed after selection. The closure has a single argument: - - endDate: The date at which the user selected the workout to end + - duration: The duration for which the workout is to be enabled */ - convenience init(workoutDurationSelectionHandler handler: @escaping (_ endDate: Date) -> Void) { + internal convenience init(workoutDurationSelectionHandler handler: @escaping (_ duration: TimeInterval) -> Void) { self.init( - title: NSLocalizedString("Use Workout Glucose Targets", comment: "The title of the alert controller used to select a duration for workout targets"), + title: NSLocalizedString("Use Workout Preset", comment: "The title of the alert controller used to select a duration for workout targets"), message: nil, preferredStyle: .actionSheet ) @@ -31,16 +33,224 @@ extension UIAlertController { let duration = NSLocalizedString("For %1$@", comment: "The format string used to describe a finite workout targets duration") addAction(UIAlertAction(title: String(format: duration, formatter.string(from: interval)!), style: .default) { _ in - handler(Date(timeIntervalSinceNow: interval)) + handler(interval) }) } - let distantFuture = NSLocalizedString("Indefinitely", comment: "The title of a target alert action specifying an indefinitely long workout targets duration") + let distantFuture = NSLocalizedString("Until I turn off", comment: "The title of a target alert action specifying workout targets duration until it is turned off by the user") addAction(UIAlertAction(title: distantFuture, style: .default) { _ in - handler(Date.distantFuture) + handler(.infinity) }) + addCancelAction() + } + + /** + Initializes an ActionSheet-styled controller for selecting a pre-meal preset duration + + - parameter handler: A closure to execute when the sheet is dismissed after selection. The closure has a single argument: + - duration: The duration for which the pre-meal preset is to be enabled + */ + internal convenience init(premealDurationSelectionHandler handler: @escaping (_ duration: TimeInterval) -> Void) { + self.init( + title: NSLocalizedString("Use Pre-Meal Preset", comment: "The title of the alert controller used to select a duration for pre-meal targets"), + message: nil, + preferredStyle: .actionSheet + ) + + let distantFuture = NSLocalizedString("Until I enter carbs", comment: "The title of a target alert action specifying pre-meal targets duration for 1 hour or until the user enters carbs (whichever comes first).") + addAction(UIAlertAction(title: distantFuture, style: .default) { _ in + handler(.hours(1)) + }) + + addCancelAction() + } + + /// Initializes an action sheet-styled controller for selecting a PumpManager + /// + /// - Parameters: + /// - availablePumpManagers: An array of available PumpManagers + /// - selectionHandler: A closure to execute when a manager is selected + /// - identifier: Identifier of the selected PumpManager + internal convenience init(availablePumpManagers: [PumpManagerDescriptor], selectionHandler: @escaping (_ identifier: String) -> Void) { + self.init( + title: NSLocalizedString("Add Pump", comment: "Action sheet title selecting Pump"), + message: nil, + preferredStyle: .actionSheet + ) + + for availablePumpManager in availablePumpManagers { + addAction(UIAlertAction( + title: availablePumpManager.localizedTitle, + style: .default, + handler: { (_) in + selectionHandler(availablePumpManager.identifier) + } + )) + } + } + + /// Initializes an action sheet-styled controller for selecting a CGMManager + /// + /// - Parameters: + /// - availableCGMManagers: An array of available CGMManagers + /// - selectionHandler: A closure to execute when either a new CGMManager or the current PumpManager is selected + /// - identifier: Identifier of the selected CGMManager + internal convenience init(availableCGMManagers: [CGMManagerDescriptor], selectionHandler: @escaping (_ identifier: String) -> Void) { + self.init( + title: NSLocalizedString("Add CGM", comment: "Action sheet title selecting CGM"), + message: nil, + preferredStyle: .actionSheet + ) + + for availableCGMManager in availableCGMManagers.sorted(by: {$0.localizedTitle < $1.localizedTitle}) { + addAction(UIAlertAction( + title: availableCGMManager.localizedTitle, + style: .default, + handler: { (_) in + selectionHandler(availableCGMManager.identifier) + } + )) + } + } + + internal convenience init(deleteCGMManagerHandler handler: @escaping (_ isDeleted: Bool) -> Void) { + self.init( + title: nil, + message: NSLocalizedString("Are you sure you want to delete this CGM?", comment: "Confirmation message for deleting a CGM"), + preferredStyle: .actionSheet + ) + + addAction(UIAlertAction( + title: NSLocalizedString("Delete CGM", comment: "Button title to delete CGM"), + style: .destructive, + handler: { (_) in + handler(true) + } + )) + + addCancelAction { (_) in + handler(false) + } + } + + /// Initializes an action sheet-styled controller for selecting a service. + /// + /// - Parameters: + /// - availableServices: An array of available services. + /// - selectionHandler: A closure to execute when a service is selected. + /// - identifier: The identifier of the selected service. + internal convenience init(availableServices: [ServiceDescriptor], selectionHandler: @escaping (_ identifier: String) -> Void) { + self.init( + title: NSLocalizedString("Add Service", comment: "Action sheet title selecting service"), + message: nil, + preferredStyle: .actionSheet + ) + + for availableService in availableServices { + addAction(UIAlertAction( + title: availableService.localizedTitle, + style: .default, + handler: { (_) in + selectionHandler(availableService.identifier) + } + )) + } + } + + internal func addCancelAction(handler: ((UIAlertAction) -> Void)? = nil) { let cancel = NSLocalizedString("Cancel", comment: "The title of the cancel action in an action sheet") - addAction(UIAlertAction(title: cancel, style: .cancel, handler: nil)) + addAction(UIAlertAction(title: cancel, style: .cancel, handler: handler)) + } +} + + +// Adapted from https://oleb.net/2018/uialertcontroller-textfield/ +extension UIAlertController { + public enum TextInputResult { + /// The user tapped Cancel. + case cancel + /// The user tapped the OK button. The payload is the text they entered in the text field. + case ok(String) + } + + /// Creates a fully configured alert controller with one text field for text input, a Cancel and + /// and an OK button. + /// + /// - Parameters: + /// - title: The title of the alert view. + /// - message: The message of the alert view. + /// - cancelButtonTitle: The title of the Cancel button. + /// - okButtonTitle: The title of the OK button. + /// - isValid: The OK button will be disabled as long as the entered text doesn't pass + /// the validation. By default, all entered text is considered valid. + /// - textFieldConfiguration: Use this to configure the text field (e.g. set placeholder text). + /// - onCompletion: Called when the user closes the alert view. The argument tells you whether + /// the user tapped the Close or the OK button (in which case this delivers the entered text). + public convenience init(title: String, message: String? = nil, + cancelButtonTitle: String, okButtonTitle: String, + validate isValid: @escaping (String) -> Bool = { _ in true }, + textFieldConfiguration: ((UITextField) -> Void)? = nil, + onCompletion: @escaping (TextInputResult) -> Void) { + self.init(title: title, message: message, preferredStyle: .alert) + + /// Observes a UITextField for various events and reports them via callbacks. + /// Sets itself as the text field's delegate and target-action target. + class TextFieldObserver: NSObject, UITextFieldDelegate { + let textFieldValueChanged: (UITextField) -> Void + let textFieldShouldReturn: (UITextField) -> Bool + + init(textField: UITextField, valueChanged: @escaping (UITextField) -> Void, shouldReturn: @escaping (UITextField) -> Bool) { + self.textFieldValueChanged = valueChanged + self.textFieldShouldReturn = shouldReturn + super.init() + textField.delegate = self + textField.addTarget(self, action: #selector(TextFieldObserver.textFieldValueChanged(sender:)), for: .editingChanged) + } + + @objc func textFieldValueChanged(sender: UITextField) { + textFieldValueChanged(sender) + } + + // MARK: UITextFieldDelegate + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + return textFieldShouldReturn(textField) + } + } + + var textFieldObserver: TextFieldObserver? + + // Every `UIAlertAction` handler must eventually call this + func finish(result: TextInputResult) { + // Capture the observer to keep it alive while the alert is on screen + // Check for non-nil first to suppress an unused variable warning + if textFieldObserver != nil { + textFieldObserver = nil + } + onCompletion(result) + } + + let cancelAction = UIAlertAction(title: cancelButtonTitle, style: .cancel, handler: { _ in + finish(result: .cancel) + }) + let okAction = UIAlertAction(title: okButtonTitle, style: .default, handler: { [unowned self] _ in + finish(result: .ok(self.textFields?.first?.text ?? "")) + }) + addAction(cancelAction) + addAction(okAction) + preferredAction = okAction + + addTextField(configurationHandler: { textField in + textFieldConfiguration?(textField) + textFieldObserver = TextFieldObserver(textField: textField, + valueChanged: { textField in + okAction.isEnabled = isValid(textField.text ?? "") + }, + shouldReturn: { textField in + isValid(textField.text ?? "") + }) + }) + // Start with a disabled OK button if necessary + okAction.isEnabled = isValid(textFields?.first?.text ?? "") } } diff --git a/Loop/Extensions/UIDevice+Loop.swift b/Loop/Extensions/UIDevice+Loop.swift new file mode 100644 index 0000000000..f8df9f58be --- /dev/null +++ b/Loop/Extensions/UIDevice+Loop.swift @@ -0,0 +1,92 @@ +// +// UIDevice+Loop.swift +// Loop +// +// Created by Darin Krauss on 5/27/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import UIKit +import LoopKit + +extension UIDevice { + + // https://stackoverflow.com/questions/26028918/how-to-determine-the-current-iphone-device-model + var modelIdentifier: String { + #if IOS_SIMULATOR + return "\(model)Simulator" + #else + var info = utsname() + uname(&info) + return withUnsafePointer(to: &info.machine) { $0.withMemoryRebound(to: CChar.self, capacity: 1) { String(validatingUTF8: $0) } } ?? "unknown" + #endif + } + + public var controllerDevice: StoredSettings.ControllerDevice { + return StoredSettings.ControllerDevice(name: name, + systemName: systemName, + systemVersion: systemVersion, + model: model, + modelIdentifier: modelIdentifier) + } + + public var controllerStatus: StoredDosingDecision.ControllerStatus { + return StoredDosingDecision.ControllerStatus(batteryState: isBatteryMonitoringEnabled ? batteryState.batteryState : nil, + batteryLevel: isBatteryMonitoringEnabled && batteryLevel != -1.0 ? batteryLevel : nil) // -1.0 indicates unknown + } +} + +extension UIDevice { + func generateDiagnosticReport(_ completion: @escaping (_ report: String) -> Void) { + var report: [String] = [ + "## Device", + "", + "* name: \(name)", + "* systemName: \(systemName)", + "* systemVersion: \(systemVersion)", + "* model: \(model)", + "* modelIdentifier: \(modelIdentifier)", + ] + if isBatteryMonitoringEnabled { + report += [ + "* batteryLevel: \(batteryLevel)", + "* batteryState: \(String(describing: batteryState))", + ] + } + completion(report.joined(separator: "\n")) + } +} + +extension UIDevice.BatteryState { + public var batteryState: StoredDosingDecision.ControllerStatus.BatteryState { + switch self { + case .unknown: + return .unknown + case .unplugged: + return .unplugged + case .charging: + return .charging + case .full: + return .full + @unknown default: + return .unknown + } + } +} + +extension UIDevice.BatteryState: CustomStringConvertible { + public var description: String { + switch self { + case .unknown: + return "unknown" + case .unplugged: + return "unplugged" + case .charging: + return "charging" + case .full: + return "full" + @unknown default: + return "unknown" + } + } +} diff --git a/Loop/Extensions/UIFont.swift b/Loop/Extensions/UIFont.swift new file mode 100644 index 0000000000..14b18f06b4 --- /dev/null +++ b/Loop/Extensions/UIFont.swift @@ -0,0 +1,41 @@ +// +// UIFont.swift +// Loop +// +// Created by Michael Pangburn on 2/27/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import UIKit + + +extension UIFont { + convenience init( + style: UIFont.TextStyle, + design: UIFontDescriptor.SystemDesign, + traits: UIFontDescriptor.SymbolicTraits = [] + ) { + var descriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: style) + + if let prettierDescriptor = descriptor.withDesign(design)?.withSymbolicTraits(traits) + { + descriptor = prettierDescriptor + } else if let prettierDescriptor = descriptor.withSymbolicTraits(traits) { + descriptor = prettierDescriptor + } + + self.init(descriptor: descriptor, size: 0) + } + + static func heavy(_ textStyle: UIFont.TextStyle) -> UIFont { + let descriptor = UIFontDescriptor + .preferredFontDescriptor(withTextStyle: textStyle) + .addingAttributes([ + .traits: [ + UIFontDescriptor.TraitKey.weight: UIFont.Weight.heavy + ] + ]) + + return UIFont(descriptor: descriptor, size: 0) + } +} diff --git a/Loop/Extensions/UIImage.swift b/Loop/Extensions/UIImage.swift index d5a572afd8..908f0c965a 100644 --- a/Loop/Extensions/UIImage.swift +++ b/Loop/Extensions/UIImage.swift @@ -31,15 +31,21 @@ extension UIImage { return suffix } - static func batteryHUDImageWithLevel(_ level: Double?) -> UIImage? { - return UIImage(named: "battery_\(imageSuffixForLevel(level))") - } - - static func reservoirHUDImageWithLevel(_ level: Double?) -> UIImage? { - return UIImage(named: "reservoir_\(imageSuffixForLevel(level))") + static func preMealImage(selected: Bool) -> UIImage? { + return UIImage(named: selected ? "Pre-Meal Selected" : "Pre-Meal") } static func workoutImage(selected: Bool) -> UIImage? { return UIImage(named: selected ? "workout-selected" : "workout") } } + +private class FrameworkBundle { + static let main = Bundle(for: FrameworkBundle.self) +} + +extension UIImage { + convenience init?(frameworkImage name: String) { + self.init(named: name, in: FrameworkBundle.main, with: nil) + } +} diff --git a/Loop/Extensions/UITableViewCell.swift b/Loop/Extensions/UITableViewCell.swift index 2049f6f1c8..5b9ac0ff2f 100644 --- a/Loop/Extensions/UITableViewCell.swift +++ b/Loop/Extensions/UITableViewCell.swift @@ -7,6 +7,7 @@ // import UIKit +import LoopCore extension UITableViewCell: IdentifiableClass { } diff --git a/Loop/Extensions/UIViewController.swift b/Loop/Extensions/UIViewController.swift new file mode 100644 index 0000000000..005aa738da --- /dev/null +++ b/Loop/Extensions/UIViewController.swift @@ -0,0 +1,27 @@ +// +// UIViewController.swift +// Loop +// +// Created by Pete Schwamb on 8/26/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import Foundation +import UIKit + +extension UIViewController { + var topmostViewController: UIViewController { + if let tabController = self as? UITabBarController { + return tabController.selectedViewController?.topmostViewController ?? self + } + if let navController = self as? UINavigationController { + return navController.visibleViewController?.topmostViewController ?? self + } + return presentedViewController?.topmostViewController ?? self + } + + /// Argumentless wrapper around `dismiss(animated:)` in order to pass as a selector + @objc func dismissWithAnimation() { + dismiss(animated: true) + } +} diff --git a/Loop/Extensions/UserDefaults+Loop.swift b/Loop/Extensions/UserDefaults+Loop.swift new file mode 100644 index 0000000000..4894dcc777 --- /dev/null +++ b/Loop/Extensions/UserDefaults+Loop.swift @@ -0,0 +1,112 @@ +// +// UserDefaults+Loop.swift +// Loop +// +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit + + +extension UserDefaults { + private enum Key: String { + case legacyPumpManagerState = "com.loopkit.Loop.PumpManagerState" + case legacyCGMManagerState = "com.loopkit.Loop.CGMManagerState" + case legacyServicesState = "com.loopkit.Loop.ServicesState" + case loopNotRunningNotifications = "com.loopkit.Loop.loopNotRunningNotifications" + case inFlightAutomaticDose = "com.loopkit.Loop.inFlightAutomaticDose" + case favoriteFoods = "com.loopkit.Loop.favoriteFoods" + } + + var legacyPumpManagerRawValue: PumpManager.RawValue? { + get { + return dictionary(forKey: Key.legacyPumpManagerState.rawValue) + } + } + func clearLegacyPumpManagerRawValue() { + set(nil, forKey: Key.legacyPumpManagerState.rawValue) + } + + + var legacyCGMManagerRawValue: CGMManager.RawValue? { + get { + return dictionary(forKey: Key.legacyCGMManagerState.rawValue) + } + } + + func clearLegacyCGMManagerRawValue() { + set(nil, forKey: Key.legacyCGMManagerState.rawValue) + } + + var legacyServicesState: [Service.RawStateValue] { + get { + return array(forKey: Key.legacyServicesState.rawValue) as? [[String: Any]] ?? [] + } + } + + func clearLegacyServicesState() { + set(nil, forKey: Key.legacyServicesState.rawValue) + } + + var inFlightAutomaticDose: AutomaticDoseRecommendation? { + get { + let decoder = JSONDecoder() + guard let data = object(forKey: Key.inFlightAutomaticDose.rawValue) as? Data else { + return nil + } + return try? decoder.decode(AutomaticDoseRecommendation.self, from: data) + } + set { + do { + if let newValue = newValue { + let encoder = JSONEncoder() + let data = try encoder.encode(newValue) + set(data, forKey: Key.inFlightAutomaticDose.rawValue) + } else { + set(nil, forKey: Key.inFlightAutomaticDose.rawValue) + } + } catch { + assertionFailure("Unable to encode AutomaticDoseRecommendation") + } + } + } + + var loopNotRunningNotifications: [StoredLoopNotRunningNotification] { + get { + let decoder = JSONDecoder() + guard let data = object(forKey: Key.loopNotRunningNotifications.rawValue) as? Data else { + return [] + } + return (try? decoder.decode([StoredLoopNotRunningNotification].self, from: data)) ?? [] + } + set { + do { + let encoder = JSONEncoder() + let data = try encoder.encode(newValue) + set(data, forKey: Key.loopNotRunningNotifications.rawValue) + } catch { + assertionFailure("Unable to encode Loop not running notification") + } + } + } + + var favoriteFoods: [StoredFavoriteFood] { + get { + let decoder = JSONDecoder() + guard let data = object(forKey: Key.favoriteFoods.rawValue) as? Data else { + return [] + } + return (try? decoder.decode([StoredFavoriteFood].self, from: data)) ?? [] + } + set { + do { + let encoder = JSONEncoder() + let data = try encoder.encode(newValue) + set(data, forKey: Key.favoriteFoods.rawValue) + } catch { + assertionFailure("Unable to encode stored favorite foods") + } + } + } +} diff --git a/Loop/Extensions/UserNotifications+Loop.swift b/Loop/Extensions/UserNotifications+Loop.swift new file mode 100644 index 0000000000..cd1959c907 --- /dev/null +++ b/Loop/Extensions/UserNotifications+Loop.swift @@ -0,0 +1,97 @@ +// +// UserNotifications+Loop.swift +// Loop +// +// Created by Darin Krauss on 5/27/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import UserNotifications + +extension UNUserNotificationCenter { + func generateDiagnosticReport(_ completion: @escaping (_ report: String) -> Void) { + getNotificationSettings() { notificationSettings in + let report: [String] = [ + "## NotificationSettings", + "", + "* authorizationStatus: \(String(describing: notificationSettings.authorizationStatus))", + "* soundSetting: \(String(describing: notificationSettings.soundSetting))", + "* badgeSetting: \(String(describing: notificationSettings.badgeSetting))", + "* alertSetting: \(String(describing: notificationSettings.alertSetting))", + "* notificationCenterSetting: \(String(describing: notificationSettings.notificationCenterSetting))", + "* lockScreenSetting: \(String(describing: notificationSettings.lockScreenSetting))", + "* carPlaySetting: \(String(describing: notificationSettings.carPlaySetting))", + "* alertStyle: \(String(describing: notificationSettings.alertStyle))", + "* showPreviewsSetting: \(String(describing: notificationSettings.showPreviewsSetting))", + "* criticalAlertSetting: \(String(describing: notificationSettings.criticalAlertSetting))", + "* providesAppNotificationSettings: \(String(describing: notificationSettings.providesAppNotificationSettings))", + "* announcementSetting: \(String(describing: notificationSettings.announcementSetting))", + ] + completion(report.joined(separator: "\n")) + } + } +} + +extension UNAuthorizationStatus: CustomStringConvertible { + public var description: String { + switch self { + case .notDetermined: + return "notDetermined" + case .denied: + return "denied" + case .authorized: + return "authorized" + case .provisional: + return "provisional" + case .ephemeral: + return "ephemeral" + @unknown default: + return "unknown" + } + } +} + +extension UNShowPreviewsSetting: CustomStringConvertible { + public var description: String { + switch self { + case .always: + return "always" + case .whenAuthenticated: + return "whenAuthenticated" + case .never: + return "never" + @unknown default: + return "unknown" + } + } +} + +extension UNNotificationSetting: CustomStringConvertible { + public var description: String { + switch self { + case .notSupported: + return "notSupported" + case .disabled: + return "disabled" + case .enabled: + return "enabled" + @unknown default: + return "unknown" + } + } +} + +extension UNAlertStyle: CustomStringConvertible { + public var description: String { + switch self { + case .none: + return "none" + case .banner: + return "banner" + case .alert: + return "alert" + @unknown default: + return "unknown" + } + } +} diff --git a/Loop/Info.plist b/Loop/Info.plist index 9709838138..db76e6e846 100644 --- a/Loop/Info.plist +++ b/Loop/Info.plist @@ -2,21 +2,18 @@ - CFBundleURLTypes + AppGroupIdentifier + $(APP_GROUP_IDENTIFIER) + AppStoreURL + $(APP_STORE_URL) + BGTaskSchedulerPermittedIdentifiers - - CFBundleURLSchemes - - $(MAIN_APP_BUNDLE_IDENTIFIER) - - + com.loopkit.background-task.critical-event-log.historical-export CFBundleDevelopmentRegion en - ITSAppUsesNonExemptEncryption - CFBundleDisplayName - Loop + $(MAIN_APP_DISPLAY_NAME) CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -28,30 +25,72 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.2.0 + $(LOOP_MARKETING_VERSION) CFBundleSignature ???? + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + $(MAIN_APP_BUNDLE_IDENTIFIER) + CFBundleURLSchemes + + $(URL_SCHEME_NAME) + + + CFBundleVersion $(CURRENT_PROJECT_VERSION) + ITSAppUsesNonExemptEncryption + LSApplicationQueriesSchemes + dexcomg6 dexcomcgm dexcomshare LSRequiresIPhoneOS + LoopLocalCacheDurationDays + $(LOOP_LOCAL_CACHE_DURATION_DAYS) + NFCReaderUsageDescription + The app uses NFC to pair with diabetes devices. + NSBluetoothAlwaysUsageDescription + The app needs to use Bluetooth to send and receive data from your diabetes devices. NSBluetoothPeripheralUsageDescription - Bluetooth is used to communicate with insulin pump and continuous glucose monitor devices + The app needs to use Bluetooth to send and receive data from your diabetes devices. + NSCameraUsageDescription + Camera is used to scan barcodes of devices. + NSFaceIDUsageDescription + Face ID is used to authenticate insulin bolus and to save changes to therapy settings. NSHealthShareUsageDescription - Meal data from the Health database is used to determine glucose effects. -Glucose data from the Health database is used for graphing and momentum calculation. + Meal data from the Health database is used to determine glucose effects. Glucose data from the Health database is used for graphing and momentum calculation. Sleep data from the Health database is used to optimize delivery of Apple Watch complication updates during the time you are awake. NSHealthUpdateUsageDescription - Carbhydrate meal data entered in the app and on the watch is stored in the Health database. -Glucose data retrieved from the CGM is stored securely in HealthKit. + Carbohydrate meal data entered in the app and on the watch is stored in the Health database. Glucose data retrieved from the CGM is stored securely in HealthKit. + NSSiriUsageDescription + Loop uses Siri to allow you to enact presets with your voice. + NSSupportsLiveActivities + + NSSupportsLiveActivitiesFrequentUpdates + + NSUserActivityTypes + + EnableOverridePresetIntent + NewCarbEntry + NewCarbEntryIntent + ViewLoopStatus + UIBackgroundModes bluetooth-central + processing + remote-notification + audio + UIDesignRequiresCompatibility + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -86,7 +125,5 @@ Glucose data retrieved from the CGM is stored securely in HealthKit. UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - AppGroupIdentifier - $(APP_GROUP_IDENTIFIER) diff --git a/Loop/InfoPlist.xcstrings b/Loop/InfoPlist.xcstrings new file mode 100644 index 0000000000..ca65707b42 --- /dev/null +++ b/Loop/InfoPlist.xcstrings @@ -0,0 +1,1169 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "CFBundleDisplayName" : { + "comment" : "Bundle display name", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Loop" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ループ" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop" + } + } + } + }, + "CFBundleName" : { + "comment" : "Bundle name", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME)" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME)" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME)" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Loop" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME)" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME)" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME)" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME)" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME)" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME)" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME)" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME)" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME)" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME)" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME)" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME)" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME)" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME)" + } + } + } + }, + "NFCReaderUsageDescription" : { + "comment" : "Privacy - NFC Scan Usage Description", + "extractionState" : "extracted_with_value", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Appen bruger NFC til at parre sig med diabetesapparater." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die App verwendet NFC zur Kopplung mit Diabetesgeräten." + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The app uses NFC to pair with diabetes devices." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "L'app utilizza NFC per l'abbinamento con i dispositivi per il diabete." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Appen bruker NFC til å koble seg sammen med diabetesenheter." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aplikacja wykorzystuje NFC do parowania z urządzeniami dla diabetyków." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aplicația folosește NFC pentru a se conecta cu dispozitive pentru diabet." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "该应用程序使用 NFC 与糖尿病设备配对。" + } + } + } + }, + "NSBluetoothAlwaysUsageDescription" : { + "comment" : "Privacy - Bluetooth Always Usage Description", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يستخدم البلوتوث للتواصل مع مضخة الأنسولين وأجهزة متابعة سكر الدم المستمرة." + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth bliver brugt til at kommunikere med din insulinpumpe og din glukosemonitor." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth wird verwendet, um mit Insulinpumpen und kontinuierlichen Blutzuckermessgeräten zu kommunizieren." + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The app needs to use Bluetooth to send and receive data from your diabetes devices." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El bluetooth se utiliza para las comunicaciones con la bomba de insulina y los dispositivos de monitoreo continuo de glucosa." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetoothin avulla kommunikoidaan insuliinipumpun ja glukoosinseurantalaitteen kanssa." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth est utilisé pour communiquer avec la pompe à insuline et les dispositifs de surveillance continue du glucose." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth משמש לתקשורת עם משאבת אינסולין, חיישנים ומכשירים נוספים." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Il Bluetooth viene utilizzato per comunicare con le pompe d'insulina e con i dispositivi di monitoraggio continuo della glicemia." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ブルートゥースは、インスリンポンプおよび連続グルコースモニタデバイスと通信するために使用されます" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth brukes til å kommunisere med insulinpumpe og kontinuerlige glukosemonitorer." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth wordt gebruikt om te communiceren met de insulinepomp en de continue glucosemeters." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth jest używany do komunikacji z pompą i urządzeniami ciągłego monitoringu glukozy." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth é utilizado para comunicação com a bomba de insulina e os dispositivos de monitoramento de glicose." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth este folosit pentru a comunica cu pompa de insulină, precum și cu dispozitivele de monitorizare glicemică continuă." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth используется для связи с инсулиновой помпой и устройствами непрерывного мониторинга глюкозы." + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth sa používa na komunikáciu s inzulínovou pumpou a zariadeniami pre kontinuálne snímanie glykémie." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth används för att kommunicera med insulinpump och CGM." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth, insülin pompası ve sürekli glikoz izleme cihazlarıyla iletişim kurmak için kullanılır." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth được sử dụng để liên lạc với máy bơm insulin và các thiết bị theo dõi đường huyết liên tục/CGM." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "蓝牙用于与胰岛素泵和连续血糖监测设备进行通信" + } + } + } + }, + "NSBluetoothPeripheralUsageDescription" : { + "comment" : "Privacy - Bluetooth Peripheral Usage Description", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يستخدم البلوتوث للتواصل مع مضخة الأنسولين وأجهزة متابعة سكر الدم المستمرة." + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth bliver brugt til at kommunikere med din insulinpumpe og din glukosemonitor." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth wird verwendet, um mit Insulinpumpen und kontinuierlichen Blutzuckermessgeräten zu kommunizieren." + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The app needs to use Bluetooth to send and receive data from your diabetes devices." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El bluetooth se utiliza para las comunicaciones con la bomba de insulina y los dispositivos de monitoreo continuo de glucosa." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetoothin avulla kommunikoidaan insuliinipumpun ja glukoosinseurantalaitteen kanssa." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth est utilisé pour communiquer avec la pompe à insuline et les dispositifs de surveillance continue du glucose." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth משמש לתקשורת עם משאבת אינסולין, חיישנים ומכשירים נוספים." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Il Bluetooth viene utilizzato per comunicare con le pompe d'insulina e con i dispositivi di monitoraggio continuo della glicemia." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ブルートゥースは、インスリンポンプおよび連続グルコースモニタデバイスと通信するために使用されます" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth brukes til å kommunisere med insulinpumpe og kontinuerlige glukosemonitorer." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth wordt gebruikt om te communiceren met de insulinepomp en de continue glucosemeters." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth jest używany do komunikacji z pompą i urządzeniami ciągłego monitoringu glukozy." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth é utilizado para comunicação com a bomba de insulina e os dispositivos de monitoramento de glicose." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth este folosit pentru a comunica cu pompa de insulină, precum și cu dispozitivele de monitorizare glicemică continuă." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth используется для связи с инсулиновой помпой и устройствами непрерывного мониторинга глюкозы." + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth sa používa na komunikáciu s inzulínovou pumpou a zariadeniami pre kontinuálne snímanie glykémie." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth används för att kommunicera med insulinpump och CGM." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth, insülin pompası ve sürekli glikoz izleme cihazlarıyla iletişim kurmak için kullanılır." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth được sử dụng để liên lạc với máy bơm insulin và các thiết bị theo dõi đường huyết liên tục/CGM." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "蓝牙用于与胰岛素泵和连续血糖监测设备进行通信" + } + } + } + }, + "NSCameraUsageDescription" : { + "comment" : "Privacy - Camera Usage Description", + "extractionState" : "extracted_with_value", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kamera bruges til at scanne stregkoder på enheder." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die Kamera wird verwendet, um Barcodes von Geräten zu scannen." + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Camera is used to scan barcodes of devices." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "La cámara se utiliza para escanear los códigos de barras de los dispositivos." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "La caméra est utilisée pour scanner les codes-barres des appareils." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "המצלמה משמשת לסריקת ברקודים של מכשירים." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "La fotocamera è utilizzata per scansionare i codici a barre dei tuoi dispositivi." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kamera brukes til å skanne strekkoder på enheter." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Camera wordt gebruikt om barcodes van apparaten te scannen." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aparat służy do skanowania kodów kreskowych urządzeń." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Camera este folosită pentru a scana codurile de bare ale dispozitivelor." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Камера используется для сканирования штрих-кодов устройств." + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fotoaparát sa používa na skenovanie čiarových kódov zariadení." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kamera, cihazların barkodlarını taramak için kullanılır." + } + } + } + }, + "NSFaceIDUsageDescription" : { + "comment" : "Privacy - Face ID Usage Description", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تستخدم هوية التعرف على الوجه للتحقق من أجل جرعة الأنسولين." + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Face ID bliver brugt til at godkende en insulinbolus." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Face ID wird benutzt, um einen Bolus zu authentifizieren." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Face ID is used to authenticate insulin bolus." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se utiliza reconocimiento facial para autenticar el bolo de insulina." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Face ID:tä käytetään annettavan boluksen vahvistamiseen." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Face ID est utilisé pour authentifier le bolus d'insuline et sauvegarder les changements dans les réglages de thérapie." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Face ID ישמש לאימות הזרקת בולוס ואישור שינויים בתוכנית הטיפול." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Face ID viene utilizzato per autenticare il bolo d'insulina e per salvare le modifiche alle impostazioni della terapia." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "フェイスIDはインスリンボーラスの認証に使用されます" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Face ID brukes til å autentisere insulin bolus." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Face ID wordt gebruikt om de insulinebolus te authenticeren en om wijzigingen in de therapieinstellingen op te slaan." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Face ID jest używane do autoryzacji podaży bolusa." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Face ID é utilizado para autenticar o bolus de insulina." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Face ID este folosit la autentificarea pentru bolus." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Идентификатор Face ID применяется для авторизации ввода инсулина" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Face ID sa používa na overenie podania bolusu inzulínu a na uloženie zmien v nastaveniach terapie." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kräv Face ID för att kunna godkänna bolus." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Face ID, bolus insülini doğrulamak ve tedavi ayarlarındaki değişiklikleri kaydetmek için kullanılır." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Face ID được sử dụng để xác thực liều insulin cho các bữa ăn." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用人脸解锁来确认输注胰岛素剂量" + } + } + } + }, + "NSHealthShareUsageDescription" : { + "comment" : "Privacy - Health Share Usage Description", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يتم استخدام بيانات الوجبات من قواعد بيانات تطبيق صحتي لتحديد تأثيرات سكر الدم. يتم استخدام بيانات سكر الدم منقواعد بيانات تطبيق صحتي للرسم البياني والتحليل. تُستخدم بيانات النوم من قواعد بيانات تطبيق صحتي لتحسين توصيل تحديثات تعقيدات ساعة أبل أثناء فترة استيقاظك." + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Måltidsdata fra Apple Health bliver brugt til at glukosens effekt på dit blodsukker. Glukose data fra Apple Health bliver brugt til at danne grafer og udregninger." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mahlzeit-Daten aus der HealthKit-Datenbank werden verwendet, um Blutzuckereffekte zu bestimmen. Blutzuckerdaten aus der HealthKit-Datenbank werden für die Grafik- und Momentumberechnung verwendet." + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Meal data from the Health database is used to determine glucose effects. Glucose data from the Health database is used for graphing and momentum calculation. Sleep data from the Health database is used to optimize delivery of Apple Watch complication updates during the time you are awake." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Datos de alimentos de la base de datos de Salud se utiliza para determinar los efectos en el nivel de glucosa. Datos de glucosa de la bsase de datos de Salud se utilizan para graficar y determinar cálculos de momento." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Terveys-sovelluksen ateriatietoja käytetään glukoosivaikutusten määrittämiseen. Terveys-sovelluksen glukoositietoja käytetään graafeissa ja laskelmissa. Unitietoja käytetään Apple Watch -komplikaation toiminnan optimointiin hereillä olon aikana." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les données sur les repas provenant de la base de données Health sont utilisées pour déterminer les effets du glucose. Les données sur la glycémie de la base de données Health sont utilisées pour le calcul graphique et le calcul du momentum. Les données de sommeil provenant de la base de données Health sont utilisées pour améliorer les cadrans Apple Watch." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "נתוני ארוחות ממאגר Health משמשים לקביעת השפעות הגלוקוז. נתוני הגלוקוז ממסד Health הבריאות משמשים לגרפים ולחישוב מגמה. נתוני שינה ממסד Health משפרים את הנראות ב-Apple Watch." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "I dati sui pasti dal database Salute vengono utilizzati per determinare gli effetti del glucosio. I dati del glucosio del database Salute vengono utilizzati per il calcolo del grafico della glicemia." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アプリに入力された炭水化物の食事データは、健康データベースに保存されます。 グルコースデータはHealthKitに安全に保存されます" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Matdata fra Health-databasen brukes til å bestemme blodsukkereffekt. Blodsukkerdata hentes fra HealthKit for opptegning og analyse." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maaltijdgegevens uit de database Gezondheid worden gebruikt om glucoseëffecten te bepalen. Glucosegegevens uit de database Gezondheid worden gebruikt voor grafieken en het berekenen van trendlijnen. Slaapgegevens uit de database Gezondheid worden gebruikt om de Apple Watch complicatie bij te werken." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dane o posiłkach z bazy aplikacji Zdrowie będą używane do określenia ich wpływu na poziom cukru. Informacje o poziomie cukru z bazy danych aplikacji Zdrowie są używane do narysowania wykresu oraz obliczenia przewidywanego poziomu cukru." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Os dados de refeições do banco de dados de saúde são utilizados para definir os efeitos da glicose para a representação gráfica e cálculo da aceleração." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Informațiile despre nutriție din baza de date Sănătate sunt folosite pentru a determina efectele glucozei. Informațiile despre glucoză din baza de date Sănătate sunt folosite pentru construirea de grafice și calcule de trend/momentum." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Данные о пище из базы данных Здоровье используются для определения влияния на гликемию. Данные гликемии из базы данных Здоровье используются для графического отображения и вычисления скорости изменений" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Údaje o jedle z databázy Health sa používajú na určenie účinkov glukózy. Údaje o glukóze z databázy Health sa používajú na vytváranie grafov a výpočet hybnosti. Údaje o spánku z databázy Health slúžia na vylepšenie komplikácie Apple Watch." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kolhydratdata från Apple Health-databasen används för att bestämma effekten på glukosvärde. Glukosvärden från Apple Health-databasen används i diagram och för beräkning av förändring." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sağlık veri tabanından alınan yemek verileri, glikoz etkilerini belirlemek için kullanılır. Sağlık veri tabanından alınan glikoz verileri, grafik ve momentum hesaplaması için kullanılır. Sağlık veritabanındaki uyku verileri, Apple Watch komplikasyonunu iyileştirmek için kullanılır." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thông số bữa ăn từ app Health được sử dụng để xác định tác động của glucose. Thông số glucose từ app Health được sử dụng cho các tính toán vẽ đồ thị và chuyển động của đường huyết." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "数据库中的膳食数据用于确定葡萄糖影响。健康数据库中的葡萄糖数据用于绘图和动量计算。" + } + } + } + }, + "NSHealthUpdateUsageDescription" : { + "comment" : "Privacy - Health Update Usage Description", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "بيانات كربوهيدرات الوجبة المدخلة للتطبيق و الساعة محفوظة في قواعد بيانات تطبيق صحتي. يتم تخزين بيانات سكر الدم المستردة من نظام متابعة سكر الدم المستمرة بشكل آمن في تطبيق صحتي." + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Data om kulhydratmåltider, der indtastes i appen og på uret, gemmes i Apples Health-database. Glukosedata, der hentes fra CGM'en, gemmes sikkert i HealthKit." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "In der App und auf der Uhr eingegebene Daten zu Kohlenhydratmahlzeiten werden in der HealthKit-Datenbank gespeichert. Vom CGM abgerufene Glukosedaten werden sicher in HealthKit-Datenbank gespeichert." + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Carbohydrate meal data entered in the app and on the watch is stored in the Health database. Glucose data retrieved from the CGM is stored securely in HealthKit." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Datos de alimentos ingresados en la aplicación y en el reloj son almacenados en la base de datos de Salud. Los datos de glucosa extraídos del monitor continuo de glucosa se almacenan de manera segura en HealthKit." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sovelluksen ja kellon kautta tallennetut hiilihydraattitiedot tallennetaan Terveys-sovellukseen. Glukoosiseurannan kautta saadut glukoositiedot tallennetaan turvallisesti HealthKitiin." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les données de glucides des repas entrées dans l'application ou la montre sont enregistrées dans la base de donnée Santé. Les données de taux de glucose provenant du CGM sont enregistrées de manière sécurisée dans HealthKit." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "נתוני ארוחות הפחמימות המוזנים באפליקציה ובשעון נשמרים במסד Health. נתוני הגלוקוז שאוחזרו מה-CGM מאוחסנים בצורה מאובטחת ב-HealthKit." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "I dati sui carboidrati dei pasti inseriti nell'app e sull'orologio sono trasferiti nel database di Salute. I dati recuperati dal sensore CGM sono storati nel database di HealthKit." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "健康データベースからの食事データは、グルコース効果を決定するために使用される。 グルコースデータはグラフ作成と解析のためにHealthKitから検索されます" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Karbohydratmåltidsdata som legges inn i appen og på klokken lagres i Helsedatabasen. Glukosedata hentet fra CGM lagres sikkert i HealthKit." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maaltijdkoolhydraten die worden ingevoerd in de app en met de watch worden opgeslagen in de database Gezondheid. Ontvangen glucosegegevens van de CGM worden veilig opgeslagen in HealthKit." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Posiłek węglowodanowy wprowadzony w aplikacji i na zegarku oraz dane o poziomie cukru pobrane z ciągłego monitoringu glukozy są bezpiecznie przechowywane w aplikacji Zdrowie." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dados de carboidratos inseridos no aplicativo e no Apple Watch são armazenados no banco de dados de saúde. Dados de glicemia recebidos do CGM são armazenados de modo seguro no HealthKit." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carbohidrații introduși în aplicație și pe ceas sunt stocați în baza de date Sănătate. Glicemiile din GCM sunt stocate în siguranță în HealthKit." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Данные об углеводах в приложении и на смарт-часах хранятся в базе данных Здоровье. Данные о гликемии полученные от систем непрерывного мониторинга хранятся в безопасности в Комплексе Здоровья HealthKit " + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Údaje o uhlohydrátoch zadané v aplikácii a na hodinkách sú uložené v databáze Health. Údaje o glukóze získané z CGM sú bezpečne uložené v HealthKit." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kolhydratvärden inmatade i appen i klockan lagras i Apple Health-databasen. Glukosvärden mottagna från CGM lagras krypterat i HealthKit." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uygulamaya ve saate girilen öğün karbonhidrat verileri Sağlık veritabanında saklanır. CGM'den alınan KŞ verileri, HealthKit'te güvenli bir şekilde saklanır." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dữ liệu Carbohydrate của bữa ăn được nhập trên phần mềm và trên đồng hồ thông minh sẽ được lưu trữ tại app Health. Các thông số glucose được lấy từ thiết bị theo dõi đường huyết liên tục/CGM sẽ được lưu trữ an toàn trong HealthKit." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在应用程序和手表中输入的碳水化合物膳食数据存储在健康数据库中。从CGM检索的葡萄糖数据安全地存储在HealthKit中。" + } + } + } + }, + "NSSiriUsageDescription" : { + "comment" : "Privacy - Siri Usage Description", + "extractionState" : "extracted_with_value", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop bruger Siri til at give dig mulighed for at udføre forudindstillinger med din stemme." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop verwendet Siri, damit Du Voreinstellungen mit Deiner Sprache ausführen kannst." + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Loop uses Siri to allow you to enact presets with your voice." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop utiliza Siri para permitirte activar ajustes preestablecidos con tu voz." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop utilise Siri pour vous permettre d’activer des préréglages avec votre voix." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop משתמש ב-Siri כדי לאפשר לך להפעיל פקודות קוליות." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop utilizza Siri per permettere l'attivazione dei Preset con la tua voce." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop bruker Siri slik at du kan bruke forhåndsinnstillinger med stemmen din." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop gebruikt Siri om programma's met je stem te laten uitvoeren." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop używa Siri, aby umożliwić wprowadzanie ustawień za pomocą głosu." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop folosește Siri pentru a vă permite să activați presetări cu ajutorul vocii." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop использует Siri, чтобы вы могли активировать пресеты своим голосом." + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop používa Siri, aby vám umožnila aktivovať predvoľby pomocou vášho hlasu." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop, ön ayarları sesinizle gerçekleştirmenize izin vermek için Siri'yi kullanır." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop 使用 Siri,让你可以通过语音来启动预设。" + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/Loop/Localizable.xcstrings b/Loop/Localizable.xcstrings new file mode 100644 index 0000000000..77f54808c1 --- /dev/null +++ b/Loop/Localizable.xcstrings @@ -0,0 +1,40917 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "" : { + + }, + " (pending: %@)" : { + "comment" : "The string format appended to active insulin that describes pending insulin. (1: pending insulin)", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : " (قيد الانتظار: %@)" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : " (afventer: %@)" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : " (stehen aus: %@)" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : " (pending: %@)" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : " (pendiente: %@)" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "(odot.: %@)" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : " (en attente : %@)" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : " (pending: %@)" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : " (in attesa: %@)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : " (保留中: %@)" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "(venter: %@ )" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : " (Wachten: %@)" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : " (oczekujące: %@)" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : " (pendente: %@)" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : " (urmează a fi administrate: %@)" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : " (В ожидании: %@)" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : " (återstår: %@)" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : " (bekliyor: %@)" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : " (đang chờ xử lý: %@)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : " (待执行: %@)" + } + } + } + }, + " Pre-meal Preset" : { + "comment" : "Status row title for premeal override enabled (leading space is to separate from symbol)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Før-måltid forudindstillinger" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : " Voreinstellung zum Essen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : " Pre-Comida Preestablecida" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : " Ennen ateriaa -esiasetus" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : " Préréglage Pré-repas" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "קדם-ארוחה" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : " Preset pre-pasto" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forhåndsinnstilling før måltid" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : " Pre-Meal Programma" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : " Przed Posiłkiem" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : " Presetare înainte de masă" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Режим до еды" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : " Preprandiellt förval" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yemek-Öncesi Ön Ayarı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : " 餐前预设" + } + } + } + }, + " remaining" : { + "comment" : "remaining time in setting's profile expiration section", + "localizations" : { + "ce" : { + "stringUnit" : { + "state" : "translated", + "value" : "remaining" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "zbývající" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : " tilbage" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : " verbleiben" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "restantes" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "restant(s)" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : " נותר" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "remaining" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : " rimanenti" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "gjenstående" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : " resterend" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : " pozostały" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : " rămas" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : " осталось" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "kalan" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "remaining" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : " 剩余" + } + } + } + }, + " Safety Notifications are OFF" : { + "comment" : "Warning text for when Notifications or Critical Alerts Permissions is disabled", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : " Sikkerhedsmeddelelser er SLÅET FRA" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sicherheitsbenachrichtigungen sind ausgeschaltet" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : " Notificaciones de seguridad desactivadas" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notifications de sécurité sont DÉSACTIVÉES" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : " התראות בטיחות כבויות" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : " Le notifiche di sicurezza sono disattivate" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sikkerhetsvarsler er AV" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : " Veiligheidsmeldingen staan UIT" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Powiadomienia dotyczące bezpieczeństwa są WYŁĄCZONE" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : " Notificările de siguranță sunt dezactivate" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : " Оповещения о безопасности выключены" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Güvenlik Bildirimleri KAPALI" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : " 安全提醒已关闭" + } + } + } + }, + " Workout Preset" : { + "comment" : "Status row title for workout override enabled (leading space is to separate from symbol)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Motion forudindstilling" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : " Zielbereichsänderung" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : " Ejercicio Preestablecido" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : " Liikuntatila" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : " Préréglage exercice" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "פעילות גופנית" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : " Preset allenamento" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forhåndsinnstilling for trening" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : " Trainingsprogramma" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : " Wstępne ustawienia treningu" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : " Presetare antrenament" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пресет физнагрузки" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : " Träningsförval" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Egzersiz Ön Ayarı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : " 运动预设" + } + } + } + }, + "." : { + "comment" : "Full stop character", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "-" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "." + } + } + } + }, + "%@ %@" : { + "comment" : "The format for an active custom preset. (1: preset symbol)(2: preset name)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ %2$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@%2$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + } + } + }, + "%@ absorbed" : { + "comment" : "Formats absorbed carb value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ استغرق" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ optaget" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ resorbiert" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ absorbido" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ imeytynyt" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ absorbé(s)" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ נספג" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ assorbiti" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 吸収済" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ absorbert" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ geabsorbeerd" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ zaabsorbowane" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ absorvida" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ absorbiți" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ усвоено" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ absorbované" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ absorberat" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Emilen %@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ được hấp thụ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 已吸收" + } + } + } + }, + "%@ remaining" : { + "comment" : "Estimated remaining duration with more than a minute", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ tilbage" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ verbleiben" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ restante" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ jäljellä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ restant" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ נותרו" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ rimanenti" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ gjenstår" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ resterend" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pozostało %@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ rămase" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Осталось %@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ återstår" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ kaldı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "剩余 %@" + } + } + } + }, + "%@ U Total" : { + "comment" : "The subtitle format describing total insulin. (1: localized insulin total)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ وحدة بشكل كامل" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ E Total" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ IE gesamt" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ U Totales" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ U yhteensä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ U total" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ U סה״כ" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ U Totali" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "計 %@ 単位" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ E Totalt" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ E Totaal" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ J łącznie" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ U Total" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ U total" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ всего ед" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ j Celkom" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ E totalt" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toplam %@ Ü" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ U Tổng cộng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 单位 总量" + } + } + } + }, + "%@." : { + "comment" : "Appends a full-stop to a statement", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@." + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ ." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@." + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ ." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@." + } + } + } + }, + "%@%@ was unable to cancel your current temporary basal rate, which is higher than the new Max Basal limit you have set. This may result in higher insulin delivery than desired.\n\nConsider suspending insulin delivery manually and then immediately resuming to enact basal delivery with the new limit in place." : { + "comment" : "Alert text for failing to cancel temp basal (1: reason description, 2: app name)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@%2$@ kunne ikke annullere din nuværende midlertidige basalrate, som er højere end den nye Max basalgrænse, du har indstillet. Dette kan resultere i en højere insulintilførsel end ønsket.\n\nOvervej at suspendere insulintilførslen manuelt og derefter straks genoptage den for at iværksætte basaltilførslen med den nye grænse på plads." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@%2$@ konnte Deine aktuelle temporäre Basalrate nicht abbrechen, da diese höher ist als die neue maximale Basalrate, die Du festgelegt hast. Dies kann zu einer höheren Insulinabgabe, als erwünscht führen.\n\nErwäge, die Insulinabgabe manuell zu unterbrechen und dann sofort wieder aufzunehmen, um die Basalabgabe mit dem neuen Grenzwert zu initiieren." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@ no pudo cancelar su basal temporal actual, que es más alta que el nuevo límite basal máximo que ha establecido. Esto puede resultar en una administración de insulina superior a la deseada. \n\n Considere suspender la administración de insulina manualmente y luego reanudarla de inmediato para activar la administración basal con el nuevo límite establecido." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@ n'a pas pu annuler votre débit basal temporaire actuel, qui est supérieur à la nouvelle limite basale max que vous avez définie. Cela peut entraîner une administration d'insuline plus élevée que souhaitée. \n\nEnvisagez de suspendre l'administration d'insuline manuellement, puis de reprendre immédiatement l'administration basale avec la nouvelle limite en place." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@%2$@ non sono stati in grado di cancellare la tua attuale basale temporanea, che è più elevata di quella impostata come nuovo limite massimo di basale. Questo potrebbe comportare una maggiore infusione d'insulina di quanto desiderato. \n\nConsidera di sospendere manualmente l'erogazione d'insulina e quindi di riattivarla immediatamente per attivare l'erogazione d'insulina basale con il corretto limite impostato." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@ kunne ikke avbryte den nåværende midlertidige basaldosen, som er høyere enn den nye maksimale basalgrensen du har angitt. Dette kan føre til høyere insulintilførsel enn ønsket. \n\n Vurder å avbryte insulintilførselen manuelt og deretter umiddelbart gjenoppta for å innføre basaltilførsel med den nye grensen på plass." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@ kon je huidige tijdelijke basaalsnelheid niet annuleren, die hoger is dan de nieuwe Maximale Basaallimiet die je hebt ingesteld. Dit kan leiden tot een hogere insulinetoediening dan gewenst. \n\nOverweeg om de insulinetoediening handmatig te onderbreken en dan direct het hervatten van de basaaltoediening uit te laten voeren met de nieuwe geldende limiet." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@ nie mógł anulować bieżącej tymczasowej dawki podstawowej, która jest wyższa niż nowy ustawiony limit maksymalnej dawki podstawowej. Może to skutkować wyższym niż pożądane podawaniem insuliny. \n\n Rozważ ręczne wstrzymanie podawania insuliny, a następnie natychmiastowe wznowienie podawania dawki podstawowej z obowiązującym nowym limitem." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@ nu a putut anula rata bazală temporară actuală, care este mai mare decât noua limită bazală maximă pe care ați setat-o. Acest lucru poate duce la o administrare de insulină mai mare decât se dorește. \n\nLuați în considerare suspendarea manuală a administrării insulinei și apoi reluarea imediată pentru a pune în aplicare administrarea bazală cu noua limită în vigoare." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@%2$@ не смог отменить вашу текущую ВБС, которая превышает установленный вами новый предел максимальной ВБС. Это может привести к большему количеству подаваемого инсулина, чем хотелось бы.\n\nРассмотрите возможность приостановить введение инсулина вручную, а затем немедленно возобновить введение базального инсулина с новым установленным лимитом." + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@%2$@ nedokázal zrušiť vašu aktuálnu dočasnú bazálnu dávku, ktorá je vyššia ako nový maximálny bazálny limit, ktorý ste nastavili. To môže viesť k vyššiemu dodávaniu inzulínu, ako je požadované.\n\nZvážte ručné pozastavenie podávania inzulínu a následné okamžité obnovenie, aby ste uplatnili novým bazálny limit." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@ , ayarladığınız yeni Maks Bazal limitinden daha yüksek olan mevcut geçici bazal oranınızı iptal edemedi. Bu, istenenden daha yüksek insülin iletimi ile sonuçlanabilir. \n\n İnsülin iletimini manuel olarak askıya almayı ve ardından yeni limit geçerliyken bazal iletimi etkinleştirmek için hemen devam etmeyi düşünün." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@%@ 无法取消当前的临时基础率,该基础率高于您新设定的最大基础率上限。这可能导致胰岛素输注量高于预期。\n\n建议您手动暂停胰岛素输注,然后立即恢复,以便根据新的上限重新执行基础输注。" + } + } + } + }, + "%1@%2@" : { + "comment" : "Adds a full-stop to a statement (1: statement, 2: full stop character)", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$1@%2$2@" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$1@%2$2@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$1@%2$2@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$1@%2$2@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$1@%2$2@" + } + } + } + }, + "%1$@ – %2$@ %3$@" : { + "comment" : "Format string for glucose target range. (1: Min target)(2: Max target)(3: glucose unit)", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ – %2$@ %3$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ – %2$@ %3$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ – %2$@ %3$@" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ – %2$@ %3$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ – %2$@ %3$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ – %2$@ %3$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ – %2$@ %3$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ – %2$@ %3$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ – %2$@ %3$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ – %2$@ %3$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ – %2$@ %3$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ - %2$@ %3$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ – %2$@ %3$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ – %2$@ %3$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ – %2$@ %3$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ – %2$@ %3$@" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ – %2$@ %3$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ – %2$@ %3$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ – %2$@ %3$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ – %2$@ %3$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ – %2$@ %3$@" + } + } + } + }, + "%1$@ %2$@" : { + "comment" : "Format string combining carb entry quantity and absorption time emoji", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@%2$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + } + } + }, + "%1$@ %2$@/U" : { + "comment" : "Format string for carb ratio average. (1: value)(2: carb unit)", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@/وحدة" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@/E" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@/IE" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@/U" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@/U" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@/U" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@/U" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@/U" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@/U" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@/U" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@ /E" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@ /E" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@/J" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@/U" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@/U" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@/ед" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@ /j" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@/E" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@/Ü" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@/U" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@/单位" + } + } + } + }, + "%1$@ + %2$@" : { + "comment" : "Format string combining carb entry time and absorption time\nFormats (1: carb start time) and (2: carb absorption duration)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ + %2$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ + %2$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ + %2$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ + %2$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ + %2$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ + %2$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ + %2$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ + %2$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ + %2$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ + %2$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ + %2$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ + %2$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ + %2$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ + %2$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ + %2$@" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ + %2$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ + %2$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ + %2$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ + %2$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ + %2$@" + } + } + } + }, + "%1$@ APP SOUNDS" : { + "comment" : "App sounds title text (1: app name)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ APP LYDE" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ SUONI DELL'APP" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ APP-LYDER" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ DŹWIĘKI APLIKACJI" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ ЗВУКИ ПРИЛОЖЕНИЯ" + } + } + } + }, + "%1$@ is operating with Closed Loop in the OFF position. Your pump and CGM will continue operating, but the app will not adjust dosing automatically." : { + "comment" : "Alert message for closed loop off informational modal. (1: app name)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ kører med Lukket Loop i positionen OFF. Din pumpe og CGM fortsætter med at fungere, men appen justerer ikke doseringen automatisk." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ arbeitet mit geschlossenem Regelkreis in der AUS-Position. Deine Pumpe und CGM funktionieren weiter, aber die App passt die Dosierung nicht automatisch an." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ está funcionando con Circuito Cerrado en la posición APAGADO. Su bomba y CGM seguirán funcionando, pero la aplicación no ajustará la dosis automáticamente." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ fonctionne avec la boucle fermée en position ARRÊT. Votre pompe et votre CGM continueront de fonctionner, mais l'application n'ajustera pas automatiquement le dosage." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ è in funzione con Loop chiuso disattivato. La pompa e il CGM continueranno a funzionare, ma l'applicazione non regolerà automaticamente il dosaggio." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ opererer med Lukket Loop i AV-posisjon. Pumpen og CGM vil fortsette å fungere, men appen vil ikke justere doseringen automatisk." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ werkt met Gesloten Loop in de UIT stand. Je pomp en CGM blijven werken, maar de app past de dosering niet automatisch aan." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ działa z zamkniętą pętlą w pozycji OFF. Twoja pompa i CGM będą nadal działać, ale aplikacja nie będzie automatycznie dostosowywać insuliny." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ operează cu bucla închisă în poziția OFF. Pompa și CGM-ul vor continua să funcționeze, dar aplicația nu va ajusta automat dozarea." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ работает с замкнутым циклом в положении ВЫКЛ. Ваша помпа и CGM будут продолжать работать, но приложение не будет регулировать дозировку автоматически." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ , KAPALI konumda Kapalı Döngü ile çalışıyor. Pompanız ve CGM çalışmaya devam edecek, ancak uygulama dozajı otomatik olarak ayarlamayacaktır." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ 当前处于非闭环模式。胰岛素泵和连续血糖监测仪仍将正常运行,但应用程序不会自动调整胰岛素剂量。" + } + } + } + }, + "%1$@ is unable to clear the alert from your device" : { + "comment" : "Message for alert shown when alert acknowledgement fails for a device, and the device does not provide a LocalizedError. (1: app name)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ kan ikke slette advarslen fra din enhed" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ kann die Benachrichtigung nicht von Deinem Gerät löschen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ no puede borrar la alerta de su dispositivo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ ne parvient pas à supprimer l'alerte de votre dispositif." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ non è in grado di cancellare l'avviso dal tuo dispositivo" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ kan ikke fjerne varselet fra enheten din" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ kan de melding niet wissen van je apparaat" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ nie może usunąć alertu z Twojego urządzenia" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ nu poate șterge alerta de pe dispozitiv" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ не удается удалить предупреждение с вашего устройства" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ , uyarıyı cihazınızdan silemiyor" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ 无法清除您设备上的警报。" + } + } + } + }, + "%1$@ is unable to communicate with your insulin pump. The app will continue trying to reach your pump, but insulin delivery information cannot be updated and no automation can continue.\nYou can wait several minutes to see if the issue resolves or tap the button below to learn more about other options." : { + "comment" : "Message for alert shown when delivery status is uncertain. (1: app name)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ kan ikke kommunikere med insulinpumpen. Loop vil fortsætte med at forsøge at kommunikere med din pumpe, men insulinafgivelsesoplysninger kan ikke opdateres og ingen automatisering kan fortsætte.\nDu kan vente et par minutter for at se, om problemet bliver løst, eller tryk på knappen nedenfor for at få mere at vide om andre muligheder." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ kann nicht mit Deiner Insulinpumpe kommunizieren. Die App versucht weiterhin, Deine Pumpe zu erreichen, aber die Informationen zur Insulinabgabe können nicht aktualisiert werden und die Automatisierung kann nicht fortgesetzt werden.\nDu kannst einige Minuten warten, um zu sehen, ob das Problem behoben ist, oder auf die Schaltfläche unten tippen, um mehr über andere Optionen zu erfahren." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ no puede comunicarse con tu bomba de insulina. La aplicación continuará intentando llegar a tu bomba, pero la información de entrega de insulina no se puede actualizar y ninguna automatización puede continuar.\nPuedes esperar varios minutos para ver si el problema resuelve o pulsa el botón de abajo para saber más sobre otras opciones." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ ei pysty kommunikoimaan insuliinipumpun kanssa. Sovellus yrittää edelleen yhdistää pumppuun, mutta insuliininannostelun tiedot eivät päivity, eikä automatiikka ole päällä.\nVoit odottaa muutaman minuutin nähdäksesi ratkeaako ongelma, tai voit napauttaa alla olevaa painiketta saadaksesi lisätietoja muista vaihtoehdoista." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ ne parvient pas à communiquer avec votre pompe à insuline. L’application continuera d’essayer d’atteindre votre pompe, mais les informations sur l’administration d’insuline ne peuvent pas être mises à jour et aucune automatisation ne peut continuer.\nVous pouvez attendre quelques minutes pour voir si le problème est résolu ou appuyer sur le bouton ci-dessous pour en savoir plus sur les autres options." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ non riesce a comunicare con la pompa. L'app continuerà a provare a contattare la pompa, ma le informazioni sull'erogazione dell'insulina non possono essere aggiornate e l'automazione non può proseguire. \nPuoi attendere alcuni minuti per vedere se il problema si risolve oppure toccare il pulsante qui sotto per saperne di più sulle altre opzioni." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ kan ikke kommunisere med insulinpumpen. Appen vil fortsette å prøve å nå pumpen din, men insulintilførselsinformasjon kan ikke oppdateres og ingen automatisering kan fortsette.\n Du kan vente flere minutter for å se om problemet løser seg eller trykke på knappen nedenfor for å lære mer om andre alternativer." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ kan niet communiceren met je insulinepomp. De app blijft proberen je pomp te bereiken, maar de insulinetoedieningsinformatie kan niet worden bijgewerkt en automatisering kan niet plaatsvinden.\nJe kunt enkele minuten wachten om te zien of het probleem is opgelost of tik op de onderstaande knop voor meer informatie over andere opties." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ nie może skomunikować się z Twoją pompą insulinową. Aplikacja będzie nadal próbowała połączyć się z pompą, ale nie można aktualizować informacji o podawaniu insuliny i nie można kontynuować automatyzacji.\n Możesz poczekać kilka minut, aby sprawdzić, czy problem został rozwiązany, lub nacisnąć poniższy przycisk, aby dowiedzieć się więcej o innych opcjach." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ nu poate comunica cu pompa de insulină. Aplicația va continua să încerce să comunice cu pompa, dar informațiile despre administrarea insulinei nu pot fi actualizate și automatizarea nu poate continua.\nPuteți aștepta câteva minute pentru a vedea dacă problema se rezolvă sau apăsați butonul de mai jos pentru a afla mai multe despre alte opțiuni." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ не может связаться с вашей инсулиновой помпой. Приложение будет продолжать попытки связаться с вашей помпой, но информация о доставке инсулина не может быть обновлена, и автоматизация не может продолжаться.\nВы можете подождать несколько минут и посмотреть, разрешится ли проблема, или нажать кнопку ниже, чтобы узнать о других возможностях." + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ nedokáže komunikovať s vašou inzulínovou pumpou. Aplikácia sa bude naďalej pokúšať kontaktovať vašu pumpu, ale informácie o podávaní inzulínu nemožno aktualizovať a nemôže pokračovať žiadna automatizácia.\nMôžete počkať niekoľko minút, aby ste zistili, či sa problém vyriešil, alebo klepnutím na tlačidlo nižšie sa dozviete viac o ďalších možnostiach." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ kan inte kommunicera med din insulinpump. Appen kommer att fortsätta att försöka nå din pump, men under tiden kan information om insulintillförsel inte uppdateras och automatisering inte fortsätta.\nDu kan vänta flera minuter för att se om problemet löser sig eller klicka på knappen nedan för att läsa mer om andra alternativ." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ insülin pompanızla iletişim kuramıyor. Uygulama pompanıza ulaşmaya çalışmaya devam edecek, ancak insülin iletim bilgileri güncellenemeyecek ve hiçbir otomasyon devam edemeyecektir.\nSorunun çözülüp çözülmediğini görmek için birkaç dakika bekleyebilir veya diğer seçenekler hakkında daha fazla bilgi edinmek için aşağıdaki butona dokunabilirsiniz." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ 无法与您的胰岛素泵通信。应用将继续尝试连接胰岛素泵,但当前无法更新胰岛素输注信息,也无法继续自动化操作。\n\n您可以等待几分钟以观察问题是否自动恢复,或点击下方按钮了解其他可选操作。" + } + } + } + }, + "%1$@ Time Settings Need Attention" : { + "comment" : "Time change alert title", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ Tidsindstillinger kræver opmærksomhed" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ Zeiteinstellungen erfordern Aufmerksamkeit" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ La configuración de la hora necesita atención" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ Paramètres d'heure requierent une attention" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ הגדרות השעה דורשות תשומת לב" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ Le impostazioni dell'orario richiedono attenzione" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ Tidsinnstillinger trenger oppmerksomhet" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ Tijdinstellingen Hebben Aandacht Nodig" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ Ustawienia czasu wymagają uwagi" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ Setările de timp necesită atenție" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ Настройки времени требуют внимания" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ Zaman Ayarlarına Dikkat Edilmesi Gerekiyor" + } + } + } + }, + "%1$@ U" : { + "comment" : "Reservoir entry (1: volume value)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ E" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ IE" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "U %1$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ E" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ E" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ J" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ Ед." + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ j" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ E" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ Ü" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U" + } + } + } + }, + "%1$@ U left" : { + "comment" : "Low reservoir alert format string. (1: Number of units remaining)", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ وحدة متبقية" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ E tilbage" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ IE verbleibend" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U left" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U restantes" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U jäljellä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U restantes" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U נותרו" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U rimaste" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "残 %1$@単位" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ E igjen" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ E over" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ J pozostało" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U restante" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U rămase" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ Ед осталось" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zostáva %1$@ j" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ E kvar" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ Ü kaldı" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U còn lại" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ 单位 剩余" + } + } + } + }, + "%1$@ U left: %2$@" : { + "comment" : "Low reservoir alert with time remaining format string. (1: Number of units remaining)(2: approximate time remaining)", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ وحدة متبقية: %2$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ E tilbage: %2$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ IE verbleibend: %2$@" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U left: %2$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U restantes: %2$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U jäljellä: %2$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U restantes: %2$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U נותרו: %2$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U rimaste: %2$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "残 %1$@単位: %2$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ E gjenstår: %2$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ E over: %2$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ J pozostało: %2$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U restante: %2$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U rămase: %2$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ Ед осталось: %2$@" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zostáva %1$@ j: %2$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ E kvar: %2$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ Ü kalan: %2$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U còn lại: %2$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ 单位 剩余: %2$@" + } + } + } + }, + "%1$@ U/hour @ %2$@" : { + "comment" : "The format for recommended temp basal rate and time. (1: localized rate number)(2: localized time)", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ وحدة/ساعة @ %2$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ E/time @ %2$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ IE/h @ %2$@" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U/hour @ %2$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U/hora @ %2$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U/h klo %2$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U/heure @ %2$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ יח׳/שעה @ %2$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U/ora @ %2$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U/時 @ %2$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ E/timen @ %2$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ E/uur @ %2$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ J/godzinę @ %2$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U/hora @ %2$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U/oră @ %2$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ ед/час @ %2$@" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ j/hod @ %2$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ E/h @ %2$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ Ü/saat @ %2$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U/giờ @ %2$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ 单位/小时 @ %2$@" + } + } + } + }, + "%1$@ v%2$@" : { + "comment" : "The format string for the app name and version number. (1: bundle name)(2: bundle version)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v %2$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ версии %2$@" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v %2$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + } + } + }, + "%1$@ will stop working in %2$@. You will need to rebuild before that." : { + "comment" : "Format string for body for notification of upcoming expiration. (1: app name) (2: amount of time until expiration", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ holder op med at fungere i %2$@. Du bliver nødt til at rebuilde før det." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ funktioniert in %2$@ nicht mehr. Sie müssen vorher einen Neuaufbau durchführen." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ smetterà di funzionare tra %2$@. Prima di allora, sarà necessario ricostruirlo." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ vil slutte å fungere i %2$@. Du må gjenoppbygge før det." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ przestanie działać w %2$@. Konieczna będzie wcześniejsza odbudowa." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ va înceta să funcționeze în %2$@. Va trebui să reconstruiți sistemul înainte de asta." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ перестанет работать в %2$@. До этого времени необходимо пересобрать приложение заново." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@将在%2$@停止工作。您需要在此之前重建。" + } + } + } + }, + "%1$@ will stop working in %2$@. You will need to update before that, with a new provisioning profile." : { + "comment" : "Format string for body for notification of upcoming provisioning profile expiration. (1: app name) (2: amount of time until expiration", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ holder op med at fungere i %2$@. Du skal opdatere inden da med en ny provisioneringsprofil." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ funktioniert nicht mehr in %2$@. Du musst vor Ablauf mit einem neuen Bereitstellungsprofil aktualisieren." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ dejará de funcionar en %2$@ . Deberá actualizar antes de eso, con un nuevo perfil de aprovisionamiento." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ cessera de fonctionner dans %2$@ . Vous devrez mettre à jour avant cela, avec un nouveau profil de provisioning." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ smetterà di funzionare tra %2$@. Prima di allora, sarà necessario effettuare l'aggiornamento con un nuovo profilo di provisioning." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ slutter å fungere om %2$@ . Du må oppdatere før det, med en ny klargjøringsprofil." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ stopt met werken in %2$@. Je zult eerder moeten updaten, met een nieuw beschikbaargesteld profiel." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ przestanie działać za %2$@ . Wcześniej konieczna będzie aktualizacja przy użyciu nowego profilu udostępniania." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ nu va mai funcționa în %2$@. Va trebui să actualizați înainte de aceasta, cu un nou profil de asigurare a accesului." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ перестанет работать в %2$@. Вам нужно будет обновить его до этого, используя новую подпись." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@ içinde çalışmayı durduracaktır. Bundan önce yeni bir provizyon profili ile güncelleme yapmanız gerekecektir." + } + } + } + }, + "%1$@: %2$@" : { + "comment" : "Formats (1: carb value) and (2: food type)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@: %2$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@: %2$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@: %2$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@: %2$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@: %2$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@: %2$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@: %2$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@: %2$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@: %2$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ : %2$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ : %2$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@: %2$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@: %2$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@: %2$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@: %2$@" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ : %2$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@: %2$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@: %2$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@: %2$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@: %2$@" + } + } + } + }, + "%1$@: %2$@ %3$@" : { + "comment" : "Description of a basal temp basal dose entry (1: title for dose type, 2: value (? if no value) in bold, 3: unit)\nDescription of a bolus dose entry (1: title for dose type, 2: value (? if no value) in bold, 3: unit)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@: %2$@ %3$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@: %2$@ %3$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@: %2$@ %3$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@: %2$@ %3$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@: %2$@ %3$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@: %2$@ %3$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ : %2$@ %3$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@: %2$@ %3$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@: %2$@ %3$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@: %2$@ %3$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@: %2$@ %3$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@: %2$@ %3$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@: %2$@ %3$@" + } + } + } + }, + "⚠️" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "⚠️" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "⚠️" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "⚠️" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "⚠️" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "⚠️" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "⚠️" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "⚠️" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "⚠️" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "⚠️" + } + } + } + }, + "15 min glucose regression coefficient (b₁), continued with decay over 30 min" : { + "comment" : "Description of the prediction input effect for glucose momentum", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "15 دقيقة معامل انحدار قراءات سكر الدم (b₁), ويستمر بالاضمحلال خلال 30 دقيقة" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "15 min. glukoseregressionskoefficient (b₁), fortsætter med henfald over 30 minutter." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "15 Minuten Blutzucker-Regressionskoeffizient (b₁), fortgesetzt mit Abfall über 30 min" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Coeficiente de regresión de glucosa de 15 minutos (b₁), continuado con decaimiento durante 30 minutos" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "15 min glukoosin regressiokerroin (b₁), hiipuen 30 min kuluessa" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Coefficient de régression du glucose de 15 minutes (b1), décroissance poursuivie au-delà de 30 min." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "15 min glucose regression coefficient (b₁), continued with decay over 30 min" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Coefficiente di regressione della glicemia a 15 minuti (b₁), continuato con decadimento per 30 minuti" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "15分 グルコース回帰係数 (b₁)、30分退化適用" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "15 minutters glukose-regresjonskoeffisient (b1), fortsatt med nedbrytning over 30 minutter." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "15 min glucose regressiecoëficiënt (b₁), gevolgd door afbouw over 30 min" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "15-minutowy współczynnik regresji glukozy (b₁), kontynuowany z rozkładem przez 30 min." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "15 min coeficiente de regressão de glicose (b₁), continuada com queda em 30 min." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "coeficient de regresie glicemică pe 15 min (b₁), continuat cu o diminuare pe 30 min." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "15-мин коэффициент регрессии гликемии (b1), продолжен с угасанием 30 мин" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "15 minútový regresný koeficient glykémie (b1), pokračujúci s poklesom počas 30 minút" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "15 minuters glukosregressionskoefficient (b₁), fortsatt med 30 minuters avklingande" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "15 dakikalık glikoz regresyon katsayısı (b₁), 30 dakika boyunca bozuk devam etti" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "15 phút hồi quy glucose (b₁), tiếp tục phân rã trên 30 phút." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "15分钟葡萄糖回归系数(b1),持续30分钟衰减" + } + } + } + }, + "30 min comparison of glucose prediction vs actual, continued with decay over 60 min" : { + "comment" : "Description of the prediction input effect for retrospective correction", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "30 دقيقة مقارنة قراءات سكر الدم المتوقعة والفعلية , ويستمر بالاضمحلال خلال 60 دقيقة" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "30 min. sammenligning af glukose-forudsigelse kontra faktisk målt glukose, med henfald over 60 minutter." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "30-minütiger Vergleich der Blutzuckerprognose gegenüber dem tatsächlichen Blutzucker, Fortsetzung mit Abfall über 60 min" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comparación de glucosa actual contra la proyectada en 30 minutos, continuada con decaimiento durante 60 minutos." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "30 min vertailu ennustetun ja todellisen glukoosin välillä, hiipuen 60 min kuluessa" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comparaison sur 30 min de la glycémie prévue par rapport à celle réelle, suivie d'une décroissance (decay) sur 60 min." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "30 min comparison of glucose prediction vs actual, continued with decay over 60 min" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confronto di 30 minuti tra la previsione della glicemia e quella effettiva, continuato con decadimento per 60 minuti" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "30分 グルコース予想値と実際値の比較、60分退化適用" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "30 min sammenligning av glukose-prediksjon vs faktisk, fortsatt med nedbrytning over 60 min." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "30 min vergelijking van glucosevoorspelling versus de werkelijke, gevolgd door afbouw over 60 min" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "30 minutowe porównanie przewidywania stężenia glukozy w stosunku do rzeczywistego, kontynuowane z rozkładem przez 60 min." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "30 min de comparação da previsão de glicose vs atual, continuada com queda em 60 min." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comparație timp de 30 de minute între predicția glicemiei și valoarea reală, continuată cu scăderea valorii pe parcursul a 60 de minute" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "30 мин сравнение предсказанной гликемии с действительной, продолжено с угасанием 60 мин" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "30 min jämförelse av glukosprediktion och faktiskt värde, fortsatt med 60 miuters avklingande." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "30 dakikalık glikoz tahmini ile gerçek karşılaştırması, 60 dakika boyunca bozuk devam etti" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "30 phút so sánh dự đoán glucose so với thực tế, tiếp tục với sự phân rã hơn 60 phút." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "30分钟葡萄糖预测与实际比较,持续60分钟以上衰减" + } + } + } + }, + "A few seconds remaining" : { + "comment" : "Estimated remaining duration with a few seconds", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Et par sekunder tilbage" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ein paar Sekunden verbleiben" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quedan unos segundos" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Muutama sekunti jäljellä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quelques secondes restantes" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "נותרו כמה שניות" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pochi secondi rimanenti" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Noen sekunder gjenstår" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nog een paar seconden" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pozostało kilka sekund" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Câteva secunde rămase" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Осталось несколько секунд" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Några sekunder återstår" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Birkaç saniye kaldı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "剩余几秒" + } + } + } + }, + "A manual glucose entry must be between %@ and %@" : { + "comment" : "Alert message for a manual glucose entry out of range error", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "En manuel blodsukkerindtastning skal være mellem %1$@ og %2$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der manuelle Blutzucker muss zwischen %1$@ und %2$@ liegen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Una entrada de glucosa manual debe estar entre %1$@ y %2$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Syötetyn glukoosiarvon on oltava välillä %1$@ – %2$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Une saisie manuelle de la glycémie doit être comprise entre %1$@ et %2$@." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Un inserimento manuale della glicemia deve essere compreso tra %1$@ e %2$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "En manuell BS-registrering må være mellom %1$@ og %2$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Een handmatige glucose-invoer moet tussen %1$@ en %2$@ liggen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ręczny wpis poziomu glukozy musi mieścić się w przedziale od %1$@ do %2$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "O intrare manuală de glicemie trebuie să fie între %1$@ și %2$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ручной ввод глюкозы должен находиться между %1$@ и %2$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Det manuellt inmatade blodsockervärdet måste vara mellan %1$@ och %2$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manuel KŞ girişi %1$@ ile %2$@ arasında olmalıdır" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请输入 %@ 到 %@ 之间的血糖值" + } + } + } + }, + "A manual glucose entry must be between %1$@ and %2$@." : { + "comment" : "Warning for simple bolus when glucose entry is out of range. (1: upper bound) (2: lower bound)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "En manuel glukoseindtastning skal være mellem %1$@ og %2$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der manuelle Blutzucker muss zwischen %1$@ und %2$@ liegen." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Una entrada de glucosa manual debe estar entre %1$@ y %2$@ ." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Une saisie manuelle de la glycémie doit être comprise entre %1$@ et %2$@." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הזנת גלוקוז ידנית חייבת להיות בין %1$@ ל- %2$@." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Un inserimento manuale della glicemia deve essere compreso tra %1$@ e %2$@." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "En manuell BS-registrering må være mellom %1$@ og %2$@ ." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Een handmatige glucose-invoer moet tussen %1$@ en %2$@ liggen." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ręczny wpis poziomu glukozy musi mieścić się w przedziale od %1$@ do %2$@ ." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "O intrare manuală de glicemie trebuie să fie între %1$@ și %2$@ ." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ручной ввод глюкозы должен находиться между %1$@ и %2$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manuel KŞ girişi %1$@ ile %2$@ arasında olmalıdır." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请输入 %1$@ 到 %2$@ 之间的血糖值" + } + } + } + }, + "A model based on the published absorption of Fiasp insulin." : { + "comment" : "Subtitle of Fiasp preset", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "نموذج يستند على امتصاص أنسولين Fiasp." + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "En model baseret på publiceret data om absorption af Fiasp insulin." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ein Modell basierend auf der veröffentlichten Resorption von Fiasp-Insulin." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "A model based on the published absorption of Fiasp insulin." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Un modelo basado en la publicación de la absorción de insulina Fiasp." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Perustuu Fiasp-insuliinin julkaistuun imeytymismalliin." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Un modèle basé sur l’absorption de l’insuline FIASP (telle que publiée)." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "A model based on the published absorption of Fiasp insulin." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Un modello basato sull'assorbimento pubblicato dell'insulina Fiasp." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fiaspインスリンの公表吸収率に基づいたモデル。" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "En modell basert på publisert absorpsjon av Fiasp-insulin." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Een model gebaseerd op de gepubliceerde opname van Fiasp insuline." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Model oparty na opublikowanej absorpcji insulin Fiasp." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Um modelo baseado na absorção publicada da insulina Fiasp." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Un model bazat pe absorbția declarată a insulinei Fiasp." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Модель, основанная на опубликованных данных усвоения FIASP инсулина." + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Model založený na publikovanej absorpcii inzulínu Fiasp." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulinmodell baserad på publicerade studier av absorption av Fiasp-insulin." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fiasp insülininin yayınlanmış emilimine dayanan bir model." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mô hình dựa trên sự hấp thụ được công bố đối với insulin Fiasp." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "基于公布的Fiasp胰岛素吸收的模型" + } + } + } + }, + "A model based on the published absorption of Humalog, Novolog, and Apidra insulin in adults." : { + "comment" : "Subtitle of Rapid-Acting – Adult preset", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "نموذج يستند على امتصاص أنسولين Humalog و Novolog و Apidra لدى البالغين." + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "En model baseret på publiceret data om absorption Humalog, Novolog, og Apidra insulin hos voksne." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ein Modell auf der Grundlage der veröffentlichten Resorption von Humalog, Novolog und Apidra Insulin bei Erwachsenen." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "A model based on the published absorption of Humalog, Novolog, and Apidra insulin in adults." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Un modelo basado en la publicación de la absorción de insulina Humalog, Novolog y Apidra en adultos." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Perustuu Humalog-, Novorapid- ja Apidra-insuliinien julkaistuun imeytymismalliin aikuisilla." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Un modèle basé sur les absorptions de l’Humalog, Novolog (Novorapid), et Apidra chez l’adulte (telles que publiées)." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "A model based on the published absorption of Humalog, Novolog, and Apidra insulin in adults." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Un modello basato sull'assorbimento pubblicato dell'insulina Humalog, Novolog e Apidra negli adulti." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "大人使用時の Humalog、Novolog、Apidraインスリンの公表吸収率に基づいたモデル。" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "En modell basert på publisert absorpsjon av Humalog, Novolog og Apidra insulin hos voksne." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Een model gebaseerd op de gepubliceerde absorptie van Humalog-, Novolog- en Apidra-insuline bij volwassenen." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Model oparty na opublikowanej absorpcji insulin Humalog, Novolog/Novorapid i Apidra u dorosłych." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Um modelo baseado na absorção publicada das insulinas Humalog, Novolog e Apidra em adultos." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Un model bazat pe absorbția declarată a insulinei Humalog, Novolog și Apidra la adulți." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Модель, основанная на опубликованных данных усвоения Humalog, Novolog и Apidra у взрослых." + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Model založený na publikovanej absorpcii inzulínu Humalog, Novolog a Apidra u dospelých." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulinmodell baserad på publicerade studier av absorption av Humalog-, Novolog- samt Apidra-insulin hos vuxna." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yetişkinlerde yayınlanmış Humalog, Novolog ve Apidra insülin emilimine dayalı bir model." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mô hình dựa trên sự hấp thụ được công bố của các loại insulin Humalog, Novolog và Apidra ở người lớn." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "基于已发表的成人使用优泌乐、诺和锐和爱倍达胰岛素吸收数据的模型。" + } + } + } + }, + "A new version of %@ is available and is recommended to continue using the app." : { + "comment" : "Software update available section footer (1: app name)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "En ny version af %@ er tilgængelig og anbefales for at fortsætte med at bruge appen." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eine neue Version von %@ ist verfügbar und es wird empfohlen, die App weiterhin zu verwenden." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hay una nueva versión de %@ disponible y se recomienda continuar usando la aplicación." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Une nouvelle version de %@ est disponible et est recommandée pour continuer à utiliser l'application." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "È disponibile una nuova versione di %@ e si consiglia di continuare a utilizzare l'app." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "En ny versjon av %@ er tilgjengelig og anbefales for å fortsette å bruke appen." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Er is een nieuwe versie van %@ beschikbaar die aanbevolen wordt om de app te kunnen blijven gebruiken." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dostępna jest nowa wersja %@, która jest zalecana do dalszego korzystania z aplikacji." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "O nouă versiune de %@ este disponibilă și se recomandă să continuați să utilizați aplicația." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Новая версия %@ доступна и рекомендуется для продолжения использования приложения." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 'ın yeni bir sürümü mevcut ve uygulamayı kullanmaya devam etmeniz önerilir." + } + } + } + }, + "A new version of %@ is available." : { + "comment" : "Required software update section footer (1: app name)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "En ny version af %@ er tilgængelig." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eine neue Version von %@ ist verfügbar." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hay una nueva versión de %@ disponible." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Une nouvelle version de %@ est disponible." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "È disponibile una nuova versione di %@." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "En ny versjon av %@ er tilgjengelig." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Er is een nieuwe versie van %@ beschikbaar." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dostępna jest nowa wersja %@ ." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Este disponibilă o nouă versiune de %@ ." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Доступна новая версия %@ ." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yeni bir %@ sürümü mevcuttur." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@有新版本可用。" + } + } + } + }, + "A pump must be configured before a bolus can be delivered." : { + "comment" : "Alert message for a missing pump error", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "En pumpe skal være konfigureret, før en bolus kan leveres." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eine Pumpe muss konfiguriert werden, bevor ein Bolus abgegeben werden kann." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Una microinfusadora debe ser configurada antes de que un bolo pueda ser entregado." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumppu on määritettävä, ennen kuin bolus voidaan annostella." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Une pompe doit être configurée avant qu'un bolus puisse être administré." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Prima di poter erogare un bolo, è necessario configurare una pompa." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "En pumpe må konfigureres før en bolus kan tilføres." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Een pomp moet worden geconfigureerd voordat een bolus kan worden toegediend." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Przed podaniem bolusa należy skonfigurować pompę." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "O pompă trebuie configurată înainte ca un bolus să poată fi livrat." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перед введением болюса необходимо настроить помпу." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "En pump måste ha lagts till och konfigurerats innan en bolus ge ges." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus iletilmeden önce bir pompanın yapılandırılması gerek." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在注射大剂量前,必须先配置胰岛素泵。" + } + } + } + }, + "Absorption Time" : { + "comment" : "Label for food absorption entry on add favorite food screen", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resorptionsdauer" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "זמן ספיגה" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Absorpsjonstid" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Timp de absorbție" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "吸收时间" + } + } + } + }, + "AcceptRecommendedBolus" : { + "comment" : "Action to copy the recommended Bolus value to the actual Bolus Field", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "استخدم قيمة الجرعة الموصى بها" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "AccepterAnbefaletBolus" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Akzeptiere empfohlenen Bolus" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "AcceptRecommendedBolus" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "AceptarBoloRecomendado" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "HyväksySuositeltuBolus" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "AcceptRecommendedBolus" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "AcceptRecommendedBolus" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "AcceptRecommendedBolus" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "推奨ボーラス値を使う" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "AksepterAnbefaltBolus" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "AccepteerVoorgesteldeBolus" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "AcceptRecommendedBolus" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "AceitarBolusRecomendado" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "AcceptRecommendedBolus" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Принятьрекомендуемыйболюс" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Akceptovať Odporúčaný Bolus" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "AcceptRecommendedBolus" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "ÖnerilenBolusuKabulEt" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "AcceptRecommendedBolus" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "按照推荐的大剂量输注" + } + } + } + }, + "Active Carbohydrates" : { + "comment" : "The title of the Carbs On-Board graph", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الكربوهيدرات النشطة" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktive kulhydrater" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktive Kohlenhydrate" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carbohidratos Activos" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktiivinen hiilihydraatti" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glucides actifs" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "פחמימות פעילות" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carboidrati attivi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "残存糖質" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktive karbohydrater" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actieve Koolhydraten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktywne węglowodany" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carboidratos Ativos" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carbohidrați activi" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Действующие углеводы" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktívne sacharidy" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktiva kolhydrater" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktif Karbonhidrat" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lượng Carbohydrates còn hoạt động" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "活性碳水化合物" + } + } + } + }, + "Active Carbohydrates: %@" : { + "comment" : "The string format describing active carbohydrates. (1: localized glucose value description)", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الكربوهيدرات النشطة: %@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktive kulhydrater: %@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktive Kohlenhydrate: %@" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Active Carbohydrates: %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carbohidratos Activos: %@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktiivinen hiilihydraatti: %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glucides actifs: %@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "פחמימות פעילות: %@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carboidrati attivi: %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "残存糖質: %@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktive karbohydrater: %@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actieve Koolhydraten: %@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktywne węglowodany: %@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carboidratos Ativos: %@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carbohidrați activi: %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Действующие углеводы: %@" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktívne sacharidy: %@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktiva kolhydrater: %@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktif Karbonhidrat: %@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lượng Carbohydrates còn hoạt động: %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "活性碳水化合物: %@" + } + } + } + }, + "Active Carbs" : { + "comment" : "Title describing quantity of still-absorbing carbohydrates", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "كارب النشط" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktive kulhydrater" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktive KH" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carbohidratos Activos" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Akt. hiilari" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glucides actifs" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "פחמימות פעילות" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carboidrati attivi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "残存糖質" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktive karbohydrater" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actieve Koolhydraten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktywne węglowodany" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carboidratos Ativos" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carbohidrați activi" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Активные углеводы" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktívne sacharidy" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktiva kolhydrater" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktif Karb." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lượng Carbs còn hoạt động" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "活性碳水" + } + } + } + }, + "Active Insulin" : { + "comment" : "Details for missing data error when active insulin amount is missing\nThe title of the Insulin On-Board graph\nTitle describing quantity of still-absorbing insulin", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "أنسولين نشط" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktivt insulin" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktives Insulin" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulina activa" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Akt. insuliini" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insuline active" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אינסולין פעיל" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulina attiva" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "残存インスリン" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktivt insulin" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actieve Insuline" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktywna insulina" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulina Ativa" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulină activă" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Активный инсулин" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktívny inzulín" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktivt insulin" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktif İnsülin" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lượng Insulin còn hoạt động" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "活性胰岛素" + } + } + } + }, + "Active Insulin: %@" : { + "comment" : "The string format describing active insulin. (1: localized insulin value description)", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الأنسولين النشط: %@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktiv insulin: %@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktives Insulin: %@" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Active Insulin: %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulina Activa: %@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktiivinen insuliini: %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insuline active: %@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אינסולין פעיל: %@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulina attiva: %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "残存インスリン: %@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktivt insulin: %@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actieve Insuline: %@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktywna Insulina: %@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulina Ativa: %@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulină activă: %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Действующий инсулин: %@" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktívny inzulín: %@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktivt insulin: %@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktif İnsülin: %@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lượng Insulin còn hoạt động: %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "活性胰岛素: %@" + } + } + } + }, + "Add a new favorite food" : { + "comment" : "Button label to open new favorite food view", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilføj en ny favoritmad" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Füge ein neues Lieblingsessen hinzu" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הוסף מאכל אהוב חדש" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aggiungi un nuovo cibo preferito" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Legg til en ny favorittmat" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voeg een nieuw favoriet eten toe" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dodaj nowe ulubione jedzenie" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adăugați o nouă mâncare preferată" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "添加常用食物" + } + } + } + }, + "Add Carb Entry" : { + "comment" : "Title of the user activity for adding carbs", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add Carb Entry" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilføj kulhydrater" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "KH hinzufügen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agregar Entrada de Carb" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lisää hiilihydraatteja" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajouter des glucides" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הוספת פחמימות" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aggiungi inserimento carboidrati" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "糖質の記入を追加" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Legg til karbohydrater" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kh. Inv. Toevoegen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wprowadź węglowodany" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adicionar Carb" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adaugă carbohidrați" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить запись углеводов" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zadať sacharidy" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lägg till kolhydrater" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Karb Girişi Ekle" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Khai báo Carb" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "添加碳水化合物" + } + } + } + }, + "Add CGM" : { + "comment" : "Action sheet title selecting CGM\nThe title of the CGM chooser in settings\nTitle text for button to add CGM device\nTitle text for button to set up a CGM", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "إضافة CGM" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilføj CGM" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "CGM hinzufügen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agregar MCG" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lisää CGM" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajouter un CGM" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הוסף חיישן" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aggiungi CGM" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "CGMを追加" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Legg til CGM" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voeg CGM toe" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dodaj CGM" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adicionar CGM" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adaugă CGM" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить CGM" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pridať CGM" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lägg till CGM" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "CGM Ekle" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Khai báo CGM" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "添加CGM" + } + } + } + }, + "Add item to bottom row" : { + "comment" : "Title for Add item" + }, + "Add Meal" : { + "comment" : "The label of the carb entry button", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "إضافة وجبة" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilføj måltid" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mahlzeit hinzufügen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agregar Alimento" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lisää ateria" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entrer un repas" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הוסף ארוחה" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aggiungi pasto" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "食事を追加" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Legg til måltid" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voeg Maaltijd toe" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dodaj posiłek" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adicionar Refeição" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adaugă masă" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить еду" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zadať jedlo" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lägg till måltid" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Öğün ekle" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Khai báo bữa ăn" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "添加用餐信息" + } + } + } + }, + "Add predictive line" : { + "comment" : "Title for predictive line toggle" + }, + "Add Pump" : { + "comment" : "Action sheet title selecting Pump\nThe title of the pump chooser in settings\nTitle text for button to add pump device\nTitle text for button to set up a Pump", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "إضافة مضخة" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilføj pumpe" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumpe hinzufügen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agregar Microinfusora" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lisää pumppu" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajouter une pompe" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הוסף משאבה" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aggiungi pompa" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ポンプを追加" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Legg til pumpe" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voeg Pomp toe" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dodaj pompę" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adicionar Bomba" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adaugă pompă" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить помпу" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pridať pumpu" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lägg till pump" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pompa Ekle" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Khai báo bơm" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "添加泵" + } + } + } + }, + "Add Service" : { + "comment" : "Action sheet title selecting service\nThe title of the add service action sheet in settings\nThe title of the add service button in settings", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilføj tjeneste" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Service hinzufügen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add Service" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lisää palvelu" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajouter un service" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הוסף שרות" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aggiungi servizio" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Legg til tjeneste" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Service Toevoegen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dodaj usługę" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adaugă Serviciu" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить сервис" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lägg till tjänst" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Servis Ekle" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "添加服务" + } + } + } + }, + "Adjusted for" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Justeret for" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Angepasst für" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajustado para" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mukautettu" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajusté(e) pour" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Regolato per" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Justert for" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aangepast voor" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dostosowane do" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajustat pentru" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скорректировано на" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Justerad för" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "için düzeltilmiş" + } + } + } + }, + "Alert Management" : { + "comment" : "Alert Permissions button text\nTitle of alert management screen", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Administration af advarsler" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alarm-Einstellungen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gestión de Alertas" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gestion des alertes" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "ניהול התראות" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gestione avvisi" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Administrasjon av varsler" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meldingbeheer" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zarządzanie alertami" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gestionarea alertelor" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Управление оповещениями" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uyarı Yönetimi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "警报管理" + } + } + } + }, + "Alert Permissions" : { + "comment" : "Alert Permissions button text\nNotification & Critical Alert Permissions screen title", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alarmtilladelser" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Benachrichtigungsberechtigungen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Permisos de Alertas" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hälytysten käyttöoikeudet" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autorisations d'alerte" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הרשאות התראה" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Permessi di avviso" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Varslingsinnstillinger" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toestemming Meldingen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uprawnienia alertów" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Permisiuni pentru alarme" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Разрешение оповещений" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Behörigheter för Varningar" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uyarı İzinleri" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "警报权限" + } + } + } + }, + "Alert Permissions and Mute Alerts" : { + "comment" : "Alert Permissions descriptive text", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Advarselstilladelser og dæmpede advarsler" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alarmberechtigungen und Stummschalten von Alarmen" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הרשאות התראה והשתקת התראות" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autorizzazioni avvisi e avvisi disattivati" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Varselstillatelser og demp varsler" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uprawnienia do alertów i wyciszanie alertów" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Permisiuni de alertă și alerte silențioase" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Разрешения на оповещения и отключение звука оповещений" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "警报权限与静音设置" + } + } + } + }, + "Algorithm Experiments" : { + "comment" : "Navigation title for algorithms experiments screen\nThe title of the Algorithm Experiments section in settings", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Algoritme-eksperimenter" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Algorithmus-Experimente" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Essais d'algorithmes" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "ניסויי אלגוריתמים" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Esperimenti algoritmo" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Algoritmeeksperimenter" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Algoritme Experimenten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Algorytmy Eksperymentalne" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Experimente algoritmice" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "算法实验" + } + } + } + }, + "Algorithm Experiments are optional modifications to the Loop Algorithm. These modifications are less tested than the standard Loop Algorithm, so please use carefully." : { + "comment" : "Algorithm Experiments description.", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Algoritmeeksperimenter er valgfrie ændringer af Loop-algoritmen. Disse ændringer er mindre testet end standard Loop-algoritmen, så brug venligst omhyggeligt." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Algorithmus-Experimente sind optionale Modifikationen des Loop-Algorithmus. Diese Modifikationen sind weniger getestet als der Standard-Loop-Algorithmus. Verwende sie daher mit Vorsicht." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les essais d'algorithme sont des modifications optionnelles de l'algorithme Loop. Ces modifications sont moins testées que l'algorithme Loop standard, alors veuillez les utiliser avec précaution." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gli esperimenti sull'algoritmo sono modifiche opzionali all'algoritmo di Loop. Queste modifiche sono meno testate rispetto all'algoritmo Loop standard, quindi utilizzale con attenzione." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Algoritmeeksperimenter er valgfrie modifikasjoner til Loop-algoritmen. Disse modifikasjonene er mindre testet enn standard Loop-algoritmen, så vær vennlig å bruke dem med forsiktighet." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Algoritme Experimenten zijn optionele aanpassingen aan het Loop Algoritme. Deze aanpassingen zijn minder grondig getest dan het standaard Loop Algoritme, dus gebruik het voorzichtig." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eksperymenty algorytmiczne to opcjonalne modyfikacje algorytmu pętli. Te modyfikacje są mniej przetestowane niż standardowy algorytm pętli, więc używaj ich ostrożnie." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Experimentele algoritmice sunt modificări opționale ale algoritmului de buclă. Aceste modificări sunt mai puțin testate decât algoritmul de buclă standard, așa că vă rugăm să le utilizați cu atenție." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "算法实验是对 Loop 算法的可选调整。这些调整的测试程度低于标准算法,请谨慎启用。" + } + } + } + }, + "Algorithm Settings" : { + "comment" : "The title of the section containing algorithm settings", + "extractionState" : "manual", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indstillinger for algoritme" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Algorithmus-Einstellungen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajustes de Algoritmo" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Algoritmin asetukset" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Paramètres de l'algorithme" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Algorithm Settings" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impostazioni algoritmo" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Algoritmeinnstillinger" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Algoritme-instellingen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ustawienia algorytmu" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Setări algoritm" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройки алгоритма" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Algoritminställningar" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Algoritma Ayarları" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "算法设置" + } + } + } + }, + "All Alerts Muted" : { + "comment" : "Warning text for when alerts are muted", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle alarmer er slået fra" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle Warnungen stummgeschaltet" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "כל ההתראות משותקות" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tutti gli avvisi disattivati" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle varsler er dempet" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wszystkie alerty wyciszone" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toate alertele sunt dezactivate" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "所有警报已静音" + } + } + } + }, + "All alerts muted until" : { + "comment" : "Label for when mute alert will end", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle advarsler er slået fra indtil" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle Alarme stummgeschaltet bis" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "כל ההתראות מושתקות עד" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tutti gli avvisi disattivati fino a" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle varsler er dempet inntil" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wszystkie alerty wyciszono do" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toate alertele sunt dezactivate până la" + } + } + } + }, + "All Favorites" : { + "comment" : "section header for list of existing FavoriteFoods", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle favoritter" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle Favoriten" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tutti i preferiti" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle favoritter" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wszystkie ulubione" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toate favoritele" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "全部常用" + } + } + } + }, + "Amount Consumed" : { + "comment" : "Label for carb quantity entry row on carb entry screen", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forbrugt mængde kulhydrater" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "KH-Menge gegessen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quantité consommée" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "כמות שנצרכה" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quantità consumata" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mengde karbohydrater\n(Mengde inntatt)" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ilość węglowodanów" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cantitate consumată" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "摄入量" + } + } + } + }, + "Amplitude" : { + "comment" : "The title of the Amplitude service", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "Amplitude" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Amplitude" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Amplitude" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Amplitude" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Amplitude" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Amplitude" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Amplitude" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Amplitude" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ampiezza" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Amplitude" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Amplitude" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Amplitude" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Amplituda" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Amplitude" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Amplitude" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Amplitude" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Amplitude" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Amplitude" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Amplitude" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Amplitude" + } + } + } + }, + "An active override is modifying your carb ratio and insulin sensitivity. If you don't want this to affect your bolus calculation and projected glucose, consider turning off the override." : { + "comment" : "Warning to ensure the carb entry is accurate during an override", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "En aktiv Override ændrer dit kulhydratforhold og din insulinfølsomhed. Hvis du ikke ønsker, at dette skal påvirke din bolusberegning og dit forventede glukose, kan du overveje at slå Override fra." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eine aktive Voreinstellung verändert Dein Kohlenhydratverhältnis und Deine Insulinempfindlichkeit. Wenn Du nicht möchtest, dass sich dies auf Deine Bolusberechnung und Deinen prognostizierten Blutzucker auswirkt, solltest Du die Voreinstellung deaktivieren." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Un Ajuste Temporal activo modifica la proporción de carbohidratos y la sensibilidad a la insulina. Si no deseas que esto afecte el cálculo del bolo y la glucosa proyectada, considera desactivar el Ajuste Temporal." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Un ajustement actif modifie votre ratio de glucides et votre sensibilité à l'insuline. Si vous ne voulez pas que cela affecte le calcul du bolus et votre glycémie projetée, envisagez de désactiver l'ajustement." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Un Override attivo sta modificando il rapporto di carboidrati e la sensibilità insulina. Se non si vuole che questo influisca sul calcolo del bolo e sulla glicemia prevista, si consiglia di disattivare la funzione di Override." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "En aktiv overstyring modifiserer ditt karbohydratforhold og insulinfølsomhet. Hvis du ikke vil at dette skal påvirke bolusberegningen og projisert blodsukker, bør du vurdere å slå av overstyringen." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Een actieve override wijzigt je koolhydraatratio en je insulinegevoeligheid. Als je niet wilt dat dit je bolusberekening en voorspelde glucose beïnvloedt, kun je overwegen de override uit te schakelen." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktywny Cel Tymczasowy (Override) modyfikuje stosunek węglowodanów i wrażliwość na insulinę. Jeśli nie chcesz, aby wpłynęło to na obliczenie bolusa i przewidywaną glikemię, rozważ wyłączenie Celu Tymczasowego." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Un modificator activa a schimbat raportul dintre carbohidrați și insulină cât și sensibilitatea la insulină. Dacă nu doriți ca acest lucru să vă afecteze calculul bolusului și gllicemia prognozată, luați în considerare dezactivarea modificatorului." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Активные временные цели изменяют соотношение углеводов и чувствительность к инсулину. Если вы не хотите, чтобы это влияло на расчет болюса и прогнозируемый уровень глюкозы, отключите эту функцию." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktif bir geçersiz kılma, karbonhidrat oranınızı ve insülin duyarlılığınızı değiştiriyor. Bunun bolus hesaplamanızı ve öngörülen glikozu etkilemesini istemiyorsanız geçersiz kılmayı kapatmayı düşünün." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当前启用的覆盖设置正在修改您的碳水比和胰岛素敏感系数。\n如果您不希望其影响大剂量的计算和预测血糖,请考虑关闭该覆盖设置。" + } + } + } + }, + "An error occurred while trying to save your carb entry." : { + "comment" : "Alert message for a carb entry persistence error", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der opstod en fejl under forsøget på at gemme kulhydratindtastning." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beim Speichern Deiner Eingabe der Kohlenhydrate ist ein Fehler aufgetreten." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se produjo un error al intentar guardar la entrada de carbohidratos." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Virhe hiilihydraattien tallennuksessa." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Une erreur est survenue lors de l'enregistrement de votre saisie manuelle de glucides." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Si è verificato un errore durante il tentativo di salvare l'inserimento dei carboidrati." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Det oppstod en feil under forsøk på å lagre karbohydratregistreringen." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Er is een fout opgetreden tijdens het opslaan van je koolhydraatinvoer." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wystąpił błąd podczas próby zapisania wpisu dotyczącego węglowodanów." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eroare la încercarea de salvare a carbohidraților." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Произошла ошибка при попытке сохранить запись об углеводах." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ett fel uppstod när du skulle spara dina kolhydrater." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Karbonhidrat girişinizi kaydetmeye çalışırken bir hata oluştu." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "保存碳水记录时出错了。" + } + } + } + }, + "An error occurred while trying to save your manual glucose entry." : { + "comment" : "Alert message for a manual glucose entry persistence error", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der opstod en fejl under forsøget på at gemme en manuel blodsukkerindtastning." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beim Speichern Deines manuell eingegebenen Blutzuckers ist ein Fehler aufgetreten." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se produjo un error al intentar guardar la entrada manual de glucosa." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Virhe glukoosiarvon tallennuksessa." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Une erreur est survenue lors de l'enregistrement de votre saisie manuelle de glycémie." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Si è verificato un errore durante il tentativo di salvare l'inserimento manuale della glicemia." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Det oppstod en feil under forsøk på å lagre den manuelle BS-registreringen." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Er is een fout opgetreden tijdens het opslaan van je handmatige glucose-invoer." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wystąpił błąd podczas próby zapisania ręcznego wpisu o glukozie." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eroare la încercarea de salvare a glicemiei introduse manual." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Произошла ошибка при попытке сохранить данные о глюкозе, введенные вручную." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ett fel uppstod när du skulle spara ditt blodsockervärde." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manuel KŞ girişinizi kaydetmeye çalışırken bir hata oluştu." + } + } + } + }, + "An unexpected onboarding error state occurred." : { + "comment" : "Invalid onboarding state", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der er opstået en uventet fejltilstand i forbindelse med onboarding." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ein unerwarteter Fehlerstatus beim Onboarding ist aufgetreten." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se ha producido un error inesperado" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Un état d'erreur innatendu a eu lieu durant le processus d'intégration" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Si è verificato un errore di onboarding imprevisto." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "En uventet onboarding-feiltilstand oppstod." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Er is een onverwachte onboarding-foutstatus opgetreden" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Niespodziewany błąd wdrażania" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "A apărut o eroare neașteptată de stare la inițiere." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Возникла непредвиденная ошибка подключения." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beklenmeyen bir ekleme hatası durumu oluştu." + } + } + } + }, + "An updated bolus recommendation is available." : { + "comment" : "Alert message when glucose data returns while on bolus screen", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "En opdateret bolusanbefaling er tilgængelig." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eine aktualisierte Bolusempfehlung ist verfügbar." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hay una recomendación actualizada de bolo." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Päivitetty bolussuositus on saatavilla." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Une recommandation de bolus mise à jour est disponible." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "È disponibile una raccomandazione aggiornata sul bolo." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "En oppdatert bolusanbefaling er tilgjengelig." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Een bijgewerkte aanbevolen bolus is beschikbaar." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dostępna jest zaktualizowana rekomendacja bolusa." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Este disponibilă o recomandare de bolus nouă." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Доступна обновленная рекомендация болюса." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Det finns nu en ny bolusrekommendation." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Güncellenmiş bir bolus önerisi mevcuttur." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已有更新的大剂量推荐值。" + } + } + } + }, + "API Key" : { + "comment" : "The title of the amplitude API key credential", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "API Key" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "API nøgle" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "API Key" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "API Key" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "API Key" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "API-avain" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clé API" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "API Key" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chiave API" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "API キー" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "API Nøkkel" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "API Key" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "API Key" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "API Key" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cheie API" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "API-ключ" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kľúč API" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "API Key" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "API Anahtarı" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "API Key" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "API Key" + } + } + } + }, + "API Secret" : { + "comment" : "The title of the nightscout API secret credential", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "API Secret" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "API Secret" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "API kodeord" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "API Secret" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "API Secret" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clave secreta API" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "API-salasana" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Secret API" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "API Secret" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "एपीआई पास्वर्ड" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chiave personale API" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "APIシークレット" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "API Hemmelighet" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "API Secret" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "API Secret" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chave API" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Secretul API" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "API Секрет" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "API Secret" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "API Secret" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "API Secret" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "API Secret" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "API Secret" + } + } + } + }, + "App Profile" : { + "comment" : "Settings app profile section", + "localizations" : { + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profil" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "App-profil" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "App-Profil" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Perfil de la aplicación" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profil de l'application" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "פרופיל אפליקציה" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profilo app" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "App-profil" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "App-Profiel" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profil aplikacji" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profil" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Профиль приложения" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uygulama Profili" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "应用配置" + } + } + } + }, + "Apple" : { + "comment" : "Default name on add favorite food screen", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apfel" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pomme" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "תפוח" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mela" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eple" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Măr" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "苹果" + } + } + } + }, + "Are you sure you want to delete all history entries?" : { + "comment" : "Action sheet confirmation message for pump history deletion", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Er du sikker på at du vil slette alle gamle indtastninger?" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Möchtest Du wirklich alle Verlaufseinträge löschen?" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Estás seguro de querer eliminar todos las entradas históricas?" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Haluatko varmasti poistaa kaikki historiatiedot?" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voulez-vous vraiment supprimer toutes les entrées de l’historique?" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "בטוח שברצונך למחוק את כל ערכי ההיסטוריה?" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sei sicuro di voler eliminare tutte le voci della cronologia?" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "入力履歴をすべて削除しますか?" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Er du sikker på at du vil slette alle historieoppføringer?" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Weet je zeker dat je de gehele pompgeschiedenis wilt verwijderen?" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Czy jesteś pewien, że chcesz usunąć z Loop wszystkie dane historyczne pompy?" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tem certeza de que deseja excluir todas as entradas do histórico?" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sigur doriți să ștergeți toate înregistrările din istoric?" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подтвердите удаление всех записей истории?" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Säkert att du vill radera all händelsehistorik?" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tüm geçmiş girişlerini silmek istediğinizden emin misiniz?" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bạn có chắc muốn xóa hết các dữ liệu cũ?" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "确定要删除所有历史记录吗?" + } + } + } + }, + "Are you sure you want to delete all logged dose entries?" : { + "comment" : "Action sheet confirmation message for logged dose deletion", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Er du sikker på, at du vil slette alle logget dosis?" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Möchtest Du wirklich alle protokollierten Dosis Einträge löschen?" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Estás seguro de que quieres borrar todas las entradas de dosis registradas?" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Haluatko varmasti poistaa kaikki kirjatut annostelutiedot?" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voulez-vous vraiment supprimer toutes les entrées de dose enregistrées?" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "בטוח שברצונך למחוק את כל ערכי המנות?" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sei sicuro di voler eliminare tutte le dosi inserite?" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Er du sikker på at du vil slette alle loggførte doseoppføringer?" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Weet je zeker dat je de gehele doseringsgeschiedenis wilt verwijderen?" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Czy na pewno chcesz usunąć wszystkie zapisane wpisy dawek?" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sigur doriți să ștergeți toate înregistrările despre dozele de insulina?" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вы уверены, что хотите удалить все зарегистрированные записи о дозах?" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Är du säker på att du vill ta bort alla dina loggade doser?" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Günlüğe kaydedilen tüm doz girişlerini silmek istediğinizden emin misiniz?" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "是否确定删除全部注射记录?" + } + } + } + }, + "Are you sure you want to delete all reservoir values?" : { + "comment" : "Action sheet confirmation message for reservoir deletion", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Er du sikker på at du vil slette alle reservoirværdier?" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Möchtest Du wirklich alle Reservoirwerte löschen?" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Estás seguro de querer eliminar todos los datos del reservorio?" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Haluatko varmasti poistaa kaikki säiliön arvot?" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voulez-vous vraiment supprimer toutes les valeurs de réservoir?" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "בטוח שברצונך למחוק את כל ערכי המאגר?" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sei sicuro di voler eliminare tutti i valori del serbatoio?" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "リザーバの値をすべて削除しますか?" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Er du sikker på at du vil slette alle reservoarverdier?" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Weet je zeker dat je alle waarden van het reservoir wilt verwijderen?" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Czy jesteś pewien, że chcesz usunąć wszystkie wartości zbiornika?" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tem certeza de que deseja excluir todos os valores do reservatório?" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sigur doriți să ștergeți toate valorile de rezervor?" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подтвердите удаление всех записей резервуара?" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Säkert att du vill radera alla reservoarvärden?" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tüm rezervuar değerlerini silmek istediğinizden emin misiniz?" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bạn có chắc muốn xóa hết mọi giá trị của ngăn chứa insulin?" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "是否确定删除全部储液量记录?" + } + } + } + }, + "Are you sure you want to delete all your %@ Data?\n(This action is not reversible)" : { + "comment" : "Confirmation before you delete all your Simulated Test Devices data", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Er du sikker på, at du vil slette alle dine %@ data?\n(Denne handling kan ikke fortrydes)" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Möchtest Du wirklich alle %@-Daten löschen?\n(Diese Aktion kann nicht rückgängig gemacht werden)" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Estás seguro de que quieres eliminar todos tus datos de %@ ?\n (Esta acción no es reversible)" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Haluatko varmasti poistaa kaikki %@ tietosi?\n(Tämä toiminto ei ole palautettavissa)" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voulez-vous vraiment effacer toutes vos valeurs de %@?\n(Cette action n'est pas réversible)" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "בטוח שברצונך למחוק את כל מידע %@?\n(פעולה זו בלתי הפיכה)" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sei sicuro di voler eliminare tutti i tuoi dati %@ ?\n (Questa azione non è reversibile)" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Er du sikker på at du vil slette alle dine %@ Data?\n(Denne handlingen kan ikke angres)" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Weet je zeker dat je al je %@ Gegevens wilt verwijderen?\n(Deze actie is niet omkeerbaar)" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Czy na pewno chcesz usunąć wszystkie swoje dane %@ ?\n (Ta czynność jest nieodwracalna)" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sunteţi sigur că doriţi să ştergeţi toate datele %@.\n(Această acţiune nu este reversibilă)" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вы уверены, что хотите удалить все свои данные %@ ?\n (Это действие необратимо)" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vill du ta bort alla dina %@data?\n(Denna åtgärd är inte reversibel)" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tüm %@ Verilerinizi silmek istediğinizden emin misiniz?\n (Bu eylem geri alınamaz)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "确定要删除所有 %@ 数据吗?(此操作无法恢复)" + } + } + } + }, + "Are you sure you want to delete this CGM?" : { + "comment" : "Confirmation message for deleting a CGM", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "هل أنت متأكد أنك تريد حذف هذا CGM؟" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Er du sikker på, at du vil slette denne CGM?" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bist Du sicher, dass Du dieses CGM löschen möchtest?" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Está seguro de que quiere eliminar este MCG?" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Haluatko varmasti poistaa CGM:n?" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voulez-vous vraiment supprimer ce CGM?" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "בטוח שברצונך למחוק את חיישן זה?" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sei sicuro di voler eliminare questo CGM?" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "このCGMを削除しますか?" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Er du sikker på at du vil slette denne CGM?" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Weet je zeker dat je deze CGM wilt verwijderen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Czy na pewno chcesz usunąć ten CGM?" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Você está certo que quer remover este CGM?" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sunteți sigur că doriți să ștergeți acest CGM?" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вы уверены, что хотите удалить этот CGM?" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Naozaj chcete odstrániť toto CGM?" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Är du säker på att du vill radera denna CGM?" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bu CGM'i silmek istediğinizden emin misiniz?" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bạn có chắc sẽ xóa CGM này?" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "确定要删除该CGM数据源?" + } + } + } + }, + "Are you sure you want to delete this food?" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Er du sikker på, at du vil slette denne mad?" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bist Du sicher, dass Du diesen Favoriten löschen möchtest?" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sei sicuro di voler eliminare questo cibo?" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Er du sikker på at du vil slette denne maten?" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Czy na pewno chcesz usunąć to jedzenie?" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sunteți sigur că doriți să ștergeți acest aliment?" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "你确定要删除该食物吗?" + } + } + } + }, + "Are you sure you want to delete this service?" : { + "comment" : "Confirmation message for deleting a service", + "extractionState" : "manual", + "localizations" : { + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opravdu chcete tuto službu smazat?" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Er du sikker på, du vil slette denne service?" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bist Du sicher, dass Du diesen Dienst löschen möchtest?" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Está seguro de que desea eliminar este servicio?" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Haluatko varmasti poistaa tämän palvelun?" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voulez-vous vraiment supprimer ce service?" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "בטוח שברצונך למחוק את שירות זה?" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sei sicuro di voler eliminare questo servizio?" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Er du sikker på at du vil slette denne tjenesten?" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Weet je zeker dat je deze service wil verwijderen?" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Czy na pewno chcesz usunąć tę usługę?" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sunteți sigur că vreți să ștergeți acest serviciu?" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вы уверены, что хотите удалить этот сервис?" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Naozaj chcete odstrániť túto službu?" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vill du ta bort den här tjänsten?" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bu servisi silmek istediğinizden emin misiniz?" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Are you sure you want to delete this service?" + } + } + } + }, + "at %@" : { + "comment" : "Format fragment for a specific time", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "في %@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "at %@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "um %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "en %@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "klo %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "à %@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "ב-%@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "a %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 時点" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "kl %@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "om %@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "o %@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "em %@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "la %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "В %@" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "o %@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "kl. %@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "lúc %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@分钟内" + } + } + } + }, + "Authenticate to Bolus %@ Units" : { + "comment" : "The message displayed during a device authentication prompt for bolus specification", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "المصادة على ضخ %@ وحدات" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Godkend bolus af %@ Enheder" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolusabgabe von %@ IE bestätigen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autenticar para Bolo %@ Unidades" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vahvista bolus %@ yksikköä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Authentifiez-vous pour faire un bolus de %@ Unités" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Authenticate to Bolus %@ Units" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Esegui l'autenticazione per il bolo di %@ Unità" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ボーラス %@単位 認証してください" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autentiserer for å gi %@ Enheter i Bolus" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Authenticeer om %@ E te Bolussen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autoryzacja Bolusa %@ jednostek" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autentique para Bolus de %@ Unidades" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autentificare pentru bolus %@ unități" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подтверждение болюса %@ ед" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Potvrdiť pre Bolus %@ Jednotky" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Godkänn bolus på %@ enheter" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus için kimlik doğrula %@ Ünite" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xác thực liều Bolus %@ Units" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "解锁以输注大剂量 %@ 单位" + } + } + } + }, + "Authenticate to log %@ Units" : { + "comment" : "The message displayed during a device authentication prompt to log an insulin dose", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Godkend for at logge %@ enheder" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Authentifiziere Dich, um %@ Einheiten zu protokollieren." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autenticarse para registrar %@ Unidades" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vahvista kirjaus %@ yksikköä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Authentifier pour enregistrer %@ unités" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Esegui l'autenticazione per registrare %@ Unità" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autentiser for å logge %@ Enheter" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Authenticeer om %@ Eenheden te registreren" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autoryzuj, aby podać %@ Jednostki" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autentificare pentru a înregistra %@ unități" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пройдите аутентификацию для подтверждения болюса %@ Единиц" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Godkänn att logga %@ enheter" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ Ünite günlüğe kaydetmek için kimlik doğrulaması yapın" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请验证身份以记录 %@U 胰岛素" + } + } + } + }, + "Basal Rate Schedule" : { + "comment" : "Details for configuration error when basal rate schedule is missing", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "جدول الضخ المستمر" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basalrateskema" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basalraten-Plan" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Perfil Basal" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basaaliohjelma" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Programme débit basal" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basal Rate Schedule" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tabella di velocità basale" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "基礎レートスケジュール" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tidsplan for basaldoser" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basaalsnelheidschema" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Harmonogram dawki standardowej" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Programação da Taxa Basal" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Orar rate bazale" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скорости базала" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Harmonogram bazálnej dávky" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basaldosschema" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bazal Oran Çizelgesi" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lịch biểu tiêm liều basal" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "基础率表" + } + } + } + }, + "Basal Rates" : { + "comment" : "The title of the basal rate profile screen\n The title text for the basal rate schedule", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الضخ المستمر" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Úrovně bazálu" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basalrater" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basalrate" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basal Rates" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tasas basales" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basaalitasot" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Débits basaux" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basal Rates" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Velocità basali" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "基礎レート" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basal-satser" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basaalsnelheden" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dawka Podstawowa (Baza)" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Taxas Basais" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rate bazale" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скорости базала" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bazálne dávky" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basaldoser" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bazal Oranlar" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lịch biểu tiêm liều nền" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "基础率" + } + } + } + }, + "Based on your predicted glucose, no bolus is recommended." : { + "comment" : "Caption for bolus screen notice when no bolus is recommended for the predicted glucose", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Baseret på din forventede glukose anbefales ingen bolus." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basierend auf Deinem vorhergesagten Blutzucker wird kein Bolus empfohlen." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basándose en su glucosa proyectada, no se recomienda ningún bolo." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sur la base de votre glycémie prévue, aucun bolus n’est recommandé." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "בהתבסס על הגלוקוז החזוי שלך, לא מומלץ שום בולוס." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "In base alla glicemia prevista, non è consigliato alcun bolo." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basert på ditt forventede blodsukker, anbefales ingen bolus." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Op basis van je voorspelde glucose, wordt een bolus niet aanbevolen." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Na podstawie przewidywanej glikemii nie zaleca się podawania bolusa." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pe baza glicemiei prognozate, nu se recomandă niciun bolus." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Основываясь на вашей прогнозируемой глюкозе, болюс не рекомендуется." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tahmini KŞ'ne bağlı olarak bolus önerilmez." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "根据您的预测血糖值,当前不建议注射大剂量。" + } + } + } + }, + "Bluetooth\nOff" : { + "comment" : "Message to the user to that the bluetooth is off", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth slået fra" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth aus" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth\nApagado" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth ei ole käytössä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth désactivé" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "בלוטות׳ כבוי" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth\nDisattivato" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blåtann\nAv" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth\nUit" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth jest wyłączony" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth \nOprit" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth\n Выключен" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth Av" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth\nKapalı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "蓝牙已关闭" + } + } + } + }, + "Bluetooth\nUnavailable" : { + "comment" : "Message to the user that bluetooth is unavailable to the app", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth ikke tilgængelig" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth nicht verfügbar" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth \nNo Disponible" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth ei ole käytettävissä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth\nIndisponible" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "בלוטות׳ לא זמין" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth\nNon disponibile" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blåtann\nUtilgjengelig" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth\nNiet Beschikbaar" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth niedostępny" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth \nIndisponibil" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth\n Недоступен" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth är inte tillgängligt" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth\nKullanılamıyor" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "蓝牙不可用" + } + } + } + }, + "Bluetooth Off Alert" : { + "comment" : "Bluetooth off alert title", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Advarsel om Bluetooth slået fra" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth-Aus Warnung" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alerta de Bluetooth apagado" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth ei ole käytössä -hälytys" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth désactivé" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "התראת בלוטות׳ כבוי" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avviso Bluetooth disattivato" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth Avslått-varsel" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth Uit Melding" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alert! Bluetooth jest wyłączony!" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alertă Bluetooth dezactivat" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Оповещение об отключении Bluetooth" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Varning Bluetooth är Av" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth Kapalı Uyarısı" + } + } + } + }, + "Bluetooth Unavailable Alert" : { + "comment" : "Bluetooth unavailable alert title", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth er ikke tilgængelig" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth nicht Verfügbar-Warnung" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alerta de Bluetooth no disponible" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth ei ole käytettävissä -hälytys" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth indisponible" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "התראת בלוטות׳ לא זמין" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avviso Bluetooth non disponibile" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Varsel om at Bluetooth er utilgjengelig" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth Niet Beschikbaar Melding" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alarm! Bluetooth niedostępny" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alertă Bluetooth indisponibil" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Оповещение о недоступности Bluetooth" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Varning Bluetooth är inte tillgänglig" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth Kullanılamıyor Uyarısı" + } + } + } + }, + "Bolus" : { + "comment" : "Label for bolus entry row on bolus screen\nLabel for bolus entry row on simple bolus screen\nThe label of the bolus entry button\nTitle for bolus entry screen", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolo" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolo" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ボーラス" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Болюс" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "大剂量" + } + } + } + }, + "Bolus Issue" : { + "comment" : "The notification title for a bolus issue", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolusproblem" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus Problem" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Problema con el bolo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Problème avec le bolus" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Problema con il bolo" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus feil" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolusprobleem" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Problem z bolusem" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Problemă bolus" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Проблема с подачей болюса" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus Sorunu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "大剂量出现问题" + } + } + } + }, + "Bolus Recommendation Updated" : { + "comment" : "Alert title for an updated bolus recommendation", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolusanbefaling opdateret" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktualisierte Bolusempfehlung" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recomendación de bolo fue actualicada" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolussuositus päivitetty" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recommandation de Bolus modifiée" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "המלצת בולוס התעדכנה" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Raccomandazioni sul bolo aggiornate" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus anbefaling er oppdatert" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aanbevolen Bolus Bijgewerkt" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zaktualizowano rekomendowanego bolusa" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actualizare Recomandare bolus" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Рекомендации по болюсу обновлены" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Det finns en ny bolusrekommendation" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus Önerisi Güncellendi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "大剂量推荐值已更新" + } + } + } + }, + "Bolus Summary" : { + "comment" : "Title for card displaying carb entry and bolus recommendation", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus-resumé" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus Zusammenfassung" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resumen del bolo" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolusyhteenveto" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Résumé du bolus" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "סיכום" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Riepilogo bolo" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus oppsummering" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolussamenvatting" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Podsumowanie bolusa" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rezumat Bolus" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Суммарный болюс" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus sammanfattning" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus Özeti" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "大剂量概要" + } + } + } + }, + "Bolus Too Small" : { + "comment" : "Alert title for a bolus too small validation error", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus for lille" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus zu gering" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolo demasiado pequeño" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus trop petit" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "בולוס קטן מדי" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolo troppo piccolo" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus er for liten" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus Te Klein" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Za mały bolus" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus prea mic" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Болюс слишком маленький" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus Çok Küçük" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "大剂量太少" + } + } + } + }, + "Bolused %1$@ of %2$@" : { + "comment" : "The format string for bolus progress. (1: delivered volume)(2: total volume)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تم ضخ %1$@ من %2$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus %1$@ af %2$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ von %2$@ verabreicht" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Administró bolo %1$@ de %2$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus %1$@ / %2$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus délivré %1$@ sur %2$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הוזרק %1$@ מתוך %2$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolo %1$@ di %2$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%2$@ のうち %1$@ ボーラス済" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus %1$@ av %2$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ van %2$@ gebolust" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Podano %1$@ z %2$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entregue %1$@ of %2$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus administrat %1$@ of %2$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подан болюс%1$@ из %2$@" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Podaný bolus %1$@ z %2$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Givit bolus %1$@ av %2$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İletilen Bolus %1$@ / %2$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đã thực hiện Bolus %1$@ của %2$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "大剂量已输注%1$@ of %2$@" + } + } + } + }, + "Bolusing %1$@" : { + "comment" : "The format string for bolus in progress showing total volume. (1: total volume)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يضخ %1$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus %1$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolusabgabe %1$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Administrando bolo de %1$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Annetaan bolus %1$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus en cours %1$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מזריק בולוס: %1$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolo in corso %1$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ ボーラス中" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gir bolus %1$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolussen %1$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Podawanie %1$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aplicando %1$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus în administrare %1$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подается болюс" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus sa podáva" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ger bolus %1$@ " + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus %1$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đang thực hiện bolus %1$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@大剂量注射中" + } + } + } + }, + "Bottom row" : { + "comment" : "Live activity Bottom row configuration title" + }, + "Bottom row configuration" : { + "comment" : "Title for Bottom row configuration" + }, + "Cancel" : { + "comment" : "Button label for cancel\nButton text to cancel\nCancel button for reset loop alert\nCancel export button title\nThe title of the cancel action in an action sheet", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "إلغاء" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zrušit" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Annuller" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abbrechen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancelar" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kumoa" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Annuler" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "בטל" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "निरस्त" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Annulla" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "キャンセル" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avbryt" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Annuleer" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anuluj" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancelar" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renunță" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отмена" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zrušiť" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avbryt" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İptal" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hủy bỏ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "取消" + } + } + } + }, + "Canceling Bolus" : { + "comment" : "The title of the cell indicating a bolus is being canceled", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "إلغاء الجرعة" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Annullerer bolus" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus abbrechen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancelando bolo" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kumotaan bolus" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Annulation du Bolus en cours" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מבטל בולוס" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Annullamento bolo in corso" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ボーラスをキャンセルします" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avbryter bolus" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus Annuleren" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anulowanie bolusa" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancelando Bolus" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Întrerupere bolus" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отмена болюса" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus sa ruší" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avbryter bolus" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus İptal ediliyor" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hủy bỏ liều Bolus" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "取消大剂量" + } + } + } + }, + "Carb effects" : { + "comment" : "Details for missing data error when carb effects are missing", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تأثيرات الكارب" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kulhydrateffekter" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kohlenhydrat-Wirkungen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Efectos de carbohidratos" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hiilihydraattivaikutus" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Effets des glucides" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "השפעות פחמימות" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Effetto carboidrati" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "糖質効果" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Karb effekter" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Koolhydraateffecten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wpływ węglowodanów" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Efeitos Carb" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Efecte carbohidrați" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Влияние углеводов" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Účinky sacharidov" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kolhydrateffekter" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Karbonhidrat etkileri" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "các tác động của Carb" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "碳水效果" + } + } + } + }, + "Carb Entry" : { + "comment" : "Label for carb entry row on bolus screen", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indtast kulhydrater" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "KH-Eintrag" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entrada de Carbohidratos" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hiilihydraatit" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entrée de glucides" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "פחמימות" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carboidrati inseriti" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Legg til karbohydrater" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Koolhydraatinvoer" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wprowadź węglowodany" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cantitate CH" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить запись углеводов" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kolhydrater" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Karb Girişi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "碳水记录" + } + } + } + }, + "Carb Quantity" : { + "comment" : "Label for carb quantity row on add favorite food screen", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Menge gegessen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quantité de glucides" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "כמות פחמימות" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carboidrati Assunti" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Antall karbohydrater" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cantitate carbohidrați" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "碳水含量" + } + } + } + }, + "Carb Ratio Schedule" : { + "comment" : "Details for configuration error when carb ratio schedule is missing", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tidsplan for kulhydratforhold" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zeitplan für das Kohlenhydratverhältnis" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Calendario de ratio de carbohidratos" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Programme des ratios Insuline-Glucides" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tabella del rapporto carboidrati" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tidsplan for karbohydratforhold" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Koolhydraatratioschema" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Harmonogram Współczynnika Węglowodanowego" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Programul raportului de carbohidrați" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Расписание соотношения углеводов" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Karb Oranı Programı" + } + } + } + }, + "Carb Ratios" : { + "comment" : "The title of the carb ratios schedule screen\n The title text for the carb ratio schedule", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "معاملات الكارب" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kulhydratforhold" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "KH-Verhältnis" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carb Ratios" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ratios de carbohidratos" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hiilihydraattisuhteet" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ratios Insuline-Glucides" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carb Ratios" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rapporti carboidrati" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carb Ratios" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Karb forhold" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Koolhydraatratio's" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Współczynniki węglowodanowe" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Taxas de Carbs" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Raport carbohidrați/insulină" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Соотношения углеводов" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inzulínovo sacharidový pomer" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulinkvoter" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Karbonhidrat Oranları" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carb Ratios" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "碳水化合物吸收率" + } + } + } + }, + "carb-entry-title-add" : { + "comment" : "The title of the view controller to create a new carb entry", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add Carb Entry" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilføj kulhydrater" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kohlenhydrate hinzufügen" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Add Carb Entry" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agregar Entrada de Carb" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lisää hiilihydraatteja" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajouter des glucides" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הוספת רשומת פחמימות" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aggiungi inserimento carboidrati" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "糖質の記入を追加" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Legg til karbohydrater" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kh. Inv. Toevoegen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wprowadź węglowodany" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adicionar Carb" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adaugă carbohidrați" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить запись углеводов" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zadať sacharidy" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lägg till kolhydrater" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Karb Girişi Ekle" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Khai báo Carb" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "添加碳水化合物" + } + } + } + }, + "Carbohydrate Entry Too Large" : { + "comment" : "Title for bolus screen warning when carbohydrate entry is too large", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kulhydratindtastning for stor" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "KH-Eintrag zu groß" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entrada de carbohidratos demasiado grande" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les glucides saisis sont trop important" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "כמות פחמימות גדולה מדי" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carboidrati inseriti troppo alti" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Karbohydratinntaket er for stort" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Koolhydraatinvoer Te Veel" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wpis węglowodanów za duży" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Intrarea de carbohidrați este prea mare" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Слишком много углеводов" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Karbonhidrat Girişi Çok Büyük" + } + } + } + }, + "Carbohydrates" : { + "comment" : "Label for carbohydrates entry row on simple bolus screen\nTitle of the prediction input effect for carbohydrates", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الكربوهيدرات" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kulhydrater" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kohlenhydrate" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carbohidratos" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hiilihydraatit" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glucides" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "פחמימות" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carboidrati" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "残存糖質" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Karbohydrater" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Koolhydraten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Węglowodany" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carboidratos" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carbohidrați" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Углеводы" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sacharidy" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kolhydrater" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Karbonhidratlar" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carbohydrates" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "碳水化合物" + } + } + } + }, + "Carbs Absorbed (g) ÷ Carb Ratio (g/U) × Insulin Sensitivity (%1$@/U)" : { + "comment" : "Description of the prediction input effect for carbohydrates. (1: The glucose unit string)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الكارب الذي تم امتصاصه (جرام) ÷ معامل الكارب (جرام لكل وحدة) × حاسية الأنسولين (%1$@/وحدة)" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kulhydrater absorberet (g) ÷ Kulhydratratio (g/E) × Insulinfølsomhed (%1$@/E)" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resorbierte Kohlenhydrate (g) ÷ Kohlenhydratfaktor (g/IE) × Insulinempfindlichkeit (%1$@/IE)" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carbohidratos absorbidos (gr) ÷ Relación de Carbohidratos (gr/U) x Sensibilidad a Insulina (%1$@/U)" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Imeytyneet hiilihydraatit (g) ÷ Hiilihydraattisuhde (g/U) × Insuliiniherkkyys (%1$@/U)" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glucides Absorbés (g) ÷ Ratio Glucides (g/U) x Sensibilité à l'insuline (%1$@/U)" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carbs Absorbed (g) ÷ Carb Ratio (g/U) × Insulin Sensitivity (%1$@/U)" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carboidrati assorbiti ÷ Rapporto carboidrati (gr/U) × Sensibilità insulinica (%1$@/U)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "吸収済糖質(g) ÷ 糖質比 (g/U) × インスリン効果値(%1$@/U)" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Absorberte karbohydrater (g) ÷ Karbforhold (g/E) × insulinfølsomhet ( %1$@ /E)" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opgenomen Koolhydraten (g) ÷ Koolhydraatratio (g/E) × Insulinegevoeligheid (%1$@/E)" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ilość węglowodanów (g) ÷ stosunek węglowodanów (g/J) × czułość insuliny (%1$@/J)" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carbs Absorvidos (g) ÷ Relação Carb (g/U) × Sensibilidade a Insulina (%1$@/U)" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carbohidrați absorbiți (g) ÷ Raport carbohidrați (g/U) × Factor de sensibilitate la insulină (%1$@/U)" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Усвоенные углеводы (г) ÷ Коэффициент углеводов (г/U) × Чувствительность к инсулину (%1$@/U)" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Absorbované sacharidy (g) ÷ Inzulínovo sacharidový pomer (g/j) × Faktor citlivosti na inzulín (%1$@/j)" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Absorberade kolhydrater (g) ÷ Insulinkvot (g/E) × Insulinkänslighet (%1$@/E)" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Emilen Karb. (gr) ÷ Karb. Oranı (gr/Ü) × İnsulin duyarlılığı (%1$@/Ü)" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Khối lượng carb tiêu thụ (g) ÷ Tỷ lệ Carb (g/U) × Độ nhạy của Insulin (%1$@/U)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已吸收碳水化合物(克)÷碳水化合物吸收率(克 / 单位)×胰岛素敏感系数 (%1$@/单位)" + } + } + } + }, + "Caution" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forsigtig" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Achtung" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "זהירות" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Attenzione" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forsiktig" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voorzichtig" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uwaga" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atenţie" + } + } + } + }, + "Change the pump battery immediately" : { + "comment" : "The notification alert describing a low pump battery", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "قم بتغيير بطارية المضخة على الفور" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Udskift pumpebatteri omgående" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wechsel sofort die Pumpenbatterie" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Change the pump battery immediately" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cambie la batería de la bomba de inmediato" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vaihda pumpun paristo välittömästi" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Changer immédiatement la pile de la pompe" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Change the pump battery immediately" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sostituisci immediatamente la batteria della pompa" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "すぐにポンプの電池を替えてください" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skift pumpebatteriet umiddelbart" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vervang direct de batterij van de pomp" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Natychmiast wymienić baterię pompy" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Troque a bateria da bomba imediatamente" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schimbați imediat bateria pompei" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Немедленно замените батарейку в помпе" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ihneď vymeňte batériu pumpy" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Byt pumpbatteri nu" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pompa pilini hemen değiştirin" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thay pin máy bơm ngay" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "立即更换胰岛素泵电池" + } + } + } + }, + "Change the pump reservoir now" : { + "comment" : "The notification alert describing an empty pump reservoir", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "قم بتغيير خزان المضخة الآن" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skift pumpereservoir nu" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wechsel jetzt das Pumpenreservoir" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Change the pump reservoir now" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cambie el depósito de la bomba ahora" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vaihda pumpun säiliö" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Changer maintenant le réservoir de la pompe" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Change the pump reservoir now" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cambia ora il serbatoio della pompa" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ポンプのレザーバを替えてください" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bytt pumpereservoar nå" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vervang het pompreservoir nu" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zmień zbiorniczek w pompie" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Troque o reservatório da bomba agora" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schimbați rezervorul pompei acum" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Замените резервуар помпы сейчас" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ihneď vymeňte zásobník pumpy" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Byt pumpreservoar nu" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pompa rezervuarını şimdi değiştirin" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thay ngăn chứa insulin bây giờ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "立即更换储药器" + } + } + } + }, + "Check settings" : { + "comment" : "Details for configuration error when one or more loop settings are missing", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تأكد من الإعدادات" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kontroller indstillinger" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Überprüfe die Einstellungen." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Check settings" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verificar ajustes" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tarkista asetukset" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vérifier les paramètres" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Check settings" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Controlla le impostazioni" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "設定を確認する" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sjekk innstillinger" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Controleer instellingen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sprawdź ustawienia" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verifique as configurações" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verificați setările" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Проверьте настройки" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kontrollera inställningarna" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ayarları kontrol et" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kiểm tra các cài đặt" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "检查设置" + } + } + } + }, + "Check that your pump is in range" : { + "comment" : "Recovery suggestion when reservoir data is missing", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تأكد أن المضخة في النطاق" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kontroller, at din pumpe er indenfor rækkevidde" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stelle sicher, dass sich Deine Pumpe in Reichweite befindet." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Check that your pump is in range" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verifique que su microinfusora esté dentro del rango" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tarkista, että pumppu on riittävän lähellä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vérifier que votre pompe est à portée" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Check that your pump is in range" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Controlla che la pompa sia nel raggio d'azione" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ポンプが近くにあることを確認してください" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sjekk at pumpen din er innenfor rekkevidde" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Controleer of je pomp binnen bereik is" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Upewnij się, że Twoja pompa jest w zasięgu" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verifique se sua bomba está dentro do alcance" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verificați că pompa este în apropriere" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Убедитесь, что помпа в зоне коммуникации" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kontrollera att pumpen är inom räckhåll" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pompanızın menzil içinde olup olmadığını kontrol edin" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kiểm tra máy bơm đang trong phạm vi cho phép" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请检查你的泵是否在范围内" + } + } + } + }, + "Check your CGM data source" : { + "comment" : "Recovery suggestion when glucose data is missing", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تأكد من مصدر قراءات السكر المستمرة" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kontroller din CGM-datakilde" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Überprüfe Deine CGM-Datenquelle." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verifique su fuente de datos CGM" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tarkista CGM-tietolähde" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vérifier votre source de données CGM" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "בדוק את מקור המידע שלך בחיישן" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Controlla la sorgente dati del sensore" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "CGM データソースを確認してください" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sjekk CGM" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Controleer je CGM gegevensbron" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sprawdź swój CGM" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verifique seu dispositivo CGM" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verificați sursa de date CGM" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Проверьте источник мониторинга СК" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kontrollera din CGM:s datakälla" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "CGM veri kaynağınızı kontrol edin" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kiểm tra nguồn dữ liệu CGM của bạn" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请检查你的动态血糖数据来源" + } + } + } + }, + "Check your device time and/or remove any invalid data from Apple Health." : { + "comment" : "Caption for bolus screen notice when glucose data is in the future", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tjek din enheds tid og/eller fjern ugyldige data fra Apple Health." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Überprüfe die Uhrzeit deines Geräts und/oder entferne alle ungültigen Daten aus Apple Health." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Compruebe la hora de su dispositivo y/o elimine cualquier dato no válido de Apple Health." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vérifiez l'heure de votre appareil et/ou supprimez toute donnée invalide d'Apple Health." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Controlla l'orario del tuo dispositivo e/o rimuovi eventuali dati non validi da Apple Salute." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sjekk enhetens klokke og/ eller fjern eventuelle ugyldige data fra Apple Helse." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Controleer de tijd op je apparaat en/of verwijder eventuele ongeldige invoer uit Apple Gezondheid." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sprawdź czas urządzenia i/lub usuń wszelkie nieprawidłowe dane z aplikacji Zdrowie." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verificați ora dispozitivului și/sau eliminați orice date incorecte din Apple Health." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Проверьте время вашего устройства и/или удалите недействительные данные из Apple Health." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aygıtınızın saatini kontrol edin ve/veya tüm geçersiz verileri Apple Sağlık'tan kaldırın." + } + } + } + }, + "Choose a longer absorption time for larger meals, or those containing fats and proteins. This is only guidance to the algorithm and need not be exact." : { + "comment" : "Carb entry section footer text explaining absorption time", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vælg en længere absorptionstid ved større måltider, eller dem med fedt og proteiner. Det er blot hjælp til algoritmen og behøver ikke være nøjagtig." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wähle eine längere Resorptionsdauer für größere Mahlzeiten oder welche die viel Fett und Proteine beinhalten. Dies ist eine Unterstützung für den Algorithmus und muss nicht genau sein." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elija un tiempo de absorción más largo para comidas más grandes, o aquellas que contienen grasas y proteínas. Esta es solo una guía para el algoritmo y no necesita ser exacta." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Valitse pidempi imeytymisaika isoille tai paljon rasvaa ja proteiineja sisältäville aterioille. Tämä on suuntaa antava ohje, eikä sen tarvitse olla tarkka." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choisissez un temps d’absorption plus long pour les gros repas ou ceux contenant des graisses et des protéines. Ceci est seulement un guide pour l'algorithme et n'a pas besoin d'être exact." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose a longer absorption time for larger meals, or those containing fats and proteins. This is only guidance to the algorithm and need not be exact." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scegli un periodo di assorbimento piu lungo per i pasti piu grandi o quelli contenenti grassi e proteine. Questa e solo una guida all’algoritmo e non e necessario che sia esatta." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "量の多い食事や脂質やたんぱく質を含んだ食事には長い吸収時間を選んでください。これはアルゴリズムのための参考で、厳密である必要はありません。" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Velg lengre absorpsjonstid for større måltider, eller de som inneholder fett og proteiner. Dette er kun veiledning til algoritmen og trenger ikke være nøyaktig." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kies een langere opnametijd voor grotere maaltijden of voor maaltijden die vetten en eiwitten bevatten. Dit is alleen een leidraad voor het algoritme en hoeft niet exact te zijn." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wybierz dłuższy czas absorpcji dla większych, bogatobiałkowych lub wysokotłuszczowych posiłków. To tylko wskazówka dla algorytmu i nie musi być bardzo dokładna." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escolha um tempo de absorção mais longo para refeições maiores ou aquelas que contenham gorduras e proteínas. Esta é apenas uma orientação para o algoritmo e não precisa ser exata." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alegeți o durată mai lungă de absorbție pentru mese mai mari sau pentru cele care conțin grăsimi și proteine. Nu e necesară o valoare exactă, scopul e să oferim doar o ghidare pentru algoritm." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите более длительное время усвоения для больших порций пищи или тех, которые содержат жиры и белки. Это лишь руководство к алгоритму и не обязательно должно быть точным." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Välj en längre absorptionstid för måltid med mycket fett eller protein. Ofta är det bäst att dela upp måltiden i snabba och långsamma kolhydrater och mata in dessa var för sig." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Daha büyük öğünler veya yağ ve protein içeren besinler için daha uzun bir emilim süresi seçin. Bu değer yalnızca algoritmaya rehberlik eder ve kesin olması gerekmez." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lựa chọn khoảng thời gian tiêu hóa dài hơn cho các bữa ăn no, hoặc những bữa ăn nhiều chất béo và protein. Đây chỉ là hướng dẫn cho thuật toán Algorithm và không cần phải chính xác." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "对于大餐或含有脂肪和蛋白质的餐食,请选择较长的吸收时间。此设置仅作为算法的参考, 无需精确。" + } + } + } + }, + "Choose Favorite:" : { + "comment" : "The label for the row where you choose saved Favorite Food", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vælg favorit:" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wähle Favorit:" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scegli il preferito:" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Velg favoritt:" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wybierz ulubione:" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alegeți Preferatul:" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择常用" + } + } + } + }, + "Close" : { + "comment" : "Button title to close view\nThe button label of the action used to dismiss the unsafe notification permission alert", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Luk" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schließen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cerrar" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sulje" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fermer" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "סגור" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chiudi" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lukk" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sluiten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zamknij" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Închide" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Закрыть" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stäng" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kapat" + } + } + } + }, + "Closed Loop" : { + "comment" : "The title text for the looping enabled switch cell", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "حلقة مغلقة" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lukket Loop" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Closed Loop" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Circuito Cerrado" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suljettu säätö" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Boucle Fermée" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Closed Loop" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop chiuso" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "クローズドループ" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lukket Loop" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gesloten Loop" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pętla Zamknięta" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ciclo Fechado" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Buclă închisă" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Замкнутый цикл" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sluten loop" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kapalı Döngü" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vòng lặp kín" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "闭环模式" + } + } + } + }, + "Closed Loop OFF" : { + "comment" : "Alert title for closed loop off informational modal", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lukket loop FRA" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Closed Loop AUS" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Asa cerrada APAGADA" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suljettu säätö pois päältä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Boucle Ouverte" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop chiuso disattivato" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lukket Loop AV" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gesloten Loop UIT" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pętla zamknięta WYŁĄCZONA" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Buclă închisă dezactivată" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Замкнутый цикл ВЫКЛ" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sluten Loop är AV" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kapalı Döngü KAPALI" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "闭环模式已关闭" + } + } + } + }, + "Closed Loop requires an active CGM Sensor Session" : { + "comment" : "The description text for the looping enabled switch cell when closed loop is not allowed because the sensor is inactive", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lukket Loop kræver en aktiv CGM sensor-session" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Closed Loop erfordert eine aktive CGM-Sensorsitzung" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "La asa cerrada requiere una sesión de sensor CGM activa" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suljettu säätö vaatii aktiivisen glukoosisensorin" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop en boucle fermée requiert une session de capteur CGM active." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop chiuso richiede almeno una sessione attiva del sensore CGM" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lukket Loop krever en aktiv CGM sensorøkt" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gesloten Loop vereist een actieve CGM Sensorsessie" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zamknięta pętla wymaga aktywnego sensora CGM" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Buclă închisă necesită o sesiune activă de senzor CGM" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Замкнутый цикл требует активного подключенного датчика CGM" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sluten Loop kräver en aktiv CGM-sensor" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kapalı Döngü, aktif bir CGM Sensör Oturumu gerektirir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "闭环需要活动的 CGM 传感器会话" + } + } + } + }, + "Closed Loop requires Setup to be Complete" : { + "comment" : "The description text for the looping enabled switch cell when onboarding is not complete", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lukket Loop kræver, at opsætningen er fuldført" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Closed Loop erfordert, dass die Einrichtung abgeschlossen ist" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El Circuito Cerrado requiere que la configuración esté completa" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop en boucle fermée nécessite une configuration complète" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop chiuso richiede che la configurazione sia completata" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lukket Loop krever at installasjonen er fullført" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gesloten Loop vereist dat Installatie is Voltooid" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zamknięta pętla wymaga zakończenia konfiguracji" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bucla închisă necesită ca setarea să fie finalizată" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Замкнутый цикл требует завершения настройки" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kapalı Döngü, Kurulumun Tamamlanmasını gerektirir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用闭环控制前需完成所有设置。" + } + } + } + }, + "com.loudnate.InsulinKit.IOBDateLabel" : { + "comment" : "The format string describing the date of an IOB value. The first format argument is the localized date.", + "extractionState" : "extracted_with_value", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "d %1$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "um %1$@" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "at %1$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "en %1$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "klo %1$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "à %1$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "ב-%1$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "a %1$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ 時点" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "kl. %1$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "om %1$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "o %1$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "em %1$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "la %1$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "в %1$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "kl %1$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "lúc %1$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在 %1$@" + } + } + } + }, + "com.loudnate.InsulinKit.totalDateLabel" : { + "comment" : "The format string describing the starting date of a total value. The first format argument is the localized date.", + "extractionState" : "extracted_with_value", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "siden %1$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "seit %1$@" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "since %1$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "desde %1$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ jälkeen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "depuis %1$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מאז %1$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "da %1$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ より" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "siden %1$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "sinds %1$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "od %1$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "desde %1$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "de la %1$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "с %1$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "sedan %1$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ den beri" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "từ lúc %1$@" + } + } + } + }, + "com.loudnate.LoopKit.errorAlertActionTitle" : { + "comment" : "The title of the action used to dismiss an error alert", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "موافق" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "OK" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אישור" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ok" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tamam" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + } + } + }, + "Complete Setup" : { + "comment" : "Title text for button to complete setup", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fuldfør opsætning" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einstellungen abschließen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Completa la Configuración" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Terminer la configuration" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Completa configurazione" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fullfør oppsett" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Volledige Installatie" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zakończ konfigurację" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configurare finalizată" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Завершить настройку" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kurulumu Tamamla" + } + } + } + }, + "Configuration" : { + "comment" : "The title of the Configuration section in settings", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "المعطيات" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Konfigurace" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Konfiguration" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Konfiguration" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Määritykset" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuration" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuration" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configurazione" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "コンフィグレーション" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Konfigurasjon" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuratie" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Konfiguracja" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuração" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configurare" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Конфигурация" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Konfigurácia" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Konfiguration" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Konfigürasyon" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cấu hình" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "配置" + } + } + } + }, + "Configuration Error: %1$@" : { + "comment" : "The error message displayed for configuration errors. (1: configuration error details)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "خطأ في المعطيات: %1$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Konfigurationsfejl: %1$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Konfigurationsfehler: %1$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Error de Configuración: %1$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Määritysvirhe: %1$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erreur de Configuration: %1$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuration Error: %1$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Errore di configurazione: %1$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "設定エラー: %1$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Konfigurasjonsfeil: %1$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuratiefout: %1$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Błąd konfiguracji: %1$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erro de Configuração: %1$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eroare configurare: %1$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ошибка конфигурации: %1$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Konfigurationsfel %1$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Konfigürasyon Hatası: %1$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lỗi cấu hình: %1$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "配置错误:%1$@" + } + } + } + }, + "Continue" : { + "comment" : "Button label for continue\nDefault alert dismissal", + "localizations" : { + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pokračovat" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fortsæt" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Weiter" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Continuar" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jatka" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Continuer" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Continue" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "जारी" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Continua" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "次へ" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fortsett" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ga Verder" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kontynuuj" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Continuar" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Continuă" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Продолжить" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pokračovať" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fortsätt" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Devam et" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tiếp tục" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "继续" + } + } + } + }, + "Continuous Glucose Monitor" : { + "comment" : "Descriptive text for Continuous Glucose Monitor", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "نظام متابعة السكر المستمرة" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kontinuerlig Blodsukker Måler (CGM)" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kontinuierliche Blutzuckermessung" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Monitor de glucosa continuo" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glukoosinseuranta (CGM)" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lecteur de glycémie en continu" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Continuous Glucose Monitor" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Monitoraggio continuo glicemia" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "CGM" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kontinuerlig glukosemonitor" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Continue Glucose Monitor" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ciągły Monitoring Glukozy" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Monitoramento Contínuo de Glicose" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Senzor de monitorizare continuă a glicemiei" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Непрерывный мониторинг гликемии" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kontinuerlig glukosmätning" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sürekli Glikoz İzleme" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kiểm soát đường huyết liên tục" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "连续血糖监测仪" + } + } + } + }, + "Correction Range" : { + "comment" : "The title of the glucose target range schedule screen\n The title text for the glucose target range schedule", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "نطاق التصحيح" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Korrektionsområde" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Korrekturbereich" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Correction Range" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rango de Corrección" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Korjausalue" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Plage de correction" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "טווח תיקון" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Intervallo di correzione" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ターゲット範囲" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Korreksjonsområde" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Correctiebereik" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zakres docelowy" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Faixa de Correção" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Interval țintă pentru corecție" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Диапазон коррекции" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Målvärde" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Düzeltme Aralığı" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Phạm vi liều Bổ sung" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "校正范围" + } + } + } + }, + "Could Not Restart %1$@" : { + "comment" : "Format string for title of reset loop alert. (1: App name)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kunne ikke genstarte %1$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ konnte nicht neu gestartet werden" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossibile riavviare %1$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kunne ikke starte %1$@ på nytt" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nie można ponownie uruchomić %1$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nu s-a putut reporni %1$@" + } + } + } + }, + "Critical Alerts" : { + "comment" : "Critical Alerts Status text", + "localizations" : { + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kritická upozornění" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kritiske advarsler" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kritische Warnungen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alertas críticas" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alertes critiques" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avvisi critici" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kritiske varsler" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kritieke Meldingen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alerty krytyczne" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alerte critice" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Критические оповещения" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kritické výstrahy" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kritik Uyarılar" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "关键通知" + } + } + } + }, + "Critical Event Log Ready" : { + "comment" : "Critical event log ready text", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kritisk begivenhedslog klar" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kritisches Ereignisprotokoll bereit" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Registro de eventos críticos listo" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tärkeiden tapahtumien loki valmis" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Journal des événements critiques prêt" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Registro eventi critici pronto" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kritisk hendelseslogg er klar" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kritieke Gebeurtenislogboek Klaar" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dziennik zdarzeń krytycznych gotowy" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jurnal evenimente critice pregătit" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Логи критических событий готовы" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kritisk händelselogg klar" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kritik Olay Günlüğü Hazır" + } + } + } + }, + "Critical Event Logs" : { + "comment" : "Critical event log export title", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kritisk begivenhedslog" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kritische Ereignisprotokolle" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Registros de eventos críticos" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tärkeiden tapahtumien loki" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Journaux des événements critiques" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Registri eventi critici" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kritiske hendelseslogger" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kritieke Gebeurtenislogboek" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dzienniki zdarzeń krytycznych" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jurnal de evenimente critice" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Логи критических событий" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kritiska händelseloggar" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kritik Olay Günlükleri" + } + } + } + }, + "Critical Event Logs were not able to be exported." : { + "comment" : "Critical event log export error alert message", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kritisk begivenhedslogs kunne ikke eksporteres." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kritische Ereignisprotokolle konnten nicht exportiert werden." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se han podido exportar los registros de eventos críticos." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tärkeiden tapahtumien lokia ei voitu viedä." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les journaux d'événements critiques n'ont pas pu être exportés." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Non è stato possibile esportare i registri degli eventi critici." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kritiske hendelseslogger kunne ikke eksporteres." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kritieke Gebeurtenislogboek konden niet worden geëxporteerd." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nie można wyeksportować dzienników zdarzeń krytycznych." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jurnalele de evenimente critice nu au putut fi exportate." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось экспортировать журналы критических событий." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kritiska händelseloggar kunde inte exporteras." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kritik Olay Günlükleri dışa aktarılamadı." + } + } + } + }, + "Current Glucose" : { + "comment" : "Label for glucose entry row on simple bolus screen", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nuværende blodsukker" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktueller Blutzucker" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glucosa actual" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nykyinen glukoosi" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glycémie actuelle" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glicemia attuale" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nåværende blodsukker" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Huidige Glucose" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktualna glukoza" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glicemia curentă" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Текущая глюкоза" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nuvarande blodglukosvärde" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mevcut KŞ" + } + } + } + }, + "Current glucose of %1$@ is below correction range." : { + "comment" : "Message when offering bolus recommendation even though bg is below range. (1: glucose value)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "قراءة سكر الدم %1$@ أقل من نطاق التصحيح." + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Den aktuelle glukose for %1$@ er under korrektionsområdet." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der aktuelle Blutzucker von %1$@ liegt unter dem Korrekturbereich." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glucosa actual de %1$@ está por debajo del rango de corrección." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nykyinen glukoosi %1$@ on korjausalueen alapuolella." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glycémie actuelle de %1$@ est en dessous de la plage de correction." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Current glucose of %1$@ is below correction range." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "La glicemia attuale di %1$@ è al di sotto dell'intervallo glicemico." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "現在の血糖値は %1$@ で補正範囲を下回っています。" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gjeldende blodsukker på %1$@ er under korreksjonsområdet." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Huidige glucose %1$@ is lager dan het correctiebereik." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Poziom glukozy %1$@ jest poniżej wartości korekcji." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : " Glicemia atual %1$@ está abaixo da zona de correção." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glicemia actuală de %1$@ se situează sub intervalul țintă de corecție." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Гликемия %1$@ ниже диапазона коррекции" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktuálna glykémie %1$@ je pod cieľovým rozsahom." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nuvarande glukosvärde %1$@ är under målvärde." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ Mevcut KŞ düzeltme aralığının altında." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : " Chỉ số glucose hiện tại %1$@ nằm dưới Phạm vi Điều chỉnh." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当前葡萄糖%1$@低于校正范围" + } + } + } + }, + "Custom Override" : { + "comment" : "The title of the cell indicating a generic temporary override is enabled", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تجاوز مخصص" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Brugerdefineret Override" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Benutzerdefinierter Override" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Custom Override" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sobreescritura personalizada" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mukautettu tilapäisasetus" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajustement personnalisé" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Custom Override" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Override personalizzato" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "カスタムオーバーライド" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilpasset overstyring" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aangepaste Override" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Custom Override" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sobreposição Personalizada" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modificare personalizată" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настраиваемое ручное управление" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anpassad Override" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Özel Geçersiz kılma" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thói quen Chồng liều" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自定义覆盖" + } + } + } + }, + "Custom preset" : { + "comment" : "The title of the cell indicating a generic custom preset is enabled" + }, + "Custom Preset" : { + "comment" : "The title of the cell indicating a generic custom preset is enabled", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Brugerdefineret forudindstilling" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voreinstellung" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajuste Preestablecido personalizado" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mukautettu esiasetus" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Préréglage personnalisé" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Programmazione" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Egendefinert forhåndsinnstilling" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aangepast Programma" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cel Tymczasowy" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Presetare particularizată" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пользовательский пресет" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anpassad förinställning" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Özel Ön Ayar" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自定义预设" + } + } + } + }, + "Date" : { + "comment" : "Date picker label", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "التاريخ" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dato" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Datum" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fecha" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aika" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Date" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "תאריך" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Data" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "日付" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dato" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Datum" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Data" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Data" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Data" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Дата" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dátum" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tid" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tarih" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ngày" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "日期" + } + } + } + }, + "dB" : { + "comment" : "The short unit display string for decibles", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "дБ" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + } + } + }, + "Delete" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "حذف" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slet" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Löschen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eliminar" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Poista" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Supprimer" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מחק" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elimina" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slett" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verwijderen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usunąć" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apagar" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ștergeți" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Odstrániť" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ta bort" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sil" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xóa" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除" + } + } + } + }, + "Delete “%@”?" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slet \"%@\"?" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "„ %@ “ löschen?" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "למחוק את \" %@ \"?" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eliminare \"%@\" ?" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slette \"%@\"?" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usunąć „ %@ ”?" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ștergeți „ %@ ”?" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除”%@”?" + } + } + } + }, + "Delete Account" : { + "comment" : "The title of the button to remove the credentials for a service", + "extractionState" : "manual", + "localizations" : { + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Smazat účet" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slet konto" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Konto löschen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eliminar Cuenta" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Poista tili" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Supprimer le compte" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מחק חשבון" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elimina account" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アカウントを削除" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slett Konto" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verwijder Account" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usuń konto" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remover Conta" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Șterge cont" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить аккаунт" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Odstrániť účet" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Radera konto" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hesabı sil" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xóa Tài khoản" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除账户" + } + } + } + }, + "Delete All" : { + "comment" : "Button title to delete all objects", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slet alle" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle löschen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eliminar Todos" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Poista kaikki" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Supprimer tout" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מחק הכל" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elimina tutto" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "すべて削除" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slett alle" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verwijder Alles" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usuń wszystko" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remover Todos" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Șterge tot" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить все" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Radera allt" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hepsini sil" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xóa hết" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除所有" + } + } + } + }, + "Delete CGM" : { + "comment" : "Button title to delete CGM", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "حذف CGM" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slet CGM" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "CGM löschen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eliminar MCG" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Poista CGM" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Supprimer le CGM" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מחק חיישן" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elimina CGM" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "CGMを削除" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slett CGM" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verwijder CGM" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usuń CGM" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remover CGM" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ștergeți CGM" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить мониторинг" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Odstrániť CGM" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Radera CGM" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "CGM Sil" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xóa CGM" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除CGM数据源" + } + } + } + }, + "Delete Food" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slet mad" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Essen löschen" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elimina cibo" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slett mat" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usuń jedzenie" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ștergeți alimentele" + } + } + } + }, + "Delete Service" : { + "comment" : "Button title to delete a service", + "extractionState" : "manual", + "localizations" : { + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Smazat službu" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slet service" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dienst löschen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eliminar servicio" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Poista palvelu" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Supprimer le service" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מחק שירות" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elimina servizio" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete Service" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slett tjeneste" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Service Verwijderen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usuń usługę" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete Service" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Șterge serviciul" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить сервис" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Odstrániť službu" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ta bort tjänst" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Servisi Sil" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete Service" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete Service" + } + } + } + }, + "Delete Testing CGM Data" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slet CGM testdata" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "CGM-Test-Daten löschen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eliminar prueba de datos CGM" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Poista testi-CGM:n tiedot" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Effacer les données de test du CGM" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מחק נתוני בדיקת חיישן" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elimina dati test CGM" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slett testdata for blodsukkermåler" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verwijder test-CGM-gegevens" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usuń testowe dane CGM" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ștergeți datele CGM de testare" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить данные тестирования CGM" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ta bort simulerade testvärden från CGM" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Test CGM Verilerini Sil" + } + } + } + }, + "Delete Testing Data" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slet testdata" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Test-Daten löschen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eliminar datos de prueba" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Poista testitiedot" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Supprimer les données de Test" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מחק נתוני בדיקה" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elimina dati test" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slett testdata" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Testgegevens Verwijderen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usuń dane testowe" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ștergeți datele de testare" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить данные тестирования" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ta bort simulerade testvärden" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Test Verilerini Sil" + } + } + } + }, + "Delete Testing Pump Data" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slet testdata for pumpe" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumpen-Test-Daten löschen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Borrar los datos de prueba de la bomba" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Poista testipumpun tiedot" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Supprimer les données de la Pompe de test" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מחק נתוני בדיקת משאבה" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elimina dati test della pompa" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slett data for testpumpe" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Testpompgegevens Verwijderen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usuń dane pompy testowej" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ștergere date pompă de testare" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить данные тестирования помпы" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ta bort simulerade pumpvärden" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Test Pompası Verilerini Sil" + } + } + } + }, + "Deliver" : { + "comment" : "Button text to deliver a bolus", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afgiv" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abgeben" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entregar" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Annostele" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Administrer" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eroga" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gi bolus" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toedienen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Podaj Bolus" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Livrează" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подать" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Podať" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ge bolus" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İlet" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "输注" + } + } + } + }, + "Delivery Limits" : { + "comment" : "Title text for delivery limits", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "حدود الضخ" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Limity podávání" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulingrænser" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abgabelimits" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delivery Limits" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Límites de Administración de Insulina" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Annostelurajat" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Limites d'Administration" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delivery Limits" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Limiti erogazione" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "注入限度" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Leveringsgrenser" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toedieningslimieten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Limity podawania" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Limites de Entrega" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Limite de livrare" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пределы подачи" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Limity podávania" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maxdoser" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İletim Limitleri" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Giới hạn liều tiêm" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "输注限制" + } + } + } + }, + "Diabetes Treatment" : { + "comment" : "Descriptive text for Therapy Settings", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Diabetesbehandling" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Diabetes-Behandlung" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tratamiento de la diabetes" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Diabeteshoito" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Traitement du diabète" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Terapia" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Diabetesbehandling" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Diabetesbehandeling" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "w leczeniu cukrzycy" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tratamentul diabetului" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Лечение диабета" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Diabetesbehandling" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Diyabet Tedavisi" + } + } + } + }, + "Did you intend to enter %1$@ grams as the amount of carbohydrates for this meal?" : { + "comment" : "Alert body when entered carbohydrates is greater than threshold (1: entered quantity in grams)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Havde du til hensigt at angive %1$@ gram som mængden af kulhydrater for dette måltid?" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wolltest Du %1$@ Gramm an Kohlenhydraten für diese Mahlzeit eingeben?" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Quería introducir %1$@ gramos como cantidad de hidratos de carbono para esta comida?" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aviez-vous l'intention d'entrer %1$@ grammes comme quantité de glucides pour ce repas?" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Intendevi inserire %1$@ grammi come quantità di carboidrati per questo pasto?" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hadde du tenkt å angi %1$@ gram som mengde karbohydrater for dette måltidet?" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Was je van plan om %1$@ gram in te voeren als hoeveelheid koolhydraten voor deze maaltijd?" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Czy zamierzałeś wprowadzić %1$@ gramów jako ilość węglowodanów dla tego posiłku?" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ați intenționat să introduceți %1$@ grame ca cantitate de carbohidrați pentru această masă?" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вы намеревались ввести %1$@ граммов углеводов для этого блюда?" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bu yemek için karbonhidrat miktarı olarak %1$@ gram girmeyi düşündünüz mü?" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "你确定本餐包含 %1$@ g 碳水吗?" + } + } + } + }, + "Disables" : { + "comment" : "The action hint of the workout mode toggle button when enabled", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تعطيل" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deaktiverer" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deaktivieren" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Desactivar" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Poistaa käytöstä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Désactive" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disables" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disattiva" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "無効にする" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deaktiverer" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uitschakelen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wyłącza" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Desativa" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dezactivează" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отключает" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stänger av" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Devre Dışı" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vô hiệu hóa" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "禁用" + } + } + } + }, + "Dismiss" : { + "comment" : "Default alert dismissal\nThe button label of the action used to dismiss an error alert", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تجاهل" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afvis" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schließen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ignorar" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ohita" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fermer" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "בטל" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "閉じる" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avvis" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sluiten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rozumiem" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dispensar" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renunță" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отклонить" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avfärda" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reddet" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Từ bỏ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "忽略" + } + } + } + }, + "Done" : { + "localizations" : { + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hotovo" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Udført" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fertig" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Completado" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Valmis" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Terminé" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "בוצע" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fine" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ferdig" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gereed" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gotowe" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Realizat" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Готово" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hotovo" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Färdig" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tamamlandı" + } + } + } + }, + "Dose Summary" : { + "comment" : "Title for card to log dose", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resumé af dosis" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dosisübersicht" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resumen de dosis" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Annoksen yhteenveto" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Résumé de la dose" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Riepilogo della dose" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dose sammendrag" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Doseersamenvatting" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Podsumowanie dawki" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rezumat doză" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сводная информация о дозе" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sammanfattning av dos" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Doz Özeti" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "注射量摘要" + } + } + } + }, + "Dosing Strategy" : { + "comment" : "The title of the Dosing Strategy section in settings", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dosingstrategi" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dosierungsstrategie" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estrategia de dosificación" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Annostelutapa" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stratégie de Dosage" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Strategia di dosaggio" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Doseringsstrategi" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Doseerstrategie" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Strategia dawkowania" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Strategie de dozare" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Режим ввода инсулина" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Strategi för insulinbehandling" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dozlama Stratejisi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "剂量策略" + } + } + } + }, + "Duration exceeds: %1$.1f hours" : { + "comment" : "Override error description: duration exceed max (1: max duration in hours).", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Varigheden overstiger: %1$.1f timer" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dauer überschritten: %1$.1f Stunden" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "La durée dépasse : %1$.1f heures" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "משך זמן חורג: %1$.1f שעות" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "La durata supera: %1$.1f ore" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Varigheten overskrider: %1$.1f timer" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Duur overschrijdt: %1$.1f uur" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Czas trwania przekracza: %1$.1f godzin" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Durata depășește: %1$.1f ore" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Продолжительность превышает: %1$.1f часов" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Süre aşıldı: %1$.1f saat" + } + } + } + }, + "Enable\nBluetooth" : { + "comment" : "Message to the user to enable bluetooth", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktiver \nbluetooth" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth einschalten" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activar \nBluetooth" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ota Bluetooth käyttöön" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activer \nbluetooth" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הפעל \n בלוטות'" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Attiva\nBluetooth" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktiver blåtann" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth\nInschakelen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Włączać\nBluetooth" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activează Bluetooth" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить\nBluetooth" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktivera Bluetooth" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etkinleştir\nBluetooth" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用蓝牙" + } + } + } + }, + "Enable Glucose Based Partial Application" : { + "comment" : "Title for Glucose Based Partial Application toggle", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktivér Glucose Based Partial Application" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glucose Based Partial Application aktivieren" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Attiva Applicazione parziale basata sulla glicemia (GBPA)" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktiver delvis anvendelse basert på glukose" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Włącz Algorytm adaptacyjny" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activează aplicarea parțială pe bază de glicemie" + } + } + } + }, + "Enable Integral Retrospective Correction" : { + "comment" : "Title for Integral Retrospective Correction toggle", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktivér Integral Retrospective Correction" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Integral Retrospective Correction aktivieren" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Attiva Correzione retrospettiva integrale (IRC)" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktivere integrert retrospektiv korrigering" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Włącz Integralną Korektę Retrospektywną (IRC)" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activează corecția retrospectivă integrală" + } + } + } + }, + "Enabled" : { + "comment" : "Title for enable live activity toggle" + }, + "Enables" : { + "comment" : "The action hint of the workout mode toggle button when disabled", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تفعيل" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktiverer" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktivieren" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Permitir" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ottaa käyttöön" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Active" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enables" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Attiva" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "有効にする" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktiverer" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inschakelen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Włącza" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ativar" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activează" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включает" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slår på" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etkin" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cho phép" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用" + } + } + } + }, + "Enter a blood glucose from a meter for a recommended bolus amount." : { + "comment" : "Caption for bolus screen notice when glucose data is missing or stale", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indtast blodsukker fra en fingerprikmåling for at få anbefalet en bolusmængde." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gib einen Blutzuckerwert von einem Messgerät ein, um eine Bolusempfehlung zu bekommen." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingrese una glucosa en sangre de un medidor por una cantidad recomendada de bolo." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Syötä sormenpäästä mitattu glukoosiarvo saadaksesi suositellun bolusmäärän." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entrez la glycémie à partir d'un mesure manuelle pour le calcul du bolus." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הכנס קריאת מד סוכר כדי לקבל המלצה לכמות בולוס." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inserirsci la glicemia capillare per ottenere la quantità di bolo consigliata." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Legg inn blodsukkerverdi fra en måler for anbefalt bolusmengde." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verkrijg een bloedglucose waarde van een meter voor een aanbevolen bolus hoeveelheid." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wprowadź poziom glukozy z glukometru, aby uzyskać zalecaną wielkość bolusa." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Introduceți o glicemie luata cu glucometrul pentru o recomandare de bolus." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Введите глюкозу крови, полученную с помощью глюкометра, для получения рекомендуемого количества болюса." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Du behöver mata in ett blodsockervärde för att få en rekommenderad bolus." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Önerilen bolus miktarı için bir ölçüm cihazından bir kan şekeri girin." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请输入血糖仪测得的血糖值,以获取推荐的大剂量。" + } + } + } + }, + "Enter Bolus" : { + "comment" : "Button text to begin entering a bolus", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indtast bolus" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus eingeben" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Introducir bolo" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Syötä bolus" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entrer un Bolus" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inserisci bolo" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skriv inn bolus" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus Invoeren" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wprowadź bolus" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Introdu bolusul" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ввести болюс" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ange Bolus" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus girin" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "输入大剂量" + } + } + } + }, + "Enter Fingerstick Glucose" : { + "comment" : "Button text prompting manual glucose entry on bolus screen", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indtast fingerprikmåling" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blutzucker eingeben" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingrese la glucosa de punción en el dedo" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Syötä mitattu glukoosiarvo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entrez la Glycémie capillaire" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הכנס קריאת מד סוכר" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inserirsci glicemia capillare" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Legg inn blodsukkerverdi fra fingerstikk" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vul Vingerprikglucose In" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wprowadź glukozę z glukometru" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Introduceți glicemia din deget" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Введите глюкозу по глюкометру" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ange kapillärt blodsocker" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Parmak Ucu KŞ Girin" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "输入指尖血糖值" + } + } + } + }, + "Enter glucose safety limit" : { + "comment" : "The placeholder text instructing users to enter a glucose safety limit", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indtast glucosesikkerhedsgrænse" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glukosesicherheitsgrenze eingeben" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Introduzca el límite de seguridad de glucosa" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aseta glukoosin turvaraja" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entrez la limite de sécurité du taux de glucose" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הגדרת בטיחות" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inserisci limite di sicurezza glicemia" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Angi sikkerhetsgrensen for blodsukker" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voer glucoseveiligheidslimiet in" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wprowadź bezpieczny limit glukozy" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Introduceți limita de siguranță pentru glicemie" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Введите безопасный предел глюкозы" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ange tröskelvärde för blodsocker" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "KŞ güvenlik limitini girin" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "输入血糖安全下限" + } + } + } + }, + "Enter suspend threshold" : { + "comment" : "The placeholder text instructing users to enter a suspend treshold", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "أدخل حد التوقف" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indtast grænse for suspendering" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Grenzwert für Hypo-Abschaltung eingeben" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enter suspend threshold" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingrese nivel de suspensión" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aseta pysäytysraja" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entrez le seuil de suspension" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enter suspend threshold" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inserisci soglia di sospensione" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "一時停止値を入力" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Angi suspenderingsgrense" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voer drempel voor onderbreking in" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wprowadź próg zawieszenia pompy" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Digite o limite de suspensão" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Introduceți limita pentru suspendare" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Введите рубеж приостановки" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ange tröskelvärde" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Askıya alma eşiğini girin" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhập ngưỡng tạm dừng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "输入暂停阈值" + } + } + } + }, + "Error Canceling Bolus" : { + "comment" : "The alert title for an error while canceling a bolus", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "خطأ في إلغاء الجرعة" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fejl ved annullering af bolus" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fehler beim Abbrechen des Bolus" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Error de cancelación de bolo" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Virhe boluksen kumoamisessa" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erreur lors de l’annulation du Bolus" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Error Canceling Bolus" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Errore durante l'annullamento del bolo" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ボーラスのキャンセルにエラー" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kunne ikke avbryte bolus" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fout bij Annuleren Bolus" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Błąd anulowania bolusa" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erro Cancelando Bolus" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eroare la anularea bolusului" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ошибка отмены болюса" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fel vid försök att avbryta bolus" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus İptali Hatası" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lỗi hủy liều Bolus" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法取消大剂量" + } + } + } + }, + "Error Exporting Logs" : { + "comment" : "Critical event log export error alert title", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der opstod en fejl under eksport af log" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fehler beim Exportieren des Protokolls" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Error al exportar registros" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Virhe lokin viennissä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erreur lors de l'exportation des journaux" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Errore durante l'esportazione dei registri" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Feil ved eksport av logger" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fout bij exporteren logboeken" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Błąd podczas eksportowania dzienników" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eroare la exportarea jurnalelor" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ошибка экспорта логов" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fel vid export av loggar" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Günlükleri Dışa Aktarırken Hata" + } + } + } + }, + "Error Resuming" : { + "comment" : "The alert title for a resume error", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "خطأ في الاستئناف" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fejl ved genoptagelse" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fehler beim Fortfahren" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Error Resuming" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Error Reanudando" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Virhe jatkamisessa" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erreur lors de la reprise" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Error Resuming" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Errore durante la ripresa" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "再開エラー" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Feil ved gjenopptagelse" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fout Bij Hervatten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Błąd wznawiania" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erro ao Retomar" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eroare în timpul reluării" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ошибка возобновления" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chyba pri obnovení" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fel vid återupptagande" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sürdürme Hatası" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lỗi khi đang tái thực hiện" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "恢复输注错误" + } + } + } + }, + "Event History" : { + "comment" : "Segmented button title for insulin delivery log event history", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hændelseshistorik" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ereignisverlauf" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Historial de Eventos" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tapahtumahistoria" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Historique des événements" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Event History" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cronologia degli eventi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Event History" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hendelseshistorie" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logboek" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Historia zdarzeń" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Event History" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Istoric evenimente" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "История событий" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Händelsehistorik" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etkinlik Geçmişi" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Event History" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "历史事件" + } + } + } + }, + "Eventually %@" : { + "comment" : "The subtitle format describing eventual glucose. (1: localized glucose value description)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "في النهاية %@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Med tiden %@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voraussichtlich %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eventualmente %@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ennuste %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Finalement %@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eventually %@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Infine %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "予想 %@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Omsider %@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uiteindelijk %@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Docelowo %@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eventualmente %@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajunge la %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "В конечном итоге %@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Förväntat %@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nihai KŞ %@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kết quả %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "最终 %@" + } + } + } + }, + "Exceeds maximum allowed bolus in settings" : { + "comment" : "Bolus error description: bolus exceeds maximum bolus in settings.", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Overskrider den maksimalt tilladte bolus i indstillingerne" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Überschreitet den maximal zulässigen Bolus in den Einstellungen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Excede el bolo máximo permitido en la configuración" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dépasse le bolus maximal défini dans les paramètres" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Supera il bolo massimo consentito nelle impostazioni" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Overskrider maksimalt tillatt bolus i innstillingene" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Overschrijdt maximale toegestane bolus in instellingen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Przekracza maksymalny dozwolony bolus." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Depășește bolusul maxim permis în setări" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Превышен максимально допустимый болюс в настройках" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ayarlarda izin verilen maksimum bolusu aşıyor" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "超出设定的最大剂量限制" + } + } + } + }, + "Exceeds maximum allowed carbs" : { + "comment" : "Carb error description: carbs exceed maximum amount.", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Overskrider det maksimalt tilladte antal kulhydrater" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Überschreitet die maximal zulässigen Kohlenhydrate" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Supera el máximo permitido de carbohidratos definido en la configuración" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dépasse le nombre de glucides maximal accepté" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Supera i carboidrati massimi consentiti" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Overskrider maksimalt tillatte karbohydrater" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Overschrijdt maximale toegestane koolhydraten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Przekracza maksymalną dopuszczalną ilość węglowodanów" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Depășește maximul de carbohidrați permis" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Превышено максимально допустимое количество углеводов" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İzin verilen maksimum karbonhidrat miktarını aşıyor" + } + } + } + }, + "Exceeds Maximum Bolus" : { + "comment" : "Alert title for a maximum bolus validation error", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يتجاوز أقصى جرعة" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Overskrider maksimal bolus" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Überschreitet den maximalen Bolus" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Excede bolo máximo" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ylittää maksimiboluksen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dépasse le bolus maximal" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exceeds Maximum Bolus" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Supera il bolo massimo" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ボーラス最大値を超えています" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Overskrider maksimal bolus" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Overschrijdt Maximale Bolus" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Osiągnięto bolus maksymalny" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Excede o Bolus Máximo" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Depășește bolusul maxim" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Превышает максимальный болюс" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Överstiger maxbolusdos" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maksimum Bolusu Aşıyor" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vượt quá liều Bolus tối đa" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "超过大剂量上限" + } + } + } + }, + "Export Critical Event Logs" : { + "comment" : "The title of the export critical event logs in support", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eksportér kritisk begivenhedslog" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Protokolle kritischer Ereignisse exportieren" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exportar registros de eventos críticos" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vie tärkeiden tapahtumien loki" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exporter les journaux d'événements critiques" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Esporta registri eventi critici" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eksporter kritiske hendelseslogger" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exporteer Kritieke Gebeurtenislogboek" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eksportuj dzienniki zdarzeń krytycznych" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exportă jurnalul de evenimente critice" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Экспорт логов критических событий" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exportera kritiska händelseloggar" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kritik Olay Günlüklerini Dışa Aktarma" + } + } + } + }, + "Export-%1$@" : { + "comment" : "The export file name formatted string (1: timestamp)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eksport-%1$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Export-%1$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exportar- %1$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vienti-%1$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Export-%1$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Esporta- %1$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eksporter- %1$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exporteren: %1$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eksportuj- %1$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Export-%1$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Экспорт -%1$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exportera-%1$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dışa Aktar- %1$@" + } + } + } + }, + "Failed to Resume Insulin Delivery" : { + "comment" : "The alert title for a resume error", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kunne ikke genoptage insulintilførsel" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wiederaufnahme der Insulinabgabe fehlgeschlagen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se pudo reanudar la administración de insulina" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Annostelun jatkaminen epäonnistui" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Échec de la reprise de l’administration d’insuline" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossibile riprendere l'erogazione dell'insulina" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kunne ikke gjenoppta insulinlevering" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulinetoediening Hervatten Mislukt" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nie udało się wznowić podawania insuliny" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nu s-a reușit reluarea administrării de insulină" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось возобновить подачу инсулина" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Obnovenie podávania inzulínu zlyhalo" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Misslyckades att återuppta insulintillförsel" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İnsülin İletimine Devam Edilemedi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "恢复胰岛素输注失败" + } + } + } + }, + "Favorite Foods" : { + "comment" : "Title for Favorite Foods view", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lieblingsessen" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מאכלים אהובים" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Favorittmat" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mâncăruri preferate" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "食物库" + } + } + } + }, + "FAVORITE FOODS" : { + "comment" : "The section title for Carb entry screen where Favorite Foods can be selected", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "FAVORIT-MAD" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Favorisiertes Essen" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מאכלים אהובים" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "CIBI SALVATI" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "FAVORITTMAT" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "FAVORIETE ETEN" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "ULUBIONE JEDZENIE" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "MÂNCĂRURI PREFERATE" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "食物库" + } + } + } + }, + "Fiasp" : { + "comment" : "Title of insulin model preset", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fiasp" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fiasp" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fiasp" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fiasp" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fiasp" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fiasp" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fiasp" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fiasp" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fiasp" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fiasp" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fiasp" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fiasp" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fiasp" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fiasp" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fiasp" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fiasp" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fiasp" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fiasp" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fiasp" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "费雅普(Fiasp)" + } + } + } + }, + "Fingerstick Glucose" : { + "comment" : "Label for manual glucose entry row on bolus screen", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fingerprik blodsukker" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blutzucker" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glucosa de punción en el dedo" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mitattu glukoosi" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glucose capilaire" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "קריאת מד סוכר" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glicemia capillare" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blodsukkerverdi fra fingerstikk" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vingerprikglucose" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glukoza z krwi" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glicemie din deget" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Глюкоза по глюкометру" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kapillärt blodsocker" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Parmak Ucu KŞ" + } + } + } + }, + "Fix now by turning Notifications, Critical Alerts and Time Sensitive Notifications ON." : { + "comment" : "Secondary text for alerts disabled warning, which appears on the main status screen.", + "localizations" : { + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opravit nyní zapnutím Oznámení, Kritických upozornění a Časově citlivých oznámení." + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Løs nu ved at slå meddelelser, kritiske advarsler og tidsfølsomme meddelelser TIL." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Behebe dies jetzt, indem Du Benachrichtigungen, kritische Alarme und zeitkritische Benachrichtigungen einschaltest." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Soluciónalo ahora activando Notificaciones, Alertas Críticas y Notificaciones Sensibles al Tiempo." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Corrigez-le maintenant en activant les notifications, les alertes critiques et les notifications urgentes." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "תקן כעת ע״י הפעלת התראות, התראות קריטיות והודעות רגישות לזמן" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Risolvilo ora attivando notifiche, avvisi critici e notifiche sensibili al tempo." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fiks nå ved å slå PÅ varsler, kritiske varsler og tidssensitive varsler." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Los nu op door Meldingen, Kritieke Meldingen en Tijdgevoelige Meldingen AAN te zetten." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Napraw teraz, włączając powiadomienia, alerty krytyczne i powiadomienia zależne od czasu." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remediați acum activând Notificări, Alerte critice și Notificări sensibile la timp." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Исправьте ситуацию, включив уведомления, критические оповещения и уведомления, чувствительные к времени." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bildirimleri, Kritik Uyarıları ve Zamana Duyarlı Bildirimleri AÇIK konuma getirerek şimdi düzeltin." + } + } + } + }, + "Food Type" : { + "comment" : "Label for food type entry on add favorite food screen", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "Food Type" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Madtype" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Essenstyp" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Food Type" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Food Type" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Type d'aliment" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "סוג מאכל" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tipo cibo" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Type mat" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Type voeding" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rodzaj żywności" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Food Type" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tip de alimente" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тип еды" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Typ jedla" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Typ av mat" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Food Type" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loại thực phẩm" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "食物类型" + } + } + } + }, + "For %1$@" : { + "comment" : "The format string used to describe a finite workout targets duration", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "لمدة %1$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "I %1$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Für %1$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Por %1$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ ajaksi" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pendant %1$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "For %1$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Per %1$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@につき" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "For %1$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voor %1$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Do %1$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Por %1$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Timp de %1$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "В течение %1$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "I %1$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ için" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "For %1$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "对于 %1$@" + } + } + } + }, + "For safety purposes, you should allow Critical Alerts, Time Sensitive and Notification Permissions (non-critical alerts) on your device to continue using %1$@ and cannot turn off individual alarms." : { + "comment" : "Description text for silencing time sensitive and non-critical alerts (1: app name)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Af sikkerhedsmæssige årsager bør du tillade, at Kritiske advarsler, Tidsfølsomme og Meddelelser (ikke-kritiske advarsler) på din enhed fortsætter med at bruge %1$@ og ikke kan slå individuelle alarmer fra." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aus Sicherheitsgründen solltest Du kritische Warnungen, zeitkritische Warnungen und Benachrichtigungsberechtigungen (nicht kritische Warnungen) auf Deinem Gerät zulassen, um %1$@ weiterhin verwenden zu können." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Per motivi di sicurezza, dovresti consentire gli avvisi critici, gli avvisi sensibili al tempo e le autorizzazioni di notifica (avvisi non critici) sul tuo dispositivo per continuare a utilizzare %1$@ e non puoi disattivare i singoli allarmi." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Av sikkerhetshensyn bør du tillate at kritiske varsler, tidssensitive varsler og varslingstillatelser (ikke-kritiske varsler) på enheten din fortsetter å bruke %1$@ og ikke kan slå av individuelle alarmer." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ze względów bezpieczeństwa należy zezwolić na alerty krytyczne, alerty zależne od czasu i uprawnienia do powiadomień (alerty niekrytyczne) na swoim urządzeniu na dalsze korzystanie z %1$@ i nie można wyłączać poszczególnych alarmów." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "În scopuri de siguranță, trebuie să permiteți alertele critice, sensibile la timp și permisiunile de notificare (alerte necritice) pe dispozitivul dvs. pentru a continua să utilizați %1$@ și nu puteți dezactiva alarmele individuale." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "В целях безопасности вам следует разрешить критические оповещения, срочные оповещения и уведомления (некритические оповещения) на вашем устройстве, чтобы продолжить использовать %1$@ и не отключать отдельные тревоги." + } + } + } + }, + "Forecasted blood glucose may still be higher than target range." : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forventet blodsukker kan stadig være højere end målområdet." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der prognostizierte Blutzucker kann immer noch über dem Zielbereich liegen." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "La glucosa en sangre pronosticada aún puede estar por encima del rango objetivo." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Le taux de glucose sanguin prévu pourrait quand-même être plus élevé que la plage cible." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "רמת הסוכר הצפויה עשויה עדיין להיות גבוהה מטווח היעד." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "La glicemia prevista potrebbe essere più elevata di quella impostata come obiettivo." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forventet blodsukker kan fortsatt være høyere enn målområdet." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voorspelde bloedglucose kan nog steeds hoger zijn dan het streefbereik." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Prognozowany poziom glukozy we krwi może nadal być wyższy niż zakres docelowy." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glicemia prognozată poate fi în continuare mai mare decât intervalul țintă." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Прогнозируемый уровень глюкозы в крови все еще может быть выше целевого диапазона." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Öngörülen KŞ, hala hedef aralıktan yüksek olabilir." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "预测的血糖值可能仍高于目标范围。" + } + } + } + }, + "Forecasted Glucose" : { + "comment" : "Title for forecast explanation modal on bolus view", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forventet blodsukker" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Prognostizierte Blutzucker" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glucosa pronosticada" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glycémie prévue" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "סוכר צפוי" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glicemia prevista" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forventet blodsukker" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voorspelde Glucose" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Prognozowana glukoza" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glicemia prognozată" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Прогнозируемый уровень глюкозы" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tahmini KŞ" + } + } + } + }, + "Frequently asked questions about alerts" : { + "comment" : "Label for link to see frequently asked questions", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ofte stillede spørgsmål om påmindelser" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Häufig gestellte Fragen zu Benachrichtigungen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Foire aux questions sur les alertes" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Domande frequenti sugli avvisi" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ofte stilte spørsmål om varsler" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Często zadawane pytania dotyczące alertów" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Întrebări frecvente privind alertele" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Часто задаваемые вопросы об оповещениях" + } + } + } + }, + "g" : { + "comment" : "The short unit display string for grams", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "г" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "gr" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "克" + } + } + } + }, + "Get help with Alert Permissions" : { + "comment" : "Get help with Alert Permissions support button text", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Få hjælp til advarselstilladelser" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hol Dir Hilfe zu den Benachrichtigungsberechtigungen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ayuda con los Permisos de Alerta" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Obtenir de l'aide avec les autorisations d'alerte" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ottieni assistenza per le autorizzazioni" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Få hjelp med varslingsinnstillinger" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Krijg hulp bij Toestemming Meldingen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uzyskaj pomoc dotyczącą uprawnień alertów" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Obțineți ajutor cu permisiunile de alertă" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Справка по разрешениям на оповещения" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uyarı İzinleri ile ilgili yardım alın" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "获取有关警报权限的帮助" + } + } + } + }, + "Glucose" : { + "comment" : "The title of the glucose and prediction graph\nTitle for predicted glucose chart on bolus screen", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "قراءات السكر" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glukóza" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glukose" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blutzucker" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glucosa" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glukoosi" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glycémie" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glucose" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "शुगर" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glicemia" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "血糖値" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blodsukker" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glucose" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glukoza" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glicose" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glicemia" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Глюкоза" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glykémia" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glukos" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kan şekeri" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đường huyết" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "葡萄糖" + } + } + } + }, + "Glucose Based Partial Application" : { + "comment" : "Title for glucose based partial application experiment description\nTitle of glucose based partial application experiment", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glucose Based Partial Application" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Teilweise Anwendung auf Glukosebasis" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Applicazione parziale basata sulla glicemia (GBPA)" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Partiell anvendelse basert på glukosenivå" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Algorytm adaptacyjny" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aplicare parțială pe bază de glicemie" + } + } + } + }, + "Glucose data is %1$@ old" : { + "comment" : "The error message when glucose data is too old to be used. (1: glucose data age in minutes)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "قراءات السكر منذ %1$@ " + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blodsukkerdata er %1$@ gamle" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blutzuckerdaten sind %1$@ alt" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Los datos de glucosa son de hace %1$@ " + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glukoositieto on %1$@ vanha" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les dernières données de glucose remontent à %1$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glucose data is %1$@ old" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "I dati sulla glicemia sono vecchi di %1$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "血糖データは %1$@ 前のものです" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blodsukkerdata er %1$@ gammel" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glucosegegevens zijn %1$@ oud" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dane o glukozie są nieaktualne od %1$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medida de glicose está %1$@ atrasada" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Datele despre glicemie au o vechime de %1$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Данные о глюкозе устарели на %1$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glukosvärde är %1$@ gammalt" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ eski KŞ verisi" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dữ liệu đường huyết %1$@ cũ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "葡萄糖%1$@分钟未更新" + } + } + } + }, + "Glucose data not available" : { + "comment" : "Description of error when glucose data is missing", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "قراءات السكر غير متوفرة" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blodsukkerdata ikke tilgængelige" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blutzuckerdaten nicht verfügbar" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Los datos de glucosa no están disponibles" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glukoositietoja ei saatavilla" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les données de glucose ne sont pas disponibles" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glucose data not available" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dati sulla glicemia non disponibili" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "グルコースデータがありません" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blodsukkerdata er utilgjengelig" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glucosegegevens niet beschikbaar" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dane o glukozie są niedostępne" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medida de glicose não disponível" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nu sunt disponibile date despre glicemie" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Данные гликемии недоступны" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glukosvärde saknas" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "KŞ verisi mevcut değil" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dữ liệu đường huyết không sẵn sàng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "血糖数据不可用" + } + } + } + }, + "Glucose Data Now Available" : { + "comment" : "Alert title when glucose data returns while on bolus screen", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blodsukkerdata er nu tilgængelige" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blutzucker jetzt verfügbar" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Datos de glucosa ahora disponibles" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glukoositiedot nyt saatavilla" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les données de glycémie sont maintenant disponibles" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dati glicemia ora disponibili" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blodsukkerdata er utilgjengelig" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glucosegegevens Nu Beschikbaar" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dane dotyczące glukozy są już dostępne" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Valorile glicemiei sunt disponibile" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Данные о глюкозе теперь доступны" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blodglukosvärde finns nu tillgängligt" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "KŞ Verileri Artık Kullanılabilir" + } + } + } + }, + "Glucose effect of suspending insulin delivery" : { + "comment" : "Description of the prediction input effect for suspension of insulin delivery", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glukoseeffekt ved afbrydelse af insulintilførsel" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glukoseeffekt der Unterbrechung der Insulinabgabe" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Effetto sulla glicemia della sospensione dell'erogazione d'insulina" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glukoseeffekt av å suspendere insulintilførsel" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wpływ wstrzymania podawania insuliny na poziom glukozy" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Efectul asupra glicemiei al suspendării administrării de insulină" + } + } + } + }, + "Glucose Entry Out of Range" : { + "comment" : "Alert title for a manual glucose entry out of range error\nTitle for bolus screen warning when glucose entry is out of range", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blodsukkerværdi er uden for intervallet" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blutzucker-Eingabe außerhalb des Bereichs" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entrada de glucosa fuera de rango" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glukoosiarvo alueen ulkopuolella" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "La glycémie saisie est hors de la plage." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glicemia inserita fuori intervallo" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blodsukkerdata er utenfor intervallet" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glucose-invoer Buiten Bereik" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wprowadzanie glukoza jest poza zakresem" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Valoarea glicemică introdusă este în afara intervalului" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ввод глюкозы вне допустимого диапазона" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Det manuellt inmatade blodsockervärdet utanför gränsvärden" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "KŞ Girişi Aralık Dışında" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "输入血糖超出范围" + } + } + } + }, + "Glucose Momentum" : { + "comment" : "Title of the prediction input effect for glucose momentum", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "مقاومة سكر الدم" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blodsukkermomentum" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blutzucker-Momentum" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Momento de Glucosa" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glukoosin liike" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Momentum de glucose" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glucose Momentum" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Momento della glicemia" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "グルコース モメンタム" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glukose Momentum" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glucosetrendlijn" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pęd glukozy" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aceleração da Glicose" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avânt glicemie" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Динамика гликемии" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glukosförändring" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "KŞ Momentumu" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chuyển động của Glucose" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "葡萄糖增量预测算法" + } + } + } + }, + "Glucose Target Range Schedule" : { + "comment" : "Details for configuration error when glucose target range schedule is missing", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tidsplan for glukosemålområde" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zeitplan für den Blutzucker-Zielbereich" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Horario para el objetivo de glucosa" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Horaire de la plage cible de glycémie" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Programma di intervalli target di glicemia" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tidsplan for blodsukker målområde" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glucosestreefbereikschema" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Harmonogram zakresu docelowego glukozy" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Programul intervalului țintă de glucoză" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "График целевого диапазона глюкозы" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "KŞ Hedef Aralığı Programı" + } + } + } + }, + "HARDWARE SOUNDS" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "HARDWARE-LYDE" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "HARDWARE-SOUNDS" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "SONS MATÉRIELS" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "SUONI HARDWARE" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "MASKINVARELYDER" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "DŹWIĘKI SPRZĘTOWE" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "SUNET HARDWARE" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "АППАРАТНЫЕ ЗВУКИ" + } + } + } + }, + "High Glucose" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Høj glukose" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hoher Blutzucker" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glycémie élevée" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glicemia alta" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Høyt glukosenivå" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wysoki poziom glukozy" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glicemie ridicată" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Высокий уровень глюкозы" + } + } + } + }, + "How can I silence non-Critical Alerts?" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hvordan kan jeg slå ikke-kritiske alarmer fra?" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wie kann ich nicht kritische Warnungen stummschalten?" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Come posso silenziare gli avvisi non critici?" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hvordan kan jeg dempe ikke-kritiske varsler?" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jak mogę wyciszyć alerty niekrytyczne?" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cum pot dezactiva sunetul alertelor non-critice?" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Как отключить некритичные оповещения?" + } + } + } + }, + "How can I silence only Time Sensitive and Non-Critical alerts?" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hvordan kan jeg kun slå lyden fra for tidsfølsomme og ikke-kritiske advarsler?" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wie kann ich nur zeitkritische und nicht kritische Warnungen stummschalten?" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Come posso silenziare solo gli avvisi sensibili al tempo e non critici?" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hvordan kan jeg deaktivere kun tidssensitive og ikke-kritiske varsler?" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jak wyciszyć tylko alerty zależne od czasu i niekrytyczne?" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cum pot dezactiva sunetul doar alertelor sensibile la timp și non-critice?" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Как отключить только срочные и некритические предупреждения?" + } + } + } + }, + "How can I temporarily silence all %1$@ app sounds?" : { + "comment" : "Title text for temporarily silencing all sounds (1: app name)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hvordan kan jeg midlertidigt slå lyden fra for alle %1$@ applyde?" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wie kann ich alle Töne der %1$@ App vorübergehend stummschalten?" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comment puis-je désactiver temporairement tous les sons de l'application %1$@?" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Come posso silenziare temporaneamente tutti i suoni dell'app %1$@ ?" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hvordan kan jeg midlertidig dempe alle %1$@-applydene?" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jak mogę tymczasowo wyciszyć wszystkie dźwięki aplikacji %1$@ ?" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cum pot dezactiva temporar toate sunetele aplicației %1$@?" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Как временно отключить все звуки приложения %1$@?" + } + } + } + }, + "How to update (LoopDocs)" : { + "comment" : "The title text for how to update", + "localizations" : { + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jak aktualizovat (LoopDocs)" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sådan opdaterer du (LoopDocs)" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wie man aktualisiert (LoopDocs)" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cómo actualizar (LoopDocs)" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comment mettre à jour (LoopDocs)" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "כיצד לעדכן (LoopDocs)" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Come Aggiornare (LoopDocs)" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hvordan oppdatere (LoopDocs)" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hoe bij te werken (LoopDocs)" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jak zaktualizować (LoopDocs)" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cum se actualizează (LoopDocs)" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Как обновить (LoopDocs)" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nasıl güncellenir (LoopDocs)" + } + } + } + }, + "https://mysite.herokuapp.com" : { + "comment" : "The placeholder text for the nightscout site URL credential", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "https://mysite.herokuapp.com" + } + } + } + }, + "If iOS Focus Mode is ON and Mute Alerts is OFF, Critical Alerts will still be delivered and non-Critical Alerts will be silenced until %1$@ is added to each Focus mode as an Allowed App." : { + "comment" : "Focus modes descriptive text (1: app name)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hvis iOS-fokustilstand er slået TIL, og Slå lyden fra-advarsler er slået fra, leveres kritiske advarsler stadig, og ikke-kritiske advarsler slås fra, indtil %1$@ føjes til hver fokustilstand som en tilladt app." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wenn der iOS-Fokusmodus aktiviert und die Stummschaltung von Warnungen deaktiviert ist, werden kritische Warnungen weiterhin übermittelt und nicht kritische Warnungen stummgeschaltet, bis %1$@ jedem Fokusmodus als zulässige App hinzugefügt wird." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Si le mode Concentration d'iOS est activé et que les Alertes Muettes sont désactivées, les Alertes Critiques seront toujours livrées et les Alertes non Critiques seront silencieuses jusqu'à ce que %1$@ soit ajouté à chaque mode Concentration en tant qu'application autorisée." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se la modalità Focus iOS è attiva e Disattiva avvisi è disattivata, gli avvisi critici verranno comunque inviati e gli avvisi non critici verranno silenziati finché %1$@ non verrà aggiunto a ciascuna modalità Focus come app consentita." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hvis iOS Focus Mode er PÅ og Mute Alerts er AV, vil kritiske varsler fortsatt bli levert og ikke-kritiske varsler vil bli dempet inntil %1$@ er lagt til i hver Focus Mode som en tillatt app." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jeśli tryb skupienia w systemie iOS jest włączony, a opcja Wycisz alerty jest wyłączona, alerty krytyczne będą nadal dostarczane, a alerty niekrytyczne będą wyciszane, dopóki %1$@ nie zostanie dodany do każdego trybu skupienia jako dozwolona aplikacja." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dacă modul Focus iOS este ACTIVAT și opțiunea Dezactivare alerte este DEZACTIVATĂ, alertele critice vor fi transmise în continuare, iar alertele non-critice vor fi dezactivate până când %1$@ este adăugat la fiecare mod Focus ca aplicație permisă." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Если режим фокусировки iOS включен, а функция отключения оповещений выключена, критические оповещения будут по-прежнему доставляться, а некритические будут глушиться до тех пор, пока %1$@ не будет добавлен в каждый режим фокусировки в качестве разрешенного приложения." + } + } + } + }, + "Immediate" : { + "comment" : "Immediate Delivery status text", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Akut" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sofort" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inmediato" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Immédiat" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Immediato" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Umiddelbar" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Onmiddellijk" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Natychmiastowy" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Imediat" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Немедленно" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Acilen" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "实时" + } + } + } + }, + "In future versions of Loop these experiments may change, end up as standard parts of the Loop Algorithm, or be removed from Loop entirely. Please follow along in the Loop Zulip chat to stay informed of possible changes to these features." : { + "comment" : "Algorithm Experiments description second paragraph.", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "I fremtidige versioner af Loop kan disse eksperimenter ændre sig, ende som standarddele af Loop-algoritmen eller blive fjernet helt fra Loop. Følg med i Loop Zulip-chatten for at holde dig informeret om mulige ændringer af disse funktioner." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "In zukünftigen Versionen von Loop können sich diese Experimente ändern, als Standardbestandteile des Loop-Algorithmus enden oder vollständig aus Loop entfernt werden. Bitte folgen Sie dem Loop Zulip-Chat, um über mögliche Änderungen dieser Funktionen auf dem Laufenden zu bleiben." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dans les futures versions de Loop, ces essais peuvent changer, devenir des parties standard de l'algorithme Loop, ou être complètement retirées de Loop. Veuillez suivre le chat Zulip de Loop pour rester informé des éventuels changements sur ces fonctionnalités." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nelle versioni future di Loop questi esperimenti potrebbero cambiare, diventare parti standard dell'algoritmo Loop o essere rimossi completamente da Loop. Segui la chat di Loop Zulip per rimanere informato su possibili modifiche a queste funzionalità." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "I fremtidige versjoner av Loop kan disse eksperimentene endres, ende opp som standarddeler av Loop-algoritmen eller fjernes helt fra Loop. Følg med i Loop Zulip-chatten for å holde deg informert om eventuelle endringer i disse funksjonene." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "W przyszłych wersjach Loop te eksperymenty mogą się zmienić, stać się standardowymi częściami algorytmu Loop lub zostać całkowicie usunięte z Loop. Śledź czat Loop Zulip, aby być na bieżąco z możliwymi zmianami w tych funkcjach." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "În versiunile viitoare ale Loop, aceste experimente se pot schimba, pot deveni părți standard ale algoritmului Loop sau pot fi eliminate complet din Loop. Vă rugăm să urmăriți chat-ul Loop Zulip pentru a fi la curent cu posibilele modificări ale acestor funcții." + } + } + } + }, + "Indefinitely" : { + "comment" : "The title of a target alert action specifying an indefinitely long workout targets duration", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "إلى أجل غير مسمى" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uendelig" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unbegrenzt" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indefinitely" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indefinidamente" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toistaiseksi" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indéfiniment" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indefinitely" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indefinitamente" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "無期限" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "På ubestemt tid" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voor onbepaalde tijd" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Niemożliwy do określenia" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indefinidamente" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nedeterminat" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "На неопределенное время" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oändligt" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Süresiz" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vô hạn định" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "永久" + } + } + } + }, + "Information" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Information" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Informasjon" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Informaţii" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "信息" + } + } + } + }, + "Insulin" : { + "comment" : "Title of the prediction input effect for insulin", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الأنسولين" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulin" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulin" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulina" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insuliini" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insuline" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulin" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulina" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "インスリン" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulin" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insuline" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulina" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulina" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulină" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Инсулин" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulin" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İnsülin" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thuốc Insulin" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "胰岛素" + } + } + } + }, + "Insulin Absorbed (U) × Insulin Sensitivity (%1$@/U)" : { + "comment" : "Description of the prediction input effect for insulin", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الأنسولين الذي تم امتصاصه (وحدات) × حساسيو الأنسولين (%1$@/وحدة)" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulin absorberet (E) × Insulinfølsomhed (%1$@/E)" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resorbiertes Insulin (IE) × Insulinempfindlichkeit (%1$@/IE)" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulina Absorbida (U) x Sensibilidad a Insulina (%1$@/U)" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Imeytynyt insuliini (U) × Insuliiniherkkyys (%1$@/U)" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insuline Absorbée (U) × Sensibilité à l'Insuline (%1$@/U)" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulin Absorbed (U) × Insulin Sensitivity (%1$@/U)" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulina assorbita (U) × Sensibilità insulinica (%1$@/U)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "吸収済インスリン(U) × インスリン効果値(%1$@/U)" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulin absorbert (E) × insulinfølsomhet ( %1$@ /E)" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opgenomen Insuline (E) x Insulinegevoeligheid (%1$@/E)" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulina podana (U) × czułość insuliny (%1$@/J)" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulina Absorvida (U) × Sensibilidade a Insulina (%1$@/U)" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulină absorbită (U) × factor de sensibilitate la insulină (%1$@/U)" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Усвоенный инсулин (U) × Чувствительность к инсулину (%1$@/U)" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulin absorberat (E) × Insulinkänslighet (%1$@/E)" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Emilen İnsulin (Ü) × İnsulin Duyarlılığı (%1$@/Ü)" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lượng insulin tiêu hao (U) × Độ nhạy của insulin (%1$@/U)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已吸收胰岛素×胰岛素敏感系数" + } + } + } + }, + "Insulin adjustments have been disabled!" : { + "comment" : "Notification body for crash recovery alert", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulinjusteringer er blevet deaktiveret!" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die Insulinanpassung wurde deaktiviert!" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¡Se han deshabilitado los ajustes de insulina!" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les ajustements d’insuline ont été désactivés!" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Le regolazioni dell'insulina sono state disattivate!" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulinjusteringer er deaktivert!" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulineaanpassingen zijn uitgeschakeld!" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Korekty insuliny zostały wyłączone!" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajustările de insulină au fost dezactivate!" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Регулировка инсулина была отключена!" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İnsülin ayarlamaları devre dışı bırakıldı!" + } + } + } + }, + "Insulin Delivery" : { + "comment" : "The title of the insulin delivery graph", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "توصيل الأنسولين" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulintilførsel" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulinabgabe" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Administración de Insulina" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insuliinin annostelu" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Administration de l'insuline" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulin Delivery" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erogazione insulina" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "インスリン放出" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulinlevering" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulinetoediening" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Podaż insuliny" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulina Entregue" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Administrare insulină" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подача инсулина" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Podávanie inzulínu" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulin doserat" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İnsulin İletimi" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Khối lượng tiêm insulin" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已输注胰岛素" + } + } + } + }, + "Insulin effects" : { + "comment" : "Details for missing data error when insulin effects are missing\nDetails for missing data error when insulin effects including pending insulin are missing", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تأثيرات الأنسولين" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulineffekter" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulinwirkungen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Efectos de la insulina" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insuliinivaikutus" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Effets de l'insuline" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulin effects" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Effetto dell’insulina" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "インスリン効果" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulin effekt" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulineëffecten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "wpływ insuliny" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Efeitos da Insulina" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Efecte insulină" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Влияние инсулина" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulineffekter" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İnsülin etkileri" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tác dụng của insulin" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "胰岛素效果" + } + } + } + }, + "Insulin Model" : { + "comment" : "Details for configuration error when insulin model is missing\n The title text for the insulin model setting row", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "نوع الأنسولين" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulinmodel" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulin-Modell" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulin Model" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modelo de Insulina" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insuliinimalli" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modèle d'insuline" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulin Model" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modello di azione dell'Insulina" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "インスリンモデル" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulin Modell" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulinemodel" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Model insuliny" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modelo de Insulina" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modelul de insulină" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Модель инсулина" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inzulínový model" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulinmodell" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İnsülin Modeli" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chủng loại Insulin" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "胰岛素模型" + } + } + } + }, + "Insulin Pump" : { + "comment" : "Descriptive text for Insulin Pump", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulinpumpe" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulinpumpe" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Microinfusadora de insulina" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insuliinipumppu" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pompe à insuline" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "משאבת אינסולין" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pompa" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulinpumpe" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulinepomp" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pompa insulinowa" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pompa de insulină" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Инсулиновая помпа" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulinpump" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İnsülin pompası" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "胰岛素泵" + } + } + } + }, + "Insulin Sensitivities" : { + "comment" : "The title of the insulin sensitivities schedule screen\n The title text for the insulin sensitivity schedule", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "حساسية الأنسولين" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulinfølsomheder" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulinempfindlichkeit" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulin Sensitivities" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sensibilidad a la insulina" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insuliiniherkkyydet" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Facteurs de sensibilité à l'insuline" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulin Sensitivities" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sensibilità insulinica" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "インスリン効果値" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulinfølsomhet" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulinegevoeligheden" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wrażliwość na insulinę (ISF)" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sensibilidades a Insulina" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Factor de sensibilitate la insulină" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Факторы чувствительности инсулина" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulinkänslighet" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İnsülin Duyarlılığı" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Độ nhạy của Insulin" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "胰岛素敏感系数" + } + } + } + }, + "Insulin Sensitivity Schedule" : { + "comment" : "Details for configuration error when insulin sensitivity schedule is missing", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulinfølsomhed tidsplan" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulinempfindlichkeit Plan" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Horario de la sensibilidad a la insulina" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Horaire de facteur de sensibilité à l'insuline" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Programma di sensibilità insulinica" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulin følsomhet tidsplan" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulinegevoeligheidschema" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Harmonogram wrażliwości na insulinę" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Programul Factorului de Sensibilitate la Insulină" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "График чувствительности к инсулину" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İnsülin Duyarlılık Programı" + } + } + } + }, + "Insulin Suspended" : { + "comment" : "The title of the cell indicating the pump is suspended", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulin suspenderet" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulinabgabe unterbrochen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulina Suspendida" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insuliini pysäytetty" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insuline suspendue" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulina sospesa" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulintilførsel utsatt" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insuline Onderbroken" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Podawanie Zawieszone" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Administrarea insulinei suspendată" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подача инсулина приостановлена" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulintillförsel pausad" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İnsülin Askıya Alındı" + } + } + } + }, + "Insulin Type" : { + "comment" : "Insulin type label", + "localizations" : { + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Typ inzulínu" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulintype" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulintyp" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tipo de Insulina" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insuliinityyppi" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Type d'insuline" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "סוג האינסולין" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tipo d'insulina" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulintype" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulinetype" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rodzaj insuliny" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tipul de insulină" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тип инсулина" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Typ inzulínu" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulintyp" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İnsülin Tipi" + } + } + } + }, + "Integral Retrospective Correction" : { + "comment" : "Title for integral retrospective correction experiment description\nTitle of integral retrospective correction experiment", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Integral Retrospective Correction" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Integrale retrospektive Korrektur" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Correzione retrospettiva integrale (IRC)" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Integrert retrospektiv korreksjon" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Integralna korekta retrospektywna" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Corecție retrospectivă integrală" + } + } + } + }, + "Integral Retrospective Correction (IRC) is an extension of the standard Retrospective Correction (RC) algorithm component in Loop, which adjusts the forecast based on the history of discrepancies between predicted and actual glucose levels.\n\nIn contrast to RC, which looks at discrepancies over the last 30 minutes, with IRC, the history of discrepancies adds up over time. So continued positive discrepancies over time will result in increased dosing. If the discrepancies are negative over time, Loop will reduce dosing further." : { + "comment" : "Description of Integral Retrospective Correction toggle.", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Integral Retrospective Correction (IRC) er en udvidelse af standard Retrospective Correction (RC) algoritmekomponent i Loop, som justerer prognosen baseret på historikken med uoverensstemmelser mellem forudsagte og faktiske glukoseniveauer.\n\nI modsætning til RC, der ser på uoverensstemmelser i løbet af de sidste 30 minutter, med IRC, tilføjes historikken over uoverensstemmelser over tid. Så fortsatte positive uoverensstemmelser over tid vil resultere i øget dosering. Hvis uoverensstemmelserne er negative over tid, vil Loop reducere doseringen yderligere." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Integral Retrospective Correction (IRC) ist eine Erweiterung der standardmäßigen Retrospective Correction (RC)-Algorithmuskomponente in Loop, die die Prognose basierend auf der Historie der Abweichungen zwischen vorhergesagten und tatsächlichen Glukosewerten anpasst. \n\nIm Gegensatz zu RC, das die Abweichungen der letzten 30 Minuten betrachtet, summiert sich bei IRC der Verlauf der Abweichungen im Laufe der Zeit. Daher führen anhaltende positive Abweichungen im Laufe der Zeit zu einer erhöhten Dosierung. Wenn die Abweichungen im Laufe der Zeit negativ sind, reduziert Loop die Dosierung weiter." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Correzione retrospettiva integrale (IRC) è un'estensione del componente standard dell'algoritmo di correzione retrospettiva (RC) in Loop, che regola la previsione in base alla cronologia delle discrepanze tra i livelli di glicemia previsti e quelli effettivi. \n\nA differenza di RC, che esamina le discrepanze negli ultimi 30 minuti, con IRC la cronologia delle discrepanze si accumula nel tempo. Pertanto, continue discrepanze positive nel tempo comporteranno un aumento del dosaggio. Se le discrepanze diventano negative nel tempo, Loop ridurrà ulteriormente il dosaggio." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Integral Retrospective Correction (IRC) er en utvidelse av standardalgoritmekomponenten Retrospective Correction (RC) i Loop, som justerer prognosen basert på historikken for avvik mellom forventede og faktiske glukosenivåer.\n\nI motsetning til RC, som ser på avvik i løpet av de siste 30 minuttene, summerer IRC avvikene over tid. Fortsatte positive avvik over tid vil derfor føre til økt dosering. Hvis avvikene er negative over tid, vil Loop redusere doseringen ytterligere." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Integralna korekta retrospektywna (IRC) jest rozszerzeniem standardowego komponentu algorytmu Korekta retrospektywna (RC) w Loop, który koryguje prognozę na podstawie historii rozbieżności między przewidywanymi a rzeczywistymi poziomami glukozy. \n\n W przeciwieństwie do RC, który analizuje rozbieżności w ciągu ostatnich 30 minut, w przypadku IRC historia rozbieżności sumuje się w czasie. Tak więc utrzymujące się dodatnie rozbieżności w czasie spowodują zwiększenie dawki. Jeśli rozbieżności są ujemne w czasie, Loop jeszcze bardziej zmniejszy podawanie insuliny." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Corecția Retrospectivă Integrală (CRI) este o extensie a componentei algoritmului standard de Corecție Retrospectivă (CR) din Loop, care ajustează prognoza pe baza istoricului discrepanțelor dintre nivelurile de glicemie prezise și cele reale. \n \nSpre deosebire de CR, care analizează discrepanțele din ultimele 30 de minute, cu CRI, istoricul discrepanțelor se adună în timp. Așadar, discrepanțele pozitive continue în timp vor duce la creșterea dozării. Dacă discrepanțele sunt negative în timp, Loop va reduce și mai mult dozarea." + } + } + } + }, + "Interrupted %1$@: %2$@ of %3$@ %4$@" : { + "comment" : "Description of an interrupted bolus dose entry (1: title for dose type, 2: value (? if no value) in bold, 3: programmed value (? if no value), 4: unit)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afbrudt %1$@: %2$@ af %3$@ %4$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ unterbrochen: %2$@ von %3$@ %4$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ interrumpido: %2$@ de %3$@ %4$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keskeytetty %1$@: %2$@ / %3$@ %4$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Interrompu %1$@: %2$@ de %3$@ %4$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Interrotto %1$@: %2$@ di %3$@ %4$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avbrutt %1$@: %2$@ av %3$@ %4$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Onderbroken %1$@: %2$@ van %3$@ %4$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Przerwane %1$@ : %2$@ z %3$@ %4$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Întrerupere %1$@: %2$@ din %3$@ %4$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Прервано %1$@: %2$@ из %3$@ %4$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avbruten %1$@: %2$@ av %3$@ %4$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ kesintiye uğradı: %3$@ %4$@'nın %2$@ 'si" + } + } + } + }, + "Invalid absorption time: %1$@ hours" : { + "comment" : "Carb error description: invalid absorption time. (1: Input duration in hours).", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ugyldig absorptionstid: %1$@ timer" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ungültige Resorptionszeit: %1$@ Stunden" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Periodo di assorbimento non valido: %1$@ ore" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ugyldig absorpsjonstid: %1$@ timer" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nieprawidłowy czas absorpcji: %1$@ godz" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Timp de absorbție nevalid: %1$@ ore" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无效的吸收时间:%1$@ 小时" + } + } + } + }, + "Invalid Bolus Amount" : { + "comment" : "Bolus error description: invalid bolus amount.", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ugyldig bolusmængde" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ungültige Bolusmenge" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quantité de bolus invalide" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "כמות בולוס לא תקינה" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quantità bolo non valida" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ugyldig bolusmengde" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ongeldige bolushoeveelheid" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nieprawidłowa wielkość bolusa" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cantitate invalidă de bolus" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Неверный объем болюса" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geçersiz Bolus Miktarı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "大剂量无效" + } + } + } + }, + "Invalid carb amount" : { + "comment" : "Carb error description: invalid carb amount.", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ugyldig kulhydratmængde" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ungültige Kohlenhydratmenge" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cantidad de carbohidratos no válida" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quantité de glucides invalide" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "כמות פחמימות לא תקינה" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quantità di carboidrati non valida" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ugyldig karbohydratmengde" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ongeldige hoeveelheid koolhydraten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nieprawidłowa ilość węglowodanów" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cantitate de carbohidrați invalidă" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Неверное количество углеводов" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geçersiz karb miktarı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "碳水含量无效" + } + } + } + }, + "Invalid data: %1$@" : { + "comment" : "The error message when invalid data was encountered. (1: details of invalid data)", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "بيانات غير صالحة: %1$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fejlagtige data: %1$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ungültige Daten: %1$@" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Invalid data: %1$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Datos no válidos: %1$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Virheellinen tieto: %1$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Données Incorrectes: %1$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Invalid data: %1$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dati non validi: %1$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "無効データ: %1$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ugyldige data: %1$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ongeldige gegevens: %1$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Błędne dane: %1$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dados inválidos: %1$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Date invalide: %1$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Неверные данные: %1$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ogiltigt värde: %1$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geçersiz veri: %1$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dữ liệu vô hiệu: %1$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无效数据: %1$@" + } + } + } + }, + "Invalid Future Glucose" : { + "comment" : "Title for bolus screen notice when glucose data is in the future", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ugyldig fremtidig glukose" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ungültige zukünftige Blutzuckerwerte" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glucosa futura no válida" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glycémie future invalide" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glicemia futura non valida" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ugyldig fremtidig blodsukker" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ongeldige Toekomstige Glucose" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Przyszła glukoza nie jest znana" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glicemie Viitoare Invalidă" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Недействительная будущая глюкоза" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geçersiz Gelecek Glikoz" + } + } + } + }, + "Invalid glucose reading with a timestamp that is %1$@ in the future" : { + "comment" : "The error message when glucose data is in the future. (1: glucose data time in future in minutes)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ugyldig glukoseaflæsning med et tidsstempel, der er %1$@ i fremtiden" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ungültiger Blutzuckermesswert mit einem Zeitstempel, der %1$@ in der Zukunft liegt" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lectura de glucosa no válida de %1$@ en el futuro" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lecture de glucose non valide avec un horodatage situé à %1$@ dans le futur" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lettura della glicemia non valida con data e ora %1$@ nel futuro" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ugyldig blodsukkermåling med et tidsstempel som er %1$@ i fremtiden" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ongeldige glucosemeting met een tijdstempel dat %1$@ in de toekomst ligt." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nieprawidłowy odczyt glukozy ze znacznikiem czasu, który wynosi %1$@ w przyszłości" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Citirea invalidă a glicemiei cu un marcaj temporal care este %1$@ în viitor" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Неверное показание глюкозы с временной меткой, которая находится на %1$@ в будущем" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gelecekte %1$@ olan bir zaman damgasıyla geçersiz KŞ okuması" + } + } + } + }, + "iOS Critical Alerts and Time Sensitive Alerts are types of Apple notifications. They are used for high-priority events. Some examples include:" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kritiske iOS-advarsler og tidsfølsomme advarsler er typer Apple-meddelelser. De bruges til begivenheder med høj prioritet. Nogle eksempler inkluderer:" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kritische iOS-Warnungen und zeitkritische Warnungen sind Arten von Apple-Benachrichtigungen. Sie werden für Ereignisse mit hoher Priorität verwendet. Einige Beispiele:" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les alertes critiques iOS et les alertes urgentes sont des types de notifications Apple. Elles sont utilisées pour des événements de haute priorité. Voici quelques exemples :" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "gli avvisi critici e gli avvisi sensibili al tempo di iOS sono tipi di notifiche Apple. Vengono utilizzati per eventi ad alta priorità. Alcuni esempi sono:" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "iOS Critical Alerts og Time Sensitive Alerts er typer Apple-varsler. De brukes ved hendelser med høy prioritet. Noen eksempler er:" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alerty krytyczne systemu iOS i alerty zależne od czasu to typy powiadomień Apple. Wykorzystuje się je do zdarzeń o wysokim priorytecie. Oto kilka przykładów:" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alertele critice iOS și alertele sensibile la timp sunt tipuri de notificări Apple. Sunt folosite pentru evenimente cu prioritate ridicată. Câteva exemple includ:" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Критические оповещения iOS и срочные оповещения — это типы уведомлений Apple. Они используются для проведения высокоприоритетных мероприятий. Вот некоторые примеры:" + } + } + } + }, + "IOS FOCUS MODES" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "iOS-FOKUSTILSTANDE" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "IOS-FOKUSMODI" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "MODES DE CONCENTRATION IOS" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "MODALITÀ FOCUS IOS" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "IOS-FOKUSMODUSER" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "TRYBY SKUPIENIA IOS" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "MODURI CONCENTRARE IOS" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "РЕЖИМЫ ФОКУСИРОВКИ IOS" + } + } + } + }, + "Issue Report" : { + "comment" : "The title text for the issue report menu item\nThe view controller title for the issue report screen", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تقرير المشكلة" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fejlrapport" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Problembericht" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Informe de Errores" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ongelmaraportti" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Créer un rapport" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Issue Report" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Report problemi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "問題を報告" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Problem Rapport" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Probleemrapportage" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zgłaszanie błędów" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gerar Relatório" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Generează raport" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сообщить об ошибке" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skapa felrapport" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sorun Raporu" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xuất bản Báo cáo" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "反馈问题" + } + } + } + }, + "It looks like you may not have logged a meal you ate. Tap to log it now." : { + "comment" : "The notification description for a meal that was possibly not logged in Loop.", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Det ser ud til, at du måske ikke har indtastet et måltid, du har spist. Tryk for at logge det nu." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Es sieht so aus, als ob du vergessen hast eine Mahlzeit einzugeben. Tippe um sie nun einzutragen." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Il semble que vous n'ayez pas enregistré un repas que vous avez pris. Appuyez pour l'enregistrer maintenant." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "ייתכן ולא הזנת ארוחה שאכלת. לחץ כדי להזין אותה כעת." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sembra che non sia stato registrato un pasto consumato. Tocca per registrarlo ora." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Det ser ut til at du kanskje ikke har logget et måltid du har spist. Trykk for å logge den nå." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Het lijkt erop dat je een maaltijd die je hebt gegeten niet hebt toegevoegd. Tik om het nu toe te voegen." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wygląda na to, że nie wprowadziłeś zjedzonego posiłku. Stuknij, aby dodać zaległy posiłek." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se pare că nu ați înregistrat o masă pe care ați mâncat-o. Atingeți pentru a o înregistra acum." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Похоже, что Вы не ввели съеденную Вами еду. Нажмите , чтобы записать ее сейчас." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Görünüşe göre yediğiniz bir yemeği kaydetmemiş olabilirsiniz. Şimdi kaydetmek için dokunun." + } + } + } + }, + "Large Meal Entered" : { + "comment" : "Title of the warning shown when a large meal was entered", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stort måltid indtastet" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Große Mahlzeit eingegeben" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comida grande ingresada" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Grand repas entré" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "ארוחה גדולה נרשמה" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pasto abbondante inserito" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Angitt Stort Måltid" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Grote Maaltijd Ingevoerd" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wprowadzono duży posiłek" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "O cantitate mare de carbohidrați a fost introdusă" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Введена большая порция еды" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Büyük Yemek Girildi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已输入大份餐" + } + } + } + }, + "Launches CGM app" : { + "comment" : "Glucose HUD accessibility hint", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "قم بتشغيل تطبيق نظام متابعة سكر الدم المستمرة" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Åbner CGM app’en" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Öffnet die CGM-App" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Launches CGM app" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lanza app MCG" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avaa CGM-sovelluksen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lance l'application CGM" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מפעיל אפליקציית חיישן" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avvia l'app CGM" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "CGMアプリを起動" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Laster inn CGM-appen" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lanceert CGM app" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uruchamia aplikację CGM" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inicia app CGM" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lansează aplicația CGM" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Запускает приложение непрерывного мониторинга CGM" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Startar CGM-app" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "CGM uygulamasına erişim" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Phát hành ứng dụng CGM" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启动CGM软件" + } + } + } + }, + "Learn More" : { + "comment" : "OK button title for alert shown when delivery status is uncertain", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lær mere" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mehr erfahren" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Más información" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lue lisää" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "En savoir plus" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מידע נוסף" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ulteriori informazioni" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lære mer" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meer Informatie" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dowiedz się więcej" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aflați mai multe" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подробнее" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lär dig mer" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Daha fazla bilgi edin" + } + } + } + }, + "Less than a minute remaining" : { + "comment" : "Estimated remaining duration with less than a minute", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mindre end 1 minut tilbage" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Weniger als eine Minute verbleiben" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Queda menos de un minuto" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle minuutti jäljellä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Moins d'une minute restante" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "נותרה פחות מדקה" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meno di un minuto rimanente" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mindre enn et minutt gjenstår" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Minder dan een minuut resterend" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pozostało mniej niż minutę" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "A rămas mai puțin de un minut" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Осталось меньше минуты" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mindre än en minut återstår" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bir dakikadan az kaldı" + } + } + } + }, + "Live activity" : { + "comment" : "Alert Permissions live activity\nLive activity screen title" + }, + "Loading..." : { + "comment" : "The loading message for the diagnostic report screen", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تحميل..." + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indlæser..." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Laden…" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cargando..." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ladataan..." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chargement..." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loading..." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Caricamento in corso..." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ロード中..." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Laster inn..." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Laden..." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ładowanie..." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carregando..." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se încarcă..." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузка..." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Laddar..." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yükleniyor..." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đang tải..." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "读取中..." + } + } + } + }, + "Log Dose" : { + "comment" : "Button text to log a dose\nTitle for dose logging screen", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Log dosis" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dosis speichern" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Registrar dosis" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kirjaa annos" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enregistrer la dose" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Registra dose" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logg Dose" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Registreer Dosis" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zarejestruj dawkę" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Înregistrează Doză" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Доза из лога" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logga Dos" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Günlük Doz" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "记录注射量" + } + } + } + }, + "Logged Insulin Dose" : { + "comment" : "The title of the screen displaying a manually entered insulin dose", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logget insulindosis" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gespeicherte Insulindosis" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dosis de insulina registrada" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kirjattu insuliiniannos" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dose d'insuline enregistrée" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dose d'insulina registrata" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logget insulindose" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geregistreerde Insulinedosis" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zarejestrowana dawka insuliny" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Doza de insulină înregistrată" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Зарегистрированная доза инсулина" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loggad insulindos" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kayıtlı İnsülin Dozu" + } + } + } + }, + "Loggly" : { + "comment" : "The title of the loggly service", + "extractionState" : "manual", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loggly" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loggly" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loggly" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loggly" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loggly" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loggly" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loggly" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loggly" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loggly" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loggly" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loggly" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loggly" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loggly" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loggly" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loggly" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loggly" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loggly" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loggly" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loggly" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loggly" + } + } + } + }, + "Loop Crashed" : { + "comment" : "Title for crash recovery alert", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop er crashet" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop ist abgestürzt" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Falla del Loop" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Panne de Loop" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop קרס" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop è crashato" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop krasjet" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop is Vastgelopen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pętla uległa awarii" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop s-a închis" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Произошел сбой приложения" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop Çöktü" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "loop 崩溃了" + } + } + } + }, + "Loop Failure" : { + "comment" : "The notification title for a loop failure", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "فشل في الحلقة المغلقة" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop-fejl" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop fehlgeschlagen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Falla del Loop" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loopin häiriö" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Echec de Loop" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop Failure" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Malfunzionamento di Loop" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ループの不良" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop-feil" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loopstoring" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Awaria pętli" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Falha no Loop" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eșec Loop" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ошибка в работе петли" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loopfel" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Döngü Hatası" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop lỗi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop失败" + } + } + } + }, + "Loop has detected an issue with your Bluetooth settings, and will not work successfully until Bluetooth is enabled. You will not receive glucose readings, or be able to bolus." : { + "comment" : "Bluetooth unavailable alert body.", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop har opdaget et problem med dine Bluetooth-indstillinger, og fungerer ikke korrekt, før Bluetooth er aktiveret. Du vil ikke få blodsukkeraflæsninger eller være i stand til at give bolus." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop hat ein Problem mit Deinen Bluetooth-Einstellungen festgestellt und funktioniert nicht, bis Bluetooth aktiviert ist. Du erhältst keine Blutzucker-Werte und es kann kein Bolus abgeben werden." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop ha detectado un problema con la configuración de Bluetooth, y no funcionará correctamente hasta que el Bluetooth esté activado. No recibirás lecturas de glucosa, ni podrás dar los bolos." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop on havainnut Bluetooth-asetuksissa ongelman, eikä se toimi oikein ennen kuin Bluetooth on otettu käyttöön. Et saa glukoosilukemia, etkä voi antaa bolusta." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop a détecté un problème avec vos paramètres Bluetooth, et ne fonctionnera pas correctement tant que le Bluetooth ne sera pas activé. Vous ne pourrez pas recevoir de lectures de glucose, ni être en mesure de faire un bolus." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop ha rilevato un problema con le tue impostazioni Bluetooth e non funzionerà correttamente finché il Bluetooth non sarà attivato. Non riceverai letture glicemiche né potrai eseguire il bolo." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop har oppdaget et problem med Bluetooth-innstillingene dine, og vil ikke fungere før Bluetooth er aktivert. Du vil ikke motta blodsukkermålinger, eller være i stand til å gi bolus." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop heeft een probleem met je Bluetoothinstellingen gedetecteerd en zal niet goed werken totdat Bluetooth wordt ingeschakeld. Je ontvangt geen glucosemetingen en je kunt niet bolussen." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop wykrył problem z ustawieniami Bluetooth i nie będzie działać poprawnie, dopóki Bluetooth nie zostanie włączony. Nie będziesz otrzymywać odczytów poziomu glukozy ani możliwości podania bolusa." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop a detectat o problemă cu setările Bluetooth și nu va funcționa cu succes până când Bluetooth nu este activat. Nu veţi primi valori ale glicemiei sau nu veţi putea face bolus." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop обнаружил проблему в настройках Bluetooth и не будет успешно работать, пока Bluetooth не будет включен. Вы не будете получать показания глюкозы и не сможете вводить болюс." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop har upptäckt ett problem med dina Bluetooth-inställningar och kommer inte att fungera förrän Bluetooth är aktiverat. Du kommer varken att kunna se dina blodsockervärden eller att kunna ge någon bolus." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop, Bluetooth ayarlarınızla ilgili bir sorun algıladı ve Bluetooth etkinleştirilene kadar başarılı bir şekilde çalışmayacaktır. KŞ okumaları almayacaksınız veya bolus yapamayacaksınız." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop 检测到您的蓝牙设置存在问题,在蓝牙启用之前将无法正常工作。\n您将无法接收血糖读数,也无法推注大剂量。" + } + } + } + }, + "Loop has detected an missed meal and estimated its size. Edit the carb amount to match the amount of any carbs you may have eaten." : { + "comment" : "Warning displayed when user is adding a meal from an missed meal notification", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop har opdaget et overset måltid og vurderet dets størrelse. Rediger mængden af kulhydrater, så den svarer til mængden af kulhydrater, du har spist." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop hat eine vergessene Mahlzeit erkannt und deren Größe geschätzt. Passe die KH auf die Menge an, die Du wahrscheinlich gegessen hast." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop a détecté un repas manqué et a estimé sa taille. Modifiez la quantité de glucides pour qu'elle corresponde à la quantité de glucides que vous avez peut-être mangée." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop זיהה ארוחה שלא הוזנה והעריך את גודלה. ערוך את מספר הפחמימות כדי להתאים אותו לזה שאכלת." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop ha rilevato un pasto saltato e ne ha stimato le dimensioni. Modifica la quantità di carboidrati in modo che corrisponda alla quantità di carboidrati che potresti aver mangiato." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop har oppdaget et glemt måltid og estimert størrelsen. Rediger karbohydratmengden for å matche mengden av karbohydrater du måtte ha spist." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop heeft een gemiste maaltijd gedetecteerd en een schatting van de hoeveelheid gedaan. Bewerk de hoeveelheid koolhydraten, zodat deze overeenkomt met de hoeveelheid koolhydraten die je mogelijk hebt gegeten." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pętla wykryła pominięty posiłek i oszacowała jego wielkość. Edytuj ilość węglowodanów, aby odpowiadała ilości węglowodanów, które mogłeś zjeść." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop a detectat o masă neanunțată și a estimat dimensiunea acesteia. Editați cantitatea de carbohidrați pentru a se potrivi cu cantitatea de carbohidrați pe care ați consumat-o." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop обнаружил пропущенный прием пищи и оценил его размер. Отредактируйте количество углеводов, чтобы оно соответствовало количеству тех углеводов, которые вы, возможно, съели." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop atlanmış bir öğün tespit etti ve boyutunu tahmin etti. Yemiş olabileceğiniz karbonhidrat miktarına uyması için karbonhidrat miktarını düzenleyin." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop 检测到一次可能遗漏的进食,并估算了碳水量。请根据实际摄入情况修改碳水数值。" + } + } + } + }, + "Loop has not completed successfully in %@" : { + "comment" : "The notification alert describing a long-lasting loop failure. The substitution parameter is the time interval since the last loop", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "لم تتم الحلقة المغلقة بنجاح منذ %@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop har ikke kørt korrekt i %@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop wurde nicht erfolgreich abgeschlossen seit %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop no ha terminado correctamente en %@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Häiriö Loopin toiminnassa %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop n'a pas bouclé avec succès depuis %@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop has not completed successfully in %@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop non ha funzionato correttamente per %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ループが %@ の間クローズされていません" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop har ikke fullført i %@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop is niet goed afgerond in %@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop nie działał poprawnie przez %@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nenhum ciclo completo com sucesso em %@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop nu a rulat cu succes timp de %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ну удалось успешно замкнуть цикл/контур в %@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop har inte lyckats köra på %@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Döngü %@ içinde başarıyla tamamlanamadı" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop không hoạt động thành công trong %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop运行异常 %@" + } + } + } + }, + "Loop normally gives 40% of your predicted insulin needs each dosing cycle.\n\nWhen the Glucose Based Partial Application experiment is enabled, Loop will vary the percentage of recommended bolus delivered each cycle with glucose level.\n\nNear correction range, it will use 20% (similar to Temp Basal), and gradually increase to a maximum of 80% at high glucose (200 mg/dL, 11.1 mmol/L).\n\nPlease be aware that during fast rising glucose, such as after an unannounced meal, this feature, combined with velocity and retrospective correction effects, may result in a larger dose than your ISF would call for." : { + "comment" : "Description of Glucose Based Partial Application toggle.", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop verabreicht normalerweise 40% Deines voraussichtlichen Insulinbedarfs pro Dosierungszyklus. \n\nWenn das Experiment zur glukosebasierten partiellen Verabreichung aktiviert ist (Glucose Based Partial Application), variiert Loop den Prozentsatz des empfohlenen Bolus, der pro Zyklus verabreicht wird, je nach Glukosespiegel. \n\nNahe dem unterem Korrekturbereich werden 20% verwendet (ähnlich wie bei Temp Basal) und bei hohem Glukosespiegel (200 mg/dl, 11,1 mmol/l) schrittweise auf maximal 80% erhöht. \n\nBitte beachte, dass diese Funktion bei schnell ansteigendem Glukosespiegel, etwa nach einer unangekündigten Mahlzeit, in Kombination mit Geschwindigkeits- und retrospektiven Korrektureffekten zu einer höheren Dosis führen kann, als Dein ISF erfordern würde." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop gir normalt 40 % av det forventede insulinbehovet ditt i hver doseringssyklus. Når eksperimentet Glukosebasert delvis tilførsel er aktivert, vil Loop variere prosentandelen av anbefalt bolus som gis i hver syklus med glukosenivået.\n\nI nærheten av korreksjonsområdet vil den bruke 20 % (på samme måte som Temp Basal), og gradvis øke til maksimalt 80 % ved høyt glukosenivå (200 mg/dL, 11,1 mmol/L). Vær oppmerksom på at ved raskt stigende glukose, for eksempel etter et uanmeldt måltid, kan denne funksjonen, kombinert med hastighet og retrospektive korreksjonseffekter, resultere i en større dose enn det ISF-en din ville ha krevd." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "În mod normal, Loop furnizează 40% din necesarul de insulină estimat pentru fiecare ciclu de dozare. \n \nCând experimentul Aplicare parțială bazată pe glicemie este activat, Loop va varia procentul de bolus recomandat administrat pentru fiecare ciclu în funcție de nivelul glicemiei. \n \nÎn apropierea intervalului de corecție, va utiliza 20% (similar cu o bazală temporar) și va crește treptat până la maximum 80% la glicemie ridicată (200 mg/dl, 11,1 mmol/l). \n \nVă rugăm să rețineți că în timpul creșterii rapide a glicemiei, cum ar fi după o masă neanunțată, această funcție, combinată cu efectele vitezei și ale corecției retrospective, poate duce la o doză mai mare decât ar necesita ISF-ul dumneavoastră." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop 通常在每个给药周期中提供您预测胰岛素需求的 40%。\n\n启用“基于血糖的部分给药实验功能”后,Loop 将根据血糖水平调整每个周期实际推注的大剂量比例。\n\n当血糖接近矫正目标范围时,仅注射 20%(类似于临时基础率),血糖升高时比例会逐步增加,在高血糖状态(200 mg/dL,11.1 mmol/L)时最高可达 80%。\n\n请注意,当血糖快速上升(例如未申报的进食)时,该功能与血糖变化速度及回顾性矫正机制叠加,可能导致注射剂量超过由胰岛素敏感系数(ISF)计算出的剂量。" + } + } + } + }, + "Loop will automatically bolus when insulin needs are above scheduled basal, and will use temporary basal rates when needed to reduce insulin delivery below scheduled basal." : { + "comment" : "Description string for automatic bolus dosing strategy", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop vil automatisk give bolus, når insulinbehov er over planlagt basal, og vil bruge midlertidige basalrater, når det er nødvendigt for at reducere insulinlevering under planlagt basal." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop gibt automatisch einen Bolus ab, wenn der Insulinbedarf über der geplanten Basalrate liegt, und verwendet temporäre Basalraten, wenn dies erforderlich ist, um die Insulinabgabe unter die geplante Basalrate zu reduzieren." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop administrará bolos automáticamente cuando las necesidades de insulina estén por encima del basal programado y utilizará ajustes temporales de basal cuando sea necesario para reducir la administración de insulina por debajo del basal programado." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop annostelee automaattisen boluksen kun insuliinitarve ylittää ohjelmoidun basaalin, ja käyttää tilapäisiä alhaisempia basaalitasoja kun tarvitaan vähemmän insuliinia." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop effectue automatiquement des bolus lorsque les besoins en insuline sont supérieurs au débit de base programmé, et utilise des débits basaux temporaires si nécessaire pour réduire l'administration d'insuline en dessous du débit de base programmé." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop eseguirà automaticamente il bolo quando il fabbisogno d'insulina è superiore alla basale programmata e utilizzerà velocità basali temporanee quando necessario per ridurre l'erogazione d'insulina al di sotto della basale programmata." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lopp vil sette bolus når insulinbehovet er over planlagt basal, og vil bruke midlertidige basale rater når det er nødvendig for å redusere insulintilførselen under planlagt basal" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop zal automatisch bolussen geven wanneer de insulinebehoefte groter is dan het ingestelde basaal, en Loop zal wanneer dat nodig is gebruik maken van tijdelijke basaalsnelheden om insulinetoediening te verlagen." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pętla automatycznie poda bolusa, kiedy zapotrzebowanie na insulinę przekroczy zaplanowaną dawkę podstawową, a w razie potrzeby zredukuje zaplanowaną dawkę podstawową (bazę)." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop va bolusa automat când necesarul de insulină este deasupra bazalei programate, și va utiliza rate bazale temporare atunci când este necesar, pentru a reduce cantitatea de insulină administrată sub nivelul bazal programat." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop автоматически вводит болюсы, когда потребность в инсулине превышает запланированную базу, и при необходимости использует ВБС, чтобы снизить подачу инсулина ниже запланированной базы." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop kommer att ge bolus automatiskt när insulinbehovet är större än ordinarie basal och kommer, vid behov, att minska din ordinarie basal med temporära basaldoser för att minska insulintillförseln." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop, insülin ihtiyaçları programlanan bazalın üzerine çıktığında otomatik olarak bolus yapacak ve gerektiğinde insülin iletimini programlanan bazalın altına düşürmek için geçici bazal oranları kullanacaktır." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当所需胰岛素高于计划基础率时,Loop 将自动推注大剂量;当所需胰岛素低于计划基础率时,则使用临时基础率以减少胰岛素输注" + } + } + } + }, + "Loop will not work successfully until Bluetooth is enabled. You will not receive glucose readings, or be able to bolus." : { + "comment" : "Bluetooth off background alert body.", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop vil ikke fungere, før Bluetooth er aktiveret. Du vil ikke modtage glukoseaflæsninger eller være i stand til at give bolus." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop funktioniert nicht, bis Bluetooth aktiviert ist. Du erhältst keine Blutzuckerwerte und es kann kein Bolus abgeben werden." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop no funcionará correctamente hasta que el Bluetooth esté habilitado. No recibirá lecturas de glucosa ni podrá administrar bolus." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop ne fonctionnera pas correctement tant que le Bluetooth ne sera pas activé. Vous ne pourrez pas recevoir de lectures de glucose, ni être en mesure de faire un bolus." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop לא תעבוד עד הפעלת Bluetooth. לא תוכל לקבל נתוני גלוקוז או להזריק בולוס." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop non funzionerà correttamente fino a quando il Bluetooth non sarà attivato. Non riceverai letture glicemiche né potrai eseguire il bolo." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop vil ikke fungere før Bluetooth er aktivert. Du vil ikke motta blodsukkermålinger, eller være i stand til å gi bolus." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop werkt pas als Bluetooth is ingeschakeld. Je zult geen glucosemetingen ontvangen of een bolus kunnen toedienen." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pętla nie będzie działać pomyślnie, dopóki Bluetooth nie zostanie włączony. Nie będziesz otrzymywać odczytów poziomu glukozy ani możliwości podania bolusa." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop nu va funcționa cu succes până când Bluetooth nu este activat. Nu veți primi citiri de glicemie sau nu veți putea să bolusați." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop не будет успешно работать, пока не будет включен Bluetooth. Вы не сможете получать показания уровня глюкозы или вводить болюсы." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth etkinleştirilene kadar döngü başarılı bir şekilde çalışmayacaktır. KŞ ölçümleri alamayacak veya bolus yapamayacaksınız." + } + } + } + }, + "Loop will set temporary basal rates to increase and decrease insulin delivery." : { + "comment" : "Description string for temp basal only dosing strategy", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop vil fastsætte midlertidige basalrater for at øge og reducere insulinafgivelse." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop stellt temporäre Basalraten ein, um die Insulinabgabe zu erhöhen oder zu verringern." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop establecerá basal temporales para aumentar y disminuir la administración de insulina." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop käyttää tilapäisiä basaalitasoja lisätäkseen tai vähentääkseen insuliinin annostelua." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop permet de définir des débits de base temporaires pour augmenter et diminuer l'administration d'insuline." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop תגדיר בזאלי זמני כדי להגדיל ולהקטין מתן אינסולין." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop imposterà velocità basali temporanee per aumentare e diminuire l'erogazione d'insulina." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop vil sette midlertidige basal rater for å øke og redusere insulin levering." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop stelt tijdelijke basaalsnelheden in om de insulinetoediening te verhogen of te verlagen." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pętla ustawi tymczasowe dawki podstawowe, aby zwiększać lub zmniejszyć podawanie insuliny." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop va stabili ratele bazale temporare pentru a crește și reduce cantitatea de insulina livrata." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Петля установит ВБС для увеличения или уменьшения подачи инсулина." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop kommer att ställa in tillfälliga basalnivåer för att öka eller minska insulintillförsel." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop, insülin iletimini artırmak ve azaltmak için geçici bazal oranlar ayarlayacaktır." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop 将设置临时基础率,以增加或减少胰岛素输注。" + } + } + } + }, + "Low Glucose" : { + "comment" : "Title for bolus screen warning when glucose is below glucose warning limit.\nTitle for bolus screen warning when glucose is below suspend threshold, but a bolus is recommended", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lav glukose" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Niedriger Blutzucker" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glucosa baja" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glycémie basse" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "גלוקוז נמוך" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glicemia bassa" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lavt blodsukker" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lage Glucose" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Niski poziom glukozy" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glicemie scăzută" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Низкий уровень глюкозы" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Düşük KŞ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "低血糖" + } + } + } + }, + "Manage Permissions in Settings" : { + "comment" : "Manage Permissions in Settings button text", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Administrer tilladelser i Indstillinger" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Berechtigungen in den Einstellungen verwalten" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Administrar permisos en Configuración" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gérer les permissions dans les paramètres" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "קבע הרשאות בהגדרות" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gestisci le autorizzazioni in Impostazioni" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Behandle tillatelser i Innstillinger" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beheer Toestemming in Instellingen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zarządzaj uprawnieniami w Ustawieniach" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gestionați permisiunile din Setări" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Управление разрешениями в настройках" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ayarlarda İzinleri Yönetin" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在系统设置中配置权限" + } + } + } + }, + "Managing Alerts" : { + "comment" : "View title for how mute alerts work", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Administration af advarsler" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verwalten von Warnungen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gestion des Alertes" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gestione degli avvisi" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Administrere varsler" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zarządzanie alertami" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gestionarea alertelor" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Управление оповещениями" + } + } + } + }, + "Manual Dose: %1$@ %2$@" : { + "comment" : "Description of a bolus dose entry (1: value (? if no value) in bold, 2: unit)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manuel dosis: %1$@ %2$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manuelle Dosis: %1$@ %2$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dosis manual: %1$@ %2$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dosage manuel : %1$@ %2$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "כמות ידנית: %1$@ %2$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dose manuale: %1$@ %2$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manuell dose: %1$@ %2$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Doseer Handmatig: %1$@ %2$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dawka z pena: %1$@ %2$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Doză manuală: %1$@ %2$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ручная доза: %1$@ %2$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manuel Doz: %1$@ %2$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "手动剂量: %1$@ %2$@" + } + } + } + }, + "Maximum Basal Rate Per Hour" : { + "comment" : "Details for configuration error when maximum basal rate per hour is missing", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maksimal basalrate pr. time" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maximale Basalte pro Stunde" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tasa basal máxima por hora" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Débit Basal Maximum par heure" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "בזאלי מקסימלי לשעה" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Velocità basale massima all'ora" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maksimal basaldose pr. time" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maximale Basaalsnelheid Per Uur" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maksymalna dawka podstawowa na godzinę" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rata bazală maximă pe oră" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Максимальная базальная скорость в час" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Saat Başı Maksimum Bazal Oran" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "每小时最大基础速率" + } + } + } + }, + "Maximum Bolus" : { + "comment" : "Details for configuration error when maximum bolus is missing", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maksimal bolus" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maximaler Bolus" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolo Máximo" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suurin sallittu bolus" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus Maximum" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "בולוס מקסימלי" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolo massimo" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "最大ボーラス" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maks bolus" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maximale Bolus" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maksymalny bolus" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus Máximo" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus maxim" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Максимальный Болюс" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maximal bolus" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maksimum Bolus" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Liều Bolus tối đa" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "最大剂量" + } + } + } + }, + "Maximum Bolus Exceeded" : { + "comment" : "Title for bolus screen warning when max bolus is exceeded", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maksimal bolus overskredet" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maximaler Bolus überschritten" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolo máximo excedido" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus Maximum atteint" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "חריגה מבולוס מקסימלי" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolo massimo superato" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maksimal bolus overskredet" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maximale Bolus Overschreden" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Przekroczono maksymalny bolus" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolusul maxim depășit" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Превышен максимальный объем болюса" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maksimum Bolus Aşıldı" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "超过最大推注量" + } + } + } + }, + "Meal Bolus" : { + "comment" : "Title for bolus entry screen when also entering carbs", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Måltidsbolus" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mahlzeiten Bolus" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolo de Comida" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ateriabolus" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus de repas" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "בולוס לכיסוי ארוחה" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolo pasto" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Måltidsbolus" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maaltijd Bolus" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus Posiłkowy" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus prandial" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Болюс на еду" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Måltidsbolus" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yemek Bolusu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "餐时注射" + } + } + } + }, + "mg/dL" : { + "comment" : "The short unit display string for milligrams of glucose per decilter", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dl" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "мг/дл" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dl" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + } + } + }, + "Missed Meal Notifications" : { + "comment" : "Title for missed meal notifications toggle", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meddelelser om glemt måltid" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verpasste Mahlzeit Benachrichtigung" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notifications de repas manqués" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "התראה על ארוחות שלא הוזנו" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notifiche di pasti mancati" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Varsler om tapte måltider" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gemiste Maaltijdmeldingen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Powiadomienia o pominiętych posiłkach" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notificări privind mesele pierdute" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Уведомления о пропущенных приемах пищи" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kaçırılan Yemek Bildirimleri" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "错过用餐通知" + } + } + } + }, + "Missing data: %1$@" : { + "comment" : "The error message for missing data. (1: missing data details)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "بيانات مفقودة: %1$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manglende data: %1$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fehlende Daten: %1$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Faltan Datos: %1$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tiedot puuttuvat: %1$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Données manquantes: %1$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "נתונים חסרים: %1$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dati mancanti: %1$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "データがありません: %1$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manglende data: %1$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ontbrekende gegevens: %1$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Brakujące dane: %1$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dados ausentes: %1$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Date lipsă: %1$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пропущены данные: %1$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Saknar data: %1$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eksik veri: %1$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dữ liệu thiếu: %1$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "数据缺失: %1$@" + } + } + } + }, + "Missing maximum allowed bolus in settings" : { + "comment" : "Bolus error description: missing maximum bolus in settings.", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manglende maksimalt tilladt bolus i indstillingerne" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maximal erlaubter Bolus in den Einstellungen fehlt" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Falta el bolo máximo permitido en la configuración" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus maximal autorisé manquant dans les paramètres" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "בולוס מקסימלי חסר בהגדרות" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manca il bolo massimo consentito nelle impostazioni" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mangler maksimalt tillatt bolus i innstillingene" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maximaal toegestane bolus ontbreekt in instellingen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Brak ustawionego maksymalnego dozwolonego bolusa" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lipsește bolusul maxim permis în setări" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отсутствие максимально допустимого болюса в настройках" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ayarlarda izin verilen maksimum bolus eksik" + } + } + } + }, + "mmol/L" : { + "comment" : "The short unit display string for millimoles of glucose per liter", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ммоль/л" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + } + } + }, + "Mode" : { + "comment" : "Title for mode live activity toggle" + }, + "Momentum effects" : { + "comment" : "Details for missing data error when momentum effects are missing", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تأثيرات النشاط" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Momentumeffekter" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Momentum-Effekte" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Efectos de Momento" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Liikevaikutukset (momentum)" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Effets de momentum" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "השפעות מומנטום" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Effetti del momento" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "モメンタム効果" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Momentum effekter" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trendlijneffecten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "wpływ pędu" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Efeitos de aceleração" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Efecte avânt" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Влияние динамики СК" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Momentumeffekter" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Momentum etkileri" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hiệu ứng động lượng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "葡萄糖动量效应" + } + } + } + }, + "More Info" : { + "comment" : "Text for more info action on notification of upcoming TestFlight expiration\nText for more info action on notification of upcoming profile expiration", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mere Info" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Weitere Info" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Más Info" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lisätietoa" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Plus d'informations" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מידע נוסף" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ulteriori informazioni" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "詳細" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mer info" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meer Informatie" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Więcej informacji" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mais Info" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detalii" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Доп. инфо" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mer info" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Daha fazla bilgi" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thêm thông tin" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "更多信息" + } + } + } + }, + "Mute All Alerts" : { + "comment" : "Label for button to mute all alerts", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slå alle alarmer fra" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle Alarme stummschalten" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Silenciar todas las alertas" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enlever le son de toutes les alertes" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "השתק את כל ההתראות" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disattiva tutti gli avvisi" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Demp alle varsler" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle Waarschuwingen Dempen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wycisz wszystkie alerty" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dezactivați toate alertele" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выключить все оповещения" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tüm Uyarıları Sessize Al" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "静音所有警报" + } + } + } + }, + "Mute All Alerts Temporarily" : { + "comment" : "Title for mute alert duration selection action sheet", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slå lyden fra for alle advarsler midlertidigt" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle Warnungen vorübergehend stummschalten" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Désactiver temporairement toutes les alertes" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disattiva temporaneamente tutti gli avvisi" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slå av alle varsler midlertidig" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tymczasowo wycisz wszystkie alerty" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dezactivați temporar toate alertele" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "暂时静音所有警报" + } + } + } + }, + "Name" : { + "comment" : "Label for name row on add favorite food screen", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الاسم" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Název" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Navn" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Name" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nombre" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nimi" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nom" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "שם" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "नाम" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nome" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "プリセット名" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Navn" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Naam" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nazwa" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nome" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nume" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Имя" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Názov" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Namn" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İsim" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tên" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "名称" + } + } + } + }, + "Needs Attention" : { + "comment" : "Sensor state description for the non-valid state", + "extractionState" : "manual", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Handling påkrævet" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erfordert Aufmerksamkeit" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Necesita Atención" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tarvitsee huomiota" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Demande votre attention" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "דורש תשומת לב" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Richiede attenzione" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "注意してください" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trenger tilsyn" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aandacht Nodig" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Potrzebuje uwagi" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Precisa de Atenção" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Necesită atenție" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Требует внимания" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kräver uppmärksamhet" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İlgilenmeniz gerekiyor" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cần chú ý" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请注意" + } + } + } + }, + "Negative duration not allowed" : { + "comment" : "Override error description: negative duration error.", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Negativ varighed ikke tilladt" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eine negative Dauer ist nicht erlaubt" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Durée négative non autorisée" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "משך זמן שלילי לא מותר" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Durata negativa non consentita" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Negativ varighet ikke tillatt" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Negatieve duur niet toegestaan" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ujemny czas trwania nie jest dozwolony" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Durata negativă nu este permisă" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отрицательная продолжительность не допускается" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Negatif süreye izin verilmez" + } + } + } + }, + "New Favorite Food" : { + "comment" : "Title of new favorite food screen", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Neues Lieblingsessen" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ny favorittmat" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Noua mâncare preferată" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "新增常用食物" + } + } + } + }, + "Nightscout" : { + "comment" : "The title of the Nightscout service", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "نايتسكاوت" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nightscout" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nightscout" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nightscout" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nightscout" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nightscout" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nightscout" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nightscout" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nightscout" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nightscout" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nightscout" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nightscout" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nightscout" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nightscout" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nightscout" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nightscout" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nightscout" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nightscout" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nightscout" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nightscout" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nightscout" + } + } + } + }, + "No alerts or alarms will sound while muted. Select how long you would you like to mute for." : { + "comment" : "Message for mute alert duration selection action sheet", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen advarsler eller alarmer lyder, mens lyden er slået fra. Vælg, hvor længe du vil slå lyden fra." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bei Stummschaltung ertönen keine Warn- oder Alarmsignale. Wähle die Dauer der Stummschaltung aus." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nessun avviso o allarme suonerà quando l'audio è disattivato. Seleziona per quanto tempo desideri disattivare l'audio." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen varsler eller alarmer vil høres mens de er dempet. Velg hvor lenge du ønsker å dempe lyden." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Po wyciszeniu nie będą emitowane żadne alerty ani alarmy. Wybierz, jak długo chcesz wyciszyć." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nicio alertă nu va suna când este dezactivat sunetul. Odată ce această perioadă se încheie, alertele și alarmele dvs. vor relua normal." + } + } + } + }, + "No Bolus Recommended" : { + "comment" : "Title for bolus screen notice when no bolus is recommended\nTitle for bolus screen warning when glucose is below suspend threshold, and a bolus is not recommended\nTitle for bolus screen warning when no bolus is recommended", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen bolus anbefalet" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kein Bolus empfohlen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No hay bolo recomendado" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolusta ei suositella" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucun Bolus Recommandé" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אין המלצה לבולוס" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nessun bolo consigliato" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen anbefalt bolus" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geen Bolus Aanbevolen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus nie jest zalecany" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Niciun bolus recomandat" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет рекомендации болюса" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen bolus rekommenderas" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus Önerilmez" + } + } + } + }, + "No connected devices, or failure during device connection" : { + "comment" : "The error message displayed for device connection errors.", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "لا يوجد أجهزة متصلة, أو يوجد خطأ أثناء الاتصال" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen tilsluttede enheder eller fejl under forbindelse til enhed" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine verbundenen Geräte oder Fehler während der Geräteverbindung" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No hay dispositivos conectados o falla durante conexión de dispositivo" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ei yhdistettyjä laitteita tai häiriö laiteyhteydessä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pas d'appareil connecté, ou échec durant la connexion à l'appareil" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אין מכשירים מחוברים, או שישנה שגיאת התחברות." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nessun dispositivo connesso o mancanza di segnale durante la connessione del dispositivo" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "機器が未接続、または接続に問題" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen tilkoblede enheter, eller feil under enhetstilkobling" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geen aangesloten apparaten of storingen tijdens het verbinden met het apparaat" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Brak podłączonych urządzeń lub awaria podczas połączenia urządzenia" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nenhum dispositivo conectado ou falha durante a conexão" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nu este conectat niciun dispozitiv sau s-a produs o eroare la conectare" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Устройства не сопряжены или произошла ошибка во время сопряжения" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen ansluten enhet, eller fel vid anslutning till enhet" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bağlı cihaz yok veya cihaz bağlantısı sırasında hata" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không có thiết bị nào được kết nối, hoặc lỗi trong quá trình kết nối" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "没有连接的设备,或设备连接期间发生故障" + } + } + } + }, + "No Maximum Bolus Configured" : { + "comment" : "Alert title for a missing maximum bolus setting error", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen maksimal bolus konfigureret" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kein maximaler Bolus konfiguriert" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No hay bolo máximo configurado" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maksimibolusta ei ole määritetty" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucun Bolus Maximum configuré" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "לא הוגדר בולוס מקסימלי" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nessun bolo massimo configurato" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen maksimal bolus er konfigurert" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geen Maximale Bolus Geconfigureerd" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nie skonfigurowano maksymalnego bolusa" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolusul maxim nu este setat" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не установлен максимальный болюс" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen maximal bolus har blivit inställd" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yapılandırılmış Maksimum Bolus Yok" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "没有设置最大推注量" + } + } + } + }, + "No Pump Configured" : { + "comment" : "Alert title for a missing pump error", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen pumpe konfigureret" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Pumpe konfiguriert" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No hay microinfusadora configurada" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumppua ei ole määritetty" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pas de pompe configurée" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "לא הוגדרה משאבה" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nessuna pompa configurata" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen pumpe er konfigurert" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geen Pomp Geconfigureerd" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nie skonfigurowano pompy" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nicio pompă configurată" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет настроенной помпы" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen pump konfigurerad" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yapılandırılmış Pompa Yok" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未配置泵" + } + } + } + }, + "No Recent Glucose" : { + "comment" : "The title of the cell indicating that there is no recent glucose", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen nyere glukose" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kein aktueller Blutzucker " + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin glucosa reciente" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ei viimeaikaisia glukoositietoja" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pas de Glycémie récente" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אין גלוקוז לאחרונה" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nessuna glicemia recente" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen ny blodsukkermåling" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geen Recente Glucose" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Brak aktualnej glukozy" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nu există date recente despre glicemie" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет актуальных данных о глюкозе" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inga senaste blodglukosvärde" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Son KŞ Yok" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无近期血糖数据" + } + } + } + }, + "No Recent Glucose Data" : { + "comment" : "Title for bolus screen notice when glucose data is missing or stale", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen nylige glukosedata" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kein aktueller Blutzucker " + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin datos de glucosa recientes" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ei viimeaikaisia glukoositietoja" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pas de Glycémie récente" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אין נתוני גלוקוז אחרונים" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nessun dato recente sulla glicemia" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen nye blodsukkerdata" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geen Recente Glucosegegevens" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Brak ostatnich danych dotyczących glukozy" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nu există date recente despre glicemie" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет актуальных данных о глюкозе" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inget aktuellt blodsockervärde" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Son KŞ Verisi Yok" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无近期血糖数据记录" + } + } + } + }, + "No Recent Pump Data" : { + "comment" : "Title for bolus screen notice when pump data is missing or stale", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen nyere pumpedata" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Pumpendaten" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin datos de microinfusora recientes" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ei viimeaikaisia pumpun tietoja" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucune donnée de pompe récente" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אין נתוני משאבה אחרונים" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nessun dato recente sulla pompa" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mangler relevant pumpdata" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geen Recente Pompgegevens" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Brak ostatnich danych pompy" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nu există date recente despre pompă" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет актуальных данных от помпы" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Det finns inga aktuella pumpdata" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Son Pompa Verisi Yok" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "没有最近的泵数据" + } + } + } + }, + "No, edit amount" : { + "comment" : "The title of the action used when rejecting the the amount of carbohydrates entered.", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nej, indstil mængde" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nein, Menge bearbeiten" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No, editar cantidad" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Non, modifier la quantité" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "לא, ערוך כמות" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "No, modifica la quantità" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nei, rediger mengde" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nee, hoeveelheid aanpassen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nie, edytuj ilość" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nu, modificați cantitatea" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет, редактировать сумму" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hayır, miktarı düzenle" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "不,修改含量" + } + } + } + }, + "None" : { + "comment" : "Indicates no favorite food is selected", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "None" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Žádný" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keiner" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ninguno" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ei mitään" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucun" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "None" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nessuno" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "なし" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Brak" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nenhum" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nimic" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не подан" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Žiadny" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingen" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hiçbiri" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "None" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无" + } + } + } + }, + "Notification Delivery" : { + "comment" : "Notification Delivery Status text", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notifikationsindstillinger" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Benachrichtigungszustellung" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entrega de notificaciones" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notification de l'administration" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "משלוח הודעות" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Invio delle notifiche" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Varslingslevering" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afleveren Meldingen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dostarczanie powiadomień" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Livrare notificare" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Доставка уведомлений" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bildirim Gönderimi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "通知推送类型" + } + } + } + }, + "Notification delivery is set to Scheduled Summary in your phone’s settings.\n\nTo avoid delay in receiving notifications from %1$@, we recommend notification delivery be set to Immediate Delivery." : { + "comment" : "Format for Critical Alerts permissions disabled alert body. (1: app name)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notifikationer er indstillet til et Planlagt resumé i telefonens indstillinger.\n\nFor at undgå forsinkelser ved modtagelse af notifikarioner fra %1$@ anbefaler vi, at notifikationslevering er indstillet til øjeblikkelig levering." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die Benachrichtigungszustellung ist in den Einstellungen Deines Telefons auf geplante Zusammenfassung eingestellt.\n\nUm Verzögerungen beim Erhalt von Benachrichtigungen von %1$@ zu vermeiden, empfehlen wir, die Benachrichtigungszustellung auf sofortige Zustellung einzustellen." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El envío de notificaciones está configurado como Resumen Programado en los ajustes del teléfono.\n\nPara evitar retrasos en la recepción de notificaciones de %1$@, le recomendamos que configure el envío de notificaciones como Entrega inmediata." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "L'envoi des notifications est réglé sur \"Résumé programmé\" dans les paramètres de votre téléphone.\n\nPour éviter tout retard dans la réception des notifications de %1$@, nous vous recommandons de régler l'envoi des notifications sur \"Envoi immédiat\"." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "משלוח התראות מוגדר לסיכום מתוזמן בהגדרות הטלפון שלך. \n\n כדי למנוע עיכוב בקבלת התראות מ- %1$@ , אנו ממליצים להגדיר את משלוח ההתראות למשלוח מיידי." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "L'invio delle notifiche è impostato su Riepilogo pianificato nelle impostazioni del telefono. \n\nPer evitare ritardi nella ricezione delle notifiche da %1$@ , ti consigliamo di impostare l'invio delle notifiche su Consegna immediata." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Varslingslevering er satt til Planlagt sammendrag i telefonens innstillinger. \n\n For å unngå forsinkelser i mottak av varsler fra %1$@ , anbefaler vi at varslingslevering settes til Umiddelbar levering." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "De aflevering van meldingen in de instellingen van je telefoon staat op Gepland Overzicht.\n\nOm vertraging bij het ontvangen van meldingen van %1$@ te voorkomen, raden we je aan om de aflevering van meldingen in te stellen op Onmiddellijk Afleveren." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dostarczanie powiadomień jest ustawione na Zaplanowane podsumowanie w ustawieniach telefonu. \n\nAby uniknąć opóźnień w otrzymywaniu powiadomień od %1$@ , zalecamy ustawienie dostarczania powiadomień na Natychmiastowe dostarczanie." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Livrarea notificărilor este setată la Rezumat programat în setările telefonului dvs. \n\nPentru a evita întârzierea primirii notificărilor de la %1$@ , vă recomandăm ca livrarea notificărilor să fie setată la Livrare imediată." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "В настройках телефона для доставки уведомлений установлено значение \"Сводка по расписанию\".\n\nЧтобы избежать задержки в получении уведомлений от %1$@, мы рекомендуем установить доставку уведомлений на Немедленную доставку." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bildirim gönderimi, telefonunuzun ayarlarında Zamanlanmış Özet olarak ayarlanmıştır.\n\n%1$@ adresinden bildirim almakta gecikmeyi önlemek için bildirim gönderimini Anında Gönderim olarak ayarlanmasını öneririz." + } + } + } + }, + "Notifications" : { + "comment" : "Notifications Status text", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notifikationer" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Benachrichtigungen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notificaciones" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notifications" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "התראות" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notifiche" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Varsler" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sta meldingen toe" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Powiadomienia" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notificări" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Уведомления" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bildirimler" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "通知" + } + } + } + }, + "Notifications Delayed" : { + "comment" : "Scheduled Delivery Enabled alert title", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notifikationer forsinkede" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Benachrichtigungsverzögerung" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retraso en las notificaciones" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notifications retardées" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "התראות מתעכבות" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notifiche ritardate" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Varsler forsinket" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meldingen Vertraagd" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spóźnione Powiadomienia" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notificări întârziate" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Уведомления временно отключены" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bildirimler Gecikti" + } + } + } + }, + "Notifications give you important %1$@ app information without requiring you to open the app." : { + "comment" : "Alert Permissions descriptive text (1: app name)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meddelelser giver dig vigtige oplysninger om %1$@-appen uden at du behøver at åbne appen." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Benachrichtigungen geben Dir wichtige %1$@-App-Informationen, ohne dass Du die App öffnen musst." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Las notificaciones te proporcionan información importante sobre la aplicación %1$@ sin que tengas que abrirla." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les notifications vous donnent des informations importantes sur l'application %1$@ sans avoir à l'ouvrir." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "התראות מעבירות לך מידע חשוב על האפליקציה %1$@ מבלי לדרוש ממך לפתוח את האפליקציה." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Le notifiche ti forniscono informazioni importanti sull'app %1$@ senza che tu debba aprire l'app." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Varslinger gir deg viktig %1$@ app informasjon uten at du behøver å åpne appen." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meldingen geven je belangrijke %1$@ appinformatie zonder dat je de app hoeft te openen." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Powiadomienia zawierają ważne informacje o aplikacji %1$@ bez konieczności otwierania aplikacji." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notificările vă oferă informații importante despre aplicație %1$@ fără a fi necesar să deschideți aplicația." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Уведомления предоставляют важную информацию о приложении %1$@, не требуя открытия приложения." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bildirimler, uygulamayı açmanıza gerek kalmadan size önemli %1$@ uygulama bilgilerini verir." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "通知可在无需打开 %1$@ 应用的情况下,向你提供重要信息。" + } + } + } + }, + "Notifications give you important %1$@ app information without requiring you to open the app.\n\nKeep these turned ON in your phone’s settings to ensure you receive %1$@ Notifications, Critical Alerts, and Time Sensitive Notifications." : { + "comment" : "Alert Permissions descriptive text (1: app name)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meddelelser giver dig vigtige oplysninger om %1$@-appen, uden at du behøver at åbne appen.\n\nLad dem være aktiveret i telefonens indstillinger for at sikre, at du modtager %1$@-meddelelser, kritiske advarsler og tidsfølsomme meddelelser." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Benachrichtigungen geben Dir wichtige %1$@ App-Informationen, ohne dass Du die App öffnen musst.\n\nLass diese in den Einstellungen Deines Telefons aktiviert, um sicherzustellen, dass Du %1$@ Benachrichtigungen, kritische Warnungen und zeitkritische Benachrichtigungen erhältst." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Las notificaciones te proporcionan información importante sobre la aplicación %1$@ sin que tengas que abrirla.\n\nManten estas activadas en los ajustes del teléfono para recibir notificaciones %1$@, alertas críticas y notificaciones sensibles al tiempo cuando se entregan." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les notifications vous donnent des informations importantes sur l’application %1$@ sans que vous ayez à ouvrir l’application.\n\nGardez-les activées dans les paramètres de votre téléphone pour vous assurer de recevoir Notifications, Alertes critiques et Notifications urgentes de %1$@." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "התראות מעניקות לך מידע חשוב על %1$@ מבלי לדרוש ממך לפתוח את האפליקציה.\n\nכדי להבטיח שתקבל התראות %1$@, התראות קריטיות והתראות רגישות לזמן, וודא שהן מופעלות." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Le notifiche ti forniscono informazioni importanti sull'app %1$@ senza che tu debba aprire l'app. \n\nTienile attive nelle impostazioni del tuo telefono per assicurarti di ricevere %1$@ notifiche, avvisi critici e notifiche sensibili al tempo." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Varsler gir deg viktig %1$@ appinformasjon uten at du trenger å åpne appen. \n\n Hold disse slått PÅ i telefonens innstillinger for å sikre at du mottar %1$@ -varsler, kritiske varsler og tidssensitive varsler." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meldingen geven je belangrijke %1$@ appinformatie zonder dat je de app hoeft te openen.\n\nHoud deze instellingen AAN in je telefooninstellingen om ervoor te zorgen dat %1$@ je Meldingen, Kritieke Meldingen en Tijdgevoelige Meldingen ontvangt." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Powiadomienia dostarczają ważnych informacji o aplikacji %1$@ bez konieczności otwierania aplikacji. \n\nWłącz je w ustawieniach telefonu, aby mieć pewność, że będziesz otrzymywać powiadomienia %1$@ , alerty krytyczne i powiadomienia zależne od czasu." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notificările vă oferă informații importante despre aplicație %1$@ fără a fi necesar să deschideți aplicația. \n\n Păstrați-le activate în setările telefonului pentru a vă asigura că primiți notificări %1$@ , alerte critice și notificări sensibile la timp." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Уведомления предоставляют важную информацию о приложении %1$@, не требуя открытия приложения.\n\nВключите их в настройках телефона, чтобы получать уведомления %1$@, критические предупреждения и уведомления, чувствительные к времени." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bildirimler, uygulamayı açmanıza gerek kalmadan size önemli %1$@ uygulama bilgilerini verir. \n\n %1$@ Bildirimler, Kritik Uyarılar ve Zamana Duyarlı Bildirimler aldığınızdan emin olmak için telefonunuzun ayarlarında bunları AÇIK durumda tutun." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "通知可在无需打开 %1$@ 应用的情况下,向你提供重要信息。\n\n请在手机设置中保持通知开启,以确保你能够接收 %1$@ 通知、关键通知和时效性通知。" + } + } + } + }, + "Off" : { + "comment" : "Notification Setting Status is Off", + "localizations" : { + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vypnuto" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slukket" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aus" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apagado" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pois päältä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Désactivé" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "כבוי" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disattivato" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Av" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uit" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wyłącz" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oprit" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выключено" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vypnuté" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Av" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kapalı" + } + } + } + }, + "Oh no! Loop crashed while dosing, and insulin adjustments have been paused until this dialog is closed. Dosing history may not be accurate. Please review Insulin Delivery charts, and monitor your blood glucose carefully." : { + "comment" : "Modal body for crash recovery alert", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Åh nej! Loop gik ned under dosering, og insulinjusteringer er blevet sat på pause, indtil denne dialogboks er lukket. Doseringshistorikken er muligvis ikke nøjagtig. Gennemgå venligst insulintilførsindstillinger og overvåg dit blodsukker omhyggeligt." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oh nein! Loop ist während des Bolus abgestürzt und die Insulinanpassungen wurden angehalten, bis dieser Dialog geschlossen wird. Der Dosierung-Verlauf ist möglicherweise nicht korrekt. Bitte prüfe die Tabellen zur Insulinabgabe und überwache Deinen Blutzucker sorgfältig." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¡Oh, no! Loop falló durante la dosificación y los ajustes de insulina se pausaron hasta que se cierre este cuadro de diálogo. El historial de dosificación puede no ser exacto. Revise los gráficos de administración de insulina y controle cuidadosamente su nivel de glucosa en sangre." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oh non ! La boucle s'est arrêtée pendant le dosage, et les ajustements d'insuline ont été mis en pause jusqu'à ce que ce dialogue soit fermé. L'historique des dosages peut ne pas être exact. Veuillez revoir les tableaux d'administration d'insuline et surveiller attentivement votre glycémie." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אוי לא! Loop קרסה בזמן הזרקה והתאמות אינסולין נעצרו עד שתסגור חלון זה. ייתכן שהיסטוריות ההזרקות לא תהיה מדוייקת. עיין בגרף מתן האינסולין ועקוב אחר הגלוקוז שלך מקרוב." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oh No! Loop si è chiuso mentre stava eseguendo un bolo e il dosaggio d'insulina è stato messo in pausa, finchè la finestra sarà chiusa. L'elenco dell'infusione d'insulina potrebbe non essere accurato. Per favore rivedi con attenzione la tabella di infusione dell'insulina e sorveglia strettamente la tua glicemia" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Å nei! Loop krasjet under dosering, og insulinjusteringer er satt på pause til denne dialogen er lukket. Doseringshistorikken er kanskje ikke nøyaktig. Vennligst sjekk diagrammer for insulin levering, og overvåk blodsukkeret nøye." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oh nee! Loop is vastgelopen tijdens het toedienen, en insulineaanpassingen zijn gepauzeerd totdat dit dialoogvenster wordt gesloten. De doseringsgeschiedenis is mogelijk niet nauwkeurig. Bekijk de Insulinetoedieningsgrafieken en controleer je bloedglucose nauwkeurig." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "O nie! Pętla uległa awarii podczas dozowania, a regulacja insuliny została wstrzymana do czasu zamknięcia tego okna dialogowego. Historia dawkowania może nie być dokładna. Przejrzyj wykresy podawania insuliny i uważnie monitoruj poziom glukozy we krwi." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oh nu! Loop s-a blocat în timpul administrării, iar ajustările insulinei au fost întrerupte până când acest dialog este închis. Istoricul administrării poate să nu fie exact. Vă rugăm să consultați tabelele de livrare a insulinei și să vă monitorizați cu atenție glicemia." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Внимание! Во время подачи произошел сбой петли, и управление помпой было приостановлено до закрытия этого диалога. История событий может быть неточной. Пожалуйста, просмотрите графики введения инсулина в помпе и внимательно следите за уровнем глюкозы в крови." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oh hayır! Dozlama sırasında Loop çöktü ve bu iletişim kutusu kapatılana kadar insülin ayarlamaları duraklatıldı. Dozlama geçmişi doğru olmayabilir. Lütfen İnsülin İletim tablolarını gözden geçirin ve kan şekerinizi dikkatle izleyin." + } + } + } + }, + "OK" : { + "comment" : "Alert acknowledgment OK button\nCritical Alert permissions disabled alert button\nDefault action for alert when alert acknowledgment fails\nNotifications permissions disabled alert button\nText for ok action on notification of upcoming TestFlight expiration\nText for ok action on notification of upcoming profile expiration\nThe title of the notification action to acknowledge a device alert", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "موافق" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אישור" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ok" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tamam" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "好的" + } + } + } + }, + "On" : { + "comment" : "Notification Setting Status is On", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "On" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zapnuto" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tændt" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "An" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Encendido" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Päällä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activé" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מופעל" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Attivato" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "オン" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "På" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aan" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Włącz" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ligado" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pornit" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включено" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zapnuté" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "På" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Açık" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "On" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "开" + } + } + } + }, + "Override Presets" : { + "comment" : "The title text for the override presets", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تخطي الإعدادات المسبقة" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Override forudindstillinger" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voreinstellungen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Override Presets" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sobreescrituras preestablecidas" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilapäisasetukset" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Préréglages ajustement" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "עקוף הגדרות מוגדרות מראש" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Override" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "オーバーライドプリセット" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Overstyr forhåndsinnstillinger" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Override Programma's" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Override Presets" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sobreposições Predefinidas" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Presetări de înlocuire" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Редактировать параметры" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Override förinställningar" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geçersiz Kılma Ön Ayarları" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cài đặt chồng liều" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "覆盖预设置" + } + } + } + }, + "Possible Missed Meal" : { + "comment" : "The notification title for a meal that was possibly not logged in Loop.", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Muligt glemt måltid" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wahrscheinlich eine Mahlzeit vergessen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Repas manqué possible" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "ייתכן שארוחה לא הוזנה" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Possibile pasto saltato" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mulig savnet måltid" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mogelijk Gemiste Maaltijd" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Możliwe pominięcie posiłku" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Posibilă masă neanunțată" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Возможный пропуск приема пищи" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Muhtemel Kaçırılan Öğün" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "可能未进餐" + } + } + } + }, + "Pre-Meal Targets" : { + "comment" : "The label of the pre-meal mode toggle button", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "أهداف ما قبل الوجبة" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Før-måltid Mål" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ziel vor dem Essen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Objetivos Pre-Comida" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ennen ateriaa -tavoite" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Objectif de Pré-Repas" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "יעדים לפני הארוחה" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Obiettivo pre-pasto" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "食前ターゲット" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Målområde før måltid" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pre-Meal Doelen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Poziom przed posiłkiem" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Meta Pré-Refeição" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ținte preprandiale" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Целевые значения до еды" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Målvärden före måltid" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yemek Öncesi Hedefler" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mục tiêu trước bữa ăn" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "餐前目标" + } + } + } + }, + "Predicted glucose at %1$@ is %2$@." : { + "comment" : "Message when offering bolus recommendation even though bg is below range and minBG is in future. (1: glucose time)(2: glucose number)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "قراءاة سكر الدم بعد %1$@ هي %2$@." + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forventet glukose ved %1$@ er %2$@." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vorhergesagte Glukose um %1$@ ist %2$@." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glucosa proyectada a las %1$@ es %2$@." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ennustettu glukoosi klo %1$@ on %2$@." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glycémie prévue à %1$@ est %2$@." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הגלוקוז החזוי ב- %1$@ הוא %2$@ ." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "La glicemia prevista a %1$@ è di %2$@." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@の予想グルコースは %2$@です。" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forventet blodsukker kl %1$@ er %2$@." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voorspelde glucose om %1$@ is %2$@." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Przewidywany poziom cukru o %1$@ wyniesie %2$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glicemia prevista em %1$@ é %2$@." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glicemia prognozată pentru %1$@ este %2$@." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Прогнозируемый уровень глюкозы на %1$@ составляет %2$@." + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Predpokladaná glykémia o %1$@ je %2$@ ." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Förväntat glukosvärde vid %1$@ är %2$@." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tahmini KŞ %1$@ %2$@." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dự đoán đường huyết vào lúc %1$@ là %2$@." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "预测%1$@时的葡萄糖是%2$@" + } + } + } + }, + "Predicted glucose is in range." : { + "comment" : "Notice when predicted glucose for bolus recommendation is in range", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Den forventede glukose er inden for intervallet." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der vorhergesagte Blutzucker liegt im Zielbereich." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "La glucosa proyectada está en rango" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "La glycémie prévue est dans la plage." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הגלוקוז החזוי נמצא בטווח." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "La glicemia prevista è nell'intervallo" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forventet blodsukker er innenfor målområdet." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voorspelde glucose is binnen bereik." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Przewidywane stężenie glukozy jest w zakresie." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glicemia prognozată este în interval." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Прогнозируемый уровень глюкозы находится в диапазоне." + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Predpokladaná glykémia je v cieľovom rozsahu." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tahmini KŞ aralık içinde." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "预测血糖在目标范围内。" + } + } + } + }, + "Predicted glucose of %1$@ is below your glucose safety limit setting." : { + "comment" : "Notice message when recommending bolus when BG is below the glucose safety limit. (1: glucose value)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Det forventede glukoseindhold på %1$@ er under din indstilling af glukose-sikkerhedsgrænsen." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der vorhergesagte Blutzucker von %1$@ liegt unter der Sicherheitsgrenze." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glucosa proyectada de %1$@ se encuentra por debajo de tu nivel de suspensión." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ennustettu glukoosi %1$@ on turvarajan alapuolella." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "La glycémie estimée à %1$@ est sous le seuil de suspension." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הגלוקוז החזוי %1$@ נמוך מגבול הבטיחות שהגדרת." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "La glicemia prevista da %1$@ è al di sotto del tuo limite glicemico di sicurezza." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forventet blodsukker på %1$@ er lavere enn innstillingen for blodsukkersikkerhet." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voorspelde glucose van %1$@ ligt onder je ingestelde glucoseveiligheidslimiet." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Przewidywana glukoza %1$@ jest poniżej ustawionego bezpiecznego limitu glukozy." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glicemia prognozată de %1$@ se situează sub limita de siguranță configurată." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Прогнозируемое значение глюкозы %1$@ ниже установленного вами предела безопасности глюкозы." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Förväntat blodsocker på %1$@ är under ditt tröskelvärde." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ tahmini KŞ, KŞ güvenlik limiti ayarınızın altında." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "预测血糖 %1$@ 低于你设定的血糖安全下限。" + } + } + } + }, + "Predicted glucose of %1$@ is below your suspend threshold setting." : { + "comment" : "Notice message when recommending bolus when BG is below the suspend threshold. (1: glucose value)", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "قراءة سكر الدم المتوقعة %1$@ أقل من قيمة تعليق الضخ في الإعدادات." + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forventet glukose på %1$@ er under din indstilling for suspenderingstærskel." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der vorhergesagte Blutzucker von %1$@ liegt unter dem Grenzwert für die Hypo-Abschaltung." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Predicted glucose of %1$@ is below your suspend threshold setting." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glucosa proyectada de %1$@ se encuentra por debajo de su nivel de suspensión." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ennustettu glukoosi %1$@ on pysäytysrajan alapuolella." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Prédiction de la glycémie à %1$@ sous le seuil de suspension défini." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הגלוקוז החזוי %1$@ נמוך מסך ההשהייה שהגדרת." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "La glicemia prevista %1$@ è inferiore al valore soglia per la sospensione dell'erogazione." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "予想グルコースは %1$@ で一時停止値を下回ります。" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forventet blodsukker %1$@ er lavere enn innstilling for insulinstopp" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verwachte glucose van %1$@ is lager dan je ingestelde insulineonderbrekingsdrempel." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Przewidywany poziom cukru %1$@ jest poniżej progu zawieszenia." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glicemia prevista de %1$@ está abaixo do limite de suspensão." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glicemia prognozată de %1$@ se situează sub limita de suspendare configurată" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Предсказываемая гликемия %1$@ ниже ваших настроек порога приостановки помпы" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Förväntat glukosvärde %1$@ är under ditt tröskelvärde." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tahmini KŞ %1$@ askıya alma eşiği ayarınızın altında." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dự đoán đường huyết %1$@ là dưới ngưỡng tạm ngưng trong cài đặt của bạn." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "预测葡萄糖%1$@低于您的暂停阈值设置" + } + } + } + }, + "Predicted: %1$@\nActual: %2$@ (%3$@)" : { + "comment" : "Format string describing retrospective glucose prediction comparison. (1: Predicted glucose)(2: Actual glucose)(3: difference)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "التوقع: %1$@\nالواقع: %2$@ (%3$@)" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forventet: %1$@\nFaktisk: %2$@ (%3$@)" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vorhergesagt: %1$@\nAktuell: %2$@ (%3$@)" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Predicción: %1$@\nActual: %2$@ (%3$@)" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ennustettu: %1$@\nTodellinen: %2$@ (%3$@)" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Prédit : %1$@\nRéel : %2$@ (%3$@)" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "חזוי: %1$@\nבפועל: %2$@ (%3$@)" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Previsto: %1$@\nEffettivo: %2$@ (%3$@)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Predicted: %1$@\nActual: %2$@ (%3$@)" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forventet: %1$@\nFaktisk: %2$@ ( %3$@ )" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voorspeld: %1$@\nActueel: %2$@ (%3$@)" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Przewidywana: %1$@Rzeczywista: %2$@ (%3$@)" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Prevista: %1$@\nAtual: %2$@ (%3$@)" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Prognozată: %1$@\nActuală: %2$@ (%3$@)" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Прогноз: %1$@\nФакт: %2$@ (%3$@)" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Förväntat: %1$@\nFaktiskt: %2$@ (%3$@)" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tahmini: %1$@\nGüncel: %2$@ (%3$@)" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Được dự đoán: %1$@\nThực tế: %2$@ (%3$@)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "预测值: %1$@\n实际值: %2$@ (%3$@)" + } + } + } + }, + "prediction-description-integral-retrospective-correction" : { + "comment" : "Format string describing integral retrospective correction. (1: Integral glucose effect)(2: Total glucose effect)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "prediction-description-integral-retrospective-correction" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Integraler Effekt: %1$@ \n Gesamtglukoseeffekt: %2$@" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Integral effect: %1$@\nTotal glucose effect: %2$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "previsione-descrizione-integrale-correzione-retrospettiva" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "prediksjon-beskrivelse-integral-retrospektiv-korreksjon" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "predykcja-opis-całka-retrospektywna-korekta" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Efect integral: %1$@ \nEfect total al glicemiei: %2$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "описание прогнозирования с помощью интегральной ретроспективной коррекции" + } + } + } + }, + "prediction-description-retrospective-correction" : { + "comment" : "Format string describing retrospective glucose prediction comparison. (1: Predicted glucose)(2: Actual glucose)(3: difference)", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "التوقع: %1$@\nالواقع: %2$@ (%3$@)" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forventet: %1$@\nFaktisk: %2$@ (%3$@)" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vorhergesagt: %1$@\nAktuell: %2$@ (%3$@)" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Predicted: %1$@\nActual: %2$@ (%3$@)" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Predicción: %1$@\nActual: %2$@ (%3$@)" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ennustettu: %1$@\nTodellinen: %2$@ (%3$@)" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Prédit : %1$@\nRéel : %2$@ (%3$@)" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "חזוי: %1$@\nבפועל: %2$@ (%3$@)" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Previsto: %1$@\nEffettivo: %2$@ (%3$@)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Predicted: %1$@\nActual: %2$@ (%3$@)" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forventet: %1$@\nFaktisk: %2$@ ( %3$@ )" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voorspeld: %1$@\nActueel: %2$@ (%3$@)" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Przewidywana: %1$@Rzeczywista: %2$@ (%3$@)" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Prevista: %1$@\nAtual: %2$@ (%3$@)" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Prognozată: %1$@\nActuală: %2$@ (%3$@)" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Прогноз: %1$@\nФакт: %2$@ (%3$@)" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Förväntat: %1$@\nFaktiskt: %2$@ (%3$@)" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tahmini: %1$@\nGüncel: %2$@ (%3$@)" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Được dự đoán: %1$@\nThực tế: %2$@ (%3$@)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "预测值: %1$@\n实际值: %2$@ (%3$@)" + } + } + } + }, + "Preparing Critical Event Logs" : { + "comment" : "Preparing critical event log text", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forbereder kritiske begivenhedslogs" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vorbereiten der kritischen Ereignis-Protokolle" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Preparando registros de eventos críticos" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Valmistellaan tärkeiden tapahtumien lokia" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Préparation des journaux d’événements critiques" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מכין יומן שגיאות קריטיות" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Preparazione dei registri degli eventi critici in corso" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forbereder logg av kritiske hendelser" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kritieke Gebeurtenislogboek Voorbereiden" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Przygotowywanie dzienników zdarzeń krytycznych" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pregătire jurnal de evenimente critice" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подготовка логов критических событий" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Förbereder kritiska händelseloggar" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kritik Olay Günlüklerinin Hazırlanması" + } + } + } + }, + "Profile Expiration" : { + "comment" : "Settings App Profile expiration view", + "localizations" : { + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vypršení platnosti profilu" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profiludløb" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ablauf des Profils" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Caducidad del perfil" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Expiration du profil" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "תפוגת פרופיל" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scadenza profilo" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profilens utløp" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profiel Vervaldatum" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wygaśnięcie profilu:" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Expirarea profilului" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Истечение срока действия профиля" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profil Sona Erme" + } + } + } + }, + "Profile expires " : { + "comment" : "Time that profile expires", + "localizations" : { + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Platnost profilu vyprší" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profil udløber " + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profil läuft ab " + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El perfil caduca" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Le profil expire le " + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "פרופיל יפוג" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Il profilo scade " + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profil utløper" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profiel verloopt " + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profil wygasa " + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profilul expiră " + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Срок действия профиля истекает" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profilin süresi doluyor" + } + } + } + }, + "Profile Expires Soon" : { + "comment" : "The title for notification of upcoming profile expiration", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profilen udløber snart" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profil läuft in Kürze ab" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El perfil caduca pronto" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Le profil expire bientôt" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "פרופיל פג בקרוב" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Il profilo scade a breve" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profil utløper snart" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profiel Verloopt Binnenkort" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profil wkrótce wygaśnie" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profilul expiră în curând" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скоро истечет срок действия профиля" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profil Yakında Sona Eriyor" + } + } + } + }, + "Pump" : { + "comment" : "The title of the pump section in settings", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "المضخة" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumpe" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumpe" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pump" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Microinfusadora" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumppu" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pompe" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "משאבה" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pompa" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ポンプ" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumpe" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pomp" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pompa" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bomba" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pompa" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Помпа" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pump" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pompa" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bơm" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "胰岛素泵" + } + } + } + }, + "Pump Battery Low" : { + "comment" : "The notification title for a low pump battery", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "بطارية المضخة منخفضة" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumpebatteri lav" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumpenbatterie schwach" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pump Battery Low" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Batería de la bomba baja" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumpun paristo vähissä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Batterie de la pompe faible" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "סוללת משאבה חלשה" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Batteria della pompa scarica" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ポンプの電池が不足" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumpebatteri lavt" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pompbatterij Bijna Leeg" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Niski poziom baterii w pompie" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Batteria da Bomba Fraca" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nivel scăzut baterie pompă" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Батарея помпы разряжена" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Låg batterinivå i pump" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pompa Pili Düşük" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pin của bơm thấp" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "胰岛素泵电量低" + } + } + } + }, + "Pump data is %1$@ old" : { + "comment" : "The error message when pump data is too old to be used. (1: pump data age in minutes)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "بيانات المضخة منذ %1$@ " + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumpedata er %1$@ gammel" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumpendaten sind %1$@ alt" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Los datos de la microinfusora son %1$@ viejos" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumpputieto on %1$@ vanha" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les données de la pompe remontent à %1$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מידע משאבה: לפני %1$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "I dati forniti dalla pompa sono di %1$@ fa" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ポンプデータが %1$@前のものです" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumpedata er %1$@ gammel" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pompgegevens zijn %1$@ oud" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dane z pompy są nieaktualne od %1$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dados da bomba são de %1$@ atrás" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Datele din pompă sunt vechi de %1$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Данные помпы от %1$@ " + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumpvärdena är %1$@ gamla" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pompa verisi %1$@ eski" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dữ liệu bơm %1$@ là cũ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "胰岛素泵数据%1$@分钟未更新" + } + } + } + }, + "Pump Event" : { + "comment" : "The title of the screen displaying a pump event", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumpe-hændelse" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumpen-Ereignis" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Evento de microinfusadora" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumpputapahtuma" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Événement pompe" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אירוע משאבה" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Evento pompa" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ポンプイベント" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumpehendelse" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pompgebeurtenis" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zdarzenie pompy" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eventos da Bomba" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eveniment de pompă" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Событие помпы" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumphändelse" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pompa Etkinliği" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pump Event" + } + } + } + }, + "Pump Expired" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumpen er udløbet" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumpe abgelaufen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pompe Expirée" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pompa scaduta" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumpen er utløpt" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pompa wygasła" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pompă expirată" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Срок работы помпы закончился" + } + } + } + }, + "Pump Manager" : { + "comment" : "Details for configuration error when pump manager is missing", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "إدارة المضخة" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumpemanager" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumpenmanager" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Administratión de Microinfusora" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumpun hallinta" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gestionnaire de pompe" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מנהל משאבה" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pump Manager" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ポンプ設定" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumpe manager" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pomp Manager" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zarządzanie Pompą" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gerenciamento da Bomba" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manager pompă" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Менеджер помпо" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumphantering" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pompa Yöneticisi" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trình quản lý bơm" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "泵管理" + } + } + } + }, + "Pump Manager Error: %1$@" : { + "comment" : "The error message displayed for pump manager errors. (1: pump manager error)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fejl i pumpemanager: %1$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumpen-Fehler: %1$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Error del administrador de bomba: %1$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erreur du gestionnaire de pompe : %1$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "שגיאה במנהל שאבה: %1$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Errore Pump Manager: %1$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pump Manager-feil: %1$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pompmanager Fout: %1$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Błąd menedżera pompy: %1$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eroare de gestionare a pompei: %1$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ошибка менеджера помп: %1$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pompa Yöneticisi Hatası: %1$@" + } + } + } + }, + "Pump Reservoir Empty" : { + "comment" : "The notification title for an empty pump reservoir", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "خزان المضخة منتهي" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumpereservoir tomt" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumpenreservoir leer" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pump Reservoir Empty" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reserva de bomba vacía" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumpun säiliö tyhjä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réservoir de la pompe vide" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מאגר משאבה ריק" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Serbatoio della pompa vuoto" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ポンプのリザーバが空です" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumpereservoaret tomt" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pompreservoir Leeg" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zbiorniczek w pompie jest pusty" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reservatório da Bomba Vazio" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rezervorul pompei este gol" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Резервуар помпы пуст" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumpreservoaren är tom" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pompa Rezervuarı Boş" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ngăn chứa hết insulin" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "储药器药量已用完" + } + } + } + }, + "Pump Reservoir Low" : { + "comment" : "The notification title for a low pump reservoir", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "خزان المضخة منخفض" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumpereservoir lavt" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumpenreservoir niedrig" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pump Reservoir Low" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reserva de bomba baja" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumpun säiliö vähissä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réservoir de la pompe bas" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מאגר משאבה נמוך" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Serbatoio della pompa scarico" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ポンプのリザーバが低です" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumpe reservoar lav" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pompreservoir Bijna Leeg" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Niski poziom w zbiorniczku pompy" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reservatório da Bomba Vazio" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rezervor de pompare scăzut" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Мало инсулина в резервуаре" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumpreservoaren har låg nivå" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pompa Rezervuarı Düşük" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ngăn chứa insulin thấp" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "药量低" + } + } + } + }, + "Pump Suspended" : { + "comment" : "The title of the cell indicating the pump is suspended", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تم إيقاف الضخ مؤقتا" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumpe Pauset" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumpe unterbrochen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pump Suspended" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Microinfusora Suspendida" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumppu pysäytetty" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pompe suspendue" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "משאבה מושהית" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pompa sospesa" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ポンプ一時停止中" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumpe suspendert" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pomp Onderbroken" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pump Suspended" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bomba Suspensa" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pompă suspendată" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Помпа приостановлена" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumpen är pausad" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pompa Askıya Alındı" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bơm đã tạm ngưng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "泵已暂停输注" + } + } + } + }, + "Pump Suspended. Automatic dosing is disabled." : { + "comment" : "The error message displayed for pumpSuspended errors.", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumpen er suspenderet. Automatisk dosering er deaktiveret." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumpe unterbrochen. Die automatische Dosierung ist deaktiviert." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Microinfusora suspendida. La dosificación automática está desactivada." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumppu pysäytetty. Automaattinen annostelu ei ole käytössä." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pompe suspendue. Le dosage automatique est désactivé." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "משאבה מושהית. מינון אוטומטי מושבת." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pompa sospesa. Il dosaggio automatico è disattivato." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumpe suspendert. Automatisk dosing er deaktivert." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pomp Onderbroken. Automatisch doseren is uitgeschakeld." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pompa zawieszona. Automatyczne dozowanie jest wyłączone." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pompă suspendată. Administrarea automată este dezactivată." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Помпа приостановлена. Авто болюсы и ВБС отключены." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pump pausad. Automatisk dosering är inaktiverad." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pompa Askıya Alındı. Otomatik dozlama devre dışı bırakıldı." + } + } + } + }, + "QUANTITY_VALUE_AND_UNIT" : { + "comment" : "Format string for combining localized numeric value and unit. (1: numeric value)(2: unit)", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ %2$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@%2$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + } + } + }, + "Rapid-Acting – Adults" : { + "comment" : "Title of insulin model preset", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "التأثيرالسريع - كبار" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hurtigt-virkende (Rapid) – Voksne" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schnell wirkend – Erwachsene" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rapid-Acting – Adults" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Acción Rápida — Adultos" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nopeavaikutteinen – aikuiset" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Action rapide - Adulte" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אינסולין מהיר - מבוגרים" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulina ultrarapida – Adulti" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "超速攻型 - 大人" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hurtigvirkende – voksne" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Snelwerkend - Volwassenen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Szybko działające – dorośli" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ação-Rápida – Adultos" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Acțiune rapidă – Adulți" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Быстродействующий - взрослые" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Snabbverkande – vuxna" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hızlı Etkili – Yetişkinler" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thuốc tác động nhanh cho người lớn" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "速效胰岛素 - 成人模型" + } + } + } + }, + "Rapid-Acting – Children" : { + "comment" : "Title of insulin model preset", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "التأثيرالسريع - أطفال" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hurtigt-virkende (Rapid) – Børn" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schnell wirkend – Kinder" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rapid-Acting – Children" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Acción Rápida — Niños" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nopeavaikutteinen – lapset" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Action rapide - Enfant" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אינסולין מהיר - ילדים" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulina ultrarapida – Bambini" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "超速攻型 - 子供" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hurtigvirkende – barn" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Snelwerkend - Kinderen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Szybko działająca – dzieci" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ação-Rapida – Crianças" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Acțiune rapidă - Copii" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Быстродействующий - дети" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Snabbverkande – barn" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hızlı Etkili – Çocuklar" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thuốc tác động nhanh cho trẻ em" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "速效胰岛素 - 儿童模型" + } + } + } + }, + "Recommendation expired: %1$@ old" : { + "comment" : "The error message when a recommendation has expired. (1: age of recommendation in minutes)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "انتهت صلاحية التوصية منذ: %1$@ " + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forslag udløbet: %1$@ gamle" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Empfehlung abgelaufen: %1$@ alt" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recomendación expiró hace: %1$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suositus vanhentunut: %1$@ vanha" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recommandation expirée, remonte à %1$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "המלצה פגה: לפני %1$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "La raccomandazione è scaduta: %1$@ fa" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "推奨が %1$@ 経過したため失効" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anbefaling utløpt: %1$@ gammelt" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aanbeveling verlopen: %1$@ oud" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rekomendacja nieaktualna od %1$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recomendação expirou: %1$@ atrás" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recomandare expirată: acum %1$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Рекомнендация истекла : от %1$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rekommendationen gick ut för %1$@ sedan" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Önerinin süresi doldu: %1$@ eski" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Khuyến cáo hết hạn: %1$@ phút" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@分钟前推荐剂量已过期" + } + } + } + }, + "Recommended Basal" : { + "comment" : "The title of the cell displaying a recommended temp basal value", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الضخ المستمر المقترح" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anbefalet basal" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Empfohlene Basalrate" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recommended Basal" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basal Recomendada" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suositeltu basaali" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recommandation basal" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "בזאלי מומלץ" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basale raccomandata" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "推奨基礎分泌量" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anbefalt Basal" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aanbevolen Basaal" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zalecana baza" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basal Recomendada" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bazala recomandată" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Рекомендуемый базал" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Odporúčaný bazal" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rekommenderad basaldos" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Önerilen Bazal" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Khuyến nghị liều Basal" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "推荐基础率" + } + } + } + }, + "Recommended Bolus" : { + "comment" : "Label for recommended bolus row on bolus screen\nLabel for recommended bolus row on simple bolus screen", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anbefalet bolus" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Empfohlener Bolus" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolo recomendado" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suositeltu bolus" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basal Recommandé" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "בולוס מומלץ" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolo raccomandato" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anbefalt bolus" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aanbevolen Bolus" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zalecany bolus" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus recomandat" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Рекомендуемый болюс" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rekommenderad bolus" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Önerilen Bolus" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "推荐大剂量" + } + } + } + }, + "Recommended Bolus Exceeds Maximum Bolus" : { + "comment" : "Title for bolus screen warning when recommended bolus exceeds max bolus", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anbefalet bolus overstiger maksimal bolus" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der empfohlene Bolus überschreitet den maximalen Bolus" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El bolo recomendado supera al bolo máximo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Le bolus recommandé dépasse le bolus maximal" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "בולוס מומלץ גבוה מבולוס מקסימלי" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Il bolo raccomandato supera la quantità del bolo massimo." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anbefalt bolus overskrider maksimal bolus" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aanbevolen Bolus Overschrijdt Maximale Bolus" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zalecany bolus przekracza maksymalny bolus" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolusul recomandat depășește bolusul maxim" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Рекомендуемый болюс превышает максимальный болюс" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Önerilen Bolus Maksimum Bolusu Aşıyor" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "推荐大剂量超出最大限制" + } + } + } + }, + "Recommended Bolus: %@ Units" : { + "comment" : "Accessibility hint describing recommended bolus units", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الجرعة الموصى بها: %@ وحدات" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Foreslået bolus: %@ Enheder" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Empfohlener Bolus: %@ IE" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recommended Bolus: %@ Units" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolo Recomendado: %@ Unidades" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suositeltu bolus: %@ yksikköä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus recommandé : %@ unités" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "בולוס מומלץ: %@ יחידות" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolo raccomandato: %@ Unità" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "推奨ボーラス: %@ 単位" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anbefalt bolus: %@ enheter" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aanbevolen Bolus: %@ Eenheden" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rekomendowany bolus: %@ jednostek" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus Recomendado: %@ Unidades" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus recomandat: %@ unități" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Рекомендуемый болюс: %@ ед" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rekommenderad bolus: %@ enheter" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Önerilen Bolus: %@ Ünite" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Liều Bolus khuyến nghị: %@ Units" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "推荐大剂量:%@单位" + } + } + } + }, + "Remote Bolus Entry: %@ U" : { + "comment" : "The notification title for a remote bolus. (1: Bolus amount)\nThe notification title for a remote failure. (1: Bolus amount)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fjernindtastning af bolus: %@ E" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entfernte Boluseingabe: %@ IE" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entrada remota de bolo: %@ U" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entrée du bolus à distance : %@ U" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הזרקת בולוס מרחוק: %@ U" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inserimento bolo remoto: %@ U" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ekstern bolusregistrering: %@ E" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remote Bolusinvoer: %@ E" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zdalne podanie bolusa: %@ J" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Introducere de la distanță a bolusului: %@ U" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удаленный ввод болюса: %@ U" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uzak Bolus Girişi: %@ U" + } + } + } + }, + "Remote Carbs Entry: %d grams" : { + "comment" : "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fjernindtastning af kulhydrater: %d gram" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ferneingabe von Kohlenhydraten: %d g" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entrada remota de carbohidratos: %d gr" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entrée de glucides à distance : %d grammes" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הזנת פחמימות מרחוק: %d גרם" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inserimento remoto di carboidrati: %d grammi" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ekstern karbohydratregistrering: %d gram" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remote Koolhydraatinvoer: %d gram" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zdalne wprowadzanie węglowodanów: %d gramów" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Introducere de la distanță a carbohidraților: %d grame" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удаленный ввод углеводов: %d грамм" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uzak Karbonhidrat Girişi: %d gram" + } + } + } + }, + "Reservoir" : { + "comment" : "Segmented button title for insulin delivery log reservoir history", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الخزان" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reservoir" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reservoir" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reservorio" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Säiliö" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réservoir" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מאגר" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Serbatoio" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reservoir" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reservoar" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reservoir" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zbiorniczek" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reservoir" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rezervor" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Резервуар" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rezervoár" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reservoar" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rezervuar" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reservoir" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "储药器" + } + } + } + }, + "Reservoir Empty" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reservoir tomt" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reservoir leer" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réservoir vide" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Serbatoio vuoto" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reservoaret er tomt" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pusty zbiorniczek" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rezervor gol" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Резервуар пуст" + } + } + } + }, + "Retrospective Correction" : { + "comment" : "Title of the prediction input effect for retrospective correction", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "التصحيح بأثر رجعي" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilbagevirkende korrektion" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nachträgliche Korrektur" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Corrección Retrospectiva" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retrospektiivinen korjaus" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Correction rétrospective" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "תיקון לאחור" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Correzione retrospettiva" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "レトロ補正" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retrospektiv korreksjon" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retroperspectieve Correctie" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Korekcja retrospektywna" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Correção Retrospectiva" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Corecție retrospectivă" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ретроспективная коррекция" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retrospektiv korrigering" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geçmişe Dönük Düzeltme" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Liều Bổ sung còn hiệu lực" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "回顾性修正" + } + } + } + }, + "Retry" : { + "comment" : "The button text for attempting a manual loop\nThe title of the notification action to retry a bolus command", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "أعد المحاولة" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zkusit znovu" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forsøg igen" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wiederholen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reintentar" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yritä uudelleen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réessayer" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "נסה שוב" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Riprova" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "やり直す" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Prøv på nytt" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opnieuw Proberen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spróbuj ponownie" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tentar de Novo" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reîncearcă" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Повторить" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skúsiť znova" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Försök igen" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yeniden dene" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thử lại" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重试" + } + } + } + }, + "Save" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gem" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sauvegarder" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Salva" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lagre" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zapisz" + } + } + } + }, + "Save as favorite food" : { + "comment" : "Button label for saving current carb entry as a new Favorite Food", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gem som favoritmad" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Als Lieblingsessen speichern" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enregistrer comme aliment préféré" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Salva come cibo preferito" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lagre som favorittmat" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opslaan als favoriet voedsel" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zapisz jako ulubione jedzenie" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Salvați ca mâncare preferată" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "保存为常用食物" + } + } + } + }, + "Save Carbs & Deliver" : { + "comment" : "Button text to save carbs and/or manual glucose entry and deliver a bolus", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Speichern und Abgeben" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Salva carboidrati ed eroga" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lagre og gi bolus" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Salvați carbohidrații și livrați" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "保存碳水摄入量并开始注射" + } + } + } + }, + "Save without Bolusing" : { + "comment" : "Button text to save carbs and/or manual glucose entry without a bolus", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gem uden at give bolus" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Speichern ohne Bolusgabe" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Guardar sin Entregar Bolo" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tallenna ilman bolusta" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enregister sans Bolus" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "שמור ללא הזרקת בולוס" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Salva senza erogare bolo" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lagre uten å sette bolus" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opslaan zonder Bolussen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zapisz bez podania Bolusa" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Salvează fără bolusare" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сохранить без болюса" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spara utan att ge bolus" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus olmadan Kaydet" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "仅保存,无大剂量" + } + } + } + }, + "Scheduled" : { + "comment" : "Scheduled Delivery status text", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Planlagt" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geplant" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Programado" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Programmé" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מתוזמן" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Programmato" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Planlagt" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gepland" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zaplanowane" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Programat" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Запланировано" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Planlanan" + } + } + } + }, + "Selecting a favorite food in the carb entry screen automatically fills in the carb quantity, food type, and absorption time fields! Tap the add button below to create your first favorite food!" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hvis du vælger en favoritmad i kulhydratindtastningsskærmen, udfyldes felterne for kulhydratmængde, madtype og absorptionstid automatisk! Tryk på tilføj-knappen nedenfor for at oprette din første favoritmad!" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wenn Du im Kohlenhydrat-Eingabebildschirm ein Lieblingsessen auswählst, werden die Felder für Kohlenhydratmenge, Lebensmittelart und Absorptionszeit automatisch ausgefüllt! Tippe unten auf die Schaltfläche „Hinzufügen“, um Dein erstes Lieblingsessen zu erstellen!" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Selezionando un alimento preferito nella schermata di inserimento dei carboidrati, i campi relativi a quantità di carboidrati, tipo di alimento e tempo di assorbimento vengono compilati automaticamente! Tocca \"Aggiungi\" qui sotto per creare il tuo primo alimento preferito!" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Når du velger en favorittmat i skjermbildet for innlegging av karbohydrater, fylles feltene for karbohydratmengde, matvaretype og opptakstid automatisk ut! Trykk på knappen Legg til nedenfor for å opprette din første favorittmat!" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Het selecteren van een favoriet voedsel in het koolhydraat invoerscherm vult automatisch de velden voor de hoeveelheid koolhydraten, het type voedsel en de absorptietijd in! Tik op de toevoegknop hieronder om je eerste favoriete voedsel te maken!" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wybór ulubionego jedzenia na ekranie wprowadzania węglowodanów powoduje automatyczne wypełnienie pól ilości węglowodanów, rodzaju jedzenia i czasu wchłaniania! Dotknij przycisku dodawania poniżej, aby stworzyć swoje pierwsze ulubione jedzenie!" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Selectarea unui aliment preferat în ecranul de introducere a carbohidraților completează automat câmpurile pentru cantitatea de carbohidrați, tipul de aliment și timpul de absorbție! Apăsați butonul de adăugare de mai jos pentru a crea primul dvs. aliment preferat!" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在碳水记录界面选择一个常用食物时,系统会自动填充碳水含量、食物类型和吸收时间等字段!点击下方的添加按钮,创建你的第一个常用食物吧!" + } + } + } + }, + "Sensor Failed" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sensorfejl" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sensorfehler" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Capteur\nDéfaillant" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sensore guasto" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sensor mislyktes" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Awaria sensora" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Senzor defect" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ошибка сенсора" + } + } + } + }, + "Services" : { + "comment" : "The title of the services section in settings", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الخدمات" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Services" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dienste" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Servicios" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Palvelut" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Services" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "שירותים" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Servizi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "サービス" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tjenester" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Services" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usługi" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Serviços" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Servicii" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Службы" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tjänster" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Servisler" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dịch vụ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "服务" + } + } + } + }, + "Settings" : { + "comment" : "Label of button that navigation user to iOS Settings\nSettings screen title\nThe label of the settings button", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الإعدادات" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indstillinger" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einstellungen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajustes" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Asetukset" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Paramètres" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הגדרות" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impostazioni" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "設定" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Innstillinger" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Instellingen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ustawienia" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configurações" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Setări" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройки" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nastavenia" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inställningar" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ayarlar" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cài đặt" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "设置" + } + } + } + }, + "Setup Incomplete" : { + "comment" : "The title of the cell indicating that onboarding is suspended", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opsætning ufuldstændig" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einrichtung unvollständig" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuración incompleta" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuration incomplète" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הגדרה לא הושלמה" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configurazione incompleta" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ufullstendig oppsett" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installatie Onvolledig" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Konfiguracja niekompletna" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configurare nefinalizată" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройка не завершена" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kurulum Tamamlanmadı" + } + } + } + }, + "Shows last loop error" : { + "comment" : "Loop Completion HUD accessibility hint", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يظهر خطأ الحلقه الاخير" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Viser sidste Loop-fejl" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zeigt den letzten Loop-Fehler an" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Muestra último error de Loop" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Näyttää Loopin viimeisimmän virheen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Affiche la dernière erreur de Loop" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Shows last loop error" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostra l'ultimo errore di Loop" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "前回のループエラー" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Viser siste Loop-feil" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toont laatste loop foutmelding" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pokazuje ostatni błąd Loop" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar último erro do ciclo" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afișează ultima eroare de loop" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывает крайнюю ошибку петли" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Visar senaste loopfelet" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Son döngü hatasını gösterir" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hiển thị lỗi của loop trước đó" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示Loop上一次的错误" + } + } + } + }, + "Simple Bolus Calculator" : { + "comment" : "Title of simple bolus view when not displaying meal entry", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Simpel bolusberegner" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einfacher Bolusrechner" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Calculadora simple de bolo" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yksinkertainen boluslaskuri" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Calculateur Simplifié de Bolus" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מחשבון בולוס פשוט" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Calcolatore bolo semplice" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enkel bolus-kalkulator" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eenvoudige Boluscalculator" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Prosty kalkulator bolusa" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Calculator simplu de bolus" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Простой калькулятор болюса" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Förenklad bolusdoskalkylator" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basit Bolus Hesaplayıcı" + } + } + } + }, + "Simple Meal Calculator" : { + "comment" : "Title of simple bolus view when displaying meal entry", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Simpel måltidsberegner" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einfacher Mahlzeitenrechner" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Calculadora simple de comida" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yksinkertainen aterialaskuri" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Calculateur Simplifié de Repas" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מחשבון ארוחה פשוט" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Calcolatore pasto semplice" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enkelt måltid kalkulator" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eenvoudige Maaltijdcalculator" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Prosty kalkulator posiłków" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Calculator simplu al mesei" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Простой калькулятор еды" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Förenklad bolusdoskalkylator" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basit Yemek Hesaplayıcı" + } + } + } + }, + "since %@" : { + "comment" : "Format fragment for a start time", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "منذ %@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "siden %@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "seit %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "desde %@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ jälkeen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "depuis %@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מאז %@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "da %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ から" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "siden %@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "sinds %@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "od %@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "desde %@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "de la %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "от %@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "sedan %@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ den beri" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "từ khi %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自从%@分钟前" + } + } + } + }, + "Site URL" : { + "comment" : "The title of the nightscout site URL credential", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "رابط الموقع" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL adresa webu" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Side-URL" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Webseiten URL" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Site URL" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL de Sitio" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL du site" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "כתובת האתר" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sito URL" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nettstedets URL" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Site URL" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Strona URL" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Site URL" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL site" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL сайта" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL adresa webu" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nightscout-URL" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nightscout URL" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Site URL" + } + } + } + }, + "Software Update" : { + "comment" : "Software update button link text", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Software-opdatering" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Software-Aktualisierung" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actualización de software" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mise à jour logicielle" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "עדכון תוכנה" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aggiornamento software" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Programvare oppdatering" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Software Update" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktualizacja oprogramowania" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actualizare de software" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обновление программного обеспечения" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yazılım güncellemesi" + } + } + } + }, + "Start time is out of range: %@" : { + "comment" : "Carb error description: invalid start time is out of range.", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Starttidspunktet er uden for området: %@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Startzeit liegt außerhalb des Bereichs: %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Le temps de début est hors de la zone définie : %@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "זמן התחלה מחוץ לטווח: %@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "L'orario di inizio non rientra nell'intervallo: %@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Starttiden er utenfor området: %@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Starttijd is buiten bereik: %@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Czas rozpoczęcia jest poza zakresem: %@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ora de începere este în afara intervalului: %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Время начала вне допустимого диапазона: %@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Başlangıç zamanı aralığın dışında: %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "开始时间超出范围:%@" + } + } + } + }, + "starting at %@" : { + "comment" : "The format for the description of a custom preset start date", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يبدأ من %@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "starter ved %@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beginnt um %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "comenzando a las %@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "alkaa %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "commence à %@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מתחיל ב-%@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "a partire da %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@から開始" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "starter på %@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "start om %@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "starting at %@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "iniciando às %@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "începând de la %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "начало с %@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Börjar kl. %@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ tarihinde başladı" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "bắt đầu lúc %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "开始于 %@" + } + } + } + }, + "Starting Bolus" : { + "comment" : "The title of the cell indicating a bolus is being sent", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "بدء الجرعة" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Starter bolus" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Starte Bolus" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comenzando Bolo" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aloitetaan bolus" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Début du bolus" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מתחיל בולוס" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avvio bolo in corso" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ボーラス注入を開始" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Starter Bolus" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus Starten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rozpoczynam podawanie bolusa" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Iniciando Bolus" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start bolus" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Начинаю болюс" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Påbörjar bolus" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus başlatılıyor" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bắt đầu liều Bolus" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "开始输注大剂量" + } + } + } + }, + "Support" : { + "comment" : "Section title for Support\nThe title of the support section in settings", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Support" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unterstützung" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ayuda" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tuki" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Support" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "תמיכה" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Supporto" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Brukerstøtte" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ondersteuning" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wsparcie" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Asistenţă" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поддержка" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Support" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Destek" + } + } + } + }, + "Suspend Threshold" : { + "comment" : "The title text in settings", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "قيمة التعليق" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pause grænseværdi" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Grenzwert für Hypo-Abschaltung" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suspend Threshold" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nivel de Suspensión" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pysäytysraja" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seuil de suspension" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "סף השהייה" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Soglia di sospensione" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "一時停止値" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Terskel for utsettelse" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Onderbrekingsdrempel" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Próg zawieszenia pompy" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Limite de Suspenção" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Limită suspendare" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Порог приостановки" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tröskelvärde" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eşiği Askıya Al" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ngưỡng Tạm dừng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "暂停阈值" + } + } + } + }, + "Suspension of Insulin Delivery" : { + "comment" : "Title of the prediction input effect for suspension of insulin delivery", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afbryd insulintilførsel" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unterbrechung der Insulinabgabe" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sospensione dell'erogazione d'insulina" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suspensjon av insulintilførsel" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wstrzymanie podawania insuliny" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suspendarea administrării insulinei" + } + } + } + }, + "Tap here to set up a CGM" : { + "comment" : "Descriptive text for button to add CGM device", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tryk her for at konfigurere en CGM" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tippe hier, um ein CGM einzurichten" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pulsa aquí para configurar un CGM" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Määritä CGM napauttamalla tästä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Appuyez ici pour configurer un CGM" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "לחץ כאן להגדיר חיישן" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tocca per impostare un CGM" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trykk her for å sette opp en CGM" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tik hier om een CGM in te stellen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stuknij tutaj, aby skonfigurować CGM" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apăsați aici pentru a configura un CGM" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нажмите здесь, чтобы настроить CGM" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tryck här för att ställa in en CGM" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bir CGM ayarlamak için buraya dokunun" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "点击此处设置 CGM" + } + } + } + }, + "Tap here to set up a pump" : { + "comment" : "Descriptive text for button to add pump device", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tryk her for at tilføje en pumpe" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tippe hier, um eine Pumpe einzurichten" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pulsa aquí para configurar una microinfusadora" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Määritä pumppu napauttamalla tästä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Appuyez ici pour paramétrer une pompe" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "לחץ כאן להגדיר משאבה" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tocca per impostare una pompa" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trykk her for å sette opp en pumpe" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tik hier om een pomp in te stellen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stuknij tutaj, aby skonfigurować pompę" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apăsați aici pentru a configura o pompă de insulină" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нажмите здесь, чтобы настроить помпу" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tryck här för att ställa in en pump" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bir pompa ayarlamak için buraya dokunun" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "点击此处设置泵" + } + } + } + }, + "Tap here to set up a Service" : { + "comment" : "The descriptive text of the add service button in settings", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tryk her for at konfigurere en tjeneste" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tippe hier, um einen Dienst einzurichten" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pulsa aquí para configurar un Servicio" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Määritä palvelu napauttamalla tästä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Appuyez ici pour configurer un service" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "לחץ כאן להגדיר שירות" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tocca per configurare un servizio" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trykk her for å sette opp en tjeneste" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tik hier om een Service in te stellen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stuknij tutaj, aby skonfigurować usługę" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apăsați aici pentru a configura un Serviciu" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нажмите здесь, чтобы настроить службу" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tryck här för att ställa in en tjänst" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bir Servis ayarlamak için buraya dokunun" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "点击此处设置服务" + } + } + } + }, + "Tap to Add" : { + "comment" : "The subtitle of the cell displaying an action to add a manually measurement glucose value", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tryk for at tilføje" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hinzufügen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pulsa para añadir" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lisää napauttamalla" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajout" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "לחץ להוסיף" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tocca per aggiungere" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trykk for å legge til" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tik voor Toevoegen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dodaj glikemię" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atinge pentru a adăuga" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нажмите, чтобы добавить" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tryck för att ange" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eklemek için dokunun" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "点击添加" + } + } + } + }, + "Tap to Resume" : { + "comment" : "The subtitle of the cell displaying an action to resume insulin delivery\nThe subtitle of the cell displaying an action to resume onboarding", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "انقر للاستئناف" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tryk for at Fortsætte" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fortsetzen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toque para reanudar" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jatka annostelua" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Appuyez pour reprendre" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "לחץ להמשך" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tocca per riprendere" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "タップして再開する" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trykk for å fortsette" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tik voor Hervatten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stuknij, aby wznowić" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toque para retomar" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apăsați pentru a relua" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нажмите чтобы возобновить" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tryck för att återuppta" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sürdürmek için dokunun" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chạm để tiếp tục" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "点击恢复输注" + } + } + } + }, + "Tap to Stop" : { + "comment" : "Message presented in the status row instructing the user to tap this row to stop a bolus", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tryk for at stoppe" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stoppen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pulsa para detener" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pysäytä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stop" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "לחץ לעצור" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tocca per interrompere" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trykk for å stoppe" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tik voor Stoppen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus STOP!" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atingeți pentru a opri" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нажмите, чтобы остановить" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tryck för att stoppa" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Durdurmak için dokunun" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "点击停止" + } + } + } + }, + "Tap to Unmute Alerts" : { + "comment" : "Label for button to unmute all alerts", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tryk for at slå alarmer til" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tippe, um die Stummschaltung von Benachrichtigungen aufzuheben" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tocca per riattivare gli avvisi" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trykk for å dempe varsler" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stuknij, aby wyłączyć wyciszenie alertów" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atingeți pentru a activa alertele" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "点按可恢复警报提示" + } + } + } + }, + "Tap Unmute to resume sound for your alerts and alarms." : { + "comment" : "The alert body for unmute alert confirmation", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tryk på Slå lyden til for at genoptage lyden for dine alarmer og alarmer." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tippe auf Stummschaltung aufheben, um den Ton für Deine Warnungen und Alarme wieder aufzunehmen." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tocca \"Riattiva\" audio per riattivare l'audio degli avvisi e delle sveglie." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trykk på Slå av lyd for å gjenoppta lyden for varsler og alarmer." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stuknij opcję Wyłącz wyciszenie, aby wznowić dźwięk alertów i alarmów." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atingeți Activare sunet pentru a relua sunetul pentru alerte și alarme." + } + } + } + }, + "TestFlight" : { + "comment" : "Settings app TestFlight section", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "TestFlight" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "TestFlight" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "TestFlight" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "TestFlight" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "TestFlight" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "TestFlight" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "TestFlight" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "TestFlight" + } + } + } + }, + "TestFlight Expiration" : { + "comment" : "Settings TestFlight expiration view", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "TestFlight Udløber" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "TestFlight-Ablauf" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Expiration de TestFlight" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scadenza di TestFlight" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "TestFlight-utløp" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wygaśnięcie TestFlight" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Expirare TestFlight" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Срок действия TestFlight" + } + } + } + }, + "TestFlight expires " : { + "comment" : "Time that build expires", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "TestFlight udløber " + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "TestFlight läuft ab " + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "TestFlight expire le " + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "TestFlight scade " + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "TestFlight utløper " + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "TestFlight wygasa" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Expiră TestFlight " + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Срок действия TestFlight истекает " + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "TestFlight 到期 " + } + } + } + }, + "TestFlight Expires Soon" : { + "comment" : "The title for notification of upcoming TestFlight expiration", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "TestFlight udløber snart" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "TestFlight läuft bald ab" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "TestFlight expire bientôt" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "TestFlight scade a breve" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "TestFlight utløper snart" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "TestFlight wkrótce wygaśnie" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "TestFlight expiră în curând" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Срок действия TestFlight скоро истечет" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "TestFlight 即将到期" + } + } + } + }, + "The bolus amount entered is smaller than the minimum deliverable." : { + "comment" : "Alert message for a bolus too small validation error", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Den indtastede bolusmængde er mindre end den mindste leverbare mængde." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die eingegebene Bolusmenge ist kleiner als die Mindestabgabemenge." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "La cantidad de bolo ingresada es menor que el mínimo que se puede administrar." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "La quantité de bolus saisie est inférieure au minimum délivrable." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "כמות הבולוס שהכנסת נמוכה מהכמות המינימלית." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "La quantità di bolo immessa è inferiore alla quantità minima erogabile." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Den angitte bolusmengden er mindre enn minimumsleveransen." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "De ingevoerde bolushoeveelheid is kleiner dan die minimaal toegediend kan worden." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wprowadzona wielkość bolusa jest mniejsza niż minimalna możliwa do podania." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cantitatea de bolus introdusă este mai mică decât valoarea minimă administrabilă." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Введенное количество болюса меньше минимально допустимого в помпе" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Girilen bolus miktarı, minimum teslim edilebilir miktardan daha küçük." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "输入的推注量小于最小可推注量。" + } + } + } + }, + "The bolus dosing algorithm uses a more conservative estimate of forecasted blood glucose than what is used to adjust your basal rate.\n\nAs a result, your forecasted blood glucose after a bolus may still be higher than your target range." : { + "comment" : "Forecast explanation modal on bolus view", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolusdoseringsalgoritmen bruger et mere konservativt skøn over det forventede blodsukker end det, der bruges til at justere din basalhastighed.\n\nSom følge heraf kan dit forventede blodglukose efter en bolus stadig være højere end dit målområde." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der Bolusdosierungsalgorithmus verwendet eine konservativere Schätzung des prognostizierten Blutzuckers als die zur Anpassung Deiner Basalrate.\n\nDaher kann Dein prognostizierter Blutzucker nach einem Bolus immer noch über Deinem Zielbereich liegen." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El algoritmo de dosificación de bolo utiliza una estimación más conservadora de la glucosa en sangre pronosticada que la que se usa para ajustar su basal. \n\n Como resultado, su glucosa en sangre pronosticada después de un bolo aún puede ser más alta que su rango objetivo." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "L'algorithme de dosage bolus utilise une estimation plus prudente de la glycémie prévue que celle utilisée pour ajuster votre débit basal. \n\nPar conséquent, votre glycémie prévue après un bolus peut rester supérieure à votre plage cible." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אלגוריתם מינון הבולוס משתמש באומדן שמרני יותר של צפי הגלוקוז ממה שמשמש כדי להתאים את הקצב הבזאל שלך.\n\nכתוצאה מכך, הגלוקוז הצפוי לאחר בולוס עדיין עשוי להיות גבוה מטווח היעד שלך." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "L'algoritmo di dosaggio del bolo utilizza una stima più conservativa della glicemia prevista rispetto a quella utilizzata per regolare la velocità basale.\n\nDi conseguenza, la glicemia prevista dopo un bolo potrebbe essere superiore all'intervallo obiettivo." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolusdoseringsalgoritmen bruker et mer konservativt estimat av anslått blodsukker enn det som brukes til å justere basalhastigheten. \n\n Som et resultat kan det anslåtte blodsukkeret ditt etter en bolus fortsatt være høyere enn målområdet ditt." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Het bolusdoseeralgoritme gebruikt een meer conservatieve schatting van de verwachte bloedglucose dan wat wordt gebruikt om je basaalsnelheid aan te passen.\n\nHierdoor kan je voorspelde bloedglucose na een bolus nog steeds hoger zijn dan je streefbereik." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Algorytm dawkowania bolusa wykorzystuje bardziej ostrożne oszacowanie przewidywanego poziomu glukozy we krwi niż to, które jest używane do dostosowania dawki podstawowej. \n\nW rezultacie przewidywany poziom glukozy we krwi po podaniu bolusa może nadal być wyższy niż zakres docelowy." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Algoritmul de dozare al bolusurilor folosește o estimare mai conservatoare a glicemiei prognozate decât cea utilizată pentru a ajusta rata bazală. \n\n Ca rezultat, glicemia estimată după un bolus poate fi în continuare mai mare decât intervalul țintă." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Алгоритм авто болюса использует более консервативную оценку прогнозируемого уровня глюкозы в крови, чем та, которая используется для корректировки с помощью ВБС.\n\nВ результате прогнозируемый уровень глюкозы в крови после болюса может оказаться выше целевого диапазона." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus dozlama algoritması, bazal hızınızı ayarlamak için kullanılandan daha ihtiyatlı bir kan şekeri tahmini kullanır. \n\n Sonuç olarak, bir bolustan sonra tahmin edilen kan şekeriniz, hedef aralığınızdan daha yüksek olabilir." + } + } + } + }, + "The bolus recommendation has updated. Please reconfirm the bolus amount." : { + "comment" : "Alert message for an updated bolus recommendation", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolusanbefalingen er opdateret. Bekræft venligst bolus." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die Bolusempfehlung wurde aktualisiert. Bitte bestätige die Bolusmenge erneut." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "La recomendación de bolo ha sido updatada. Reconfirme el bolo." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolussuositus on päivittynyt. Vahvista bolus uudelleen." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "La recommandation du bolus a changé. Veuillez reconfirmer la quantité du bolus" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "המלצת הבולוס עודכנה. אשר מחדש את כמות הבולוס." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "La raccomandazione del bolo è stata aggiornata. Si prega di riconfermare la quantità di bolo." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus-anbefalingen er oppdatert. Bekreft bolusverdien på nytt." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "De aanbevolen bolus is bijgewerkt. Bevestig de bolus opnieuw." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rekomendacja dotycząca bolusa została zaktualizowana. Potwierdź ponownie wielkość bolusa." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recomandarea pentru bolus a fost actualizată. Vă rugăm să reconfirmaţi valoarea bolusului." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Рекомендация по болюсу была обновлена. Пожалуйста, подтвердите новое количество инсулина." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolusrekommendationen har uppdaterats. Konfirmera bolus" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus önerisi güncellendi. Lütfen bolus miktarını yeniden onaylayın." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "大剂量推荐值已更新。请重新确认推注剂量。" + } + } + } + }, + "The legacy model used by Loop, allowing customization of action duration." : { + "comment" : "Subtitle description of Walsh insulin model setting", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "النموذج المستخدم بالتطبيق، يسمح بتخصيص مدة الفعالية." + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ældre model, der bruges af Loop, og som gør det muligt at tilpasse insulinens varighed." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Das von Loop verwendete Legacy-Modell, das die Anpassung der Aktivitätsdauer ermöglicht." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The legacy model used by Loop, allowing customization of action duration." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El modelo heredado utilizado por Loop, que permite personalizar la duración de la acción de la insulina." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loopin vanha insuliinimalli, jossa voi muokata insuliinin vaikutusaikaa." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Le modèle original utilisé par Loop, permettant de gérer la durée d'action de l'insuline." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מודל מדור קודם בשימוש Loop, מאפשר שינוי משך זמן פעולה." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Il modello legacy utilizzato da Loop, che consente la personalizzazione della durata dell'azione." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ループのレガシーモデルで、作用期間をカスタマイズできます。" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Den eldre modellen brukt av Loop, som tillater tilpasning av handlingsvarigheten." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Het model gebruikt bij Loop, staat verandering van actieduur toe." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Model umożliwiający dostosowanie czasu działania insuliny." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "O modelo antigo utilizado pelo Loop permitindo personalização da duração da ação." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modelul vechi utilizat de Loop, permite personalizarea duratei de acțiune." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Устаревшая модель, используемая Loop, позволяющая настраивать продолжительность действия." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Äldre modell använd av Loop, vilken tillåter anpassning av insulinets verkningstid." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop tarafından kullanılan ve eylem süresinin özelleştirilmesine izin veren eski model." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mô hình cũ được Loop sử dụng, cho phép tùy chỉnh thời lượng hành động." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop使用的默认模型参数,您可以自行修改胰岛素代谢时间。" + } + } + } + }, + "The maximum allowed amount is %@ grams." : { + "comment" : "Alert body displayed for quantity greater than max (1: maximum quantity in grams)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Den maksimalt tilladte mængde er %@ gram." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die maximal zulässige Menge beträgt %@ Gramm." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "La cantidad máxima permitida es %@ gr." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "La quantité maximale autorisée est de %@ grammes" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "כמות מקסימלית מותרת היא: %@ גרם." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "La quantità massima consentita è %@ grammi." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maksimalt tillatt mengde er %@ gram." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "De maximaal toegestane hoeveelheid is %@ gram." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maksymalna dozwolona ilość to %@ gramów" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cantitatea maximă admisă este de %@ grame." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Максимально допустимое количество составляет %@ грамм." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İzin verilen maksimum miktar %@ gramdır." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "允许的最大数量为%@克。" + } + } + } + }, + "The maximum amount allowed is %1$@." : { + "comment" : "Warning for simple bolus when carbohydrate entry is too large. (1: maximum carbohydrate entry)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Den maksimale tilladte mængde er %1$@." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der maximal zulässige Wert ist %1$@." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "La cantidad máxima permitida es %1$@." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "La quantité maximum autorisée est de %@." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "כמות מקסימלית מותרת היא: %1$@." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "La quantità massima consentita è %1$@ ." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maksimal tillatt mengde er %1$@ ." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "De maximaal toegestane hoeveelheid is %1$@." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maksymalna dozwolona ilość to: %1$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cantitatea maximă permisă este %1$@." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Максимально допустимая сумма составляет %1$@." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İzin verilen maksimum tutar %1$@." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "允许的最大量为:%1$@" + } + } + } + }, + "The maximum bolus amount is %@ U." : { + "comment" : "Alert message for a maximum bolus validation error (1: max bolus value)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Den maksimale bolus er %@ enheder." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die maximale Bolusmenge beträgt %@ IE." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolo máximo es %@ Unidades." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suurin sallittu bolus on %@ U." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "La quantité maximum du bolus est de %@ U." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "בולוס מקסימלי הוא %@ U." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "La quantità massima consentita del bolo è %@ U." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maksimal bolus er satt til %@ E." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "De maximale bolushoeveelheid is %@ E." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maksymalna wielkość bolusa to %@ J." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Valoarea maximă bolus este %@ U." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Максимальный объем болюса составляет %@ ед." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Den högst tillåtna bolusmängden är %@ E." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maksimum bolus miktarı %@ Ü." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "最大推注量为:%@ U。" + } + } + } + }, + "The maximum bolus amount is %@ Units" : { + "comment" : "Body of the alert describing a maximum bolus validation error. (1: The localized max bolus value)", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الحد الأقصى للجرعة هو %@ وحدات" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Den maksimale bolusmængde er %@ enheder" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die maximale Bolus beträgt %@ IE" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The maximum bolus amount is %@ Units" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolo máximo es %@ Unidades" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suurin sallittu bolus on %@ yksikköä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Le bolus maximal est de %@ unités" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "בולוס מקסימלי הוא %@ יחידות." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "La quantità massima consentita del bolo è %@ Unità" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "最大ボーラス量は %@単位です" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maksimal bolusmengde er %@ enheter" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "De maximale bolushoeveelheid is %@ Eenheden" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maksymalny bolus wynosi %@ jednostek" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "O bolus máximo é %@ Unidades" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cantitatea maximă de bolus este de %@ unități" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "максимальный болюс %@ ед" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Den maximala bolusdosen är %@ enheter" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maksimum bolus miktarı %@ Ünitedir" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Số lượng bolus tối đa là %@ Units" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "大剂量推注最大限制为 %@ 单位" + } + } + } + }, + "The maximum bolus setting must be configured before a bolus can be delivered." : { + "comment" : "Alert message for a missing maximum bolus setting error", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maksimal bolus skal konfigureres, før en bolus kan leveres." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die Boluseinstellungen müssen konfiguriert werden, bevor ein Bolus abgegeben werden kann." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El ajuste de bolo máximo debe configurarse antes de que se pueda administrar un bolo." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suurin sallittu bolus -asetus täytyy määrittää ennen kuin bolus voidaan annostella." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Le réglage de bolus maximum doit être configuré avant qu’un bolus puisse être effectué." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "יש לקבוע הגדרת בולוס מקסימלי לפני מתן בולוס." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "L'impostazione del bolo massimo deve essere configurata prima di poter erogare un bolo." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Innstillingen for maksimal bolus må konfigureres før bolus kan leveres." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "De maximale bolusinstelling moet worden ingesteld voordat een bolus kan worden afgeleverd." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Przed podaniem bolusa musi zostać skonfigurowane ustawienie maksymalnego bolusa." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trebuie sa configurați o valoare maxima pentru bolus înainte ca acesta să poată fi livrat." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перед подачей болюса необходимо настроить максимальное значение болюса." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Den högst tillåtna bolusmängden måste ställas in innan en bolus kan ges." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bir bolus verilmeden önce maksimum bolus ayarı yapılandırılmalıdır." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请先设置最大推注剂量,才能进行推注。" + } + } + } + }, + "Therapy Settings" : { + "comment" : "Title text for button to Therapy Settings", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Behandlingsindstillinger" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Therapieeinstellungen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajustes de la Terapia" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hoitoasetukset" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réglages Thérapeutique" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "תכנית טיפול" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impostazioni terapia" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Behandlingsinnstillinger" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Therapieinstellingen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ustawienia terapii" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Setări Terapie" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройки терапии" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Behandlingsinställningar" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tedavi Ayarları" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "治疗设置" + } + } + } + }, + "This option only applies when Loop's Dosing Strategy is set to Automatic Bolus." : { + "comment" : "String shown when glucose based partial application cannot be enabled because dosing strategy is not set to Automatic Bolus", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Denne mulighed gælder kun, når Loops doseringsstrategi er indstillet til Automatisk bolus." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Diese Option gilt nur, wenn die Dosierstrategie von Loop auf „Automatischer Bolus“ eingestellt ist." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cette option ne s’applique que lorsque la stratégie de dosage de Loop est réglée sur Bolus automatique." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Questa opzione si applica solo quando Strategia di dosaggio di Loop è impostata su Bolo automatico." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dette alternativet gjelder bare når Loops doseringsstrategi er satt til Automatisk bolus." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ta opcja ma zastosowanie tylko wtedy, gdy Strategia dawkowania pętli jest ustawiona na Automatyczny bolus." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Această opțiune se aplică numai atunci când Strategia de dozare a Loop este setată pe Bolus automat." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Эта опция применима только в том случае, если для стратегии дозирования петли установлено значение «Автоматический болюс»." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此选项仅在 Loop 的给药策略设为“自动大剂量”时适用。" + } + } + } + }, + "Time Sensitive Alerts" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tidsfølsomme advarsler" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zeitkritische Warnungen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alertes Urgentes" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notifiche sensibili al tempo" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tidssensitive varsler" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alerty zależne od czasu" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alerte sensibile la timp" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Срочные оповещения" + } + } + } + }, + "Time Sensitive Notifications" : { + "comment" : "Time Sensitive Status text", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tidsfølsomme meddelelser" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zeitkritische Benachrichtigungen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notificaciones sensibles al tiempo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notifications urgentes" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הודעות רגישות לזמן" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notifiche sensibili al tempo" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tidssensitive varsler" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tijdgevoelige Meldingen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Powiadomienia zależne od czasu" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notificări urgente" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Уведомления, чувствительные к времени" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zamana Duyarlı Bildirimler" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "时效性通知" + } + } + } + }, + "Transmitter Low Battery" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sender lavt batteri" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Niedriger Batteriestatus" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Batterie faible de l'émetteur" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Batteria del trasmettitore scarica" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Svakt batteri i senderen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Słaba bateria transmitera" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Baterie descărcată a transmițătorului" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Батарейка трансмиттера садится" + } + } + } + }, + "Try Again" : { + "comment" : "Critical event log export error alert try again button", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Prøv igen" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nochmals versuchen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inténtalo de nuevo" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yritä uudelleen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réessayer" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "נסה שוב" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Riprova" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Prøv på nytt" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Probeer Opnieuw" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spróbuj ponownie" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reîncercați" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Попробуйте еще раз" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Försök igen" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tekrar deneyin" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重试" + } + } + } + }, + "Turn off the volume on your iOS device or add %1$@ as an allowed app to each Focus Mode. Time Sensitive and Critical Alerts will still sound, but non-Critical Alerts will be silenced." : { + "comment" : "Description text for temporarily silencing non-critical alerts (1: app name)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sluk for lydstyrken på din iOS-enhed eller føj %1$@ som en tilladt app til hver fokustilstand. Tidsfølsomme og kritiske advarsler lyder stadig, men ikke-kritiske advarsler slås fra." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schalte die Lautstärke Deines iOS-Geräts aus oder fügen Sie %1$@ als zulässige App zu jedem Fokusmodus hinzu. Zeitkritische und kritische Warnungen ertönen weiterhin, nicht kritische Warnungen werden jedoch stummgeschaltet." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Désactivez le volume sur votre appareil iOS ou ajoutez %1$@ comme application autorisée à chaque mode Concentration. Les Alertes urgentes et critiques sonneront toujours, mais les Alertes non critiques seront silencieuses." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disattiva il volume sul tuo dispositivo iOS o aggiungi %1$@ come app consentita per ciascuna modalità Focus. Gli avvisi sensibili al tempo e critici continueranno a suonare, ma gli avvisi non critici verranno silenziati." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slå av volumet på iOS-enheten eller legg til %1$@ som en tillatt app i hver fokusmodus. Tidssensitive og kritiske varsler vil fortsatt høres, men ikke-kritiske varsler blir dempet." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wyłącz głośność na swoim urządzeniu iOS lub dodaj %1$@ jako dozwoloną aplikację do każdego trybu skupienia. Alerty zależne od czasu i krytyczne będą nadal emitowane, ale alerty niekrytyczne zostaną wyciszone." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dezactivați volumul pe dispozitivul iOS sau adăugați %1$@ ca aplicație permisă pentru fiecare Mod de concentrare. Alertele sensibile la timp și cele critice vor suna în continuare, dar alertele non-critice vor fi dezactivate." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выключите громкость на устройстве iOS или добавьте %1$@ в качестве разрешенного приложения для каждого режима фокусировки. Срочные и критические оповещения по-прежнему будут звучать, но некритические оповещения будут отключены." + } + } + } + }, + "Turn on Bluetooth to receive alerts, alarms or sensor glucose readings." : { + "comment" : "Bluetooth off foreground alert body", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slå bluetooth til for at modtage advarsler, alarmer eller blodsukkermålinger." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schalte Bluetooth ein, um Warnungen, Alarme oder Gewebeglukosewerte zu erhalten." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activa Bluetooth para recibir alertas, alarmas o medidas de glucosa del sensor." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ota Bluetooth käyttöön saadaksesi hälytyksiä tai sensorin glukoosilukemia." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activez le Bluetooth pour recevoir des alertes, alarmes ou les données de capteurs de glycémie." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הפעל את Bluetooth כדי לקבל תזכורות, התראות או קריאות גלוקוז." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Attiva il Bluetooth per ricevere avvisi, allarmi o letture glicemiche del sensore." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slå på Bluetooth for å motta varsler, alarmer eller avlesninger fra glukosesensor." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schakel Bluetooth in om waarschuwingen, alarmen of sensorglucosemetingen te ontvangen." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Włącz Bluetooth, aby otrzymywać powiadomienia, alarmy lub odczyty poziomu glukozy z sensora." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activați Bluetooth pentru a primi alerte, alarme sau citiri de glicemie de la senzor." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включите Bluetooth для получения предупреждений, сигналов тревоги или показаний датчика глюкозы." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktivera Bluetooth för att ta emot varningar, larm eller blodglukosavläsningar." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uyarıları, alarmları veya sensör KŞ okumalarını almak için Bluetooth'u açın." + } + } + } + }, + "U" : { + "comment" : "The short unit display string for international units of insulin", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "وحدة" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "E" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "IE" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "U" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "U" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "U" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "U" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "U" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "U" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "E" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "E" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "J" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "U" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "U" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ед" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "j" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "E" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ü" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "U" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "U" + } + } + } + }, + "Unable To Clear Alert" : { + "comment" : "Title for alert shown when alert acknowledgement fails", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kan ikke rydde en advarsel" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Benachrichtigung kann nicht gelöscht werden" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se puede borrar la alerta" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossible d'effacer l'alerte" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "לא ניתן לבטל התראה" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossibile cancellare l'avviso" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kan ikke fjerne varsel" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kan Waarschuwing Niet Wissen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nie można wyczyścić alertu" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nu se poate șterge alerta" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Невозможно снять предупреждение" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uyarı Silinemiyor" + } + } + } + }, + "Unable To Reach Pump" : { + "comment" : "Title for alert shown when delivery status is uncertain", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kan ikke få kontakt til pumpen" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumpe kann nicht erreicht werden" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se puede contactar con la bomba" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumppuun ei voitu yhdistää" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossible de contacter la pompe" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "לא ניתן לתקשר עם המשאבה" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossibile contattare la pompa" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kommunikasjonsfeil" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kan Pomp Niet Bereiken" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nie można połączyć się z pompą" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nu se poate conecta la pompă" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Невозможно достучаться до помпы" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Det går inte att nå pump" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pompaya Ulaşılamıyor" + } + } + } + }, + "Unable to Save Carb Entry" : { + "comment" : "Alert title for a carb entry persistence error", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kan ikke gemme kulhydrat indtastningen" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "KH-Eintrag kann nicht gespeichert werden" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se puede guardar la entrada de carbohidratos" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hiilihydraatteja ei voitu tallentaa" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossible d'enregistrer la saisie des Glucides" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "לא ניתן לשמור ערך פחמימות" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossibile salvare l'inserimento di carboidrati" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kunne ikke lagre karbohydrater" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kan Koolhydraatinvoer Niet Opslaan" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nie można zapisać wprowadzonych węglowodanów" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nu se pot salva Carbohidrații" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удается сохранить запись углеводов" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Det går inte att spara kolhydrater" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Karb Girişi Kaydedilemiyor" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法保存碳水记录" + } + } + } + }, + "Unable to Save Manual Glucose Entry" : { + "comment" : "Alert title for a manual glucose entry persistence error", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Det manuelt indtastet blodsukker kan ikke gemmes" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manuelle Glukose-Eingaben kann nicht gespeichert werden" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se puede guardar la entrada manual de glucosa" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glukoosiarvoa ei voitu tallentaa" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossible d’enregistrer la glycémie saisie" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "לא ניתן לשמור ערך גלוקוז ידני" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossibile salvare l'inserimento manuale delle glicemie" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kan ikke lagre manuell blodsukkerregistrering" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kan Handmatige Glucose-invoer Niet Opslaan" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nie można zapisać ręcznie wprowadzonego poziomu glukozy" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nu se poate salva glicemia manuala" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Невозможно сохранить ручной ввод глюкозы" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Det går inte att spara manuellt inmatat blodsockervärde" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manuel KŞ Girişi Kaydedilemiyor" + } + } + } + }, + "Unable to stop the bolus in progress. Move your iPhone closer to the pump and try again. Check your insulin delivery history for details, and monitor your glucose closely." : { + "comment" : "The alert body for an error while canceling a bolus", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kan ikke stoppe igangværende bolus. Flyt din iPhone tættere på pumpen og prøv igen. Tjek din historik for insulinafgivelse for detaljer og overvåg nøje dit blodsukkerniveau." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der laufende Bolus kann nicht gestoppt werden. Bewegen Dein iPhone näher an die Pumpe und versuche es erneut. Überprüfe den Verlauf der Insulinabgabe auf Einzelheiten und überwache Deinen Blutzucker genau." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se puede detener el bolo en progreso. Mueve tu iPhone más cerca de la microinfusora e inténtalo de nuevo. Revisa tu historial de entrega de insulina para más detalles y supervisa tu glucosa." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Käynnissä olevaa bolusta ei voitu pysäyttää. Siirrä iPhone lähemmäksi pumppua ja yritä uudelleen. Tarkista tiedot insuliinin annosteluhistoriasta ja seuraa glukoosia tarkasti." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossible d'arrêter le bolus en cours. Déplacez votre iPhone plus près de la pompe et réessayez. Vérifiez votre historique de distribution d'insuline pour plus de détails et surveillez votre glycémie de près." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "לא ניתן להפסיק את הבולוס בתהליך. קרב את האייפון שלך למשאבה ונסה שוב. בדוק את היסטוריית מתן האינסולין שלך, ועקוב מקרוב אחר הגלוקוז שלך." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossibile interrompere il bolo in corso. Avvicina l'iPhone alla pompa e riprova. Controlla la cronologia delle erogazioni d'insulina per maggiori dettagli e monitora attentamente la glicemia." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kan ikke stoppe bolusen som pågår. Flytt iPhone nærmere pumpen og prøv igjen. Sjekk insulinleveringshistorikken for detaljer, og overvåk glukosen nøye." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kan de toedienende bolus niet stoppen. Plaats je iPhone dichter bij de pomp en probeer het opnieuw. Controleer je insulinetoedieningsgeschiedenis voor details, en houd je glucose nauwlettend in de gaten." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nie można zatrzymać podawanego bolusa. Przybliż iPhone'a do pompy i spróbuj ponownie. Aby uzyskać szczegółowe informacje, sprawdź historię podawania insuliny i uważnie monitoruj poziom glukozy." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nu s-a putut opri livrarea bolusului. Mutați iPhone-ul mai aproape de pompă și încercați din nou. Verificați istoricul administrării insulinei pentru detalii și monitorizați îndeaproape glicemia." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удается остановить текущий болюс. Переместите свой iPhone ближе к помпе и попробуйте еще раз. Проверьте историю введения инсулина и внимательно следите за уровнем глюкозы." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Det går inte att stoppa pågående bolus. Flytta din iPhone närmare din pump och försök igen. Kontrollera händelsehistorik för insulin för mer detaljerad information och var vaksam på sjunkande blodsocker." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Devam etmekte olan bolus durdurulamıyor. iPhone'unuzu pompaya yaklaştırın ve tekrar deneyin. Ayrıntılar için insülin uygulama geçmişinizi kontrol edin ve kan şekerinizi yakından takip edin." + } + } + } + }, + "Unknown" : { + "comment" : "Event title displayed when StoredPumpEvent.title is not set\nThe default description to use when an entry has no dose description\nlabel for when the alert mute end time is unknown\nresult when time cannot be formatted", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unknown" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ukendt" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unbekannt" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Desconocido" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tuntematon" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inconnu" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "לא ידוע" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sconosciuto" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "不明" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ukjent" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Onbekend" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nieznany" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Desconhecido" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Necunoscut" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Неизвестно" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Neznáme" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Okänd" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bilinmeyen" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không nhận ra" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未知" + } + } + } + }, + "Unknown Error: %1$@" : { + "comment" : "The error message displayed for unknown errors. (1: unknown error)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "خطأ غير معروف" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Neznámá chyba: %1$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ukendt fejl: %1$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unbekannter Fehler: %1$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Error desconocido: %1$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erreur inconnue : %1$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "תקלה לא ידועה %1$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Errore sconosciuto: %1$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ukjent feil: %1$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Onbekende Fout: %1$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nieznany błąd: %1$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eroare necunoscută: %1$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Неизвестная ошибка: %1$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bilinmeyen Hata: %1$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未知错误:%1$@" + } + } + } + }, + "Unknown preset: %1$@" : { + "comment" : "Override error description: unknown preset (1: preset name).", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ukendt forudindstilling: %1$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unbekannte Voreinstellung: %1$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Préréglage inconnu : %1$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הגדרה לא ידועה: %1$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Preset sconosciuto: %1$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ukjent forhåndsinnstilling: %1$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Onbekende voorinstelling: %1$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nieznane ustawienie: %1$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Presetare necunoscută: %1$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Неизвестный пресет: %1$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bilinmeyen ön ayar: %1$@" + } + } + } + }, + "Unknown time" : { + "comment" : "Unknown amount of time in settings' profile expiration section", + "localizations" : { + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Neznámý čas" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ukendt tidspunkt" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unbekannte Zeit" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tiempo desconocido" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Temps inconnu" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "זמן לא ידוע" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Orario sconosciuto" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ukjent tid" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Onbekende tijd" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nieznany czas" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Timp necunoscut" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Неизвестное время" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bilinmeyen zaman" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未知时间" + } + } + } + }, + "Unmute" : { + "comment" : "The title of the action used to unmute alerts", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slå lyden til" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stummschaltung aufheben" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Riattiva" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oppheve demping" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wyłącz wyciszenie" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activează sunetul" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить звук" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "取消静音" + } + } + } + }, + "Unmute Alerts?" : { + "comment" : "The alert title for unmute alert confirmation", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slå alarmer til?" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stummschaltung für Warnungen aufheben?" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disattivare gli avvisi?" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oppheve demping av varsler?" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wyciszyć Alerty?" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activați sunetul alertelor?" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить звук оповещений?" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "取消静音警报?" + } + } + } + }, + "Unsupported Notification Service: %1$@" : { + "comment" : "Error message when a service can't be found to handle a push notification. (1: Service Identifier)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ikke-understøttet meddelelsestjeneste: %1$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nicht unterstützter Benachrichtigungsdienst: %1$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Servizio di notifica non supportato: %1$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Varslingstjeneste som ikke støttes: %1$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nieobsługiwana usługa powiadomień: %1$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Serviciu de notificare neacceptat: %1$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Неподдерживаемая служба уведомлений: %1$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "不支持的通知服务:%1$@" + } + } + } + }, + "until %@" : { + "comment" : "The format for the description of a custom preset end date", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "حتى %@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "indtil %@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Endet um %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "hasta las %@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ asti" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "jusqu’à %@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "עד %@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "fino a %@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@まで" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "til %@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "tot %@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "do %@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "até %@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "până la %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "до %@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "fram till %@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ tarihine kadar" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "cho đến khi %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "直到 %@" + } + } + } + }, + "Until %1$@" : { + "comment" : "indication of when alerts will be unmuted (1: time when alerts unmute)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indtil %1$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bis %1$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fino a %1$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inntil %1$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Do %1$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Până la %1$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "До %1$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "直到 %1$@" + } + } + } + }, + "Until I enter carbs" : { + "comment" : "The title of a target alert action specifying pre-meal targets duration for 1 hour or until the user enters carbs (whichever comes first).", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indtil jeg indtaster kulhydrater" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bis ich KH eingebe" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hasta que registre carbohidratos" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kunnes syötän hiilihydraatteja" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jusqu'à l'apport de glucides" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "עד שאזין פחמימות" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fino a quando non inserisco carboidrati" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Frem til jeg legger inn karbohydrater" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Totdat ik koolhydraten invoer" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dopóki nie wprowadzę węglowodanów" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Până când introduc carbohidrații" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пока я не введу углеводы" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tills jag anger kolhydrater" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Karb girene kadar" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在输入碳水前保持激活" + } + } + } + }, + "Until I turn off" : { + "comment" : "The title of a target alert action specifying workout targets duration until it is turned off by the user", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indtil jeg slukker" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bis ich ausschalte" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hasta que lo desactive" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kunnes laitan pois päältä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jusqu'à ce que je désactive" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "עד שאכבה" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Finché non disattivato" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Frem til jeg skrur av" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Totdat ik uitschakel" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dopóki nie wyłączę" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Până mă opresc" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пока я не выключу" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tills jag stänger av" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ben kapatana kadar" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "直到我关闭" + } + } + } + }, + "Urgent Low" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kritisk lav" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dringend niedrig" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Urgent bas" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ipo urgente" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Øyeblikkelig lav" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pilny niski" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hipoglicemie urgentă" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Очень низкий" + } + } + } + }, + "Use BG coloring" : { + "comment" : "Title for BG coloring" + }, + "Use Pre-Meal Preset" : { + "comment" : "The title of the alert controller used to select a duration for pre-meal targets", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Brug Før-måltid" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voreinstellung „Vor dem Essen“ verwenden" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usar Pre-Comida" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Käytä Ennen ateriaa -esiasetusta" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utiliser le préréglage Pré-repas" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "השתמש בהגדרת טרום-ארוחה" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usa il Preset pre-pasto" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bruk pre-måltidsmål" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gebruik Pre-Meal Programma" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Użyj ustawień przed posiłkiem" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utilizare presetare înainte de masă" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Использовать функцию до еды" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Använd förval 'Före måltid'" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yemek Öncesi Ön Ayarı Kullanın" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用餐前预设" + } + } + } + }, + "Use the Mute Alerts feature. It allows you to temporarily silence all of your alerts and alarms via the %1$@ app, including Critical Alerts and Time Sensitive Alerts." : { + "comment" : "Description text for temporarily silencing all sounds (1: app name)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Brug funktionen Mute Alerts. Den giver dig mulighed for midlertidigt at slå alle dine advarsler og alarmer fra via %1$@-appen, inklusive Kritiske advarsler og Tidsfølsomme advarsler." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verwende die Funktion „Warnmeldungen stummschalten“. Damit kannst Du alle Deine Warnmeldungen und Alarme über die %1$@ App vorübergehend stummschalten, einschließlich kritischer und zeitkritischer Warnmeldungen." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utilisez la fonction Muet Alertes. Elle vous permet de mettre temporairement en silence toutes vos alertes et alarmes via l'application %1$@, y compris les Alertes Critiques et les Alertes Urgentes." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utilizza la funzione Disattiva avvisi. Consente di silenziare temporaneamente tutti gli avvisi e gli allarmi tramite l'app %1$@, compresi gli avvisi critici e gli avvisi sensibili al tempo." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bruk funksjonen Demp varsler. Med denne funksjonen kan du midlertidig dempe alle varsler og alarmer via %1$@-appen, inkludert kritiske varsler og tidssensitive varsler." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Użyj funkcji Wycisz alerty. Umożliwia tymczasowe wyciszenie wszystkich alertów i alarmów za pośrednictwem aplikacji %1$@ , w tym alertów krytycznych i alertów zależnych od czasu." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Folosește funcția Dezactivare sunet alerte. Aceasta îți permite să dezactivezi temporar toate alertele și alarmele prin intermediul aplicației %1$@, inclusiv alertele critice și alertele sensibile la timp." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Используйте функцию отключения оповещений. Она позволяет временно отключить все ваши оповещения и тревоги с помощью кнопки %1$@ , включая критические оповещения и срочные оповещения." + } + } + } + }, + "Use Workout Glucose Targets" : { + "comment" : "The title of the alert controller used to select a duration for workout targets", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "استخدم أهداف قراءات سكر الدم للتمارين" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anvend blodsukkermål for motion" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zielbereich für Sport verwenden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Use Workout Glucose Targets" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usar Objetivos de Glucosa de Ejercicio" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Käytä liikuntatilan glukoositavoitetteita" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utiliser les objectifs exercice" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "השתמש בטווחי גלוקוז לאימון" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utilizza i target glicemi per l'allenamento" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "運動時ターゲットを使用" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bruk treningsmodus for BS-målområde" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gebruik Trainingsglucosedoelen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Użyj zakresu glukozy dla wysiłku fizycznego" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usar Metas de Glicemia de Exercício" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Folosește țintele glicemice de activitate sportivă" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Применить цели гликемии как для физической нагрузки" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Använd målvärden för träning" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Egzersiz KŞ Hedeflerini Kullanın" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sử dụng Workout Glucose Targets" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用运动状态的血糖目标" + } + } + } + }, + "Use Workout Preset" : { + "comment" : "The title of the alert controller used to select a duration for workout targets", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Brug forudindstillinger for motion" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trainingsvoreinstellung verwenden" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usar Ejercicio" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Käytä liikuntatilaa" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utiliser le préréglage exercice" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "השתמש בהגדרת אימון" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usa il Preset allenamento" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bruk forhåndsinnstilling for treningsøkt" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gebruik Trainingsprogramma" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Użyj wstępnego ustawienia treningu" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utilizare presetare antrenament" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Использовать пресет физнагрузки" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Använd förval 'Träning'" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Egzersiz Ön Ayarını Kullan" + } + } + } + }, + "Walsh" : { + "comment" : "Title of insulin model setting", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "Walsh" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Walsh" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Walsh" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Walsh" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Walsh" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Walsh" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Walsh" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Walsh" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Walsh" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Walsh" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Walsh" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Walsh" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Walsh" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Walsh" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Walsh" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Walsh" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Walsh" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Walsh" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Walsh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Walsh" + } + } + } + }, + "Warning! Safety notifications are turned OFF" : { + "comment" : "Alert Permissions Need Attention alert title", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Advarsel! Sikkerhedsmeddelelser er slået FRA" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Warnung! Sicherheitsbenachrichtigungen sind AUSGESCHALTET" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atención Las notificaciones de seguridad están desactivadas" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Attention! Les notifications de sécurité sont DÉSACTIVÉES" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אזהרה! התראות בטיחות כבויות" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Attenzione! Le notifiche di sicurezza sono disattivate" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Advarsel! Sikkerhetsvarsler er slått AV" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Waarschuwing! Veiligheidsmeldingen staan UIT" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uwaga! Powiadomienia dotyczące bezpieczeństwa są WYŁĄCZONE" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atenție! Notificările de siguranță sunt DEZACTIVATE" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Внимание! Уведомления о безопасности отключены" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uyarı! Güvenlik bildirimleri KAPALI" + } + } + } + }, + "What are examples of Critical and Time Sensitive alerts?" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hvad er eksempler på kritiske og tidsfølsomme alarmer?" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Was sind Beispiele für kritische und zeitkritische Warnungen?" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quels sont des exemples d'alertes critiques et urgentes?" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quali sono alcuni esempi di avvisi critici e sensibili al tempo?" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hva er eksempler på kritiske og tidssensitive varsler?" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jakie są przykłady alertów krytycznych i alerty zależne od czasu?" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Care sunt exemple de alerte critice și alerte sensibile la timp?" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Каковы примеры критических и срочных оповещений?" + } + } + } + }, + "When current or forecasted glucose is below the glucose safety limit, Loop will not recommend a bolus, and will always recommend a temporary basal rate of 0 units per hour." : { + "comment" : "Explanation of glucose safety limit", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Når den nuværende eller forventede blodsukker ligger under blodsukkersikkerhedsgrænsen, vil Loop ikke anbefale en bolus og vil altid anbefale en midlertidig basalrate på 0 enheder i timen." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wenn der aktuelle oder prognostizierte Glukosewert unter dem Glukosesicherheitsgrenzwert liegt, empfiehlt Loop keinen Bolus und immer eine temporäre Basalrate von 0 Einheiten pro Stunde." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cuando la glucosa actual o proyectada se encuentre debajo del nivel de suspensión, Loop no recomendará un bolo y siempre recomendará un basal temporal de 0 unidades por hora." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kun nykyinen tai ennustettu glukoosi on glukoosin turvarajan alapuolella, Loop ei suosittele bolusta ja suosittelee aina tilapäiseksi basaaliksi 0 yksikköä tunnissa." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lorsque la glycémie actuelle ou prévue est inférieure au seuil de suspension, Loop ne recommandera pas de bolus et recommandera toujours un débit basal temporaire de 0 unité par heure." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "כאשר גלוקוז נוכחי או צפוי קטן מגבול הבטיחות, Loop לא תמליץ על בולוס, ותמיד תמליץ על קצב בזאלי זמני של 0 יחידות לשעה." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quando la glicemia attuale o prevista è inferiore al limite di sicurezza, Loop non consiglia un bolo e raccomanda sempre una velocità basale temporanea di 0 unità all'ora." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Når gjeldende eller anslått glukose er under glukosesikkerhetsgrensen, vil ikke Loop anbefale en bolus, og vil alltid anbefale en midlertidig basaldose på 0 enheter pr. time." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wanneer de huidige of voorspelde glucose onder de glucoseveiligheidslimiet ligt, zal Loop geen bolus aanbevelen en zal het altijd een tijdelijke basissnelheid van 0 eenheden per uur aanbevelen." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kiedy aktualny lub prognozowany poziom glukozy znajduje się poniżej granicy bezpieczeństwa, Loop nie zaleca bolusa i zawsze zaleca tymczasową dawkę podstawową wynoszącą 0 jednostek na godzinę." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "În momentul în care glicemia actuală sau cea prognozată se situează sub limita de siguranța, Loop nu va recomanda un bolus și va recomanda întotdeauna o rată bazală temporară de 0 unități pe ora." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Если текущий или прогнозируемый уровень глюкозы ниже безопасного предела глюкозы, Loop не будет рекомендовать болюс и всегда будет рекомендовать временную базальную скорость 0 единиц в час." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "När nuvarande eller förväntat blodglukosvärde är under tröskelvärde kommer Loop inte att rekommendera en bolus, och kommer också alltid att föreslå en temporär basal på 0 enheter per timme." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mevcut veya tahmin edilen KŞ, KŞ güvenlik sınırının altında olduğunda Loop bir bolus önermez ve her zaman saatte 0 ünite geçici bazal oran önerir." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当前或预测的血糖低于血糖安全下限时,Loop 将不会推荐大剂量,并始终建议将临时基础速率设为 0 单位/小时。" + } + } + } + }, + "When current or forecasted glucose is below the suspend threshold, Loop will not recommend a bolus, and will always recommend a temporary basal rate of 0 units per hour." : { + "comment" : "Explanation of suspend threshold", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "عندما تكون قراءات السكر الحالية أو المتوقعة أقل من قيمة التعليق المؤقت ، لن يوصي التطبيق بجرعة، وسيوصي دائمًا بمعدل ضخ مؤقت يبلغ 0 وحدة في الساعة." + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Når nuværende og forventet glukose er under suspenderingsgrænsen, vil Loop ikke anbefale en bolus, og vil altid anbefale en midlertidig basal rate på 0 enheder i timen." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wenn der aktuelle oder prognostizierte Blutzucker unter dem Schwellenwert für die Hypo-Abschaltung liegt, empfiehlt Loop keinen Bolus, sondern immer eine temporäre Basalrate von 0 IE/h." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "When current or forecasted glucose is below the suspend threshold, Loop will not recommend a bolus, and will always recommend a temporary basal rate of 0 units per hour." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cuando la glucosa actual o proyectada se encuentre debajo del nivel de suspensión, Loop no recomendará un bolo y siempre recomendará un basal temporal de 0 unidades por hora." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kun nykyinen tai ennustettu glukoosi on pysäytysrajan alapuolella, Loop ei suosittele bolusta ja suosittelee aina tilapäiseksi basaaliksi 0 yksikköä tunnissa." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lorsque le glucose actuel ou prévu est inférieur au seuil de suspension, Loop ne recommandera pas de bolus et recommandera toujours un débit basal temporaire de 0 unité par heure." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "כאשר גלוקוז נוכחי או צפוי קטן מסף ההשהיה, Loop לא תמליץ על בולוס, ותמיד תמליץ על בזאלי זמני של 0 יחידות לשעה." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quando la glicemia attuale o prevista è sotto la soglia di sospensione, Loop non consiglia un bolo e raccomanda sempre una velocità basale temporanea di 0 unità per ora." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "現在または予測グルコースが一時停止値を下回るため、ループはボーラスを推奨しません。0単位/時の一時的基礎レートを推奨します。" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Når gjeldende eller anslått glukose er under suspenderingsterskelen, vil ikke Loop anbefale en bolus, og vil alltid anbefale en midlertidig basaldose på 0 enheter pr. time." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wanneer de huidige of voorspelde glucose onder de onderbrekingsdrempel ligt, zal Loop geen bolus aanbevelen en zal Loop altijd een tijdelijke basaalsnelheid van 0 eenheden per uur aanbevelen." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kiedy aktualna lub prognozowana glukoza znajduje się poniżej progu zawieszenia, Loop nie zaleca bolusa i zawsze zaleca tymczasową dawkę podstawową wynoszącą 0 jednostek na godzinę." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quando a glicose atual ou prevista estiver abaixo do limite de suspensão, o Loop não recomendará um bolus e sempre recomendará uma taxa basal temporária de 0 unidades por hora" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "În momentul în care glicemia actuală sau cea prognozată se situează sub limita de suspendare, Loop nu va recomanda un bolus și va recomanda întotdeauna o rată bazală temporară de 0 unități pe ora." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Если текущая или предсказываемая гликемия ниже порога приостановки помпы, алгоритм цикла ипж не рекомендует болюс и всегда рекомендует временный базал 0 ед/час" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "När nuvarande eller förväntat slutglukosvärde är under tröskelvärdet kommer Loop inte att rekommendera en bolus, utan kommer alltid att föreslå en temporär basal på 0 enheter per timme." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mevcut veya tahmin edilen KŞ askıya alma eşiğinin altında olduğunda, Loop bir bolus önermez ve her zaman saatte 0 birimlik geçici bir bazal hız önerir." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Khi mức glucose hiện tại hoặc được dự báo thấp hơn ngưỡng tạm dừng, Loop sẽ không khuyến nghị một liều bolus và sẽ luôn khuyến nghị liều basal là 0 unit mỗi giờ." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当前或预测的葡萄糖低于暂停阈值时,Loop不推荐推注,并且总是建议每小时0单位的临时基础速率。" + } + } + } + }, + "When enabled, Loop can notify you when it detects a meal that wasn't logged." : { + "comment" : "Description of missed meal notifications.", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Når aktiveret, kan Loop give dig besked, når den registrerer et måltid, som ikke er logget." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wenn aktiviert, kann Loop Dich benachrichtigen, wenn es eine Mahlzeit erkennt, die nicht protokolliert wurde." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lorsqu'il est activé, Loop peut vous avertir lorsqu'il détecte un repas qui n'a pas été enregistré." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "בהפעלה, Loop יכול להודיע לך כשהוא מזהה ארוחה שלא הוזנה." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se attivato, Loop può avvisarti quando rileva un pasto che non è stato registrato." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Når den er aktivert, kan Loop varsle deg når den oppdager et måltid som ikke ble logget." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Indien ingeschakeld, kan Loop je op de hoogte stellen wanneer het een maaltijd detecteert die niet is toegevoegd." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Po włączeniu Loop może powiadomić Cię, gdy wykryje posiłek, który nie został wprowadzony." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Când este activat, Loop vă poate notifica când detectează o masă care nu a fost înregistrată." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Если эта функция включена, Loop может уведомить вас, когда обнаружит прием пищи, который не был внесен." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etkinleştirildiğinde Loop, günlüğe kaydedilmemiş bir öğün tespit ettiğinde sizi bilgilendirebilir." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用后,当 Loop 检测到疑似未记录的进餐时,会提醒你。" + } + } + } + }, + "When out of Closed Loop mode, the app uses a simplified bolus calculator like a typical pump." : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Når der ikke køres lukket Loop, bruger appen en forenklet bolusberegner som en typisk pumpe." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ist der Loop-Modus ausgeschaltet, dann verwendet die App einen vereinfachten Bolusrechner wie eine typische Pumpe." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cuando está fuera del modo Closed Loop, la aplicación utiliza una calculadora de bolo simplificada como una microinfusadora típica." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kun suljettu säätö ei ole päällä, sovellus käyttää yksinkertaista boluslaskuria, kuten tavallisessa pumpussa." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "En dehors du mode Boucle fermée, l'application utilise un calcul de bolus simplifié comme pour une pompe classique." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "ב-Loop פתוח האפליקציה תחשב ערכי בולוס בצורה פשוטה, כמו משאבה רגילה." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quando non è in modalità Loop chiuso, l'applicazione utilizza un calcolatore di bolo semplificato come una tipica pompa." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Når den er ute av lukket Loop-modus, bruker appen en forenklet boluskalkulator som en vanlig pumpe." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Als de Gesloten Loop modus is uitgeschakeld, gebruikt de app een vereenvoudigde boluscalculator zoals bij een gewone pomp." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Poza trybem pętli zamkniętej aplikacja korzysta z uproszczonego kalkulatora bolusa, takiego jak typowa pompa." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Când modul Buclă închisa nu este activat, aplicația folosește un calculator simplificat pentru bolus similar cu cel al unei pompe tipice." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Когда приложение выходит из режима замкнутого цикла, оно использует упрощенный калькулятор болюса, как в обычной помпе." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "När läget sluten Loop inte används, kommer appen använda en förenklad bolusdoskalkylator likt den en vanlig pump använder." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uygulama, Kapalı Döngü modundan çıktığında, tipik bir pompa gibi basitleştirilmiş bir bolus hesaplayıcı kullanır." + } + } + } + }, + "While mute alerts is on, all alerts from your %1$@ app including Critical and Time Sensitive alerts will temporarily display without sounds and will vibrate only." : { + "comment" : "App sounds descriptive text (1: app name)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mens lydløse advarsler er slået til, vises alle advarsler fra din %1$@ app inklusive kritiske og tidsfølsomme advarsler midlertidigt uden lyde og vil kun vibrere." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Während die Stummschaltung von Warnungen aktiviert ist, werden alle Warnungen Deiner %1$@ App, einschließlich kritischer und zeitkritischer Warnungen, vorübergehend ohne Ton und nur durch Vibration angezeigt." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tant que les alertes muettes sont activées, toutes les alertes de votre application %1$@, y compris les alertes critiques et urgentes, s'afficheront temporairement sans son et vibreront uniquement." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quando Disattiva avvisi è attiva, tutti gli avvisi dell'app %1$@, compresi gli avvisi critici e quelli sensibili al tempo, vengono temporaneamente visualizzati senza suoni e solo con una vibrazione." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Når lydløse varsler er på, vil alle varsler fra %1$@-appen, inkludert kritiske og tidssensitive varsler, midlertidig vises uten lyd og kun vibrere." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gdy włączone jest wyciszenie alertów, wszystkie alerty z aplikacji %1$@ w tym alerty krytyczne i alerty zależne od czasu, będą tymczasowo wyświetlane bez dźwięków i będą jedynie wibrować." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "În timp ce alertele dezactivate sunt activate, toate alertele de la aplicația %1$@ inclusiv alertele critice și cele sensibile la timp, se vor afișa temporar fără sunete și vor vibra doar." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пока отключение звука оповещений включено, все оповещения от вашего %1$@ приложения, включая критические и срочные оповещения, будут временно отображаться без звуков и будут только вибрировать." + } + } + } + }, + "While mute alerts is on, your insulin pump and CGM hardware may still sound." : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mens lydløs alarm er slået til, kan din insulinpumpe og CGM-hardware stadig afgive lyd." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Während die Stummschaltung von Warnmeldungen aktiviert ist, können Deine Insulinpumpe und die CGM-Hardware weiterhin Töne abgeben." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lorsque les alertes sont en mode muettes, votre pompe à insuline et le matériel CGM peuvent toujours émettre des sons." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anche se gli avvisi sono disattivati, la pompa per insulina e l'hardware CGM potrebbero comunque emettere suoni." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Selv om dempede varsler er aktivert, kan insulinpumpen og CGM-maskinvaren fortsatt avgi lyd." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gdy włączone jest wyciszenie alertów, pompa insulinowa i sprzęt CGM mogą nadal wydawać dźwięki." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "În timp ce alertele silențioase sunt activate, este posibil ca pompa de insulină și hardware-ul CGM să sune în continuare." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "При отключении звуковых сигналов ваша инсулиновая помпа и мониторинг могут продолжать издавать звуки." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当“静音警报”开启时,胰岛素泵和连续血糖监测(CGM)设备仍可能发出声音。" + } + } + } + }, + "While trying to restart %1$@ an error occured.\n\n%2$@" : { + "comment" : "Format string for message of reset loop alert. (1: App name) (2: error description)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Under forsøg på at genstarte %1$@ opstod der en fejl.\n\n%2$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beim Versuch %1$@ neu zu starten, ist ein Fehler aufgetreten. \n \n %2$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Durante il tentativo di riavviare %1$@ si è verificato un errore.\n\n%2$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Det oppstod en feil da du prøvde å starte %1$@ på nytt.\n\n%2$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Podczas próby ponownego uruchomienia %1$@ wystąpił błąd. \n\n %2$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "În timp ce încercam să repornesc %1$@ a apărut o eroare. \n \n %2$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "При попытке перезапустить %1$@ произошла ошибка. \n\n %2$@" + } + } + } + }, + "Workout Targets" : { + "comment" : "The label of the workout mode toggle button", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "أهداف التمارين" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Motion Mål" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zielbereich für Sport" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Objetivos de Ejercicio" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Liikuntatavoitteet" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Objectifs d'exercice" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "טווחים באימון" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Obiettivi di allenamento" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "運動時ターゲット" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Målområder for trening" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trainingsdoelen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zakres w czasie wysiłku fizycznego" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Metas de Exercício" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ținte de activitate sportivă" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Целевые значения при физической нагрузке" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Målvärden för träning" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Egzersiz Hedefleri" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mục tiêu tập luyện" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "运动模式血糖目标" + } + } + } + }, + "Workout Temp Adjust has been turned on for more than 24 hours. Make sure you still want it enabled, or turn it off in the app." : { + "comment" : "Workout override still on reminder alert body.", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Træning Temp Adjust har været tændt i mere end 24 timer. Sørg for, at du stadig vil have den aktiveret, eller slå den fra i appen." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zielbereichsänderung ist seit mehr als 24 Stunden eingeschaltet. Stelle sicher, dass Du es weiterhin aktiviert haben möchtest, oder schalte es aus." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El Ajuste Temporal por Ejercicio se ha activado durante más de 24 horas. Asegúrate de que todavía quieres que esté habilitado o desactívalo en la aplicación." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Liikuntatila on ollut käytössä yli 24 tuntia. Varmista, että haluat sen olevan edelleen käytössä, tai poista se käytöstä sovelluksessa." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Le réglage de la température d'entraînement est activé depuis plus de 24 heures. Vérifiez que vous souhaitez toujours l'activer ou désactivez-le dans l'application. Vérifiez que vous souhaitez toujours le garder actif ou désactivez-le dans l'application." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "התאמה זמנית באימון מופעלת במשך יותר מ-24 שעות. ודא שאתה עדיין רוצה שהיא תופעל, או כבה אותה באפליקציה." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "L'Override di allenamento è attivo da più di 24 ore. Assicurati di volerlo mantenere attivato oppure disattivalo nell'app." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Temp Adjust for trening har vært slått på i mer enn 24 timer. Forsikre deg om at du fortsatt vil ha den aktivert, eller slå den av i appen." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tijdelijk Trainingsprogramma staat meer dan 24 uur ingeschakeld. Zorg ervoor dat je deze nog steeds wilt inschakelen of schakel deze uit in de app." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cel Tymczasowy (trening) był włączony przez ponad 24 godziny. Upewnij się, że nadal chcesz, aby był włączony, lub wyłącz go w aplikacji." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajustarea temporară de antrenament fost activată de mai mult de 24 de ore. Asigurați-vă că încă doriți să fie activată sau dezactivați-o din aplicație." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Функция физнагрузки была включена более 24 часов. Убедитесь, что вы все еще хотите, чтобы она была включена, или выключите ее в приложении." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ett träningsförval har varit aktivt i mer än 24 timmar. Kontrollera om du fortfarande vill ha den på, eller stäng av den i appen." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geçici Egzersiz Ayarı 24 saatten uzun süredir açık. Hala etkin olmasını istediğinizden emin olun veya uygulamadan kapatın." + } + } + } + }, + "Workout Temp Adjust Still On" : { + "comment" : "Workout override still on reminder alert title", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Træning Temp Justere stadig på" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zielbereichsänderung ist noch an" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajuste Temporal por Ejercicio todavía encendido" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Liikuntatila on yhä päällä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Le préréglage exercice temporaire est encore actif" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "התאמה זמנית באימון עדיין מופעלת" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "L'Override di allenamento è ancora attivo" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Midlertidig justering for treningsøkter er fortsatt på" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tijdelijk Trainingsprogramma Nog Steeds Aan" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tymczasowy profil treningowy nadal działa" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajustare temporara antrenament încă activa" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Физнагрузка все еще включена" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Träningsförval fortfarande på" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geçici Egzersiz Ayarı Hala Açık" + } + } + } + }, + "Yes" : { + "comment" : "The title of the action used when confirming entered amount of carbohydrates.", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ja" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ja" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sí" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oui" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "כן" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "SÌ" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ja" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ja" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tak" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Da" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Да" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Áno" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Evet" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "是" + } + } + } + }, + "You may not get sound, visual or vibration alerts regarding critical safety information.\n\nTo fix the issue, tap ‘Settings’ and make sure Notifications, Critical Alerts and Time Sensitive Notifications are turned ON." : { + "comment" : "Format for Notifications permissions disabled alert body. (1: app name)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Du får muligvis ikke lyd-, visuelle eller vibrationsadvarsler om vigtige sikkerhedsoplysninger.\n\nFor at løse problemet skal du trykke på \"Indstillinger\" og sikre dig, at Meddelelser, Kritiske advarsler og Tidsfølsomme meddelelser er slået til." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Möglicherweise erhältst Du keine akustischen, optischen oder Vibrationswarnungen zu wichtigen Sicherheitsinformationen.\n\nUm das Problem zu beheben, tippe auf „Einstellungen“ und vergewissere Dich, dass „Benachrichtigungen“, „Dringende Warnungen“ und „Zeitkritische Benachrichtigungen“ aktiviert sind." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Es posible que no reciba alertas sonoras, visuales o por vibración relativas a información de seguridad crítica.\n\nPara solucionar el problema, toque \"Ajustes\" y asegúrese de que las notificaciones, las alertas críticas y las notificaciones sensibles al tiempo están activadas." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Il se peut que vous ne receviez pas d'alertes sonores, visuelles ou vibratoires concernant des informations de sécurité critiques. \n\n Pour résoudre le problème, appuyez sur \"Paramètres\" et assurez-vous que les notifications, les alertes critiques et les notifications exigeant une intervention rapide sont activées." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "ייתכן שלא תקבל התראות קוליות, חזותיות או רטט בנוגע למידע בטיחותי קריטי.\n\nכדי לתקן את הבעיה, הקש על 'הגדרות' וודא שהתראות, התראות קריטיות והודעות רגישות לזמן מופעלות." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Potresti non ricevere avvisi sonori, visivi o con vibrazione relativi a informazioni critiche sulla sicurezza. \n\nPer risolvere il problema, tocca Impostazioni e assicurati che notifiche, avvisi critici e notifiche sensibili al tempo siano attivate." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Du får kanskje ikke lyd-, visuelle eller vibrasjonsvarsler angående kritisk sikkerhetsinformasjon. \n\n For å fikse problemet, trykk på \"Innstillinger\" og sørg for at varsler, kritiske varsler og tidssensitive varsler er slått PÅ." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mogelijk ontvangt je geen geluids-, visuele of trillingswaarschuwingen met betrekking tot kritieke veiligheidsinformatie. \n\nOm het probleem op te lossen, tikt op 'Instellingen' en zorg ervoor dat Meldingen, Kritieke Meldingen en Tijdgevoelige Meldingen AAN staan." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Możesz nie otrzymywać alertów dźwiękowych, wizualnych lub wibracyjnych dotyczących krytycznych informacji o bezpieczeństwie. \n\nAby rozwiązać ten problem, wybierz „Ustawienia” i upewnij się, że Powiadomienia, Alerty Krytyczne i Powiadomienia Zależne od Czasu są WŁĄCZONE." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Este posibil să nu primiți alerte sonore, vizuale sau cu vibrații referitoare la informații critice de siguranță. \n\nPentru a remedia problema, atingeți „Setări” și asigurați-vă că notificările, alertele critice și notificările sensibile la timp sunt activate." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вы можете не получать звуковые, визуальные или вибрационные предупреждения о важной информации по безопасности.\n\nЧтобы решить эту проблему, нажмите \"Настройки\" и убедитесь, что уведомления, критические предупреждения и уведомления с учетом времени включены." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kritik güvenlik bilgileriyle ilgili sesli, görsel veya titreşimli uyarılar alamayabilirsiniz. \n\n Sorunu çözmek için 'Ayarlar'a dokunun ve Bildirimlerin, Kritik Uyarıların ve Zamana Duyarlı Bildirimlerin AÇIK olduğundan emin olun." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "你可能无法收到与关键安全信息相关的声音、视觉或震动警报。\n若要解决此问题,请点击“设置”,确保已开启通知、关键警报和时间敏感通知。" + } + } + } + }, + "Your %1$@’s time has been changed. %2$@ needs accurate time records to make predictions about your glucose and adjust your insulin accordingly.\n\nCheck in your %1$@ Settings (General / Date & Time) and verify that 'Set Automatically' is turned ON. Failure to resolve could lead to serious under-delivery or over-delivery of insulin." : { + "comment" : "Time change alert body. (1: app name)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Din %1$@'s tid er blevet ændret. %2$@ har brug for nøjagtige tidsregistreringer for at kunne forudsige din glukose og justere din insulin i overensstemmelse hermed.\n\nTjek i dine %1$@-indstillinger (Generelt / Dato og tid), og kontrollér, at \"Indstilles automatisk\" er slået til. Hvis dette ikke løses, kan det føre til alvorlig under- eller overlevering af insulin." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deine Zeit %1$@ wurde geändert. %2$@ benötigt genaue Zeitaufzeichnungen, um Vorhersagen über Deinen Blutzuckerwert zu treffen und Dein Insulin entsprechend anzupassen.\n\nÜberprüfe %1$@ Einstellungen (Allgemein / Datum & Uhrzeit) und vergewissere Dich, dass „Automatisch einstellen“ aktiviert ist. Wird dies nicht behoben, kann dies zu einer schwerwiegenden Unter- oder Über-Abgabe von Insulin führen." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se ha cambiado la hora de su %1$@. %2$@ necesita registros horarios precisos para hacer predicciones sobre su glucosa y ajustar su insulina.\n\nCompruebe en su %1$@ Ajustes (General / Fecha y Hora) y verifique que 'Ajustar automáticamente' está activado. Si no se resuelve, podría producirse un grave suministro insuficiente o excesivo de insulina." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "L'heure de votre %1$@ a été modifiée. %2$@ a besoin d'enregistrements de temps précis pour établir des prédictions sur votre glycémie et ajuster votre insuline en conséquence. \n\nEnregistrez vos paramètres %1$@ (Général / Date et heure) et vérifiez que \"Régler automatiquement\" est activé. L'absence de résolution pourrait entraîner une sous-administration ou une sur-administration grave d'insuline." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הזמן של %1$@ השתנה. %2$@ צריך זמן מדויק כדי לחזות את הגלוקוז שלך ולאזן את מתן האינסולין.\n\nבדוק את הגדרות הזמן ב-%1$@ כדי לוודא שהוא מכוון אוטומטית, אחרת ייתכנו השפעות רציניות על מתן נמוך או גבוה מדי של אינסולין." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "L'orario di %1$@ è stato modificato. La %2$@ ha bisogno di registrazioni accurate dell'orario per fare previsioni sulle glicemie e regolare l'insulina di conseguenza.\n\nControlla in Impostazioni di %1$@ (Generale / Data e ora) e verifica che l'opzione Imposta automaticamente sia attivata. In caso contrario, l'insulina potrebbe essere erogata in quantità insufficiente o eccessiva." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tidsinnstillingen til %1$@ er endret. %2$@ trenger nøyaktige tidsregistreringer for å gi spådommer om blodsukker og justere insulinet deretter. \n\nSjekk inn %1$@ innstillingene (Generelt / Dato og klokkeslett) og bekreft at 'Sett automatisk' er slått PÅ. Unnlatelse av å løse problemet kan føre til alvorlig under- eller overlevering av insulin." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Je %1$@'s tijd is veranderd. %2$@ heeft nauwkeurige tijdsregistraties nodig om voorspellingen te doen over je glucose en dienovereenkomstig je insuline aan te passen.\n\nControleer in je %1$@ Instellingen (Algemeen / Datum & Tijd) en controleer of 'Automatisch Instellen' is INGESCHAKKELD. Als dit niet wordt opgelost, kan dit leiden tot ernstig te weinig toediening of tot ernstige overmatige toediening van insuline." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Czas %1$@ został zmieniony. %2$@ potrzebuje dokładnych zapisów czasu, aby przewidywać poziom glukozy i odpowiednio dostosowywać poziom insuliny. \n\nSprawdź w ustawieniach %1$@ (Ogólne / Data i godzina) i upewnij się, że opcja „Ustaw automatycznie” jest WŁĄCZONA. Niepowodzenie w rozwiązaniu problemu może prowadzić do poważnego niedostatecznego lub nadmiernego podawania insuliny." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ora %1$@ dumneavoastră a fost schimbată. %2$@ are nevoie de înregistrări precise ale timpului pentru a face predicții despre glicemia și pentru a ajusta insulina în consecință. \n\nVerificați setările %1$@ (General / Data și Ora) și verificați dacă „Setare automată” este activată. Nerezolvarea poate duce la o administrare insuficientă sau excesivă de insulină." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Время вашего %1$@ было изменено. %2$@ нуждается в точном учете времени, чтобы делать прогнозы уровня глюкозы и соответствующим образом корректировать инсулин.\n\nПроверьте настройки %1$@ (Общие / Дата и время) и убедитесь, что опция \"Устанавливать автоматически\" включена. Невыполнение этого требования может привести к серьезному дефициту или избытку инсулина." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ 'inizin saati değiştirildi. %2$@ KŞ ile ilgili tahminlerde bulunmak ve insülininizi buna göre ayarlamak için doğru zaman kayıtlarına ihtiyaç duyar. \n\n %1$@ Ayarlarınızı (Genel / Tarih ve Saat) kontrol edin ve 'Otomatik Olarak Ayarla' seçeneğinin AÇIK olduğunu doğrulayın. Çözülmemesi, insülinin ciddi şekilde yetersiz veya fazla verilmesine yol açabilir." + } + } + } + }, + "Your glucose is below %1$@. Are you sure you want to bolus?" : { + "comment" : "Format string for simple bolus screen warning when glucose is below glucose warning limit.", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dit glukose er under %1$@. Er du sikker på, at du ønsker at give bolus?" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dein Blutzucker liegt unter %1$@. Bist Du sicher, dass Du einen Bolus abgeben möchtest?" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tu glucosa está por debajo de %1$@. ¿Estás seguro de que deseas administrar un bolo?" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre glycémie est inférieure à %1$@. Êtes-vous sûr de vouloir administrer un bolus?" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הגלוקוז שלך נמוך מ-%1$@. בטוח שברצונך להזריק בולוס?" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "La glicemia è inferiore a %1$@. Sei sicuro di voler fare il bolo?" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blodsukkeret ditt er under %1$@ . Er du sikker på at du vil gi bolus?" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Je glucose is lager dan %1$@. Weet je zeker dat je een bolus wilt toedienen?" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Twój poziom glukozy jest poniżej %1$@ . Czy na pewno chcesz podać bolus?" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glicemia dumneavoastră este sub %1$@. Sunteți sigur că doriți să bolusați?" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ваш уровень глюкозы ниже %1$@. Вы уверены, что хотите ввести болюс?" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "KŞ %1$@ altında. Bolus yapmak istediğinizden emin misiniz?" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "您的血糖低于%1$@。你确定要进行大剂量推注吗" + } + } + } + }, + "Your glucose is below or predicted to go below your glucose safety limit, %@." : { + "comment" : "Caption for bolus screen notice when no bolus is recommended due to prediction dropping below glucose safety limit", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dit blodsukker er under eller forventes at ligge under din blodsukkersikkerhedsgrænse, %@." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dein Blutzucker liegt unter Deiner Sicherheitsgrenze %@ oder wird voraussichtlich unter diese fallen." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tu nivel de glucosa está por debajo o se prevé que vaya a estar por debajo de tu límite de seguridad de glucosa, %@." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glukoosi on alle tai sen ennustetaan alittavan glukoosin turvarajan %@." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre glycémie est en-dessous ou prévue pour aller en dessous de votre limite de sécurité de glycémie, %@." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הגלוקוז שלך נמוך או חזוי להיות נמוך מגבול הביטחון, %@." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "La tua glicemia è inferiore o si prevede che scenderà al di sotto del limite di sicurezza della glicemia, %@." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blodsukkeret ditt er under eller forventes å gå under BS-sikkerhetsgrensen din, %@." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Je glucose is onder of zal naar verwachting onder je glucoseveiligheidslimiet komen, %@." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Twój poziom glukozy jest poniżej lub przewiduje się, że spadnie poniżej granicy bezpieczeństwa, %@." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glicemia dumneavoastră este sub sau se anticipează că va scădea sub limita de siguranță, %@." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ваш уровень глюкозы ниже или прогнозируется, что он будет ниже безопасного предела глюкозы, %@." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ditt blodsocker är lägre än eller förväntas bli lägre än ditt inställda tröskelvärde, %@." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "KŞ güvenlik sınırınızın %@ altındadır veya altına düşeceği tahmin edilmektedir." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "你的血糖已低于或预计将低于你的血糖安全下限:%@。" + } + } + } + }, + "Your glucose is below your glucose safety limit, %1$@." : { + "comment" : "Format string for bolus screen warning when no bolus is recommended due input value below glucose safety limit. (1: suspendThreshold)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dit blodsukker ligger under din blodsukkersikkerhedsgrænse, %1$@." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dein Blutzucker liegt unter Deiner Sicherheitsgrenze von %1$@." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tu glucosa está por debajo del límite de seguridad de glucosa, %1$@." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glukoosi on alle glukoosin turvarajan %1$@." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre glycémie est inférieure au seuil de suspension, %1$@." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הגלוקוז שלך נמוך מגבול הביטחון %1$@." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "La tua glicemia è al di sotto del limite di sicurezza, %1$@ ." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Din glukose er under din glukosesikkerhetsgrense, %1$@." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Je glucose is onder je glucoseveiligheidslimiet, %1$@." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Twój poziom glukozy jest poniżej granicy bezpieczeństwa, %1$@." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glicemia ta este sub limita de siguranță, %1$@." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ваш уровень глюкозы ниже безопасного предела глюкозы, %1$@." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ditt blodsocker är lägre än ditt tröskelvärde, %1$@." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "KŞ güvenlik sınırınızın altında, %1$@ ." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "你的血糖已低于安全下限:%1$@。" + } + } + } + }, + "Your glucose is low. Eat carbs and consider waiting to bolus until your glucose is in a safe range." : { + "comment" : "Format string for meal bolus screen warning when no bolus is recommended due to glucose input value below recommendation threshold", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dit glukose er lavt. Spis kulhydrater, og overvej at vente med at give bolus, indtil dit glukoseniveau er i et sikkert område." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dein Blutzucker ist niedrig. Iss Kohlenhydrate und erwäge, mit dem Bolus zu warten, bis Dein Blutzucker in einem sicheren Bereich liegt." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tu glucosa está baja. Come carbohidratos y considera esperar para administrar el bolo hasta que tu glucosa esté en un rango seguro." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre glycémie est basse. Mangez des glucides et envisagez d'attendre pour le bolus jusqu'à ce que votre glycémie se situe dans une plage sûre." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הגלוקוז שלך נמוך. אכול פחמימות ושקול להמתין עם הבולוס עד שהגלוקוז יגיע לטווח בטוח." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "La tua glicemia è bassa. Mangia carboidrati e valuta di aspettare il bolo finché la glicemia non torna a un intervallo sicuro." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Din glukose er lav. Spis karbohydrater og vurder å vente med bolus til glukosen er innenfor et trygt område." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Je glucose is laag. Eet koolhydraten en overweeg te wachten met een bolus totdat je glucose binnen een veilig bereik is." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Twój poziom glukozy jest niski. Zjedz węglowodany i rozważ odczekanie z podaniem bolusa, aż poziom glukozy znajdzie się w bezpiecznym zakresie." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glicemia dumneavoastră este scăzută. Consumați carbohidrați și luați în considerare posibilitatea de a aștepta să faceți un bolus până când glicemia se află într-un interval sigur." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "У вас низкий уровень глюкозы. Ешьте углеводы и подумайте о том, чтобы подождать с болюсом, пока уровень глюкозы не достигнет безопасного диапазона." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "KŞ düşük. Karbonhidrat yiyin ve KŞ güvenli bir aralığa gelene kadar bolus yapmayı erteleyin." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "您的血糖偏低。请摄入碳水,并等到血糖恢复到安全范围后再进行大剂量推注。" + } + } + } + }, + "Your glucose is low. Eat carbs and monitor closely." : { + "comment" : "Bolus screen warning when no bolus is recommended due to glucose input value below recommendation threshold for meal bolus", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Din glukose er lav. Spis kulhydrater og overvåg nøje." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dein Blutzucker ist niedrig. Kohlenhydrate essen und genau beobachten." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tienes la glucosa baja. Come carbohidratos y vigílalo de cerca." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre glycémie est basse. Mangez des glucides et surveillez de près." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הגלוקוז שלך נמוך. אכול פחמימות ועקוב מקרוב." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "La tua glicemia è bassa. Mangia carboidrati e monitora attentamente." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Din glukose er lav. Spis karbohydrater og følg nøye med." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Je glucose is laag. Eet koolhydraten en houd alles nauwlettend in de gaten." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Twój poziom glukozy jest niski. Zjedz węglowodany i uważnie monitoruj poziom glikemii." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glicemia dumneavoastră este scăzută. Mâncați carbohidrați și monitorizați cu atenție." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "У вас низкий уровень глюкозы. Съешьте углеводы и внимательно следите за состоянием здоровья." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "KŞ düşük. Karbonhidrat yiyin ve yakından izleyin." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "您的血糖偏低。请补充碳水并密切监测。" + } + } + } + }, + "Your maximum bolus amount is %1$@." : { + "comment" : "Warning for simple bolus when max bolus is exceeded. (1: maximum bolus)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Din maksimale bolusmængde er %1$@." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deine maximale Bolusmenge beträgt %1$@." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Su cantidad máxima de bolo es %1$@ ." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "La quantité maximum de votre bolus est de %1$@ U." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "כמות הבולוס המקסימלית שלך היא %1$@." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "La quantità massima consentita del tuo bolo è %1$@." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Din maksimale bolusmengde er %1$@ ." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Je maximale bolushoeveelheid is %1$@." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Twoja maksymalna wielkość bolusa to %1$@ ." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Valoarea maximă a bolusului este %1$@ ." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ваш максимальный объем болюса составляет %1$@." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maksimum bolus miktarınız %1$@ ." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "你的最大输注剂量是 %1$@" + } + } + } + }, + "Your pump data is stale. %1$@ cannot recommend a bolus amount." : { + "comment" : "Caption for bolus screen notice when pump data is missing or stale", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dine pumpedata er forældede. %1$@ kan ikke anbefale en bolusmængde." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deine Pumpendaten sind veraltet. %1$@ kann keine Bolusmenge empfehlen." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Los datos de su bomba están obsoletos. %1$@ no puede recomendar una cantidad de bolo." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les données de votre pompe ne pas sont à jour. %1$@ ne peut pas faire de recommandation de bolus." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מידע מהמשאבה שלך מיושן. %1$@ לא יכול להמליץ על כמות בולוס." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "I dati della pompa sono obsoleti. %1$@ non può consigliare un bolo." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumpedataene er foreldede. %1$@ kan ikke anbefale en bolusmengde." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Je pompgegevens zijn verouderd. %1$@ kan geen bolushoeveelheid aanbevelen." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dane Twojej pompy są nieaktualne. %1$@ nie może zalecić wielkości bolusa." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Datele pompei dumneavoastră sunt învechite. %1$@ nu poate recomanda o cantitate de bolus." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Данные вашей помпы устарели. %1$@ не может рекомендовать количество инсулина." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pompa verileriniz eski. %1$@ bir bolus miktarı öneremez." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "您的胰岛素泵数据已过期。%1$@ 无法推荐大剂量。" + } + } + } + }, + "Your pump is delivering a manual temporary basal rate." : { + "comment" : "The description text for the looping enabled switch cell when closed loop is not allowed because the pump is delivering a manual temp basal.", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Din pumpe leverer en manuel midlertidig basalrate." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deine Pumpe liefert eine manuelle temporäre Basalrate." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Su bomba está suministrando una tasa basal temporal manual." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre pompe délivre un débit basal temporaire manuel." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "המשאבה שלך מזליפה בזאלי ידני זמני." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "La pompa sta erogando una velocità basale temporanea manuale." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pumpen leverer en manuell midlertidig basaldose." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Je pomp geeft een handmatig ingestelde tijdelijke basaalsnelheid af." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pompa podaje tymczasową dawkę podstawową ustawioną ręcznie." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pompa dumneavoastră furnizează o rată bazală temporară manuală." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ваша помпа вводит ВБС вручную." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pompanız manuel bir geçici bazal oran veriyor." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在执行手动临时基础率。" + } + } + } + }, + "Your recommended bolus exceeds your maximum bolus amount of %1$@." : { + "comment" : "Warning for simple bolus when recommended bolus exceeds max bolus. (1: maximum bolus)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Din anbefalede bolus overstiger din maksimale bolusmængde på %1$@." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der empfohlene Bolus übersteigt die maximale Bolus von %1$@." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Su bolo recomendado excede su cantidad máxima de bolo de %1$@." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre bolus recommandé dépasse votre bolus maximum de %1$@." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "הבולוס המומלץ עבורך גבוה מכמות הבולוס המקסימלית של %1$@." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Il bolo raccomandato supera la quantità del bolo massimo di %1$@." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Den anbefalte bolusen overskrider den maksimale bolusmengden på %1$@ ." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Je aanbevolen bolus overschrijdt je maximale bolushoeveelheid van %1$@." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Twój zalecany bolus przekracza maksymalną wartość bolusa wynoszącą %1$@ ." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolusul recomandat depășește cantitatea maximă de bolus de %1$@ ." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Рекомендуемый вами объем болюса превышает лимит подачи болюса %1$@." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Önerilen bolus miktarı, maksimum bolus miktarınız olan %1$@ değerini aşıyor." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "推荐的大剂量超过了你设定的最大剂量限制:%1$@。" + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/Loop/Loop.entitlements b/Loop/Loop.entitlements index 8d88cb3139..e6a2f9b9f0 100644 --- a/Loop/Loop.entitlements +++ b/Loop/Loop.entitlements @@ -2,8 +2,22 @@ + aps-environment + development com.apple.developer.healthkit + com.apple.developer.healthkit.access + + com.apple.developer.healthkit.background-delivery + + com.apple.developer.nfc.readersession.formats + + TAG + + com.apple.developer.siri + + com.apple.developer.usernotifications.time-sensitive + com.apple.security.application-groups $(APP_GROUP_IDENTIFIER) diff --git a/Loop/Managers/AlertMuter.swift b/Loop/Managers/AlertMuter.swift new file mode 100644 index 0000000000..4f47445eeb --- /dev/null +++ b/Loop/Managers/AlertMuter.swift @@ -0,0 +1,140 @@ +// +// AlertMuter.swift +// Loop +// +// Created by Nathaniel Hamming on 2022-09-14. +// Copyright © 2022 LoopKit Authors. All rights reserved. +// + +import Foundation +import Combine +import SwiftUI +import LoopKit + +public class AlertMuter: ObservableObject { + struct Configuration: Equatable, RawRepresentable { + typealias RawValue = [String: Any] + + enum ConfigurationKey: String { + case duration + case startTime + } + + init?(rawValue: [String : Any]) { + guard let duration = rawValue[ConfigurationKey.duration.rawValue] as? TimeInterval + else { return nil } + + self.duration = duration + self.startTime = rawValue[ConfigurationKey.startTime.rawValue] as? Date + } + + var rawValue: [String : Any] { + var rawValue: [String : Any] = [:] + rawValue[ConfigurationKey.duration.rawValue] = duration + rawValue[ConfigurationKey.startTime.rawValue] = startTime + return rawValue + } + + var duration: TimeInterval + + var startTime: Date? + + var shouldMute: Bool { + guard let mutingEndTime = mutingEndTime else { return false } + return mutingEndTime >= Date() + } + + var mutingEndTime: Date? { + startTime?.addingTimeInterval(duration) + } + + init(startTime: Date? = nil, duration: TimeInterval = AlertMuter.allowedDurations[0]) { + self.duration = duration + self.startTime = startTime + } + + func shouldMuteAlert(scheduledAt timeFromNow: TimeInterval = 0, now: Date = Date()) -> Bool { + guard let mutingEndTime = mutingEndTime else { return false } + + let alertTriggerTime = now.advanced(by: timeFromNow) + guard let startTime = startTime, + alertTriggerTime >= startTime, + alertTriggerTime < mutingEndTime + else { return false } + + return true + } + } + + @Published var configuration: Configuration { + didSet { + if oldValue != configuration { + updateMutePeriodEndingWatcher() + } + } + } + + private var mutePeriodEndingTimer: Timer? + + private lazy var cancellables = Set() + + static var allowedDurations: [TimeInterval] { [.minutes(30), .hours(1), .hours(2), .hours(4)] } + + init(configuration: Configuration = Configuration()) { + self.configuration = configuration + + NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification) + .sink { [weak self] _ in + self?.updateMutePeriodEndingWatcher() + } + .store(in: &cancellables) + + updateMutePeriodEndingWatcher() + } + + convenience init(startTime: Date? = nil, duration: TimeInterval = AlertMuter.allowedDurations[0]) { + self.init(configuration: Configuration(startTime: startTime, duration: duration)) + } + + private func updateMutePeriodEndingWatcher(_ now: Date = Date()) { + mutePeriodEndingTimer?.invalidate() + + guard let mutingEndTime = configuration.mutingEndTime else { return } + + guard mutingEndTime > now else { + configuration.startTime = nil + return + } + + let timeInterval = mutingEndTime.timeIntervalSince(now) + mutePeriodEndingTimer = Timer.scheduledTimer(withTimeInterval: timeInterval, repeats: false) { [weak self] _ in + self?.configuration.startTime = nil + } + } + + func shouldMuteAlert(scheduledAt timeFromNow: TimeInterval = 0) -> Bool { + return configuration.shouldMuteAlert(scheduledAt: timeFromNow) + } + + func shouldMuteAlert(_ alert: LoopKit.Alert, issuedDate: Date? = nil, now: Date = Date()) -> Bool { + switch alert.trigger { + case .immediate: + return shouldMuteAlert(scheduledAt: (issuedDate ?? now).timeIntervalSince(now)) + case .delayed(let interval), .repeating(let interval): + let triggerInterval = ((issuedDate ?? now) + interval).timeIntervalSince(now) + return shouldMuteAlert(scheduledAt: triggerInterval) + } + } + + func unmuteAlerts() { + configuration.startTime = nil + } + + var formattedEndTime: String { + guard let endTime = configuration.mutingEndTime else { return NSLocalizedString("Unknown", comment: "result when time cannot be formatted") } + let formatter = DateFormatter() + formatter.timeStyle = .short + formatter.dateStyle = .none + return formatter.string(from: endTime) + } +} diff --git a/Loop/Managers/AlertPermissionsChecker.swift b/Loop/Managers/AlertPermissionsChecker.swift new file mode 100644 index 0000000000..bae4512e6a --- /dev/null +++ b/Loop/Managers/AlertPermissionsChecker.swift @@ -0,0 +1,243 @@ +// +// AlertPermissionsChecker.swift +// Loop +// +// Created by Rick Pasetto on 6/25/21. +// Copyright © 2021 LoopKit Authors. All rights reserved. +// + +import Foundation +import Combine +import LoopKit +import SwiftUI + +protocol AlertPermissionsCheckerDelegate: AnyObject { + func notificationsPermissions(requiresRiskMitigation: Bool, scheduledDeliveryEnabled: Bool) +} + +public class AlertPermissionsChecker: ObservableObject { + + private var isAppInBackground: Bool { + return UIApplication.shared.applicationState == UIApplication.State.background + } + + private lazy var cancellables = Set() + private var listeningToNotificationCenter = false + + @Published var notificationCenterSettings: NotificationCenterSettingsFlags = .none + + var showWarning: Bool { + notificationCenterSettings.requiresRiskMitigation + } + + weak var delegate: AlertPermissionsCheckerDelegate? + + init() { + // Check on loop complete, but only while in the background. + NotificationCenter.default.publisher(for: .LoopCompleted) + .receive(on: RunLoop.main) + .sink { [weak self] _ in + guard let self = self else { return } + if self.isAppInBackground { + self.check() + } + } + .store(in: &cancellables) + + NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification) + .sink { [weak self] _ in + self?.check() + } + .store(in: &cancellables) + + NotificationCenter.default.publisher(for: UIApplication.didEnterBackgroundNotification) + .sink { [weak self] _ in + self?.check() + } + .store(in: &cancellables) + } + + func checkNow() { + check { + // Note: we do this, instead of calling notificationCenterSettingsChanged directly, so that we only + // get called when it _changes_. + self.listenToNotificationCenter() + } + } + + private func check(then completion: (() -> Void)? = nil) { + UNUserNotificationCenter.current().getNotificationSettings { settings in + DispatchQueue.main.async { + var newSettings = self.notificationCenterSettings + newSettings.notificationsDisabled = settings.alertSetting == .disabled + if FeatureFlags.criticalAlertsEnabled { + newSettings.criticalAlertsDisabled = settings.criticalAlertSetting == .disabled + } + if #available(iOS 15.0, *) { + newSettings.scheduledDeliveryEnabled = settings.scheduledDeliverySetting == .enabled + newSettings.timeSensitiveNotificationsDisabled = settings.alertSetting != .disabled && settings.timeSensitiveSetting == .disabled + } + self.notificationCenterSettings = newSettings + completion?() + } + } + } + + static func gotoSettings() { + // TODO with iOS 16 this API changes to UIApplication.openNotificationSettingsURLString + if #available(iOS 15.4, *) { + UIApplication.shared.open(URL(string: UIApplicationOpenNotificationSettingsURLString)!) + } else { + UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!) + } + } +} + +extension AlertPermissionsChecker { + private func listenToNotificationCenter() { + if !listeningToNotificationCenter { + $notificationCenterSettings + .receive(on: RunLoop.main) + .removeDuplicates() + .sink(receiveValue: notificationCenterSettingsChanged) + .store(in: &cancellables) + listeningToNotificationCenter = true + } + } + + // MARK: Unsafe Notification Permissions Alert + static let unsafeNotificationPermissionsAlertIdentifier = Alert.Identifier(managerIdentifier: "LoopAppManager", alertIdentifier: "unsafeNotificationPermissionsAlert") + + private static let unsafeNotificationPermissionsAlertContent = Alert.Content( + title: NSLocalizedString("Warning! Safety notifications are turned OFF", + comment: "Alert Permissions Need Attention alert title"), + body: String(format: NSLocalizedString("You may not get sound, visual or vibration alerts regarding critical safety information.\n\nTo fix the issue, tap ‘Settings’ and make sure Notifications, Critical Alerts and Time Sensitive Notifications are turned ON.", + comment: "Format for Notifications permissions disabled alert body. (1: app name)"), + Bundle.main.bundleDisplayName), + acknowledgeActionButtonLabel: NSLocalizedString("OK", comment: "Notifications permissions disabled alert button") + ) + + static let unsafeNotificationPermissionsAlert = Alert(identifier: unsafeNotificationPermissionsAlertIdentifier, + foregroundContent: nil, + backgroundContent: unsafeNotificationPermissionsAlertContent, + trigger: .immediate) + + static func constructUnsafeNotificationPermissionsInAppAlert(acknowledgementCompletion: @escaping () -> Void ) -> UIAlertController { + dispatchPrecondition(condition: .onQueue(.main)) + let alertController = UIAlertController(title: Self.unsafeNotificationPermissionsAlertContent.title, + message: Self.unsafeNotificationPermissionsAlertContent.body, + preferredStyle: .alert) + let titleImageAttachment = NSTextAttachment() + titleImageAttachment.image = UIImage(systemName: "exclamationmark.triangle.fill")?.withTintColor(.critical) + titleImageAttachment.bounds = CGRect(x: titleImageAttachment.bounds.origin.x, y: -10, width: 40, height: 35) + let titleWithImage = NSMutableAttributedString(attachment: titleImageAttachment) + titleWithImage.append(NSMutableAttributedString(string: "\n\n", attributes: [.font: UIFont.systemFont(ofSize: 8)])) + titleWithImage.append(NSMutableAttributedString(string: Self.unsafeNotificationPermissionsAlertContent.title, attributes: [.font: UIFont.preferredFont(forTextStyle: .headline)])) + alertController.setValue(titleWithImage, forKey: "attributedTitle") + + let messageImageAttachment = NSTextAttachment() + messageImageAttachment.image = UIImage(named: "notification-permissions-on") + messageImageAttachment.bounds = CGRect(x: 0, y: -12, width: 228, height: 126) + let messageWithImageAttributed = NSMutableAttributedString(string: "\n", attributes: [.font: UIFont.systemFont(ofSize: 8)]) + messageWithImageAttributed.append(NSMutableAttributedString(string: Self.unsafeNotificationPermissionsAlertContent.body, attributes: [.font: UIFont.preferredFont(forTextStyle: .footnote)])) + messageWithImageAttributed.append(NSMutableAttributedString(string: "\n\n", attributes: [.font: UIFont.systemFont(ofSize: 12)])) + messageWithImageAttributed.append(NSMutableAttributedString(attachment: messageImageAttachment)) + alertController.setValue(messageWithImageAttributed, forKey: "attributedMessage") + + alertController.addAction(UIAlertAction(title: NSLocalizedString("Settings", comment: "Label of button that navigation user to iOS Settings"), + style: .default, + handler: { _ in + AlertPermissionsChecker.gotoSettings() + acknowledgementCompletion() + })) + alertController.addAction(UIAlertAction(title: NSLocalizedString("Close", comment: "The button label of the action used to dismiss the unsafe notification permission alert"), + style: .cancel, + handler: { _ in acknowledgementCompletion() + })) + return alertController + } + + // MARK: Scheduled Delivery Enabled Alert + private static let scheduledDeliveryEnabledAlertIdentifier = Alert.Identifier(managerIdentifier: "LoopAppManager", + alertIdentifier: "scheduledDeliveryEnabledAlert") + private static let scheduledDeliveryEnabledAlertContent = Alert.Content( + title: NSLocalizedString("Notifications Delayed", + comment: "Scheduled Delivery Enabled alert title"), + body: String(format: NSLocalizedString(""" + Notification delivery is set to Scheduled Summary in your phone’s settings. + + To avoid delay in receiving notifications from %1$@, we recommend notification delivery be set to Immediate Delivery. + """, + comment: "Format for Critical Alerts permissions disabled alert body. (1: app name)"), + Bundle.main.bundleDisplayName), + acknowledgeActionButtonLabel: NSLocalizedString("OK", comment: "Critical Alert permissions disabled alert button") + ) + static let scheduledDeliveryEnabledAlert = Alert(identifier: scheduledDeliveryEnabledAlertIdentifier, + foregroundContent: scheduledDeliveryEnabledAlertContent, + backgroundContent: scheduledDeliveryEnabledAlertContent, + trigger: .immediate) + + private func notificationCenterSettingsChanged(_ newValue: NotificationCenterSettingsFlags) { + delegate?.notificationsPermissions(requiresRiskMitigation: newValue.requiresRiskMitigation, scheduledDeliveryEnabled: newValue.scheduledDeliveryEnabled) + } +} + +struct NotificationCenterSettingsFlags: OptionSet { + let rawValue: Int + + static let none = NotificationCenterSettingsFlags([]) + static let notificationsDisabled = NotificationCenterSettingsFlags(rawValue: 1 << 0) + static let criticalAlertsDisabled = NotificationCenterSettingsFlags(rawValue: 1 << 1) + static let timeSensitiveNotificationsDisabled = NotificationCenterSettingsFlags(rawValue: 1 << 2) + static let scheduledDeliveryEnabled = NotificationCenterSettingsFlags(rawValue: 1 << 3) + + static let requiresRiskMitigation: NotificationCenterSettingsFlags = [ .notificationsDisabled, .criticalAlertsDisabled, .timeSensitiveNotificationsDisabled ] +} + +extension NotificationCenterSettingsFlags { + var notificationsDisabled: Bool { + get { + contains(.notificationsDisabled) + } + set { + update(.notificationsDisabled, newValue) + } + } + var criticalAlertsDisabled: Bool { + get { + contains(.criticalAlertsDisabled) + } + set { + update(.criticalAlertsDisabled, newValue) + } + } + var timeSensitiveNotificationsDisabled: Bool { + get { + contains(.timeSensitiveNotificationsDisabled) + } + set { + update(.timeSensitiveNotificationsDisabled, newValue) + } + } + var scheduledDeliveryEnabled: Bool { + get { + contains(.scheduledDeliveryEnabled) + } + set { + update(.scheduledDeliveryEnabled, newValue) + } + } + var requiresRiskMitigation: Bool { + !self.intersection(.requiresRiskMitigation).isEmpty + } +} + +fileprivate extension OptionSet { + mutating func update(_ element: Self.Element, _ value: Bool) { + if value { + insert(element) + } else { + remove(element) + } + } +} diff --git a/Loop/Managers/Alerts/AlertManager.swift b/Loop/Managers/Alerts/AlertManager.swift new file mode 100644 index 0000000000..50b99666e2 --- /dev/null +++ b/Loop/Managers/Alerts/AlertManager.swift @@ -0,0 +1,850 @@ +// +// AlertManager.swift +// Loop +// +// Created by Rick Pasetto on 4/9/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import LoopKit +import UIKit +import Combine + +protocol AlertManagerResponder: AnyObject { + /// Method for our Handlers to call to kick off alert response. Differs from AlertResponder because here we need the whole `Identifier`. + func acknowledgeAlert(identifier: Alert.Identifier) +} + +public enum AlertUserNotificationUserInfoKey: String { + case alert, alertTimestamp +} + +/// Main (singleton-ish) class that is responsible for: +/// - managing the different targets (handlers) that will post alerts +/// - managing the different responders that might acknowledge the alert +/// - serializing alerts to storage +/// - etc. +public final class AlertManager { + private static let soundsDirectoryURL = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).last!.appendingPathComponent("Sounds") + + private let log = DiagnosticLog(category: "AlertManager") + + static let managerIdentifier = "Loop" + + private var responders: [String: Weak] = [:] + private var soundVendors: [String: Weak] = [:] + + // Defer issuance of new alerts until playback is done + private var deferredAlerts: [Alert] = [] + private var playbackFinished: Bool + + private let fileManager: FileManager + private let alertPresenter: AlertPresenter + + private var modalAlertScheduler: InAppModalAlertScheduler! + private var userNotificationAlertScheduler: UserNotificationAlertScheduler + private var unsafeNotificationPermissionsAlertController: UIAlertController? + var alertMuter: AlertMuter + + let alertStore: AlertStore + + private let bluetoothPoweredOffIdentifier = Alert.Identifier(managerIdentifier: managerIdentifier, alertIdentifier: "bluetoothPoweredOff") + + var analyticsServicesManager: AnalyticsServicesManager + + lazy private var cancellables = Set() + + // For testing + var getCurrentDate = { return Date() } + + init(alertPresenter: AlertPresenter, + modalAlertScheduler: InAppModalAlertScheduler? = nil, + userNotificationAlertScheduler: UserNotificationAlertScheduler, + fileManager: FileManager = FileManager.default, + alertStore: AlertStore? = nil, + expireAfter: TimeInterval = 24 /* hours */ * 60 /* minutes */ * 60 /* seconds */, + bluetoothProvider: BluetoothProvider, + analyticsServicesManager: AnalyticsServicesManager, + preventIssuanceBeforePlayback: Bool = true + ) { + self.fileManager = fileManager + self.analyticsServicesManager = analyticsServicesManager + playbackFinished = !preventIssuanceBeforePlayback + let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first + let alertStoreDirectory = documentsDirectory?.appendingPathComponent("AlertStore") + if let alertStoreDirectory = alertStoreDirectory { + do { + try fileManager.ensureDirectoryExists(at: alertStoreDirectory, with: FileProtectionType.completeUntilFirstUserAuthentication) + log.debug("AlertStore directory ensured") + } catch { + log.error("Could not create AlertStore directory: %@", error.localizedDescription) + } + } + self.alertStore = alertStore ?? AlertStore(storageDirectoryURL: alertStoreDirectory, expireAfter: expireAfter) + self.alertPresenter = alertPresenter + self.alertMuter = AlertMuter(configuration: UserDefaults.standard.alertMuterConfiguration) + self.userNotificationAlertScheduler = userNotificationAlertScheduler + self.modalAlertScheduler = modalAlertScheduler ?? InAppModalAlertScheduler(alertPresenter: alertPresenter, alertManagerResponder: self) + + bluetoothProvider.addBluetoothObserver(self, queue: .main) + + NotificationCenter.default.publisher(for: .LoopCompleted) + .sink { [weak self] publisher in + if let loopDataManager = publisher.object as? LoopDataManager { + self?.loopDidComplete(loopDataManager.lastLoopCompleted) + } + } + .store(in: &cancellables) + + alertMuter.$configuration + .removeDuplicates() + .receive(on: RunLoop.main) + .dropFirst() + .sink(receiveValue: rescheduleMutedAlerts) + .store(in: &cancellables) + } + + public func addAlertResponder(managerIdentifier: String, alertResponder: AlertResponder) { + responders[managerIdentifier] = Weak(alertResponder) + } + + public func removeAlertResponder(managerIdentifier: String) { + responders.removeValue(forKey: managerIdentifier) + } + + public func addAlertSoundVendor(managerIdentifier: String, soundVendor: AlertSoundVendor) { + soundVendors[managerIdentifier] = Weak(soundVendor) + initializeSoundVendor(managerIdentifier, soundVendor) + } + + public func removeAlertSoundVendor(managerIdentifier: String) { + soundVendors.removeValue(forKey: managerIdentifier) + } + + // MARK: - Bluetooth alerts + + private func onBluetoothPermissionDenied() { + log.default("Bluetooth permission denied") + let title = NSLocalizedString("Bluetooth Unavailable Alert", comment: "Bluetooth unavailable alert title") + let body = NSLocalizedString("Loop has detected an issue with your Bluetooth settings, and will not work successfully until Bluetooth is enabled. You will not receive glucose readings, or be able to bolus.", comment: "Bluetooth unavailable alert body.") + let content = Alert.Content(title: title, + body: body, + acknowledgeActionButtonLabel: NSLocalizedString("Dismiss", comment: "Default alert dismissal")) + issueAlert(Alert(identifier: bluetoothPoweredOffIdentifier, foregroundContent: content, backgroundContent: content, trigger: .immediate)) + } + + private func onBluetoothPoweredOn() { + log.default("Bluetooth powered on") + retractAlert(identifier: bluetoothPoweredOffIdentifier) + } + + private func onBluetoothPoweredOff() { + log.default("Bluetooth powered off") + let title = NSLocalizedString("Bluetooth Off Alert", comment: "Bluetooth off alert title") + let bgBody = NSLocalizedString("Loop will not work successfully until Bluetooth is enabled. You will not receive glucose readings, or be able to bolus.", comment: "Bluetooth off background alert body.") + let bgcontent = Alert.Content(title: title, + body: bgBody, + acknowledgeActionButtonLabel: NSLocalizedString("Dismiss", comment: "Default alert dismissal")) + let fgBody = NSLocalizedString("Turn on Bluetooth to receive alerts, alarms or sensor glucose readings.", comment: "Bluetooth off foreground alert body") + let fgcontent = Alert.Content(title: title, + body: fgBody, + acknowledgeActionButtonLabel: NSLocalizedString("Dismiss", comment: "Default alert dismissal")) + issueAlert(Alert(identifier: bluetoothPoweredOffIdentifier, + foregroundContent: fgcontent, + backgroundContent: bgcontent, + trigger: .immediate, + interruptionLevel: .critical)) + } + + // MARK: - Loop Not Running alerts + + func loopDidComplete(_ lastLoopDate: Date? = nil) { + // use now if there is no lastLoopDate + rescheduleLoopNotRunningNotifications(lastLoopDate ?? Date()) + } + + private func rescheduleLoopNotRunningNotifications() { + guard let lastLoopDate = getLastLoopDate() else { return } + rescheduleLoopNotRunningNotifications(lastLoopDate) + } + + func rescheduleLoopNotRunningNotifications(_ lastLoopDate: Date) { + clearLoopNotRunningNotifications() + scheduleLoopNotRunningNotifications(lastLoopDate) + } + + func scheduleLoopNotRunningNotifications(_ lastLoopDate: Date) { + // Give a little extra time for a loop-in-progress to complete + let gracePeriod = TimeInterval(minutes: 0.5) + + var scheduledNotifications: [StoredLoopNotRunningNotification] = [] + + for (minutes, isCritical) in [(20.0, false), (40.0, false), (60.0, true), (120.0, true)] { + let warningInterval = TimeInterval(minutes: minutes) + let timeUntilNotification = lastLoopDate.addingTimeInterval(warningInterval).timeIntervalSinceNow + guard timeUntilNotification >= 0 else { break } + + let formatter = DateComponentsFormatter() + formatter.maximumUnitCount = 1 + formatter.allowedUnits = [.hour, .minute] + formatter.unitsStyle = .full + + let notificationContent = UNMutableNotificationContent() + if let failureIntervalString = formatter.string(from: warningInterval)?.localizedLowercase { + notificationContent.body = String(format: NSLocalizedString("Loop has not completed successfully in %@", comment: "The notification alert describing a long-lasting loop failure. The substitution parameter is the time interval since the last loop"), failureIntervalString) + } + + notificationContent.title = NSLocalizedString("Loop Failure", comment: "The notification title for a loop failure") + let shouldMuteAlert = alertMuter.shouldMuteAlert(scheduledAt: timeUntilNotification) + if isCritical, FeatureFlags.criticalAlertsEnabled { + if #available(iOS 15.0, *) { + notificationContent.interruptionLevel = .critical + } + notificationContent.sound = shouldMuteAlert ? .defaultCriticalSound(withAudioVolume: 0.0) : .defaultCritical + } else { + if #available(iOS 15.0, *) { + notificationContent.interruptionLevel = .timeSensitive + } + notificationContent.sound = shouldMuteAlert ? nil : .default + } + notificationContent.categoryIdentifier = LoopNotificationCategory.loopNotRunning.rawValue + notificationContent.threadIdentifier = LoopNotificationCategory.loopNotRunning.rawValue + + let trigger = UNTimeIntervalNotificationTrigger( + timeInterval: timeUntilNotification + gracePeriod, + repeats: false + ) + + + let request = UNNotificationRequest( + identifier: "\(LoopNotificationCategory.loopNotRunning.rawValue)\(warningInterval)", + content: notificationContent, + trigger: trigger + ) + + if let nextTriggerDate = trigger.nextTriggerDate() { + let scheduledNotification = StoredLoopNotRunningNotification( + alertAt: nextTriggerDate, + title: notificationContent.title, + body: notificationContent.body, + isCritical: isCritical) + scheduledNotifications.append(scheduledNotification) + } + UNUserNotificationCenter.current().add(request) + } + UserDefaults.appGroup?.loopNotRunningNotifications = scheduledNotifications + } + + func inferDeliveredLoopNotRunningNotifications() { + // Infer that any past alerts have been delivered at this point + let now = getCurrentDate() + var stillPendingNotifications = [StoredLoopNotRunningNotification]() + for notification in UserDefaults.appGroup?.loopNotRunningNotifications ?? [] { + if notification.alertAt < now { + let alertIdentifier = Alert.Identifier(managerIdentifier: "Loop", alertIdentifier: "loopNotLooping") + let content = Alert.Content(title: notification.title, body: notification.body, acknowledgeActionButtonLabel: "ios-notification-default") + let interruptionLevel: Alert.InterruptionLevel = notification.isCritical ? .critical : .timeSensitive + let alert = Alert(identifier: alertIdentifier, foregroundContent: nil, backgroundContent: content, trigger: .immediate, interruptionLevel: interruptionLevel) + recordIssued(alert: alert, at: notification.alertAt) + } else { + stillPendingNotifications.append(notification) + } + } + UserDefaults.appGroup?.loopNotRunningNotifications = stillPendingNotifications + } + + func clearLoopNotRunningNotifications() { + inferDeliveredLoopNotRunningNotifications() + + // Clear out any existing not-running notifications + UNUserNotificationCenter.current().getDeliveredNotifications { (notifications) in + let loopNotRunningIdentifiers = notifications.filter({ + $0.request.content.categoryIdentifier == LoopNotificationCategory.loopNotRunning.rawValue + }).map({ + $0.request.identifier + }) + + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: loopNotRunningIdentifiers) + } + } + + private func getLastLoopDate() -> Date? { + ExtensionDataManager.lastLoopCompleted + } + + // MARK: - Workout reminder + private func scheduleWorkoutOverrideReminder() { + issueAlert(workoutOverrideReminderAlert) + } + + private func retractWorkoutOverrideReminder() { + retractAlert(identifier: AlertManager.workoutOverrideReminderAlertIdentifier) + } + + static var workoutOverrideReminderAlertIdentifier: Alert.Identifier { + return Alert.Identifier(managerIdentifier: managerIdentifier, alertIdentifier: "WorkoutOverrideReminder") + } + + private var workoutOverrideReminderAlert: Alert { + let title = NSLocalizedString("Workout Temp Adjust Still On", comment: "Workout override still on reminder alert title") + let body = NSLocalizedString("Workout Temp Adjust has been turned on for more than 24 hours. Make sure you still want it enabled, or turn it off in the app.", comment: "Workout override still on reminder alert body.") + let content = Alert.Content(title: title, + body: body, + acknowledgeActionButtonLabel: NSLocalizedString("Dismiss", comment: "Default alert dismissal")) + return Alert(identifier: AlertManager.workoutOverrideReminderAlertIdentifier, + foregroundContent: content, + backgroundContent: content, + trigger: .delayed(interval: .hours(24))) + } + + // MARK: - Rescheduling Muted Alerts + + func rescheduleMutedAlerts(_ newValue: AlertMuter.Configuration) { + UserDefaults.standard.alertMuterConfiguration = newValue + rescheduleLoopNotRunningNotifications() + + lookupAllPendingDelayedOrRepeatingAlerts() { [weak self] result in + switch result { + case .success(let persistedAlerts): + for persistedAlert in persistedAlerts { + self?.rescheduleAlertWithSchedulers(persistedAlert.alert, issuedDate: persistedAlert.issuedDate) + } + case .failure(let error): + self?.log.error("error looking up all delayed or repeating alerts: %{public}@", String(describing: error)) + } + } + } +} + +// MARK: AlertManagerResponder implementation + +extension AlertManager: AlertManagerResponder { + func acknowledgeAlert(identifier: Alert.Identifier) { + if let responder = responders[identifier.managerIdentifier]?.value { + responder.acknowledgeAlert(alertIdentifier: identifier.alertIdentifier) { (error) in + if let error = error { + self.presentAcknowledgementFailedAlert(error: error) + } + } + } + userNotificationAlertScheduler.acknowledgeAlert(identifier: identifier) + alertStore.recordAcknowledgement(of: identifier) + } + + func presentAcknowledgementFailedAlert(error: Error) { + DispatchQueue.main.async { + let message: String + if let localizedError = error as? LocalizedError { + message = [localizedError.localizedDescription, localizedError.recoverySuggestion].compactMap({$0}).joined(separator: "\n\n") + } else { + message = String(format: NSLocalizedString("%1$@ is unable to clear the alert from your device", comment: "Message for alert shown when alert acknowledgement fails for a device, and the device does not provide a LocalizedError. (1: app name)"), Bundle.main.bundleDisplayName) + } + self.log.info("Alert acknowledgement failed: %{public}@", message) + + let alert = UIAlertController( + title: NSLocalizedString("Unable To Clear Alert", comment: "Title for alert shown when alert acknowledgement fails"), + message: message, + preferredStyle: .alert) + alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "Default action for alert when alert acknowledgment fails"), style: .default)) + + self.alertPresenter.present(alert, animated: true) + } + } +} + +// MARK: AlertIssuer implementation + +extension AlertManager: AlertIssuer { + + public func issueAlert(_ alert: Alert) { + guard playbackFinished else { + deferredAlerts.append(alert) + return + } + analyticsServicesManager.didIssueAlert(identifier: alert.identifier.value, interruptionLevel: alert.interruptionLevel) + scheduleAlertWithSchedulers(alert) + alertStore.recordIssued(alert: alert) + } + + public func retractAlert(identifier: Alert.Identifier) { + unscheduleAlertWithSchedulers(identifier: identifier) + alertStore.recordRetraction(of: identifier) + } + + private func replayAlert(_ alert: Alert) { + guard alert.identifier != AlertPermissionsChecker.unsafeNotificationPermissionsAlertIdentifier else { + // this alert does not replay through the alert system, since it provides a button to navigate to settings + presentUnsafeNotificationPermissionsInAppAlert() + return + } + + // Only alerts with foreground content are replayed + if alert.foregroundContent != nil { + modalAlertScheduler.scheduleAlert(alert) + } + } + + private func scheduleAlertWithSchedulers(_ alert: Alert, issuedDate: Date = Date()) { + modalAlertScheduler.scheduleAlert(alert) + userNotificationAlertScheduler.scheduleAlert(alert, muted: alertMuter.shouldMuteAlert(alert, issuedDate: issuedDate)) + } + + private func unscheduleAlertWithSchedulers(identifier: Alert.Identifier) { + modalAlertScheduler.unscheduleAlert(identifier: identifier) + userNotificationAlertScheduler.unscheduleAlert(identifier: identifier) + } + + private func rescheduleAlertWithSchedulers(_ alert: Alert, issuedDate: Date) { + unscheduleAlertWithSchedulers(identifier: alert.identifier) + scheduleAlertWithSchedulers(alert, issuedDate: issuedDate) + } +} + +// MARK: Sound Support + +extension AlertManager { + + public static func soundURL(for alert: Alert) -> URL? { + return soundURL(managerIdentifier: alert.identifier.managerIdentifier, sound: alert.sound) + } + + private static func soundURL(managerIdentifier: String, sound: Alert.Sound?) -> URL? { + guard let soundFileName = sound?.filename else { return nil } + + // Seems all the sound files need to be in the sounds directory, so we namespace the filenames + return soundsDirectoryURL.appendingPathComponent("\(managerIdentifier)-\(soundFileName)") + } + + private func initializeSoundVendor(_ managerIdentifier: String, _ soundVendor: AlertSoundVendor) { + let sounds = soundVendor.getSounds() + guard let baseURL = soundVendor.getSoundBaseURL(), !sounds.isEmpty else { + return + } + do { + try fileManager.createDirectory(at: AlertManager.soundsDirectoryURL, withIntermediateDirectories: true, attributes: nil) + for sound in sounds { + if let fromFilename = sound.filename, + let toURL = AlertManager.soundURL(managerIdentifier: managerIdentifier, sound: sound) { + try fileManager.copyIfNewer(from: baseURL.appendingPathComponent(fromFilename), to: toURL) + } + } + } catch { + log.error("Unable to copy sound files from soundVendor %@: %@", managerIdentifier, String(describing: error)) + } + } + +} + +// MARK: Alert Playback + +extension AlertManager { + + func playbackAlertsFromPersistence() { + playbackAlertsFromAlertStore() + } + + private func playbackAlertsFromAlertStore() { + let updateGroup = DispatchGroup() + updateGroup.enter() + alertStore.lookupAllUnacknowledgedUnretracted { + switch $0 { + case .failure(let error): + self.log.error("Could not fetch unacknowledged alerts: %@", error.localizedDescription) + case .success(let alerts): + alerts.forEach { alert in + do { + if let alert = try Alert(from: alert, adjustedForStorageTime: true) { + self.replayAlert(alert) + } + } catch { + self.log.error("Error decoding alert from persistent storage: %@", error.localizedDescription) + } + } + } + updateGroup.leave() + } + updateGroup.enter() + alertStore.lookupAllAcknowledgedUnretractedRepeatingAlerts { + switch $0 { + case .failure(let error): + self.log.error("Could not fetch acknowledged unretracted repeating alerts: %@", error.localizedDescription) + case .success(let alerts): + alerts.forEach { alert in + do { + if let alert = try Alert(from: alert, adjustedForStorageTime: true) { + self.replayAlert(alert) + } + } catch { + self.log.error("Error decoding alert from persistent storage: %@", error.localizedDescription) + } + } + } + updateGroup.leave() + } + updateGroup.notify(queue: .main) { + self.playbackFinished = true + for alert in self.deferredAlerts { + self.issueAlert(alert) + } + } + } + +} + +// MARK: Alert storage access +extension AlertManager { + + func getStoredEntries(startDate: Date, completion: @escaping (_ report: String) -> Void) { + alertStore.executeQuery(since: startDate, limit: 100) { result in + switch result { + case .failure(let error): + completion("Error: \(error)") + case .success(_, let objects): + let encoder = JSONEncoder() + let report = "## Alerts\n" + objects.map { object in + return """ + **\(object.title ?? "??")** + + * identifier: \(object.identifier.value) + * issued: \(object.issuedDate) + * acknowledged: \(object.acknowledgedDate?.description ?? "n/a") + * retracted: \(object.retractedDate?.description ?? "n/a") + * trigger: \(object.trigger) + * interruptionLevel: \(object.interruptionLevel) + * foregroundContent: \((try? encoder.encodeToStringIfPresent(object.foregroundContent)) ?? "n/a") + * backgroundContent: \((try? encoder.encodeToStringIfPresent(object.backgroundContent)) ?? "n/a") + * sound: \((try? encoder.encodeToStringIfPresent(object.sound)) ?? "n/a") + * metadata: \((try? encoder.encodeToStringIfPresent(object.metadata)) ?? "n/a") + + """ + }.joined(separator: "\n") + completion(report) + } + } + } +} + +// MARK: PersistedAlertStore +extension AlertManager: PersistedAlertStore { + public func doesIssuedAlertExist(identifier: Alert.Identifier, completion: @escaping (Result) -> Void) { + alertStore.lookupAllMatching(identifier: identifier) { result in + switch result { + case .success(let storedAlerts): + completion(.success(!storedAlerts.isEmpty)) + case .failure(let error): + completion(.failure(error)) + } + } + } + + public func lookupAllUnretracted(managerIdentifier: String, completion: @escaping (Result<[PersistedAlert], Error>) -> Void) { + alertStore.lookupAllUnretracted(managerIdentifier: managerIdentifier) { + switch $0 { + case .success(let alerts): + do { + let result = try alerts.compactMap { + if let alert = try Alert(from: $0, adjustedForStorageTime: false) { + return PersistedAlert( + alert: alert, + issuedDate: $0.issuedDate, + retractedDate: $0.retractedDate, + acknowledgedDate: $0.acknowledgedDate + ) + } else { + return nil + } + } + completion(.success(result)) + } catch { + completion(.failure(error)) + } + case .failure(let error): + completion(.failure(error)) + } + } + } + + public func lookupAllUnacknowledgedUnretracted(managerIdentifier: String, completion: @escaping (Result<[PersistedAlert], Error>) -> Void) { + alertStore.lookupAllUnacknowledgedUnretracted(managerIdentifier: managerIdentifier) { + switch $0 { + case .success(let alerts): + do { + let result = try alerts.compactMap { + if let alert = try Alert(from: $0, adjustedForStorageTime: false) { + return PersistedAlert( + alert: alert, + issuedDate: $0.issuedDate, + retractedDate: $0.retractedDate, + acknowledgedDate: $0.acknowledgedDate + ) + } else { + return nil + } + } + completion(.success(result)) + } catch { + completion(.failure(error)) + } + case .failure(let error): + completion(.failure(error)) + } + } + } + + private func lookupAllPendingDelayedOrRepeatingAlerts(completion: @escaping (Result<[PersistedAlert], Error>) -> Void) { + // the interval provided is not used in the search. Just the trigger stored type value + alertStore.lookupAllUnacknowledgedUnretracted(filteredByTriggers: [Alert.Trigger.delayed(interval: 0).storedType, Alert.Trigger.repeating(repeatInterval: 0).storedType]) { + switch $0 { + case .success(let alerts): + do { + let result = try alerts.compactMap { + if let alert = try Alert(from: $0, adjustedForStorageTime: false) { + return PersistedAlert( + alert: alert, + issuedDate: $0.issuedDate, + retractedDate: $0.retractedDate, + acknowledgedDate: $0.acknowledgedDate + ) + } else { + return nil + } + } + completion(.success(result)) + } catch { + completion(.failure(error)) + } + case .failure(let error): + completion(.failure(error)) + } + } + } + + public func recordRetractedAlert(_ alert: Alert, at date: Date) { + alertStore.recordRetractedAlert(alert, at: date) + } + + private func recordIssued(alert: Alert, at date: Date = Date(), completion: ((Result) -> Void)? = nil) { + alertStore.recordIssued(alert: alert, at: date, completion: completion) + } +} + +// MARK: Extensions + +fileprivate extension SyncAlertObject { + var title: String? { + return foregroundContent?.title ?? backgroundContent?.title + } +} + +extension FileManager { + + func ensureDirectoryExists(at url: URL, with protectionType: FileProtectionType? = nil) throws { + try createDirectory(at: url, withIntermediateDirectories: true, attributes: protectionType.map { [FileAttributeKey.protectionKey: $0 ] }) + guard let protectionType = protectionType else { + return + } + // double check protection type + var attrs = try attributesOfItem(atPath: url.path) + if attrs[FileAttributeKey.protectionKey] as? FileProtectionType != protectionType { + attrs[FileAttributeKey.protectionKey] = protectionType + try setAttributes(attrs, ofItemAtPath: url.path) + } + } + + func copyIfNewer(from fromURL: URL, to toURL: URL) throws { + if fileExists(atPath: toURL.path) { + // If the source file is newer, remove the old one, otherwise skip it. + let toCreationDate = try toURL.fileCreationDate(self) + let fromCreationDate = try fromURL.fileCreationDate(self) + if fromCreationDate > toCreationDate { + try removeItem(at: toURL) + } else { + return + } + } + try copyItem(at: fromURL, to: toURL) + } +} + +fileprivate extension URL { + + func fileCreationDate(_ fileManager: FileManager) throws -> Date { + return try fileManager.attributesOfItem(atPath: self.path)[.creationDate] as! Date + } +} + + +// MARK: - BluetoothObserver +extension AlertManager: BluetoothObserver { + public func bluetoothDidUpdateState(_ state: BluetoothState) { + switch state { + case .poweredOn: + onBluetoothPoweredOn() + case .poweredOff: + onBluetoothPoweredOff() + case .unauthorized: + onBluetoothPermissionDenied() + default: + return + } + } +} + + +// MARK: - PresetActivationObserver +extension AlertManager: PresetActivationObserver { + func presetActivated(context: TemporaryScheduleOverride.Context, duration: TemporaryScheduleOverride.Duration) { + switch context { + case .legacyWorkout: + if duration == .indefinite { + scheduleWorkoutOverrideReminder() + } + default: + break + } + } + + func presetDeactivated(context: TemporaryScheduleOverride.Context) { + switch context { + case .legacyWorkout: + retractWorkoutOverrideReminder() + default: + break + } + } +} + +// MARK: - Issue/Retract Alert Permissions Warning +extension AlertManager: AlertPermissionsCheckerDelegate { + func notificationsPermissions(requiresRiskMitigation: Bool, scheduledDeliveryEnabled: Bool) { + if !issueOrRetract(alert: AlertPermissionsChecker.unsafeNotificationPermissionsAlert, + condition: requiresRiskMitigation, + alreadyIssued: UserDefaults.standard.hasIssuedNotificationPermissionsAlert, + setAlreadyIssued: { UserDefaults.standard.hasIssuedNotificationPermissionsAlert = $0 }, + issueHandler: { alert in + // in-app modal is presented with a button to navigate to settings + self.presentUnsafeNotificationPermissionsInAppAlert() + self.userNotificationAlertScheduler.scheduleAlert(alert, muted: self.alertMuter.shouldMuteAlert(alert)) + self.recordIssued(alert: alert) + }, + retractionHandler: { alert in + // need to dismiss the in-app alert outside of the alert system + self.recordRetractedAlert(alert, at: Date()) + self.dismissUnsafeNotificationPermissionsInAppAlert() + }) { + _ = issueOrRetract(alert: AlertPermissionsChecker.scheduledDeliveryEnabledAlert, + condition: scheduledDeliveryEnabled, + alreadyIssued: UserDefaults.standard.hasIssuedScheduledDeliveryEnabledAlert, + setAlreadyIssued: { UserDefaults.standard.hasIssuedScheduledDeliveryEnabledAlert = $0 }, + issueHandler: { alert in self.issueAlert(alert) }, + retractionHandler: { alert in self.retractAlert(identifier: alert.identifier) }) + } + } + + private func issueOrRetract(alert: LoopKit.Alert, + condition: Bool, + alreadyIssued: Bool, + setAlreadyIssued: (Bool) -> Void, + issueHandler: @escaping (LoopKit.Alert) -> Void, + retractionHandler: @escaping (LoopKit.Alert) -> Void) -> Bool { + + if condition { + if !alreadyIssued { + issueHandler(alert) + setAlreadyIssued(true) + } + return true + } else { + if alreadyIssued { + setAlreadyIssued(false) + retractionHandler(alert) + } + return false + } + } + + private func presentUnsafeNotificationPermissionsInAppAlert() { + DispatchQueue.main.async { + let alertController = AlertPermissionsChecker.constructUnsafeNotificationPermissionsInAppAlert() { [weak self] in + self?.acknowledgeAlert(identifier: AlertPermissionsChecker.unsafeNotificationPermissionsAlertIdentifier) + } + self.alertPresenter.present(alertController, animated: true) { [weak self] in + // the completion is called after the alert is presented + self?.unsafeNotificationPermissionsAlertController = alertController + } + } + } + + private func dismissUnsafeNotificationPermissionsInAppAlert() { + guard let alertController = unsafeNotificationPermissionsAlertController else { return } + alertPresenter.dismissAlert(alertController, animated: true) { [weak self] in + self?.unsafeNotificationPermissionsAlertController = nil + } + } +} + +extension AlertManager { + func presentLoopResetConfirmationAlert(confirmAction: @escaping (@escaping () -> Void) -> Void, cancelAction: @escaping () -> Void) { + let alert = UIAlertController(title: "Loop Reset Requested", message: "We've detected a Loop reset may be needed. Tapping confirm will reset Loop and quit the app.", preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "Confirm", style: .default, handler: { _ in + confirmAction() { + fatalError("DEBUG: Resetting Loop") + } + })) + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: { _ in + cancelAction() + })) + + alertPresenter.present(alert, animated: true) + } + + func presentCouldNotResetLoopAlert(error: Error) { + let titleString = String(format: NSLocalizedString("Could Not Restart %1$@", comment: "Format string for title of reset loop alert. (1: App name)"), Bundle.main.bundleDisplayName) + let message = String(format: NSLocalizedString("While trying to restart %1$@ an error occured.\n\n%2$@", comment: "Format string for message of reset loop alert. (1: App name) (2: error description)"), Bundle.main.bundleDisplayName, error.localizedDescription) + let alert = UIAlertController(title: titleString, message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "Cancel button for reset loop alert"), style: .cancel)) + + alertPresenter.present(alert, animated: true) + } +} + +fileprivate extension UserDefaults { + private enum Key: String { + case hasIssuedNotificationPermissionsAlert = "com.loopkit.Loop.HasIssuedNotificationPermissionsAlert" + case hasIssuedScheduledDeliveryEnabledAlert = "com.loopkit.Loop.HasIssuedScheduledDeliveryEnabledAlert" + case alertMuterConfiguration = "com.loopkit.Loop.alertMuterConfiguration" + } + + var hasIssuedNotificationPermissionsAlert: Bool { + get { + return object(forKey: Key.hasIssuedNotificationPermissionsAlert.rawValue) as? Bool ?? false + } + set { + set(newValue, forKey: Key.hasIssuedNotificationPermissionsAlert.rawValue) + } + } + + var hasIssuedScheduledDeliveryEnabledAlert: Bool { + get { + return object(forKey: Key.hasIssuedScheduledDeliveryEnabledAlert.rawValue) as? Bool ?? false + } + set { + set(newValue, forKey: Key.hasIssuedScheduledDeliveryEnabledAlert.rawValue) + } + } + + var alertMuterConfiguration: AlertMuter.Configuration { + get { + if let alertMuterConfigurationRawValue = object(forKey: Key.alertMuterConfiguration.rawValue) as? AlertMuter.Configuration.RawValue, + let alertMuterConfiguration = AlertMuter.Configuration(rawValue: alertMuterConfigurationRawValue) + { + return alertMuterConfiguration + } else { + return AlertMuter().configuration + } + } + set { + set(newValue.rawValue, forKey: Key.alertMuterConfiguration.rawValue) + } + } +} diff --git a/Loop/Managers/Alerts/AlertStore.swift b/Loop/Managers/Alerts/AlertStore.swift new file mode 100644 index 0000000000..d8d6db7e5c --- /dev/null +++ b/Loop/Managers/Alerts/AlertStore.swift @@ -0,0 +1,636 @@ +// +// AlertStore.swift +// Loop +// +// Created by Rick Pasetto on 5/11/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import CoreData +import LoopKit + +public protocol AlertStoreDelegate: AnyObject { + /** + Informs the delegate that the alert store has updated alert data. + + - Parameter alertStore: The alert store that has updated alert data. + */ + func alertStoreHasUpdatedAlertData(_ alertStore: AlertStore) +} + +public class AlertStore { + public weak var delegate: AlertStoreDelegate? + + static let totalFetchLimit = 500 + + public enum AlertStoreError: Error { + case notFound + } + + private enum PostUpdateAction { + case save, delete + } + private typealias ManagedObjectUpdateBlock = (StoredAlert) -> PostUpdateAction + + // Available for tests only + let managedObjectContext: NSManagedObjectContext + + private let persistentContainer: NSPersistentContainer + + private let expireAfter: TimeInterval + + private let log = DiagnosticLog(category: "AlertStore") + + // This is terribly inconvenient, but it turns out that executing the following expression in CoreData _differs_ + // depending on whether it is in-memory or SQLite + private let predicateExpressionNotYetExpiredSQLite = "issuedDate + triggerInterval < %@" + private let predicateExpressionNotYetExpiredInMemory = "CAST(issuedDate, 'NSNumber') + triggerInterval < CAST(%@, 'NSNumber')" + private let predicateExpressionNotYetExpired: String + + public init(storageDirectoryURL: URL? = nil, expireAfter: TimeInterval = 24 /* hours */ * 60 /* minutes */ * 60 /* seconds */) { + managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) + managedObjectContext.mergePolicy = NSMergeByPropertyStoreTrumpMergePolicy + managedObjectContext.automaticallyMergesChangesFromParent = true + + let storeDescription = NSPersistentStoreDescription() + if let storageDirectoryURL = storageDirectoryURL { + let storageFileURL = storageDirectoryURL + .appendingPathComponent("AlertStore.sqlite") + storeDescription.url = storageFileURL + predicateExpressionNotYetExpired = predicateExpressionNotYetExpiredSQLite + } else { + storeDescription.type = NSInMemoryStoreType + predicateExpressionNotYetExpired = predicateExpressionNotYetExpiredInMemory + } + storeDescription.shouldMigrateStoreAutomatically = true + storeDescription.shouldInferMappingModelAutomatically = true + persistentContainer = NSPersistentContainer(name: "AlertStore") + persistentContainer.persistentStoreDescriptions = [storeDescription] + + let group = DispatchGroup() + group.enter() + persistentContainer.loadPersistentStores { _, error in + if let error = error { + fatalError("Unable to load persistent stores: \(error)") + } + group.leave() + } + group.wait() + + managedObjectContext.persistentStoreCoordinator = persistentContainer.persistentStoreCoordinator + + self.expireAfter = expireAfter + } + + public func recordIssued(alert: Alert, at date: Date = Date(), completion: ((Result) -> Void)? = nil) { + self.managedObjectContext.performAndWait { + _ = StoredAlert(from: alert, context: self.managedObjectContext, issuedDate: date) + do { + try self.managedObjectContext.save() + self.log.default("Recorded alert: %{public}@", alert.identifier.value) + self.purgeExpired() + self.delegate?.alertStoreHasUpdatedAlertData(self) + completion?(.success) + } catch { + self.log.error("Could not store alert: %{public}@, %{public}@", alert.identifier.value, String(describing: error)) + completion?(.failure(error)) + } + } + } + + public func recordRetractedAlert(_ alert: Alert, at date: Date, completion: ((Result) -> Void)? = nil) { + self.managedObjectContext.performAndWait { + let storedAlert = StoredAlert(from: alert, context: self.managedObjectContext, issuedDate: date) + storedAlert.retractedDate = date + do { + try self.managedObjectContext.save() + self.log.default("Recorded retracted alert: %{public}@", alert.identifier.value) + self.purgeExpired() + self.delegate?.alertStoreHasUpdatedAlertData(self) + completion?(.success) + } catch { + self.log.error("Could not store retracted alert: %{public}@, %{public}@", alert.identifier.value, String(describing: error)) + completion?(.failure(error)) + } + } + } + + public func recordAcknowledgement(of identifier: Alert.Identifier, at date: Date = Date(), + completion: ((Result) -> Void)? = nil) { + recordUpdateOfAll(identifier: identifier, + addingPredicate: NSPredicate(format: "acknowledgedDate == nil"), + with: { + $0.acknowledgedDate = date + return .save + }, + completion: completion) + } + + public func recordRetraction(of identifier: Alert.Identifier, at date: Date = Date(), + completion: ((Result) -> Void)? = nil) { + recordUpdateOfLatest(identifier: identifier, + addingPredicate: NSPredicate(format: "retractedDate == nil"), + with: { + // if the alert was retracted before it was ever shown, delete it. + // Note: this only applies to .delayed or .repeating alerts! + if let delay = $0.trigger.interval, $0.issuedDate + delay >= date { + return .delete + } else { + $0.retractedDate = date + return .save + } + }, + completion: completion) + } + + public func lookupAllMatching(identifier: Alert.Identifier, completion: @escaping (Result<[StoredAlert], Error>) -> Void) { + managedObjectContext.perform { + do { + let fetchRequest: NSFetchRequest = StoredAlert.fetchRequest() + let predicates = [ + NSPredicate(format: "managerIdentifier = %@", identifier.managerIdentifier), + NSPredicate(format: "alertIdentifier = %@", identifier.alertIdentifier), + ] + fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) + fetchRequest.sortDescriptors = [ NSSortDescriptor(key: "modificationCounter", ascending: true) ] + let result = try self.managedObjectContext.fetch(fetchRequest) + completion(.success(result)) + } catch { + completion(.failure(error)) + } + } + } + + public func lookupAllUnretracted(managerIdentifier: String? = nil, completion: @escaping (Result<[StoredAlert], Error>) -> Void) { + managedObjectContext.perform { + do { + let fetchRequest: NSFetchRequest = StoredAlert.fetchRequest() + var predicates = [ + NSPredicate(format: "retractedDate == nil"), + ] + if let managerIdentifier = managerIdentifier { + predicates.insert(NSPredicate(format: "managerIdentifier = %@", managerIdentifier), at: 0) + } + fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) + fetchRequest.sortDescriptors = [ NSSortDescriptor(key: "modificationCounter", ascending: true) ] + let result = try self.managedObjectContext.fetch(fetchRequest) + completion(.success(result)) + } catch { + completion(.failure(error)) + } + } + } + + public func lookupAllUnacknowledgedUnretracted(managerIdentifier: String? = nil, filteredByTriggers triggersStoredType: [AlertTriggerStoredType]? = nil, completion: @escaping (Result<[StoredAlert], Error>) -> Void) { + managedObjectContext.perform { + do { + let fetchRequest: NSFetchRequest = StoredAlert.fetchRequest() + var predicates = [ + NSPredicate(format: "acknowledgedDate == nil"), + NSPredicate(format: "retractedDate == nil"), + ] + if let managerIdentifier = managerIdentifier { + predicates.insert(NSPredicate(format: "managerIdentifier = %@", managerIdentifier), at: 0) + } + if let triggersStoredType = triggersStoredType { + var triggerPredicates: [NSPredicate] = [] + for triggerStoredType in triggersStoredType { + triggerPredicates.append(NSPredicate(format: "triggerType == %d", triggerStoredType)) + } + let triggerFilterPredicate = NSCompoundPredicate(orPredicateWithSubpredicates: triggerPredicates) + predicates.append(triggerFilterPredicate) + } + fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) + fetchRequest.sortDescriptors = [ NSSortDescriptor(key: "modificationCounter", ascending: true) ] + let result = try self.managedObjectContext.fetch(fetchRequest) + completion(.success(result)) + } catch { + completion(.failure(error)) + } + } + } + + public func lookupAllAcknowledgedUnretractedRepeatingAlerts(completion: @escaping (Result<[StoredAlert], Error>) -> Void) { + managedObjectContext.perform { + do { + let fetchRequest: NSFetchRequest = StoredAlert.fetchRequest() + let repeatingTrigger = Alert.Trigger.repeating(repeatInterval: 0) + fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ + NSPredicate(format: "acknowledgedDate != nil"), + NSPredicate(format: "retractedDate == nil"), + NSPredicate(format: "triggerType == \(repeatingTrigger.storedType)") + ]) + fetchRequest.sortDescriptors = [ NSSortDescriptor(key: "modificationCounter", ascending: true) ] + let result = try self.managedObjectContext.fetch(fetchRequest) + completion(.success(result)) + } catch { + completion(.failure(error)) + } + } + } + +} + +// MARK: Private functions + +extension AlertStore { + + private func recordUpdateOfAll(identifier: Alert.Identifier, + addingPredicate predicate: NSPredicate, + with updateBlock: @escaping ManagedObjectUpdateBlock, + completion: ((Result) -> Void)?) { + managedObjectContext.performAndWait { + self.lookupAll(identifier: identifier, predicate: predicate) { + switch $0 { + case .success(let objects): + if objects.count > 0 { + let result = self.update(objects: objects, with: updateBlock) + completion?(result) + } else { + self.log.error("Alert not found for update: %{public}@", identifier.value) + completion?(.failure(AlertStoreError.notFound)) + } + case .failure(let error): + completion?(.failure(error)) + } + } + } + } + + private func recordUpdateOfLatest(identifier: Alert.Identifier, + addingPredicate predicate: NSPredicate, + with updateBlock: @escaping ManagedObjectUpdateBlock, + completion: ((Result) -> Void)?) { + managedObjectContext.performAndWait { + self.lookupLatest(identifier: identifier, predicate: predicate) { + switch $0 { + case .success(let object): + if let object = object { + let result = self.update(objects: [object], with: updateBlock) + completion?(result) + } else { + self.log.error("Alert not found for update: %{public}@", identifier.value) + completion?(.failure(AlertStoreError.notFound)) + } + case .failure(let error): + completion?(.failure(error)) + } + } + } + } + + private func update(objects: [StoredAlert], with updateBlock: @escaping ManagedObjectUpdateBlock) -> Result { + objects.forEach { alert in + let shouldDelete = updateBlock(alert) == .delete + if shouldDelete { + self.managedObjectContext.delete(alert) + } + self.log.default("%{public}@ alert: %{public}@", shouldDelete ? "Deleted" : "Recorded", alert.identifier.value) + } + do { + try self.managedObjectContext.save() + } catch { + return .failure(error) + } + self.purgeExpired() + self.delegate?.alertStoreHasUpdatedAlertData(self) + return .success + } + + + private func lookupAll(identifier: Alert.Identifier, predicate: NSPredicate, completion: @escaping (Result<[StoredAlert], Error>) -> Void) { + managedObjectContext.perform { + do { + let fetchRequest: NSFetchRequest = StoredAlert.fetchRequest() + fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ + identifier.equalsPredicate, + predicate + ]) + fetchRequest.fetchLimit = Self.totalFetchLimit + let result = try self.managedObjectContext.fetch(fetchRequest) + completion(.success(result)) + } catch { + completion(.failure(error)) + } + } + } + + private func lookupLatest(identifier: Alert.Identifier, predicate: NSPredicate, completion: @escaping (Result) -> Void) { + managedObjectContext.perform { + do { + let fetchRequest: NSFetchRequest = StoredAlert.fetchRequest() + fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ + identifier.equalsPredicate, + predicate + ]) + fetchRequest.sortDescriptors = [ NSSortDescriptor(key: "modificationCounter", ascending: false) ] + fetchRequest.fetchLimit = 1 + let result = try self.managedObjectContext.fetch(fetchRequest) + completion(.success(result.last)) + } catch { + completion(.failure(error)) + } + } + } +} + +// MARK: Alert Purging + +extension AlertStore { + var expireDate: Date { + return Date(timeIntervalSinceNow: -expireAfter) + } + + // Must be invoked within NSManagedObjectContext perform or performAndWait block + private func purgeExpired() { + purge(before: expireDate) + } + + func purge(before date: Date, completion: (Error?) -> Void) { + var error: Error? + self.managedObjectContext.performAndWait { + error = purge(before: date) + } + completion(error) + } + + @discardableResult + func purge(before date: Date) -> Error? { + do { + let fetchRequest: NSFetchRequest = StoredAlert.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "issuedDate < %@", date as NSDate) + let count = try self.managedObjectContext.deleteObjects(matching: fetchRequest) + self.log.info("Purged %d StoredAlerts", count) + return nil + } catch let error { + self.log.error("Unable to purge StoredAlerts: %{public}@", String(describing: error)) + return error + } + } +} + +// MARK: Query Support + +public protocol QueryFilter { + var predicate: NSPredicate? { get } +} + +extension AlertStore { + + public struct QueryAnchor: RawRepresentable, Equatable { + public typealias RawValue = [String: Any] + + internal var modificationCounter: Int64 + + public init() { + self.modificationCounter = 0 + } + + public init?(rawValue: RawValue) { + guard let modificationCounter = rawValue["modificationCounter"] as? Int64 else { + return nil + } + self.modificationCounter = modificationCounter + } + + public var rawValue: RawValue { + var rawValue: RawValue = [:] + rawValue["modificationCounter"] = modificationCounter + return rawValue + } + } + + public struct SinceDateFilter: QueryFilter { + public let predicateExpressionNotYetExpired: String + public let date: Date + public let excludingFutureAlerts: Bool + public let now: Date + public var predicate: NSPredicate? { + let datePredicate = NSPredicate(format: "issuedDate >= %@", date as NSDate) + // This predicate only _includes_ a record if it either has no interval (i.e. is 'immediate') + // _or_ it is a 'delayed' or 'repeating' alert (a non-nil triggerInterval) whose time has already come + // (that is, issuedDate + triggerInterval < now). + let futurePredicate = NSPredicate(format: "triggerInterval == nil OR \(predicateExpressionNotYetExpired)", now as NSDate) + return excludingFutureAlerts ? + NSCompoundPredicate(andPredicateWithSubpredicates: [datePredicate, futurePredicate]) + : datePredicate + } + } + + public enum AlertQueryResult { + case success(QueryAnchor, [SyncAlertObject]) + case failure(Error) + } + + func executeQuery(fromQueryAnchor queryAnchor: QueryAnchor? = nil, since date: Date, excludingFutureAlerts: Bool = true, now: Date = Date(), limit: Int, completion: @escaping (AlertQueryResult) -> Void) { + let sinceDateFilter = SinceDateFilter(predicateExpressionNotYetExpired: predicateExpressionNotYetExpired, + date: date, + excludingFutureAlerts: excludingFutureAlerts, + now: now) + executeAlertQuery(fromQueryAnchor: queryAnchor, queryFilter: sinceDateFilter, limit: limit, completion: completion) + } + + func executeAlertQuery(fromQueryAnchor queryAnchor: QueryAnchor?, queryFilter: QueryFilter? = nil, limit: Int, completion: @escaping (AlertQueryResult) -> Void) { + var queryAnchor = queryAnchor ?? QueryAnchor() + var queryResult = [SyncAlertObject]() + var queryError: Error? + + guard limit > 0 else { + completion(.success(queryAnchor, [])) + return + } + + self.managedObjectContext.performAndWait { + let storedRequest: NSFetchRequest = StoredAlert.fetchRequest() + + let queryAnchorPredicate = NSPredicate(format: "modificationCounter > %d", queryAnchor.modificationCounter) + if let queryFilterPredicate = queryFilter?.predicate { + storedRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [queryAnchorPredicate, queryFilterPredicate]) + } else { + storedRequest.predicate = queryAnchorPredicate + } + storedRequest.sortDescriptors = [NSSortDescriptor(key: "modificationCounter", ascending: true)] + storedRequest.fetchLimit = limit + + do { + let stored = try self.managedObjectContext.fetch(storedRequest) + if let modificationCounter = stored.max(by: { $0.modificationCounter < $1.modificationCounter })?.modificationCounter { + queryAnchor.modificationCounter = modificationCounter + } + queryResult.append(contentsOf: stored.compactMap { try? SyncAlertObject(managedObject: $0) }) + } catch let error { + queryError = error + return + } + } + + if let queryError = queryError { + completion(.failure(queryError)) + return + } + + completion(.success(queryAnchor, queryResult)) + } + + // At the moment, this is only used for unit testing + internal func fetch(identifier: Alert.Identifier? = nil, completion: @escaping (Result<[StoredAlert], Error>) -> Void) { + self.managedObjectContext.perform { + let storedRequest: NSFetchRequest = StoredAlert.fetchRequest() + storedRequest.predicate = identifier?.equalsPredicate + storedRequest.sortDescriptors = [NSSortDescriptor(key: "modificationCounter", ascending: true)] + do { + let stored = try self.managedObjectContext.fetch(storedRequest) + completion(.success(stored)) + } catch { + completion(.failure(error)) + } + } + } +} + +extension Alert.Identifier { + var equalsPredicate: NSPredicate { + return NSCompoundPredicate(andPredicateWithSubpredicates: [ + NSPredicate(format: "managerIdentifier == %@", managerIdentifier), + NSPredicate(format: "alertIdentifier == %@", alertIdentifier) + ]) + } +} + +extension Alert.Trigger { + var interval: TimeInterval? { + switch self { + case .delayed(let interval): return interval + case .repeating(let repeatInterval): return repeatInterval + case .immediate: return nil + } + } +} + +extension Result where Success == Void { + static var success: Result { + return Result.success(Void()) + } +} + +// MARK: - Critical Event Log Export + +extension AlertStore: CriticalEventLog { + private var exportProgressUnitCountPerObject: Int64 { 1 } + private var exportFetchLimit: Int { Int(criticalEventLogExportProgressUnitCountPerFetch / exportProgressUnitCountPerObject) } + + public var exportName: String { "Alerts.json" } + + public func exportProgressTotalUnitCount(startDate: Date, endDate: Date? = nil) -> Result { + var result: Result? + + self.managedObjectContext.performAndWait { + do { + let request: NSFetchRequest = StoredAlert.fetchRequest() + request.predicate = self.exportDatePredicate(startDate: startDate, endDate: endDate) + + let objectCount = try self.managedObjectContext.count(for: request) + result = .success(Int64(objectCount) * exportProgressUnitCountPerObject) + } catch let error { + result = .failure(error) + } + } + + return result! + } + + public func export(startDate: Date, endDate: Date, to stream: DataOutputStream, progress: Progress) -> Error? { + let encoder = JSONStreamEncoder(stream: stream) + var modificationCounter: Int64 = 0 + var fetching = true + var error: Error? + + while fetching && error == nil { + self.managedObjectContext.performAndWait { + do { + guard !progress.isCancelled else { + throw CriticalEventLogError.cancelled + } + + let request: NSFetchRequest = StoredAlert.fetchRequest() + request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [NSPredicate(format: "modificationCounter > %d", modificationCounter), + self.exportDatePredicate(startDate: startDate, endDate: endDate)]) + request.sortDescriptors = [NSSortDescriptor(key: "modificationCounter", ascending: true)] + request.fetchLimit = self.exportFetchLimit + + let objects = try self.managedObjectContext.fetch(request) + if objects.isEmpty { + fetching = false + return + } + + try encoder.encode(objects) + + modificationCounter = objects.last!.modificationCounter + + progress.completedUnitCount += Int64(objects.count) * exportProgressUnitCountPerObject + } catch let fetchError { + error = fetchError + } + } + } + + if let closeError = encoder.close(), error == nil { + error = closeError + } + + return error + } + + private func exportDatePredicate(startDate: Date, endDate: Date? = nil) -> NSPredicate { + var issuedDatePredicate = NSPredicate(format: "issuedDate >= %@", startDate as NSDate) + var acknowledgedDatePredicate = NSPredicate(format: "acknowledgedDate >= %@", startDate as NSDate) + var retractedDatePredicate = NSPredicate(format: "retractedDate >= %@", startDate as NSDate) + if let endDate = endDate { + issuedDatePredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [issuedDatePredicate, NSPredicate(format: "issuedDate < %@", endDate as NSDate)]) + acknowledgedDatePredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [acknowledgedDatePredicate, NSPredicate(format: "acknowledgedDate < %@", endDate as NSDate)]) + retractedDatePredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [retractedDatePredicate, NSPredicate(format: "retractedDate < %@", endDate as NSDate)]) + } + return NSCompoundPredicate(orPredicateWithSubpredicates: [issuedDatePredicate, acknowledgedDatePredicate, retractedDatePredicate]) + } +} + +// MARK: - Core Data (Bulk) - TEST ONLY + +extension AlertStore { + struct DatedAlert { + let date: Date + let alert: Alert + let syncIdentifier: UUID + } + + func addAlerts(alerts: [DatedAlert]) -> Error? { + guard !alerts.isEmpty else { + return nil + } + + var error: Error? + + self.managedObjectContext.performAndWait { + for alert in alerts { + let storedAlert = StoredAlert(from: alert.alert, context: self.managedObjectContext, issuedDate: alert.date, syncIdentifier: alert.syncIdentifier) + storedAlert.acknowledgedDate = alert.date + } + + do { + try self.managedObjectContext.save() + } catch let saveError { + error = saveError + } + } + + guard error == nil else { + return error + } + + self.delegate?.alertStoreHasUpdatedAlertData(self) + + self.log.info("Added %d StoredAlerts", alerts.count) + return nil + } +} diff --git a/Loop/Managers/Alerts/AlertStore.xcdatamodeld/AlertStore.xcdatamodel/contents b/Loop/Managers/Alerts/AlertStore.xcdatamodeld/AlertStore.xcdatamodel/contents new file mode 100644 index 0000000000..49a439dd37 --- /dev/null +++ b/Loop/Managers/Alerts/AlertStore.xcdatamodeld/AlertStore.xcdatamodel/contents @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Loop/Managers/Alerts/InAppModalAlertScheduler.swift b/Loop/Managers/Alerts/InAppModalAlertScheduler.swift new file mode 100644 index 0000000000..b00c809f7a --- /dev/null +++ b/Loop/Managers/Alerts/InAppModalAlertScheduler.swift @@ -0,0 +1,154 @@ +// +// InAppModalAlertScheduler.swift +// LoopKit +// +// Created by Rick Pasetto on 4/9/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import UIKit +import LoopKit + +public class InAppModalAlertScheduler { + + private weak var alertPresenter: AlertPresenter? + private weak var alertManagerResponder: AlertManagerResponder? + + private var alertsPresented: [Alert.Identifier: (UIAlertController, Alert)] = [:] + private var alertsPending: [Alert.Identifier: (Timer, Alert)] = [:] + + typealias ActionFactoryFunction = (String?, UIAlertAction.Style, ((UIAlertAction) -> Void)?) -> UIAlertAction + private let newActionFunc: ActionFactoryFunction + + typealias TimerFactoryFunction = (TimeInterval, Bool, (() -> Void)?) -> Timer + private let newTimerFunc: TimerFactoryFunction + + init(alertPresenter: AlertPresenter?, + alertManagerResponder: AlertManagerResponder, + newActionFunc: @escaping ActionFactoryFunction = UIAlertAction.init, + newTimerFunc: TimerFactoryFunction? = nil) + { + self.alertPresenter = alertPresenter + self.alertManagerResponder = alertManagerResponder + self.newActionFunc = newActionFunc + self.newTimerFunc = newTimerFunc ?? { timeInterval, repeats, block in + return Timer.scheduledTimer(withTimeInterval: timeInterval, repeats: repeats) { _ in block?() } + } + } + + public func scheduleAlert(_ alert: Alert) { + switch alert.trigger { + case .immediate: + show(alert: alert) + case .delayed(let interval): + schedule(alert: alert, interval: interval, repeats: false) + case .repeating(let interval): + schedule(alert: alert, interval: interval, repeats: true) + } + } + + public func unscheduleAlert(identifier: Alert.Identifier) { + DispatchQueue.main.async { + self.removePendingAlert(identifier: identifier) + self.removePresentedAlert(identifier: identifier) + } + } + + func removePresentedAlert(identifier: Alert.Identifier, completion: (() -> Void)? = nil) { + guard let alertPresented = alertsPresented[identifier] else { + completion?() + return + } + alertPresenter?.dismissAlert(alertPresented.0, animated: true, completion: completion) + clearPresentedAlert(identifier: identifier) + } + + func removePendingAlert(identifier: Alert.Identifier) { + guard let alertPending = alertsPending[identifier] else { return } + alertPending.0.invalidate() + clearPendingAlert(identifier: identifier) + } +} + +/// Private functions +extension InAppModalAlertScheduler { + + private func schedule(alert: Alert, interval: TimeInterval, repeats: Bool) { + guard alert.foregroundContent != nil else { + return + } + DispatchQueue.main.async { + if self.isAlertPending(identifier: alert.identifier) { + return + } + let timer = self.newTimerFunc(interval, repeats) { [weak self] in + self?.show(alert: alert) + if !repeats { + self?.clearPendingAlert(identifier: alert.identifier) + } + } + self.addPendingAlert(alert: alert, timer: timer) + } + } + + private func show(alert: Alert) { + guard let content = alert.foregroundContent else { + return + } + DispatchQueue.main.async { + if self.isAlertPresented(identifier: alert.identifier) { + return + } + let alertController = self.constructAlert(title: content.title, + message: content.body, + action: content.acknowledgeActionButtonLabel, + isCritical: alert.interruptionLevel == .critical) { [weak self] in + // the completion is called after the alert is acknowledged + self?.clearPresentedAlert(identifier: alert.identifier) + self?.alertManagerResponder?.acknowledgeAlert(identifier: alert.identifier) + } + self.alertPresenter?.present(alertController, animated: true) { [weak self] in + // the completion is called after the alert is presented + self?.addPresentedAlert(alert: alert, controller: alertController) + } + } + } + + private func addPendingAlert(alert: Alert, timer: Timer) { + dispatchPrecondition(condition: .onQueue(.main)) + alertsPending[alert.identifier] = (timer, alert) + } + + private func addPresentedAlert(alert: Alert, controller: UIAlertController) { + dispatchPrecondition(condition: .onQueue(.main)) + alertsPresented[alert.identifier] = (controller, alert) + } + + private func clearPendingAlert(identifier: Alert.Identifier) { + dispatchPrecondition(condition: .onQueue(.main)) + alertsPending[identifier] = nil + } + + private func clearPresentedAlert(identifier: Alert.Identifier) { + dispatchPrecondition(condition: .onQueue(.main)) + alertsPresented[identifier] = nil + } + + private func isAlertPending(identifier: Alert.Identifier) -> Bool { + dispatchPrecondition(condition: .onQueue(.main)) + return alertsPending.index(forKey: identifier) != nil + } + + private func isAlertPresented(identifier: Alert.Identifier) -> Bool { + dispatchPrecondition(condition: .onQueue(.main)) + return alertsPresented.index(forKey: identifier) != nil + } + + private func constructAlert(title: String, message: String, action: String, isCritical: Bool, acknowledgeCompletion: @escaping () -> Void) -> UIAlertController { + dispatchPrecondition(condition: .onQueue(.main)) + // For now, this is a simple alert with an "OK" button + let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) + alertController.addAction(newActionFunc(action, .default, { _ in acknowledgeCompletion() })) + return alertController + } +} diff --git a/Loop/Managers/Alerts/StoredAlert+CoreDataClass.swift b/Loop/Managers/Alerts/StoredAlert+CoreDataClass.swift new file mode 100644 index 0000000000..5d892cb12f --- /dev/null +++ b/Loop/Managers/Alerts/StoredAlert+CoreDataClass.swift @@ -0,0 +1,44 @@ +// +// StoredAlert+CoreDataClass.swift +// Loop +// +// Created by Rick Pasetto on 5/22/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// +// + +import Foundation +import CoreData +import LoopKit + +public class StoredAlert: NSManagedObject { + + var interruptionLevel: Alert.InterruptionLevel { + get { + willAccessValue(forKey: "interruptionLevel") + defer { didAccessValue(forKey: "interruptionLevel") } + return Alert.InterruptionLevel(storedValue: primitiveInterruptionLevel)! + } + set { + willChangeValue(forKey: "interruptionLevel") + defer { didChangeValue(forKey: "interruptionLevel") } + primitiveInterruptionLevel = newValue.storedValue + } + } + + var hasUpdatedModificationCounter: Bool { changedValues().keys.contains("modificationCounter") } + + func updateModificationCounter() { setPrimitiveValue(managedObjectContext!.modificationCounter!, forKey: "modificationCounter") } + + public override func awakeFromInsert() { + super.awakeFromInsert() + updateModificationCounter() + } + + public override func willSave() { + if isUpdated && !hasUpdatedModificationCounter { + updateModificationCounter() + } + super.willSave() + } +} diff --git a/Loop/Managers/Alerts/StoredAlert+CoreDataProperties.swift b/Loop/Managers/Alerts/StoredAlert+CoreDataProperties.swift new file mode 100644 index 0000000000..7f087782d3 --- /dev/null +++ b/Loop/Managers/Alerts/StoredAlert+CoreDataProperties.swift @@ -0,0 +1,72 @@ +// +// StoredAlert+CoreDataProperties.swift +// Loop +// +// Created by Rick Pasetto on 5/22/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// +// + +import Foundation +import CoreData + + +extension StoredAlert { + + @nonobjc public class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "StoredAlert") + } + + @NSManaged public var acknowledgedDate: Date? + @NSManaged public var alertIdentifier: String + @NSManaged public var backgroundContent: String? + @NSManaged public var foregroundContent: String? + @NSManaged public var primitiveInterruptionLevel: NSNumber + @NSManaged public var issuedDate: Date + @NSManaged public var managerIdentifier: String + @NSManaged public var metadata: String? + @NSManaged public var modificationCounter: Int64 + @NSManaged public var retractedDate: Date? + @NSManaged public var sound: String? + @NSManaged public var syncIdentifier: UUID? + @NSManaged public var triggerInterval: NSNumber? + @NSManaged public var triggerType: Int16 + +} + +extension StoredAlert: Encodable { + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(acknowledgedDate, forKey: .acknowledgedDate) + try container.encode(alertIdentifier, forKey: .alertIdentifier) + try container.encodeIfPresent(backgroundContent, forKey: .backgroundContent) + try container.encodeIfPresent(foregroundContent, forKey: .foregroundContent) + try container.encode(interruptionLevel, forKey: .interruptionLevel) + try container.encode(issuedDate, forKey: .issuedDate) + try container.encode(managerIdentifier, forKey: .managerIdentifier) + try container.encodeIfPresent(metadata, forKey: .metadata) + try container.encode(modificationCounter, forKey: .modificationCounter) + try container.encodeIfPresent(retractedDate, forKey: .retractedDate) + try container.encodeIfPresent(sound, forKey: .sound) + try container.encodeIfPresent(syncIdentifier, forKey: .syncIdentifier) + try container.encodeIfPresent(triggerInterval?.doubleValue, forKey: .triggerInterval) + try container.encode(triggerType, forKey: .triggerType) + } + + private enum CodingKeys: String, CodingKey { + case acknowledgedDate + case alertIdentifier + case backgroundContent + case foregroundContent + case interruptionLevel + case issuedDate + case managerIdentifier + case metadata + case modificationCounter + case retractedDate + case sound + case syncIdentifier + case triggerInterval + case triggerType + } +} diff --git a/Loop/Managers/Alerts/StoredAlert.swift b/Loop/Managers/Alerts/StoredAlert.swift new file mode 100644 index 0000000000..39ecc0a041 --- /dev/null +++ b/Loop/Managers/Alerts/StoredAlert.swift @@ -0,0 +1,262 @@ +// +// StoredAlert.swift +// Loop +// +// Created by Rick Pasetto on 5/11/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import CoreData +import LoopKit +import UIKit + +extension StoredAlert { + + static let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + return encoder + }() + + static let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + }() + + convenience init(from alert: Alert, context: NSManagedObjectContext, issuedDate: Date = Date(), syncIdentifier: UUID = UUID()) { + do { + /// This code, using the `init(entity:insertInto:)` instead of the `init(context:)` avoids warnings during unit testing that look like this: + /// `CoreData: warning: Multiple NSEntityDescriptions claim the NSManagedObject subclass 'Loop.StoredAlert' so +entity is unable to disambiguate.` + /// This mitigates that. See https://stackoverflow.com/a/54126839 for more info. + let name = String(describing: type(of: self)) + let entity = NSEntityDescription.entity(forEntityName: name, in: context)! + self.init(entity: entity, insertInto: context) + self.issuedDate = issuedDate + self.alertIdentifier = alert.identifier.alertIdentifier + self.managerIdentifier = alert.identifier.managerIdentifier + self.triggerType = alert.trigger.storedType + self.triggerInterval = alert.trigger.storedInterval + self.interruptionLevel = alert.interruptionLevel + self.syncIdentifier = syncIdentifier + // Encode as JSON strings + let encoder = StoredAlert.encoder + self.sound = try encoder.encodeToStringIfPresent(alert.sound) + self.foregroundContent = try encoder.encodeToStringIfPresent(alert.foregroundContent) + self.backgroundContent = try encoder.encodeToStringIfPresent(alert.backgroundContent) + self.metadata = try encoder.encodeToStringIfPresent(alert.metadata) + } catch { + fatalError("Failed to encode: \(error)") + } + } + + public var trigger: Alert.Trigger { + get { + do { + return try Alert.Trigger(storedType: triggerType, storedInterval: triggerInterval) + } catch { + fatalError("\(error): \(triggerType) \(String(describing: triggerInterval))") + } + } + } + + public var title: String? { + return try? Alert.Content(contentString: foregroundContent ?? backgroundContent)?.title + } + + public var identifier: Alert.Identifier { + return Alert.Identifier(managerIdentifier: managerIdentifier, alertIdentifier: alertIdentifier) + } +} + +extension Alert { + init?(from storedAlert: StoredAlert, adjustedForStorageTime: Bool) throws { + guard let bgContent = try Alert.Content(contentString: storedAlert.backgroundContent) else { + // all alerts must have background content + return nil + } + + let fgContent = try Alert.Content(contentString: storedAlert.foregroundContent) + let sound = try Alert.Sound(soundString: storedAlert.sound) + let metadata = try Alert.Metadata(metadataString: storedAlert.metadata) + let trigger = try Alert.Trigger(storedType: storedAlert.triggerType, + storedInterval: storedAlert.triggerInterval, + storageDate: adjustedForStorageTime ? storedAlert.issuedDate : nil) + self.init(identifier: storedAlert.identifier, + foregroundContent: fgContent, + backgroundContent: bgContent, + trigger: trigger, + interruptionLevel: storedAlert.interruptionLevel, + sound: sound, + metadata: metadata) + } +} + +extension Alert.Content { + init?(contentString: String?) throws { + guard let contentString = contentString else { + return nil + } + guard let contentData = contentString.data(using: .utf8) else { + throw JSONEncoderError.stringEncodingError + } + self = try StoredAlert.decoder.decode(Alert.Content.self, from: contentData) + } +} + +extension Alert.Sound { + init?(soundString: String?) throws { + guard let soundString = soundString else { + return nil + } + guard let soundData = soundString.data(using: .utf8) else { + throw JSONEncoderError.stringEncodingError + } + self = try StoredAlert.decoder.decode(Alert.Sound.self, from: soundData) + } +} + +extension Alert.Metadata { + init?(metadataString: String?) throws { + guard let metadataString = metadataString else { + return nil + } + guard let metadataData = metadataString.data(using: .utf8) else { + throw JSONEncoderError.stringEncodingError + } + self = try StoredAlert.decoder.decode(Alert.Metadata.self, from: metadataData) + } +} + +public typealias AlertTriggerStoredType = Int16 + +extension Alert.Trigger { + enum StorageError: Error { + case invalidStoredInterval, invalidStoredType + } + + var storedType: AlertTriggerStoredType { + switch self { + case .immediate: return 0 + case .delayed: return 1 + case .repeating: return 2 + } + } + var storedInterval: NSNumber? { + switch self { + case .immediate: return nil + case .delayed(let interval): return NSNumber(value: interval) + case .repeating(let repeatInterval): return NSNumber(value: repeatInterval) + } + } + + init(storedType: Int16, storedInterval: NSNumber?, storageDate: Date? = nil, now: Date = Date()) throws { + switch storedType { + case 0: self = .immediate + case 1: + if let storedInterval = storedInterval { + if let storageDate = storageDate, storageDate <= now { + let intervalLeft = storedInterval.doubleValue - now.timeIntervalSince(storageDate) + if intervalLeft <= 0 { + self = .immediate + } else { + self = .delayed(interval: intervalLeft) + } + } else { + self = .delayed(interval: storedInterval.doubleValue) + } + } else { + throw StorageError.invalidStoredInterval + } + case 2: + // Strange case here: if it is a repeating trigger, we can't really play back exactly + // at the right "remaining time" and then repeat at the original period. So, I think + // the best we can do is just use the original trigger + if let storedInterval = storedInterval { + self = .repeating(repeatInterval: storedInterval.doubleValue) + } else { + throw StorageError.invalidStoredInterval + } + default: + throw StorageError.invalidStoredType + } + } +} + +extension Alert.InterruptionLevel { + + var storedValue: NSNumber { + // Since this is arbitrary anyway, might as well make it match iOS's values + switch self { + case .active: + if #available(iOS 15.0, *) { + return NSNumber(value: UNNotificationInterruptionLevel.active.rawValue) + } else { + // https://developer.apple.com/documentation/usernotifications/unnotificationinterruptionlevel/active + return 1 + } + case .timeSensitive: + if #available(iOS 15.0, *) { + return NSNumber(value: UNNotificationInterruptionLevel.timeSensitive.rawValue) + } else { + // https://developer.apple.com/documentation/usernotifications/unnotificationinterruptionlevel/timesensitive + return 2 + } + case .critical: + if #available(iOS 15.0, *) { + return NSNumber(value: UNNotificationInterruptionLevel.critical.rawValue) + } else { + // https://developer.apple.com/documentation/usernotifications/unnotificationinterruptionlevel/critical + return 3 + } + } + } + + init?(storedValue: NSNumber) { + switch storedValue { + case Self.active.storedValue: self = .active + case Self.timeSensitive.storedValue: self = .timeSensitive + case Self.critical.storedValue: self = .critical + default: + return nil + } + } +} + + + +enum JSONEncoderError: Swift.Error { + case stringEncodingError +} + +extension JSONEncoder { + func encodeToStringIfPresent(_ encodable: T?) throws -> String? where T: Encodable { + guard let encodable = encodable else { return nil } + let data = try self.encode(encodable) + guard let result = String(data: data, encoding: .utf8) else { + throw JSONEncoderError.stringEncodingError + } + return result + } +} + +extension SyncAlertObject { + init?(managedObject: StoredAlert) throws { + guard let syncIdentifier = managedObject.syncIdentifier else { + return nil + } + self.init(identifier: managedObject.identifier, + trigger: try Alert.Trigger(storedType: managedObject.triggerType, storedInterval: managedObject.triggerInterval), + interruptionLevel: managedObject.interruptionLevel, + foregroundContent: try Alert.Content(contentString: managedObject.foregroundContent), + backgroundContent: try Alert.Content(contentString: managedObject.backgroundContent), + sound: try Alert.Sound(soundString: managedObject.sound), + metadata: try Alert.Metadata(metadataString: managedObject.metadata), + issuedDate: managedObject.issuedDate, + acknowledgedDate: managedObject.acknowledgedDate, + retractedDate: managedObject.retractedDate, + syncIdentifier: syncIdentifier + ) + } +} diff --git a/Loop/Managers/Alerts/UserNotificationAlertScheduler.swift b/Loop/Managers/Alerts/UserNotificationAlertScheduler.swift new file mode 100644 index 0000000000..a1eb654209 --- /dev/null +++ b/Loop/Managers/Alerts/UserNotificationAlertScheduler.swift @@ -0,0 +1,134 @@ +// +// UserNotificationAlertScheduler.swift +// LoopKit +// +// Created by Rick Pasetto on 4/9/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import LoopKit +import UIKit + +public protocol UserNotificationCenter { + func add(_ request: UNNotificationRequest, withCompletionHandler: ((Error?) -> Void)?) + func removePendingNotificationRequests(withIdentifiers: [String]) + func removeDeliveredNotifications(withIdentifiers: [String]) + func getDeliveredNotifications(completionHandler: @escaping ([UNNotification]) -> Void) + func getPendingNotificationRequests(completionHandler: @escaping ([UNNotificationRequest]) -> Void) +} +extension UNUserNotificationCenter: UserNotificationCenter {} + +public class UserNotificationAlertScheduler { + + let userNotificationCenter: UserNotificationCenter + let log = DiagnosticLog(category: "UserNotificationAlertScheduler") + + init(userNotificationCenter: UserNotificationCenter) { + self.userNotificationCenter = userNotificationCenter + } + + func scheduleAlert(_ alert: Alert, muted: Bool = false) { + scheduleAlert(alert, timestamp: Date(), muted: muted) + } + + func scheduleAlert(_ alert: Alert, timestamp: Date, muted: Bool = false) { + DispatchQueue.main.async { + let request = UNNotificationRequest(from: alert, timestamp: timestamp, muted: muted) + self.userNotificationCenter.add(request) { error in + if let error = error { + self.log.error("Something went wrong posting the user notification: %@", error.localizedDescription) + } + } + // For now, UserNotifications do not not acknowledge...not yet at least + } + } + + func unscheduleAlert(identifier: Alert.Identifier) { + DispatchQueue.main.async { + self.userNotificationCenter.removePendingNotificationRequests(withIdentifiers: [identifier.value]) + self.userNotificationCenter.removeDeliveredNotifications(withIdentifiers: [identifier.value]) + } + } +} + +extension UserNotificationAlertScheduler: AlertManagerResponder { + func acknowledgeAlert(identifier: Alert.Identifier) { + DispatchQueue.main.async { + self.log.debug("Removing notification %@ from delivered notifications", identifier.value) + self.userNotificationCenter.removeDeliveredNotifications(withIdentifiers: [identifier.value]) + } + } +} + +fileprivate extension Alert { + func getUserNotificationContent(timestamp: Date, muted: Bool) -> UNNotificationContent { + let userNotificationContent = UNMutableNotificationContent() + userNotificationContent.title = backgroundContent.title + userNotificationContent.body = backgroundContent.body + userNotificationContent.sound = userNotificationSound(muted: muted) + if #available(iOS 15.0, *) { + userNotificationContent.interruptionLevel = interruptionLevel.userNotificationInterruptLevel + } + // TODO: Once we have a final design and approval for custom UserNotification buttons, we'll need to set categoryIdentifier +// userNotificationContent.categoryIdentifier = LoopNotificationCategory.alert.rawValue + userNotificationContent.threadIdentifier = identifier.value // Used to match categoryIdentifier, but I /think/ we want multiple threads for multiple alert types, no? + userNotificationContent.userInfo = [ + LoopNotificationUserInfoKey.managerIDForAlert.rawValue: identifier.managerIdentifier, + LoopNotificationUserInfoKey.alertTypeID.rawValue: identifier.alertIdentifier, + ] + return userNotificationContent + } + + private func userNotificationSound(muted: Bool) -> UNNotificationSound? { + guard !muted else { return interruptionLevel == .critical ? .defaultCriticalSound(withAudioVolume: 0) : nil } + + switch sound { + case .vibrate: + // setting the audio volume of critical alert to 0 only vibrates + return interruptionLevel == .critical ? .defaultCriticalSound(withAudioVolume: 0) : nil + default: + if let actualFileName = AlertManager.soundURL(for: self)?.lastPathComponent { + let unname = UNNotificationSoundName(rawValue: actualFileName) + return interruptionLevel == .critical ? UNNotificationSound.criticalSoundNamed(unname) : UNNotificationSound(named: unname) + } + } + + return interruptionLevel == .critical ? .defaultCritical : .default + } +} + +fileprivate extension Alert.InterruptionLevel { + @available(iOS 15.0, *) + var userNotificationInterruptLevel: UNNotificationInterruptionLevel { + switch self { + case .critical: + return .critical + case .timeSensitive: + return .timeSensitive + case .active: + return .active + } + } +} + +fileprivate extension UNNotificationRequest { + convenience init(from alert: Alert, timestamp: Date, muted: Bool) { + let content = alert.getUserNotificationContent(timestamp: timestamp, muted: muted) + self.init(identifier: alert.identifier.value, + content: content, + trigger: UNTimeIntervalNotificationTrigger(from: alert.trigger)) + } +} + +fileprivate extension UNTimeIntervalNotificationTrigger { + convenience init?(from alertTrigger: Alert.Trigger) { + switch alertTrigger { + case .immediate: + return nil + case .delayed(let timeInterval): + self.init(timeInterval: timeInterval, repeats: false) + case .repeating(let repeatInterval): + self.init(timeInterval: repeatInterval, repeats: true) + } + } +} diff --git a/Loop/Managers/AnalyticsManager.swift b/Loop/Managers/AnalyticsManager.swift deleted file mode 100644 index 51ef938d64..0000000000 --- a/Loop/Managers/AnalyticsManager.swift +++ /dev/null @@ -1,131 +0,0 @@ -// -// AnalyticsManager.swift -// Naterade -// -// Created by Nathan Racklyeft on 4/28/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import Foundation -import Amplitude - - -final class AnalyticsManager { - - var amplitudeService: AmplitudeService { - didSet { - try! KeychainManager().setAmplitudeAPIKey(amplitudeService.APIKey) - } - } - - init() { - if let APIKey = KeychainManager().getAmplitudeAPIKey() { - amplitudeService = AmplitudeService(APIKey: APIKey) - } else { - amplitudeService = AmplitudeService(APIKey: nil) - } - } - - static let sharedManager = AnalyticsManager() - - // MARK: - Helpers - - private var isSimulator: Bool = TARGET_OS_SIMULATOR != 0 - - private func logEvent(_ name: String, withProperties properties: [AnyHashable: Any]? = nil, outOfSession: Bool = false) { - if isSimulator { - NSLog("\(name) \(properties ?? [:])") - } else { - amplitudeService.client?.logEvent(name, withEventProperties: properties, outOfSession: outOfSession) - } - - } - - // MARK: - UIApplicationDelegate - - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [AnyHashable: Any]?) { - logEvent("App Launch") - } - - // MARK: - Screens - - func didDisplayBolusScreen() { - logEvent("Bolus Screen") - } - - func didDisplaySettingsScreen() { - logEvent("Settings Screen") - } - - func didDisplayStatusScreen() { - logEvent("Status Screen") - } - - // MARK: - Config Events - - func didChangeRileyLinkConnectionState() { - logEvent("RileyLink Connection") - } - - func transmitterTimeDidDrift(_ drift: TimeInterval) { - logEvent("Transmitter time change", withProperties: ["value" : drift]) - } - - func pumpBatteryWasReplaced() { - logEvent("Pump battery replacement") - } - - func reservoirWasRewound() { - logEvent("Pump reservoir rewind") - } - - func didChangeBasalRateSchedule() { - logEvent("Basal rate change") - } - - func didChangeCarbRatioSchedule() { - logEvent("Carb ratio change") - } - - func didChangeInsulinActionDuration() { - logEvent("Insulin action duration change") - } - - func didChangeInsulinSensitivitySchedule() { - logEvent("Insulin sensitivity change") - } - - func didChangeGlucoseTargetRangeSchedule() { - logEvent("Glucose target range change") - } - - func didChangeMaximumBasalRate() { - logEvent("Maximum basal rate change") - } - - func didChangeMaximumBolus() { - logEvent("Maximum bolus change") - } - - // MARK: - Loop Events - - func didAddCarbsFromWatch(_ carbs: Double) { - logEvent("Carb entry created", withProperties: ["source" : "Watch", "value": carbs], outOfSession: true) - } - - func didRetryBolus() { - logEvent("Bolus Retry", outOfSession: true) - } - - func didSetBolusFromWatch(_ units: Double) { - logEvent("Bolus set", withProperties: ["source" : "Watch", "value": units], outOfSession: true) - } - - func loopDidSucceed() { - logEvent("Loop success", outOfSession: true) - } - - func loopDidError() { - logEvent("Loop error", outOfSession: true) - } -} diff --git a/Loop/Managers/AnalyticsServicesManager.swift b/Loop/Managers/AnalyticsServicesManager.swift new file mode 100644 index 0000000000..808a34c81a --- /dev/null +++ b/Loop/Managers/AnalyticsServicesManager.swift @@ -0,0 +1,272 @@ +// +// AnalyticsServicesManager.swift +// Loop +// +// Created by Nathan Racklyeft on 4/28/16. +// Copyright © 2016 Nathan Racklyeft. All rights reserved. +// + +import Foundation +import LoopKit +import LoopCore +import HealthKit + +final class AnalyticsServicesManager { + + private lazy var log = DiagnosticLog(category: "AnalyticsServicesManager") + + private var analyticsServices = [AnalyticsService]() + + init() {} + + func addService(_ analyticsService: AnalyticsService) { + analyticsServices.append(analyticsService) + } + + func restoreService(_ analyticsService: AnalyticsService) { + analyticsServices.append(analyticsService) + } + + func removeService(_ analyticsService: AnalyticsService) { + analyticsServices.removeAll { $0.pluginIdentifier == analyticsService.pluginIdentifier } + } + + private func logEvent(_ name: String, withProperties properties: [AnyHashable: Any]? = nil, outOfSession: Bool = false) { + log.debug("%{public}@ %{public}@", name, String(describing: properties)) + analyticsServices.forEach { $0.recordAnalyticsEvent(name, withProperties: properties, outOfSession: outOfSession) } + } + + func identify(_ property: String, value: String) { + log.debug("Identify %{public}@: %{public}@", property, value) + analyticsServices.forEach { $0.recordIdentify(property, value: value) } + } + + func identify(_ property: String, array: [String]) { + log.debug("Identify %{public}@: %{public}@", property, array) + analyticsServices.forEach { $0.recordIdentify(property, array: array) } + } + + + // MARK: - UIApplicationDelegate + + func application(didFinishLaunchingWithOptions launchOptions: [AnyHashable: Any]?) { + logEvent("App Launch") + } + + func identifyAppName(_ appName: String) { + identify("App Name", value: appName) + } + + func identifyWorkspaceGitRevision(_ revision: String) { + identify("Workspace Revision", value: revision) + } + + // MARK: - Device Type + func identifyPumpType(_ pumpType: String) { + identify("Pump Type", value: pumpType) + } + + func identifyCGMType(_ cgmType: String) { + identify("CGM Type", value: cgmType) + } + + // MARK: - Screens + + func didDisplayBolusScreen() { + logEvent("Bolus Screen") + } + + func didDisplayCarbEntryScreen() { + logEvent("Carb Entry Screen") + } + + func didDisplaySettingsScreen() { + logEvent("Settings Screen") + } + + func didDisplayStatusScreen() { + logEvent("Status Screen") + } + + // MARK: - Config Events + + func transmitterTimeDidDrift(_ drift: TimeInterval) { + logEvent("Transmitter time change", withProperties: ["value" : drift], outOfSession: true) + } + + func pumpTimeDidDrift(_ drift: TimeInterval) { + logEvent("Pump time change", withProperties: ["value": drift], outOfSession: true) + } + + func pumpBatteryWasReplaced() { + logEvent("Pump battery replacement", outOfSession: true) + } + + func reservoirWasRewound() { + logEvent("Pump reservoir rewind", outOfSession: true) + } + + func didChangeBasalRateSchedule() { + logEvent("Basal rate change") + } + + func didChangeCarbRatioSchedule() { + logEvent("Carb ratio change") + } + + func didChangeInsulinModel() { + logEvent("Insulin model change") + } + + func didChangeInsulinSensitivitySchedule() { + logEvent("Insulin sensitivity change") + } + + func didChangeLoopSettings(from oldValue: LoopSettings, to newValue: LoopSettings) { + if newValue.maximumBasalRatePerHour != oldValue.maximumBasalRatePerHour { + logEvent("Maximum basal rate change") + } + + if newValue.maximumBolus != oldValue.maximumBolus { + logEvent("Maximum bolus change") + } + + if newValue.suspendThreshold != oldValue.suspendThreshold { + logEvent("Minimum BG Guard change") + } + + if newValue.dosingEnabled != oldValue.dosingEnabled { + logEvent("Closed loop enabled change") + } + + if newValue.basalRateSchedule?.timeZone != oldValue.basalRateSchedule?.timeZone { + logEvent("Therapy schedule time zone change") + } + + if newValue.scheduleOverride != oldValue.scheduleOverride { + logEvent("Temporary schedule override change") + } + + if newValue.glucoseTargetRangeSchedule != oldValue.glucoseTargetRangeSchedule { + logEvent("Glucose target range change") + } + } + + // MARK: - Loop Events + + func pumpWasRemoved() { + logEvent("Pump Removed") + } + + func pumpWasAdded(identifier: String) { + logEvent("Pump Added", withProperties: ["identifier" : identifier]) + } + + func cgmWasRemoved() { + logEvent("CGM Removed") + } + + func cgmWasAdded(identifier: String) { + logEvent("CGM Added", withProperties: ["identifier" : identifier]) + } + + func didAddCarbs(source: String, amount: Double, inSession: Bool = false) { + logEvent("Carb entry created", withProperties: ["source" : source, "amount": "\(amount)"], outOfSession: inSession) + } + + func didRetryBolus() { + logEvent("Bolus Retry") + } + + func didBolus(source: String, units: Double, inSession: Bool = false) { + logEvent("Bolus set", withProperties: ["source" : source, "units": "\(units)"], outOfSession: true) + } + + func didFetchNewCGMData() { + logEvent("CGM Fetch", outOfSession: true) + } + + func loopDidSucceed(_ duration: TimeInterval) { + logEvent("Loop success", withProperties: ["duration": duration], outOfSession: true) + } + + func loopDidError(error: LoopError) { + var props = [AnyHashable: Any]() + + props["issueId"] = error.issueId + + for (detailKey, detail) in error.issueDetails { + props[detailKey] = detail + } + + logEvent("Loop error", withProperties: props, outOfSession: true) + } + + func didIssueAlert(identifier: String, interruptionLevel: Alert.InterruptionLevel) { + logEvent("Alert Issued", withProperties: ["identifier": identifier, "interruptionLevel": interruptionLevel.rawValue]) + } + + func didEnactOverride(name: String, symbol: String, duration: TemporaryScheduleOverride.Duration, insulinSensitivityMultiplier: Double = 1.0, targetRange: ClosedRange? = nil) + { + let combinedName = "\(symbol) - \(name)" + + var properties: [String: Any] = [ + "name": name, + "symbol": symbol, + "sensitivityMultiplier": insulinSensitivityMultiplier, + "nameWithEmoji": combinedName + ] + + if let targetUpperBound = targetRange?.upperBound.doubleValue(for: HKUnit.milligramsPerDeciliter) { + properties["targetUpperBound"] = targetUpperBound + } + if let targetLowerBound = targetRange?.lowerBound.doubleValue(for: HKUnit.milligramsPerDeciliter) { + properties["targetLowerBound"] = targetLowerBound + } + + + logEvent("Override Enacted", withProperties: properties) + } + + func didCancelOverride(name: String) { + logEvent("Override Canceled", withProperties: ["name": name]) + } +} + + +// MARK: - PresetActivationObserver +extension AnalyticsServicesManager: PresetActivationObserver { + func presetActivated(context: TemporaryScheduleOverride.Context, duration: TemporaryScheduleOverride.Duration) { + switch context { + case .legacyWorkout: + didEnactOverride(name: "workout", symbol: "", duration: duration) + case .preMeal: + didEnactOverride(name: "preMeal", symbol: "", duration: duration) + case .custom: + didEnactOverride(name: "custom", symbol: "", duration: duration) + case .preset(let preset): + didEnactOverride(name: preset.name, symbol: preset.symbol, duration: duration, insulinSensitivityMultiplier: preset.settings.effectiveInsulinNeedsScaleFactor, targetRange: preset.settings.targetRange) + } + } + + func presetDeactivated(context: TemporaryScheduleOverride.Context) { + switch context { + case .legacyWorkout: + break + default: + break + } + } +} + +extension AutomaticDosingStrategy { + var analyticsValue: String { + switch self { + case .automaticBolus: + return "Automatic Bolus" + case .tempBasalOnly: + return "Temp Basal" + } + } +} + diff --git a/Loop/Managers/AppExpirationAlerter.swift b/Loop/Managers/AppExpirationAlerter.swift new file mode 100644 index 0000000000..d5dd84518f --- /dev/null +++ b/Loop/Managers/AppExpirationAlerter.swift @@ -0,0 +1,160 @@ +// +// AppExpirationAlerter.swift +// Loop +// +// Created by Pete Schwamb on 8/21/21. +// Copyright © 2021 LoopKit Authors. All rights reserved. +// + +import Foundation +import UserNotifications +import LoopCore + + +class AppExpirationAlerter { + + static let expirationAlertWindow: TimeInterval = .days(20) + static let settingsPageExpirationWarningModeWindow: TimeInterval = .days(3) + + static func alertIfNeeded(viewControllerToPresentFrom: UIViewController) { + + let now = Date() + + guard let profileExpiration = BuildDetails.default.profileExpiration else { + return + } + + let expirationDate = calculateExpirationDate(profileExpiration: profileExpiration) + + let timeUntilExpiration = expirationDate.timeIntervalSince(now) + + if timeUntilExpiration > expirationAlertWindow { + return + } + + let minimumTimeBetweenAlerts: TimeInterval = timeUntilExpiration > .hours(24) ? .days(2) : .hours(1) + + if let lastAlertDate = UserDefaults.appGroup?.lastProfileExpirationAlertDate { + guard now > lastAlertDate + minimumTimeBetweenAlerts else { + return + } + } + + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.day, .hour] + formatter.unitsStyle = .full + formatter.zeroFormattingBehavior = .dropLeading + formatter.maximumUnitCount = 1 + let timeUntilExpirationStr = formatter.string(from: timeUntilExpiration) + + let alertMessage = createVerboseAlertMessage(timeUntilExpirationStr: timeUntilExpirationStr!) + + var dialog: UIAlertController + if isTestFlightBuild() { + dialog = UIAlertController( + title: NSLocalizedString("TestFlight Expires Soon", comment: "The title for notification of upcoming TestFlight expiration"), + message: alertMessage, + preferredStyle: .alert) + dialog.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "Text for ok action on notification of upcoming TestFlight expiration"), style: .default, handler: nil)) + dialog.addAction(UIAlertAction(title: NSLocalizedString("More Info", comment: "Text for more info action on notification of upcoming TestFlight expiration"), style: .default, handler: { (_) in + UIApplication.shared.open(URL(string: "https://loopkit.github.io/loopdocs/gh-actions/gh-update/")!) + })) + + } else { + dialog = UIAlertController( + title: NSLocalizedString("Profile Expires Soon", comment: "The title for notification of upcoming profile expiration"), + message: alertMessage, + preferredStyle: .alert) + dialog.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: "Text for ok action on notification of upcoming profile expiration"), style: .default, handler: nil)) + dialog.addAction(UIAlertAction(title: NSLocalizedString("More Info", comment: "Text for more info action on notification of upcoming profile expiration"), style: .default, handler: { (_) in + UIApplication.shared.open(URL(string: "https://loopkit.github.io/loopdocs/build/updating/")!) + })) + } + viewControllerToPresentFrom.present(dialog, animated: true, completion: nil) + + UserDefaults.appGroup?.lastProfileExpirationAlertDate = now + } + + static func createVerboseAlertMessage(timeUntilExpirationStr:String) -> String { + if isTestFlightBuild() { + return String(format: NSLocalizedString("%1$@ will stop working in %2$@. You will need to rebuild before that.", comment: "Format string for body for notification of upcoming expiration. (1: app name) (2: amount of time until expiration"), Bundle.main.bundleDisplayName, timeUntilExpirationStr) + } else { + return String(format: NSLocalizedString("%1$@ will stop working in %2$@. You will need to update before that, with a new provisioning profile.", comment: "Format string for body for notification of upcoming provisioning profile expiration. (1: app name) (2: amount of time until expiration"), Bundle.main.bundleDisplayName, timeUntilExpirationStr) + } + } + + static func isNearExpiration(expirationDate:Date) -> Bool { + return expirationDate.timeIntervalSinceNow < settingsPageExpirationWarningModeWindow + } + + static func createProfileExpirationSettingsMessage(expirationDate:Date) -> String { + let nearExpiration = isNearExpiration(expirationDate: expirationDate) + let maxUnitCount = nearExpiration ? 2 : 1 // only include hours in the msg if near expiration + let readableRelativeTime: String? = relativeTimeFormatter(maxUnitCount: maxUnitCount).string(from: expirationDate.timeIntervalSinceNow) + let relativeTimeRemaining: String = readableRelativeTime ?? NSLocalizedString("Unknown time", comment: "Unknown amount of time in settings' profile expiration section") + let verboseMessage = createVerboseAlertMessage(timeUntilExpirationStr: relativeTimeRemaining) + let conciseMessage = relativeTimeRemaining + NSLocalizedString(" remaining", comment: "remaining time in setting's profile expiration section") + return nearExpiration ? verboseMessage : conciseMessage + } + + private static func relativeTimeFormatter(maxUnitCount:Int) -> DateComponentsFormatter { + let formatter = DateComponentsFormatter() + let includeHours = maxUnitCount == 2 + formatter.allowedUnits = includeHours ? [.day, .hour] : [.day] + formatter.unitsStyle = .full + formatter.zeroFormattingBehavior = .dropLeading + formatter.maximumUnitCount = maxUnitCount + return formatter + } + + static func buildDate() -> Date? { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "EEE MMM d HH:mm:ss 'UTC' yyyy" + dateFormatter.locale = Locale(identifier: "en_US_POSIX") // Set locale to ensure parsing works + dateFormatter.timeZone = TimeZone(identifier: "UTC") + + guard let dateString = BuildDetails.default.buildDateString, + let date = dateFormatter.date(from: dateString) else { + return nil + } + + return date + } + + static func isTestFlightBuild() -> Bool { + // If the target environment is a simulator, then + // this is not a TestFlight distribution. Return false. + #if targetEnvironment(simulator) + return false + #else + + // If an "embedded.mobileprovision" is present in the main bundle, then + // this is an Xcode, Ad-Hoc, or Enterprise distribution. Return false. + if Bundle.main.url(forResource: "embedded", withExtension: "mobileprovision") != nil { + return false + } + + // If an app store receipt is not present in the main bundle, then we cannot + // say whether this is a TestFlight or App Store distribution. Return false. + guard let receiptName = Bundle.main.appStoreReceiptURL?.lastPathComponent else { + return false + } + + // A TestFlight distribution presents a "sandboxReceipt", while an App Store + // distribution presents a "receipt". Return true if we have a TestFlight receipt. + return "sandboxReceipt".caseInsensitiveCompare(receiptName) == .orderedSame + #endif + } + + static func calculateExpirationDate(profileExpiration: Date) -> Date { + let isTestFlight = isTestFlightBuild() + + if isTestFlight, let buildDate = buildDate() { + let testflightExpiration = Calendar.current.date(byAdding: .day, value: 90, to: buildDate)! + + return testflightExpiration + } else { + return profileExpiration + } + } +} diff --git a/Loop/Managers/BluetoothStateManager.swift b/Loop/Managers/BluetoothStateManager.swift new file mode 100644 index 0000000000..f26da54511 --- /dev/null +++ b/Loop/Managers/BluetoothStateManager.swift @@ -0,0 +1,111 @@ +// +// BluetoothStateManager.swift +// Loop +// +// Created by Nathaniel Hamming on 2020-07-03. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import CoreBluetooth +import LoopKit +import LoopKitUI + +public class BluetoothStateManager: NSObject, BluetoothProvider { + private var completion: ((BluetoothAuthorization) -> Void)? + private var centralManager: CBCentralManager? + private var bluetoothObservers = WeakSynchronizedSet() + + override init() { + super.init() + + if bluetoothAuthorization != .notDetermined { + self.centralManager = CBCentralManager(delegate: self, queue: nil) + } + } + + public var bluetoothAuthorization: BluetoothAuthorization { + return BluetoothAuthorization(CBCentralManager.authorization) + } + + public var bluetoothState: BluetoothState { + guard let centralManager = centralManager else { + return .unknown + } + return BluetoothState(centralManager.state) + } + + public func authorizeBluetooth(_ completion: @escaping (BluetoothAuthorization) -> Void) { + guard centralManager == nil else { + completion(bluetoothAuthorization) + return + } + self.completion = completion + self.centralManager = CBCentralManager(delegate: self, queue: nil) + } + + public func addBluetoothObserver(_ observer: BluetoothObserver, queue: DispatchQueue = .main) { + bluetoothObservers.insert(observer, queue: queue) + } + + public func removeBluetoothObserver(_ observer: BluetoothObserver) { + bluetoothObservers.removeElement(observer) + } +} + +// MARK: - CBCentralManagerDelegate + +extension BluetoothStateManager: CBCentralManagerDelegate { + public func centralManagerDidUpdateState(_ central: CBCentralManager) { + if let completion = completion { + completion(bluetoothAuthorization) + self.completion = nil + } + bluetoothObservers.forEach { $0.bluetoothDidUpdateState(BluetoothState(central.state)) } + } +} + +// MARK: - BluetoothAuthorization + +extension BluetoothAuthorization { + fileprivate init(_ authorization: CBManagerAuthorization) { + switch authorization { + case .notDetermined: + self = .notDetermined + case .restricted: + self = .restricted + case .denied: + self = .denied + case .allowedAlways: + self = .authorized + @unknown default: + self = .notDetermined + } + } +} + +// MARK: - BluetoothState + +extension BluetoothState { + fileprivate init(_ state: CBManagerState) { + switch state { + case .unknown: + self = .unknown + case .resetting: + self = .resetting + case .unsupported: + #if IOS_SIMULATOR + self = .poweredOn // Simulator reports unsupported, but pretend it is powered on + #else + self = .unsupported + #endif + case .unauthorized: + self = .unauthorized + case .poweredOff: + self = .poweredOff + case .poweredOn: + self = .poweredOn + @unknown default: + self = .unknown + } + } +} diff --git a/Loop/Managers/CGMManager.swift b/Loop/Managers/CGMManager.swift new file mode 100644 index 0000000000..fe39e3926c --- /dev/null +++ b/Loop/Managers/CGMManager.swift @@ -0,0 +1,47 @@ +// +// CGMManager.swift +// Loop +// +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +import LoopKit +import LoopKitUI +import MockKit + +let staticCGMManagersByIdentifier: [String: CGMManager.Type] = [ + MockCGMManager.pluginIdentifier: MockCGMManager.self +] + +var availableStaticCGMManagers: [CGMManagerDescriptor] { + if FeatureFlags.allowSimulators { + return [ + CGMManagerDescriptor(identifier: MockCGMManager.pluginIdentifier, localizedTitle: MockCGMManager.localizedTitle) + ] + } else { + return [] + } +} + +func CGMManagerFromRawValue(_ rawValue: [String: Any]) -> CGMManager? { + guard let managerIdentifier = rawValue["managerIdentifier"] as? String, + let rawState = rawValue["state"] as? CGMManager.RawStateValue, + let Manager = staticCGMManagersByIdentifier[managerIdentifier] + else { + return nil + } + + return Manager.init(rawState: rawState) +} + +extension CGMManager { + + typealias RawValue = [String: Any] + + var rawValue: [String: Any] { + return [ + "managerIdentifier": pluginIdentifier, + "state": self.rawState + ] + } +} diff --git a/Loop/Managers/CGMStalenessMonitor.swift b/Loop/Managers/CGMStalenessMonitor.swift new file mode 100644 index 0000000000..82cdc9267d --- /dev/null +++ b/Loop/Managers/CGMStalenessMonitor.swift @@ -0,0 +1,84 @@ +// +// CGMStalenessMonitor.swift +// Loop +// +// Created by Pete Schwamb on 10/15/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit +import LoopCore + +protocol CGMStalenessMonitorDelegate: AnyObject { + func getLatestCGMGlucose(since: Date, completion: @escaping (_ result: Swift.Result) -> Void) +} + +class CGMStalenessMonitor { + + private let log = DiagnosticLog(category: "CGMStalenessMonitor") + + private var cgmStalenessTimer: Timer? + + weak var delegate: CGMStalenessMonitorDelegate? = nil { + didSet { + if delegate != nil { + checkCGMStaleness() + } + } + } + + @Published var cgmDataIsStale: Bool = true { + didSet { + self.log.debug("cgmDataIsStale: %{public}@", String(describing: cgmDataIsStale)) + } + } + + private static var cgmStalenessTimerTolerance: TimeInterval = .seconds(10) + + public func cgmGlucoseSamplesAvailable(_ samples: [NewGlucoseSample]) { + guard samples.count > 0 else { + return + } + + let mostRecentGlucose = samples.map { $0.date }.max()! + let cgmDataAge = -mostRecentGlucose.timeIntervalSinceNow + if cgmDataAge < LoopCoreConstants.inputDataRecencyInterval { + self.cgmDataIsStale = false + self.updateCGMStalenessTimer(expiration: mostRecentGlucose.addingTimeInterval(LoopCoreConstants.inputDataRecencyInterval)) + } else { + self.cgmDataIsStale = true + } + } + + private func updateCGMStalenessTimer(expiration: Date) { + self.log.debug("Updating CGM Staleness timer to fire at %{public}@", String(describing: expiration)) + cgmStalenessTimer?.invalidate() + cgmStalenessTimer = Timer.scheduledTimer(withTimeInterval: expiration.timeIntervalSinceNow, repeats: false) { [weak self] _ in + self?.log.debug("cgmStalenessTimer fired") + self?.checkCGMStaleness() + } + cgmStalenessTimer?.tolerance = CGMStalenessMonitor.cgmStalenessTimerTolerance + } + + private func checkCGMStaleness() { + delegate?.getLatestCGMGlucose(since: Date(timeIntervalSinceNow: -LoopCoreConstants.inputDataRecencyInterval)) { (result) in + DispatchQueue.main.async { + self.log.debug("Fetched latest CGM Glucose for checkCGMStaleness: %{public}@", String(describing: result)) + switch result { + case .success(let sample): + if let sample = sample { + self.cgmDataIsStale = false + self.updateCGMStalenessTimer(expiration: sample.startDate.addingTimeInterval(LoopCoreConstants.inputDataRecencyInterval + CGMStalenessMonitor.cgmStalenessTimerTolerance)) + } else { + self.cgmDataIsStale = true + } + case .failure(let error): + self.log.error("Unable to get latest CGM clucose: %{public}@ ", String(describing: error)) + // Some kind of system error; check again in 5 minutes + self.updateCGMStalenessTimer(expiration: Date(timeIntervalSinceNow: .minutes(5))) + } + } + } + } +} diff --git a/Loop/Managers/CriticalEventLogExportManager.swift b/Loop/Managers/CriticalEventLogExportManager.swift new file mode 100644 index 0000000000..6b8f699e5c --- /dev/null +++ b/Loop/Managers/CriticalEventLogExportManager.swift @@ -0,0 +1,553 @@ +// +// CriticalEventLogExportManager.swift +// Loop +// +// Created by Darin Krauss on 7/1/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import os.log +import UIKit +import LoopKit + +public enum CriticalEventLogExportError: Error { + case exportInProgress + case archiveFailure +} + +public protocol CriticalEventLogExporter { + + /// The delegate for the exporter to send progress updates. + var delegate: CriticalEventLogExporterDelegate? { get set } + + /// The export progress. + var progress: Progress { get } + + /// Export + /// - Parameter now: The current time. + /// - Parameter completion: Competion handler returning any error. + func export(now: Date, completion: @escaping (Error?) -> Void) +} + +public extension CriticalEventLogExporter { + + /// Has the export been cancelled? + var isCancelled: Bool { progress.isCancelled } + + /// Cancel the export progress. + func cancel() { progress.cancel() } + + /// Export using the current date. + /// - Parameter completion: Competion handler returning any error. + func export(completion: @escaping (Error?) -> Void) { export(now: Date(), completion: completion) } +} + +public protocol CriticalEventLogExporterDelegate: AnyObject { + + /// Some progress was made towards the export. + /// - Parameter progress: The current percent progress made (0.0 to 1.0) to complete the export. + func exportDidProgress(_ progress: Double) +} + +// MARK: - CriticalEventLogExportManager + +fileprivate protocol CriticalEventLogSynchronizedExporter: CriticalEventLogExporter { + func exportSynchronized(now: Date) -> Error? +} + +public class CriticalEventLogExportManager { + public let logs: [CriticalEventLog] + public let directory: URL + public let historicalDuration: TimeInterval + public let fileManager: FileManager + + private var synchronizeSemaphore = DispatchSemaphore(value: 1) + private let lockedActiveExporter = Locked(nil) + + private let log = OSLog(category: "CriticalEventLogExportManager") + + public init(logs: [CriticalEventLog], directory: URL, historicalDuration: TimeInterval, fileManager: FileManager = FileManager.default) { + self.logs = logs + self.directory = directory + self.historicalDuration = historicalDuration + self.fileManager = fileManager + } + + public func nextExportHistoricalDate(now: Date = Date()) -> Date { + switch latestExportDate() { + case .failure(let error): + log.error("Failure determining next export historical date: %{public}@", String(describing: error)) + case .success(let latestExportDate): + if latestExportDate >= recentDate(from: now) { + return exportDate(for: date(byAddingDays: 1, to: now)) + } + } + return now + } + + private let retryExportHistoricalDuration: TimeInterval = .hours(1) + + public func retryExportHistoricalDate(now: Date = Date()) -> Date { + return now.addingTimeInterval(retryExportHistoricalDuration) + } + + // MARK: - Exporter + + public func createExporter(to url: URL) -> CriticalEventLogExporter { + return CriticalEventLogFullExporter(manager: self, to: url) + } + + public func createHistoricalExporter() -> CriticalEventLogExporter { + return CriticalEventLogHistoricalExporter(manager: self) + } + + // MARK: - Export synchronization + + private let synchronizeExportExpireDuration: TimeInterval = .seconds(5) + private let synchronizeExportCancelDuration: TimeInterval = .seconds(0.1) + + fileprivate func synchronizeExport(for exporter: CriticalEventLogSynchronizedExporter, cancellingActive: Bool, now: Date) -> Error? { + guard !exporter.isCancelled else { + return CriticalEventLogError.cancelled + } + + if cancellingActive { + if let activeExporter = lockedActiveExporter.value { + activeExporter.cancel() + } + + let expireDate = Date(timeIntervalSinceNow: synchronizeExportExpireDuration) + while !obtainSynchronizeSemaphore(waiting: synchronizeExportCancelDuration) { + if exporter.isCancelled { + return CriticalEventLogError.cancelled + } + if Date() > expireDate { + log.error("Failure to cancel active exporter before expiration") + return CriticalEventLogExportError.exportInProgress + } + } + } else { + if !obtainSynchronizeSemaphore() { + return CriticalEventLogExportError.exportInProgress + } + } + defer { synchronizeSemaphore.signal() } + + assert(lockedActiveExporter.value == nil) + lockedActiveExporter.value = exporter + defer { lockedActiveExporter.value = nil } + + return exporter.exportSynchronized(now: now) + } + + private func obtainSynchronizeSemaphore(waiting timeInterval: TimeInterval = 0) -> Bool { + switch synchronizeSemaphore.wait(timeout: .now() + timeInterval) { + case .timedOut: + return false + case .success: + return true + } + } + + // MARK: - Utilities + + fileprivate func latestExportDate() -> Result { + var exportDate: Date = .distantPast + + do { + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) + for fileURL in try fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) { + if fileURL.pathExtension == archiveExtension, let fileDate = date(from: fileURL.deletingPathExtension().lastPathComponent) { + if fileDate > exportDate { + exportDate = fileDate + } + } + } + return .success(exportDate) + } catch let error { + return .failure(error) + } + } + + var archiveExtension: String { "zip" } + + func recentDate(from now: Date) -> Date { exportDate(for: date(byAddingDays: -1, to: now)) } + + func exportDate(for date: Date) -> Date { calendar.startOfDay(for: date) } + + func date(byAddingDays days: Int, to date: Date) -> Date { calendar.date(byAdding: .day, value: days, to: date)! } + + func date(from timestamp: String) -> Date? { timestampFormatter.date(from: timestamp) } + + func timestamp(from date: Date) -> String { timestampFormatter.string(from: date) } + + fileprivate var timestampFormatter: ISO8601DateFormatter { Self.timestampFormatter } + + fileprivate var calendar: Calendar { Self.calendar } + + private static let timestampFormatter: ISO8601DateFormatter = { + let timestampFormatter = ISO8601DateFormatter() + timestampFormatter.timeZone = calendar.timeZone + timestampFormatter.formatOptions = [.withYear, .withMonth, .withDay, .withTime, .withTimeZone] + return timestampFormatter + }() + + private static let calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + return calendar + }() +} + +// MARK: - CriticalEventLogBaseExporter + +public class CriticalEventLogBaseExporter { + public weak var delegate: CriticalEventLogExporterDelegate? + + public let progress: Progress + + fileprivate let manager: CriticalEventLogExportManager + + fileprivate init(manager: CriticalEventLogExportManager) { + self.progress = Progress.discreteProgress(totalUnitCount: 0) + self.manager = manager + } + + fileprivate func exportSynchronized(now: Date) -> Error? { + guard !progress.isCancelled else { + return CriticalEventLogError.cancelled + } + + switch exportProgressTotalUnitCount(now: now) { + case .failure(let error): + return error + case .success(let progressTotalUnitCount): + progress.totalUnitCount = progressTotalUnitCount + return nil + } + } + + fileprivate func exportProgressTotalUnitCount(now: Date) -> Result { + return exportProgressTotalUnitCount(through: nil, now: now) + } + + fileprivate func exportProgressTotalUnitCount(through endDate: Date?, now: Date) -> Result { + switch manager.latestExportDate() { + case .failure(let error): + return .failure(error) + case .success(let latestExportDate): + let startDate = max(historicalDate(from: now), latestExportDate) + var progressTotalUnitCount: Int64 = 0 + for log in manager.logs { + switch log.exportProgressTotalUnitCount(startDate: startDate, endDate: endDate) { + case .failure(let error): + return .failure(error) + case .success(let logProgressTotalUnitCount): + progressTotalUnitCount += logProgressTotalUnitCount + } + } + return .success(progressTotalUnitCount) + } + } + + fileprivate func export(startDate: Date, endDate: Date, to url: URL, progress: Progress) -> Error? { + guard !progress.isCancelled else { + return CriticalEventLogError.cancelled + } + + guard let archive = ZipArchive(url: url) else { + return CriticalEventLogExportError.archiveFailure + } + defer { archive.close() } + + for log in manager.logs { + if let error = export(startDate: startDate, endDate: endDate, from: log, to: archive, progress: progress) { + return error + } + } + + return archive.close() + } + + private func export(startDate: Date, endDate: Date, from log: CriticalEventLog, to archive: ZipArchive, progress: Progress) -> Error? { + guard !progress.isCancelled else { + return CriticalEventLogError.cancelled + } + + let stream = archive.createArchiveFile(withPath: log.exportName) + + if let error = log.export(startDate: startDate, endDate: endDate, to: stream, progress: progress) { + return error + } + + do { + try stream.finish(sync: true) + } catch { + return error + } + + return nil + } + + fileprivate func historicalDate(from now: Date) -> Date { manager.exportDate(for: manager.date(byAddingDays: -Int(manager.historicalDuration.days), to: now)) } + + fileprivate func exportsFileURL(for date: Date) -> URL { manager.directory.appendingPathComponent(exportsFileName(for: date)).appendingPathExtension(manager.archiveExtension) } + + fileprivate func exportsFileName(for date: Date) -> String { manager.timestamp(from: date) } +} + +// MARK: - CriticalEventLogHistoricalExporter + +public class CriticalEventLogHistoricalExporter: CriticalEventLogBaseExporter, CriticalEventLogSynchronizedExporter { + private let log = OSLog(category: "CriticalEventLogHistoricalExporter") + + public func export(now: Date, completion: @escaping (Error?) -> Void) { + completion(manager.synchronizeExport(for: self, cancellingActive: false, now: now)) + } + + fileprivate override func exportSynchronized(now: Date) -> Error? { + if let error = super.exportSynchronized(now: now) { + return error + } + + let observation = progress.observe(\.fractionCompleted, options: []) { [weak self] object, _ in + self?.delegate?.exportDidProgress(object.fractionCompleted) + } + defer { observation.invalidate() } + + return exportSynchronized(progress: progress, now: now) + } + + fileprivate func exportSynchronized(progress: Progress, now: Date) -> Error? { + guard !isCancelled else { + return CriticalEventLogError.cancelled + } + + purge(now: now) // Purge first to reduce disk space + + do { + try manager.fileManager.createDirectory(at: manager.directory, withIntermediateDirectories: true, attributes: nil) + + var startDate = historicalDate(from: now) + while startDate < manager.recentDate(from: now) { + guard !isCancelled else { + return CriticalEventLogError.cancelled + } + + let endDate = manager.date(byAddingDays: 1, to: startDate) + let exportFileURL = exportsFileURL(for: endDate) + if !manager.fileManager.fileExists(atPath: exportFileURL.path) { + log.default("Exporting %{public}@...", exportFileURL.lastPathComponent) + + let temporaryFileURL = manager.fileManager.temporaryFileURL + defer { try? manager.fileManager.removeItem(at: temporaryFileURL) } + + if let error = export(startDate: startDate, endDate: endDate, to: temporaryFileURL, progress: progress) { + return error + } + + try manager.fileManager.moveItem(at: temporaryFileURL, to: exportFileURL) + + log.default("Exported %{public}@", exportFileURL.lastPathComponent) + } + + startDate = endDate + } + return nil + } catch let error { + return error + } + } + + fileprivate override func exportProgressTotalUnitCount(now: Date) -> Result { + return exportProgressTotalUnitCount(through: manager.recentDate(from: now), now: now) + } + + private func purge(now: Date) { + do { + try manager.fileManager.createDirectory(at: manager.directory, withIntermediateDirectories: true, attributes: nil) + for fileURL in try manager.fileManager.contentsOfDirectory(at: manager.directory, includingPropertiesForKeys: nil) { + if fileURL.pathExtension == manager.archiveExtension, let fileDate = manager.date(from: fileURL.deletingPathExtension().lastPathComponent) { + if fileDate <= historicalDate(from: now) { + log.default("Purging %{public}@...", fileURL.lastPathComponent) + try manager.fileManager.removeItem(at: fileURL) + log.default("Purged %{public}@", fileURL.lastPathComponent) + } + } + } + } catch let error { + log.error("Failure purging historical export with error: %{public}@", String(describing: error)) + } + } +} + +// MARK: - CriticalEventLogFullExporter + +public class CriticalEventLogFullExporter: CriticalEventLogBaseExporter, CriticalEventLogSynchronizedExporter { + private let historicalExporter: CriticalEventLogHistoricalExporter + private let url: URL + private var backgroundTaskIdentifier: UIBackgroundTaskIdentifier? + + private let log = OSLog(category: "CriticalEventLogFullExporter") + + fileprivate init(manager: CriticalEventLogExportManager, to url: URL) { + self.historicalExporter = CriticalEventLogHistoricalExporter(manager: manager) + self.url = url + super.init(manager: manager) + } + + public func export(now: Date, completion: @escaping (Error?) -> Void) { + DispatchQueue.main.async { + NotificationCenter.default.addObserver(self, selector: #selector(self.willEnterForegroundNotificationReceived(_:)), name: UIApplication.willEnterForegroundNotification, object: nil) + self.beginBackgroundTask() + } + + DispatchQueue.global(qos: .utility).async { + completion(self.manager.synchronizeExport(for: self, cancellingActive: true, now: now)) + + DispatchQueue.main.async { + self.endBackgroundTask() + NotificationCenter.default.removeObserver(self) + } + } + } + + private let progressUnitCountArchivePerDay: Int64 = 10 + private let progressUnitCountMove: Int64 = 10 + + fileprivate override func exportSynchronized(now: Date) -> Error? { + if let error = super.exportSynchronized(now: now) { + return error + } + + let observation = progress.observe(\.fractionCompleted, options: []) { [weak self] object, _ in + self?.delegate?.exportDidProgress(object.fractionCompleted) + } + defer { observation.invalidate() } + + if let error = historicalExporter.exportSynchronized(progress: progress, now: now) { + return error + } + + guard !isCancelled else { + return CriticalEventLogError.cancelled + } + + let recentFileURL = exportsFileURL(for: now) + let recentTemporaryFileURL = manager.fileManager.temporaryFileURL + defer { try? manager.fileManager.removeItem(at: recentTemporaryFileURL) } + + log.default("Exporting %{public}@...", recentFileURL.lastPathComponent) + + if let error = export(startDate: manager.recentDate(from: now), endDate: now, to: recentTemporaryFileURL, progress: progress) { + return error + } + + log.default("Exported %{public}@", recentFileURL.lastPathComponent) + + guard !isCancelled else { + return CriticalEventLogError.cancelled + } + + log.default("Exporting final archive %{public}@", url.lastPathComponent) + + let archiveTemporaryFileURL = manager.fileManager.temporaryFileURL + defer { try? manager.fileManager.removeItem(at: archiveTemporaryFileURL) } + + guard let archive = ZipArchive(url: archiveTemporaryFileURL) else { + return CriticalEventLogExportError.archiveFailure + } + defer { archive.close() } + + var date = historicalDate(from: now) + while date < manager.recentDate(from: now) { + guard !isCancelled else { + return CriticalEventLogError.cancelled + } + + date = manager.date(byAddingDays: 1, to: date) + + let exportFileURL = exportsFileURL(for: date) + log.default("Bundling %{public}@", exportFileURL.lastPathComponent) + if let error = archive.createArchiveFile(withPath: exportFileURL.lastPathComponent, contentsOf: exportFileURL, compressionMethod: .none) { + return error + } + + progress.completedUnitCount += progressUnitCountArchivePerDay + } + + guard !isCancelled else { + return CriticalEventLogError.cancelled + } + + log.default("Bundling %{public}@", recentFileURL.lastPathComponent) + if let error = archive.createArchiveFile(withPath: recentFileURL.lastPathComponent, contentsOf: recentTemporaryFileURL, compressionMethod: .none) { + return error + } + + progress.completedUnitCount += progressUnitCountArchivePerDay + + if let error = archive.close() { + return error + } + + guard !isCancelled else { + return CriticalEventLogError.cancelled + } + + do { + try manager.fileManager.moveItem(at: archiveTemporaryFileURL, to: url) + } catch let error { + return error + } + + progress.completedUnitCount += progressUnitCountMove + + log.default("Exported final archive %{public}@", url.lastPathComponent) + return nil + } + + fileprivate override func exportProgressTotalUnitCount(now: Date) -> Result { + switch super.exportProgressTotalUnitCount(now: now) { + case .failure(let error): + return .failure(error) + case .success(let progressTotalUnitCount): + return .success(progressTotalUnitCount + Int64(manager.historicalDuration.days) * progressUnitCountArchivePerDay + progressUnitCountMove) + } + } + + private func beginBackgroundTask() { + dispatchPrecondition(condition: .onQueue(.main)) + + self.endBackgroundTask() + + self.backgroundTaskIdentifier = UIApplication.shared.beginBackgroundTask { + self.log.default("Invoked critical event log full export background task expiration handler") + self.endBackgroundTask() + } + + self.log.default("Begin critical event log full export background task") + } + + private func endBackgroundTask() { + dispatchPrecondition(condition: .onQueue(.main)) + + if let backgroundTaskIdentifier = self.backgroundTaskIdentifier { + UIApplication.shared.endBackgroundTask(backgroundTaskIdentifier) + self.backgroundTaskIdentifier = nil + self.log.default("End critical event log full export background task") + } + } + + @objc private func willEnterForegroundNotificationReceived(_ notification: Notification) { + beginBackgroundTask() + } +} + +// MARK: - FileManager + +fileprivate extension FileManager { + var temporaryFileURL: URL { + return temporaryDirectory.appendingPathComponent(UUID().uuidString) + } +} diff --git a/Loop/Managers/DeeplinkManager.swift b/Loop/Managers/DeeplinkManager.swift new file mode 100644 index 0000000000..86b17f625b --- /dev/null +++ b/Loop/Managers/DeeplinkManager.swift @@ -0,0 +1,51 @@ +// +// DeeplinkManager.swift +// Loop +// +// Created by Cameron Ingham on 6/26/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import UIKit + +enum Deeplink: String, CaseIterable { + case carbEntry = "carb-entry" + case bolus = "manual-bolus" + case preMeal = "pre-meal-preset" + case customPresets = "custom-presets" + + init?(url: URL?) { + guard let url, let host = url.host, let deeplink = Deeplink.allCases.first(where: { $0.rawValue == host }) else { + return nil + } + + self = deeplink + } +} + +class DeeplinkManager { + + private weak var rootViewController: UIViewController? + + init(rootViewController: UIViewController?) { + self.rootViewController = rootViewController + } + + func handle(_ url: URL) -> Bool { + guard let rootViewController = rootViewController as? RootNavigationController, let deeplink = Deeplink(url: url) else { + return false + } + + rootViewController.navigate(to: deeplink) + return true + } + + func handle(_ deeplink: Deeplink) -> Bool { + guard let rootViewController = rootViewController as? RootNavigationController else { + return false + } + + rootViewController.navigate(to: deeplink) + return true + } +} diff --git a/Loop/Managers/DeliveryUncertaintyAlertManager.swift b/Loop/Managers/DeliveryUncertaintyAlertManager.swift new file mode 100644 index 0000000000..d163d9d227 --- /dev/null +++ b/Loop/Managers/DeliveryUncertaintyAlertManager.swift @@ -0,0 +1,67 @@ +// +// DeliveryUncertaintyAlertManager.swift +// Loop +// +// Created by Pete Schwamb on 8/31/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import Foundation +import UIKit +import LoopKitUI + +class DeliveryUncertaintyAlertManager { + private let pumpManager: PumpManagerUI + private let alertPresenter: AlertPresenter + private var uncertainDeliveryAlert: UIAlertController? + + init(pumpManager: PumpManagerUI, alertPresenter: AlertPresenter) { + self.pumpManager = pumpManager + self.alertPresenter = alertPresenter + } + + private func showUncertainDeliveryRecoveryView() { + var controller = pumpManager.deliveryUncertaintyRecoveryViewController(colorPalette: .default, allowDebugFeatures: FeatureFlags.allowDebugFeatures) + controller.completionDelegate = self + self.alertPresenter.present(controller, animated: true) + } + + func showAlert(animated: Bool = true) { + if self.uncertainDeliveryAlert == nil { + let alert = UIAlertController( + title: NSLocalizedString("Unable To Reach Pump", comment: "Title for alert shown when delivery status is uncertain"), + message: String(format: NSLocalizedString("%1$@ is unable to communicate with your insulin pump. The app will continue trying to reach your pump, but insulin delivery information cannot be updated and no automation can continue.\nYou can wait several minutes to see if the issue resolves or tap the button below to learn more about other options.", comment: "Message for alert shown when delivery status is uncertain. (1: app name)"), Bundle.main.bundleDisplayName), + preferredStyle: .alert) + + let actionTitle = NSLocalizedString("Learn More", comment: "OK button title for alert shown when delivery status is uncertain") + let action = UIAlertAction(title: actionTitle, style: .default) { (_) in + self.uncertainDeliveryAlert = nil + self.showUncertainDeliveryRecoveryView() + } + alert.addAction(action) + self.alertPresenter.dismissTopMost(animated: false) { + self.alertPresenter.present(alert, animated: animated) + } + self.uncertainDeliveryAlert = alert + } + } + + func clearAlert() { + self.uncertainDeliveryAlert?.dismiss(animated: true, completion: nil) + self.uncertainDeliveryAlert = nil + } +} + + +extension DeliveryUncertaintyAlertManager: CompletionDelegate { + func completionNotifyingDidComplete(_ object: CompletionNotifying) { + // If delivery still uncertain after recovery view dismissal, present modal alert again. + if let vc = object as? UIViewController { + vc.dismiss(animated: true) { + if self.pumpManager.status.deliveryIsUncertain { + self.showAlert(animated: false) + } + } + } + } +} diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 4dad875a39..2751f18f50 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -6,1094 +6,1746 @@ // Copyright © 2015 Nathan Racklyeft. All rights reserved. // -import Foundation -import CarbKit -import CoreData -import G4ShareSpy -import GlucoseKit +import BackgroundTasks import HealthKit -import InsulinKit import LoopKit -import LoopUI -import MinimedKit -import NightscoutUploadKit -import RileyLinkKit -import ShareClient -import xDripG5 +import LoopKitUI +import LoopCore +import LoopTestingKit +import UserNotifications +import Combine +final class DeviceDataManager { -final class DeviceDataManager: CarbStoreDelegate, CarbStoreSyncDelegate, DoseStoreDelegate, TransmitterDelegate, ReceiverDelegate { + private let queue = DispatchQueue(label: "com.loopkit.DeviceManagerQueue", qos: .utility) + + private let log = DiagnosticLog(category: "DeviceDataManager") - // MARK: - Utilities + let pluginManager: PluginManager + weak var alertManager: AlertManager! + let bluetoothProvider: BluetoothProvider + weak var onboardingManager: OnboardingManager? - let logger = DiagnosticLogger() + /// Remember the launch date of the app for diagnostic reporting + private let launchDate = Date() - /// Manages all the RileyLinks - let rileyLinkManager: RileyLinkDeviceManager + /// The last error recorded by a device manager + /// Should be accessed only on the main queue + private(set) var lastError: (date: Date, error: Error)? - /// Manages remote data (TODO: the lazy initialization isn't thread-safe) - lazy var remoteDataManager = RemoteDataManager() + private var deviceLog: PersistentDeviceLog - private var nightscoutDataManager: NightscoutDataManager! + // MARK: - App-level responsibilities - // The Dexcom Share receiver object - private var receiver: Receiver? { - didSet { - receiver?.delegate = self - enableRileyLinkHeartbeatIfNeeded() - } - } + private var alertPresenter: AlertPresenter + + private var deliveryUncertaintyAlertManager: DeliveryUncertaintyAlertManager? + + @Published var cgmHasValidSensorSession: Bool - var receiverEnabled: Bool { - get { - return receiver != nil - } - set { - receiver = newValue ? Receiver() : nil - UserDefaults.standard.receiverEnabled = newValue - } - } + @Published var pumpIsAllowingAutomation: Bool - var sensorInfo: SensorDisplayable? { - return latestGlucoseG5 ?? latestGlucoseG4 ?? latestGlucoseFromShare ?? latestPumpStatusFromMySentry - } + private var lastCGMLoopTrigger: Date = .distantPast - var latestPumpStatus: RileyLinkKit.PumpStatus? + private let automaticDosingStatus: AutomaticDosingStatus - // Returns a value in the range 0 - 1 - var pumpBatteryChargeRemaining: Double? { - get { - if let status = latestPumpStatusFromMySentry { - return Double(status.batteryRemainingPercent) / 100 - } else if let status = latestPumpStatus { - return batteryChemistry.chargeRemaining(voltage: status.batteryVolts) - } else { - return nil - } + var closedLoopDisallowedLocalizedDescription: String? { + if !cgmHasValidSensorSession { + return NSLocalizedString("Closed Loop requires an active CGM Sensor Session", comment: "The description text for the looping enabled switch cell when closed loop is not allowed because the sensor is inactive") + } else if !pumpIsAllowingAutomation { + return NSLocalizedString("Your pump is delivering a manual temporary basal rate.", comment: "The description text for the looping enabled switch cell when closed loop is not allowed because the pump is delivering a manual temp basal.") + } else { + return nil } } - // Battery monitor - func observeBatteryDuring(_ block: () -> Void) { - let oldVal = pumpBatteryChargeRemaining - block() - if let newVal = pumpBatteryChargeRemaining { - if newVal == 0 { - NotificationManager.sendPumpBatteryLowNotification() - } + lazy private var cancellables = Set() + + lazy var allowedInsulinTypes: [InsulinType] = { + var allowed = InsulinType.allCases + if !FeatureFlags.fiaspInsulinModelEnabled { + allowed.remove(.fiasp) + } + if !FeatureFlags.lyumjevInsulinModelEnabled { + allowed.remove(.lyumjev) + } + if !FeatureFlags.afrezzaInsulinModelEnabled { + allowed.remove(.afrezza) + } - if let oldVal = oldVal, newVal - oldVal >= 0.5 { - AnalyticsManager.sharedManager.pumpBatteryWasReplaced() + for insulinType in InsulinType.allCases { + if !insulinType.pumpAdministerable { + allowed.remove(insulinType) } } - } + return allowed + }() - // MARK: - RileyLink + private var cgmStalenessMonitor: CGMStalenessMonitor - @objc private func receivedRileyLinkManagerNotification(_ note: Notification) { - NotificationCenter.default.post(name: note.name, object: self, userInfo: note.userInfo) - } + private var displayGlucoseUnitObservers = WeakSynchronizedSet() - /** - Called when a new idle message is received by the RileyLink. + public private(set) var displayGlucosePreference: DisplayGlucosePreference + + var deviceWhitelist = DeviceWhitelist() - Only MySentryPumpStatus messages are handled. + // MARK: - CGM - - parameter note: The notification object - */ - @objc private func receivedRileyLinkPacketNotification(_ note: Notification) { - if let - device = note.object as? RileyLinkDevice, - let data = note.userInfo?[RileyLinkDevice.IdleMessageDataKey] as? Data, - let message = PumpMessage(rxData: data) - { - switch message.packetType { - case .mySentry: - switch message.messageBody { - case let body as MySentryPumpStatusMessageBody: - updatePumpStatus(body, from: device) - case is MySentryAlertMessageBody, is MySentryAlertClearedMessageBody: - break - case let body: - logger.addMessage(["messageType": Int(message.messageType.rawValue), "messageBody": body.txData.hexadecimalString], toCollection: "sentryOther") + var cgmManager: CGMManager? { + didSet { + dispatchPrecondition(condition: .onQueue(.main)) + setupCGM() + + if cgmManager?.pluginIdentifier != oldValue?.pluginIdentifier { + if let cgmManager = cgmManager { + analyticsServicesManager.cgmWasAdded(identifier: cgmManager.pluginIdentifier) + } else { + analyticsServicesManager.cgmWasRemoved() } - default: - break } - } - } - @objc private func receivedRileyLinkTimerTickNotification(_ note: Notification) { - backfillGlucoseFromShareIfNeeded() { - self.assertCurrentPumpData() + NotificationCenter.default.post(name: .CGMManagerChanged, object: self, userInfo: nil) + rawCGMManager = cgmManager?.rawValue + UserDefaults.appGroup?.clearLegacyCGMManagerRawValue() } } - func connectToRileyLink(_ device: RileyLinkDevice) { - connectedPeripheralIDs.insert(device.peripheral.identifier.uuidString) + @PersistedProperty(key: "CGMManagerState") + var rawCGMManager: CGMManager.RawValue? - rileyLinkManager.connectDevice(device) + // MARK: - Pump - AnalyticsManager.sharedManager.didChangeRileyLinkConnectionState() - } + var pumpManager: PumpManagerUI? { + didSet { + dispatchPrecondition(condition: .onQueue(.main)) - func disconnectFromRileyLink(_ device: RileyLinkDevice) { - connectedPeripheralIDs.remove(device.peripheral.identifier.uuidString) + // If the current CGMManager is a PumpManager, we clear it out. + if cgmManager is PumpManagerUI { + cgmManager = nil + } - rileyLinkManager.disconnectDevice(device) + if pumpManager?.pluginIdentifier != oldValue?.pluginIdentifier { + if let pumpManager = pumpManager { + analyticsServicesManager.pumpWasAdded(identifier: pumpManager.pluginIdentifier) + } else { + analyticsServicesManager.pumpWasRemoved() + } + } - AnalyticsManager.sharedManager.didChangeRileyLinkConnectionState() + setupPump() - if connectedPeripheralIDs.count == 0 { - NotificationManager.clearPendingNotificationRequests() - } - } + NotificationCenter.default.post(name: .PumpManagerChanged, object: self, userInfo: nil) - /// Controls the management of the RileyLink timer tick, which is a reliably-changing BLE - /// characteristic which can cause the app to wake. For most users, the G5 Transmitter and - /// G4 Receiver are reliable as hearbeats, but users who find their resources extremely constrained - /// due to greedy apps or older devices may choose to always enable the timer by always setting `true` - private func enableRileyLinkHeartbeatIfNeeded() { - if transmitter != nil { - rileyLinkManager.timerTickEnabled = false - } else if receiverEnabled { - rileyLinkManager.timerTickEnabled = false - } else { - rileyLinkManager.timerTickEnabled = true + rawPumpManager = pumpManager?.rawValue + UserDefaults.appGroup?.clearLegacyPumpManagerRawValue() } } - // MARK: Pump data + @PersistedProperty(key: "PumpManagerState") + var rawPumpManager: PumpManager.RawValue? - var latestPumpStatusFromMySentry: MySentryPumpStatusMessageBody? + + var doseEnactor = DoseEnactor() + + // MARK: Stores + let healthStore: HKHealthStore + + let carbStore: CarbStore + + let doseStore: DoseStore + + let glucoseStore: GlucoseStore - /** - Handles receiving a MySentry status message, which are only posted by MM x23 pumps. + let cgmEventStore: CgmEventStore - This message has two important pieces of info about the pump: reservoir volume and battery. + private let cacheStore: PersistenceController - Because the RileyLink must actively listen for these packets, they are not a reliable heartbeat. However, we can still use them to assert glucose data is current. + let dosingDecisionStore: DosingDecisionStore + + /// All the HealthKit types to be read by stores + private var readTypes: Set { + var readTypes: Set = [] - - parameter status: The status message body - - parameter device: The RileyLink that received the message - */ - private func updatePumpStatus(_ status: MySentryPumpStatusMessageBody, from device: RileyLinkDevice) { - var pumpDateComponents = status.pumpDateComponents - var glucoseDateComponents = status.glucoseDateComponents + if FeatureFlags.observeHealthKitCarbSamplesFromOtherApps { + readTypes.insert(HealthKitSampleStore.carbType) + } + if FeatureFlags.observeHealthKitDoseSamplesFromOtherApps { + readTypes.insert(HealthKitSampleStore.insulinQuantityType) + } + if FeatureFlags.observeHealthKitGlucoseSamplesFromOtherApps { + readTypes.insert(HealthKitSampleStore.glucoseType) + } - pumpDateComponents.timeZone = pumpState?.timeZone - glucoseDateComponents?.timeZone = pumpState?.timeZone + readTypes.insert(HKObjectType.categoryType(forIdentifier: HKCategoryTypeIdentifier.sleepAnalysis)!) - // The pump sends the same message 3x, so ignore it if we've already seen it. - guard status != latestPumpStatusFromMySentry, let pumpDate = pumpDateComponents.date else { - return - } + return readTypes + } + + /// All the HealthKit types to be shared by stores + private var shareTypes: Set { + return Set([ + HealthKitSampleStore.glucoseType, + HealthKitSampleStore.carbType, + HealthKitSampleStore.insulinQuantityType, + ]) + } - observeBatteryDuring { - latestPumpStatusFromMySentry = status - } + var sleepDataAuthorizationRequired: Bool { + return healthStore.authorizationStatus(for: HKObjectType.categoryType(forIdentifier: HKCategoryTypeIdentifier.sleepAnalysis)!) == .notDetermined + } + + var sleepDataSharingDenied: Bool { + return healthStore.authorizationStatus(for: HKObjectType.categoryType(forIdentifier: HKCategoryTypeIdentifier.sleepAnalysis)!) == .sharingDenied + } - // Gather PumpStatus from MySentry packet - let pumpStatus: NightscoutUploadKit.PumpStatus? - if let pumpDate = pumpDateComponents.date, let pumpID = pumpID { + /// True if any stores require HealthKit authorization + var authorizationRequired: Bool { + return healthStore.authorizationStatus(for: HealthKitSampleStore.glucoseType) == .notDetermined || + healthStore.authorizationStatus(for: HealthKitSampleStore.carbType) == .notDetermined || + healthStore.authorizationStatus(for: HealthKitSampleStore.insulinQuantityType) == .notDetermined || + sleepDataAuthorizationRequired + } - let batteryStatus = BatteryStatus(percent: status.batteryRemainingPercent) - let iobStatus = IOBStatus(timestamp: pumpDate, iob: status.iob) + private(set) var statefulPluginManager: StatefulPluginManager! + + // MARK: Services - pumpStatus = NightscoutUploadKit.PumpStatus(clock: pumpDate, pumpID: pumpID, iob: iobStatus, battery: batteryStatus, reservoir: status.reservoirRemainingUnits) - } else { - pumpStatus = nil - self.logger.addError("Could not interpret pump clock: \(pumpDateComponents)", fromSource: "RileyLink") - } - - // Trigger device status upload, even if something is wrong with pumpStatus - nightscoutDataManager.uploadDeviceStatus(pumpStatus) - - backfillGlucoseFromShareIfNeeded() - - // Minimed sensor glucose - switch status.glucose { - case .active(glucose: let glucose): - if let date = glucoseDateComponents?.date { - glucoseStore?.addGlucose( - HKQuantity(unit: HKUnit.milligramsPerDeciliterUnit(), doubleValue: Double(glucose)), - date: date, - isDisplayOnly: false, - device: nil - ) { (success, _, error) in - if let error = error { - self.logger.addError(error, fromSource: "GlucoseStore") - } + private(set) var servicesManager: ServicesManager! - if success { - NotificationCenter.default.post(name: .GlucoseUpdated, object: self) - } - } - } - default: - break - } + var analyticsServicesManager: AnalyticsServicesManager - // Upload sensor glucose to Nightscout - remoteDataManager.nightscoutUploader?.uploadSGVFromMySentryPumpStatus(status, device: device.deviceURI) + var settingsManager: SettingsManager - // Sentry packets are sent in groups of 3, 5s apart. Wait 11s before allowing the loop data to continue to avoid conflicting comms. - DispatchQueue.global(qos: DispatchQoS.QoSClass.utility).asyncAfter(deadline: DispatchTime.now() + Double(Int64(11 * NSEC_PER_SEC)) / Double(NSEC_PER_SEC)) { - self.updateReservoirVolume(status.reservoirRemainingUnits, at: pumpDate, withTimeLeft: TimeInterval(minutes: Double(status.reservoirRemainingMinutes))) - } + var remoteDataServicesManager: RemoteDataServicesManager { return servicesManager.remoteDataServicesManager } - } + var criticalEventLogExportManager: CriticalEventLogExportManager! - /** - Store a new reservoir volume and notify observers of new pump data. + var crashRecoveryManager: CrashRecoveryManager - - parameter units: The number of units remaining - - parameter date: The date the reservoir was read - - parameter timeLeft: The approximate time before the reservoir is empty - */ - private func updateReservoirVolume(_ units: Double, at date: Date, withTimeLeft timeLeft: TimeInterval?) { - doseStore.addReservoirValue(units, atDate: date) { (newValue, previousValue, areStoredValuesContinuous, error) -> Void in - if let error = error { - self.logger.addError(error, fromSource: "DoseStore") - return - } + private(set) var pumpManagerHUDProvider: HUDProvider? - if self.preferredInsulinDataSource == .pumpHistory || !areStoredValuesContinuous { - self.fetchPumpHistory { (error) in - // Notify and trigger a loop as long as we have fresh, reliable pump data. - if error == nil || areStoredValuesContinuous { - NotificationCenter.default.post(name: .PumpStatusUpdated, object: self) - } - } - } else { - NotificationCenter.default.post(name: .PumpStatusUpdated, object: self) - } + private var trustedTimeChecker: TrustedTimeChecker - // Send notifications for low reservoir if necessary - if let newVolume = newValue?.unitVolume, let previousVolume = previousValue?.unitVolume { - guard newVolume > 0 else { - NotificationManager.sendPumpReservoirEmptyNotification() - return - } + // MARK: - WatchKit - let warningThresholds: [Double] = [10, 20, 30] + private var watchManager: WatchDataManager! - for threshold in warningThresholds { - if newVolume <= threshold && previousVolume > threshold { - NotificationManager.sendPumpReservoirLowNotificationForAmount(newVolume, andTimeRemaining: timeLeft) - } - } + // MARK: - Status Extension - if newVolume > previousVolume + 1 { - AnalyticsManager.sharedManager.reservoirWasRewound() - } + private var statusExtensionManager: ExtensionDataManager! + + // MARK: - Initialization + + private(set) var loopManager: LoopDataManager! + + init(pluginManager: PluginManager, + alertManager: AlertManager, + settingsManager: SettingsManager, + loggingServicesManager: LoggingServicesManager, + analyticsServicesManager: AnalyticsServicesManager, + bluetoothProvider: BluetoothProvider, + alertPresenter: AlertPresenter, + automaticDosingStatus: AutomaticDosingStatus, + cacheStore: PersistenceController, + localCacheDuration: TimeInterval, + overrideHistory: TemporaryScheduleOverrideHistory, + trustedTimeChecker: TrustedTimeChecker) + { + + let fileManager = FileManager.default + let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first! + let deviceLogDirectory = documentsDirectory.appendingPathComponent("DeviceLog") + if !fileManager.fileExists(atPath: deviceLogDirectory.path) { + do { + try fileManager.createDirectory(at: deviceLogDirectory, withIntermediateDirectories: false) + } catch let error { + preconditionFailure("Could not create DeviceLog directory: \(error)") } } - } + deviceLog = PersistentDeviceLog(storageFile: deviceLogDirectory.appendingPathComponent("Storage.sqlite"), maxEntryAge: localCacheDuration) + self.pluginManager = pluginManager + self.alertManager = alertManager + self.bluetoothProvider = bluetoothProvider + self.alertPresenter = alertPresenter + + self.healthStore = HKHealthStore() + self.cacheStore = cacheStore + self.settingsManager = settingsManager + + let absorptionTimes = LoopCoreConstants.defaultCarbAbsorptionTimes + let sensitivitySchedule = settingsManager.latestSettings.insulinSensitivitySchedule + + let carbHealthStore = HealthKitSampleStore( + healthStore: healthStore, + observeHealthKitSamplesFromOtherApps: FeatureFlags.observeHealthKitCarbSamplesFromOtherApps, // At some point we should let the user decide which apps they would like to import from. + type: HealthKitSampleStore.carbType, + observationStart: Date().addingTimeInterval(-absorptionTimes.slow * 2) + ) + + self.carbStore = CarbStore( + healthKitSampleStore: carbHealthStore, + cacheStore: cacheStore, + cacheLength: localCacheDuration, + defaultAbsorptionTimes: absorptionTimes, + carbRatioSchedule: settingsManager.latestSettings.carbRatioSchedule, + insulinSensitivitySchedule: sensitivitySchedule, + overrideHistory: overrideHistory, + carbAbsorptionModel: FeatureFlags.nonlinearCarbModelEnabled ? .nonlinear : .linear, + provenanceIdentifier: HKSource.default().bundleIdentifier + ) - /** - Polls the pump for new history events and stores them. - - - parameter completion: A closure called after the fetch is complete. This closure takes a single argument: - - error: An error describing why the fetch and/or store failed - */ - private func fetchPumpHistory(_ completionHandler: @escaping (_ error: Error?) -> Void) { - guard let device = rileyLinkManager.firstConnectedDevice else { - return + let insulinModelProvider: InsulinModelProvider + if FeatureFlags.adultChildInsulinModelSelectionEnabled { + insulinModelProvider = PresetInsulinModelProvider(defaultRapidActingModel: settingsManager.latestSettings.defaultRapidActingModel?.presetForRapidActingInsulin) + } else { + insulinModelProvider = PresetInsulinModelProvider(defaultRapidActingModel: nil) } - let startDate = doseStore.pumpEventQueryAfterDate + self.analyticsServicesManager = analyticsServicesManager - device.ops?.getHistoryEvents(since: startDate) { (result) in - switch result { - case let .success(events, _): - self.doseStore.add(events) { (error) in - if let error = error { - self.logger.addError("Failed to store history: \(error)", fromSource: "DoseStore") - } + let insulinHealthStore = HealthKitSampleStore( + healthStore: healthStore, + observeHealthKitSamplesFromOtherApps: FeatureFlags.observeHealthKitDoseSamplesFromOtherApps, + type: HealthKitSampleStore.insulinQuantityType, + observationStart: Date().addingTimeInterval(-absorptionTimes.slow * 2) + ) - completionHandler(error) - } - case .failure(let error): - self.logger.addError("Failed to fetch history: \(error)", fromSource: "RileyLink") + self.doseStore = DoseStore( + healthKitSampleStore: insulinHealthStore, + cacheStore: cacheStore, + cacheLength: localCacheDuration, + insulinModelProvider: insulinModelProvider, + longestEffectDuration: ExponentialInsulinModelPreset.rapidActingAdult.effectDuration, + basalProfile: settingsManager.latestSettings.basalRateSchedule, + insulinSensitivitySchedule: sensitivitySchedule, + overrideHistory: overrideHistory, + lastPumpEventsReconciliation: nil, // PumpManager is nil at this point. Will update this via addPumpEvents below + provenanceIdentifier: HKSource.default().bundleIdentifier + ) + + let glucoseHealthStore = HealthKitSampleStore( + healthStore: healthStore, + observeHealthKitSamplesFromOtherApps: FeatureFlags.observeHealthKitGlucoseSamplesFromOtherApps, + type: HealthKitSampleStore.glucoseType, + observationStart: Date().addingTimeInterval(-.hours(24)) + ) + + self.glucoseStore = GlucoseStore( + healthKitSampleStore: glucoseHealthStore, + cacheStore: cacheStore, + cacheLength: localCacheDuration, + provenanceIdentifier: HKSource.default().bundleIdentifier + ) + + cgmStalenessMonitor = CGMStalenessMonitor() + cgmStalenessMonitor.delegate = glucoseStore + + cgmEventStore = CgmEventStore(cacheStore: cacheStore, cacheLength: localCacheDuration) + + dosingDecisionStore = DosingDecisionStore(store: cacheStore, expireAfter: localCacheDuration) + + cgmHasValidSensorSession = false + pumpIsAllowingAutomation = true + self.automaticDosingStatus = automaticDosingStatus + + // HealthStorePreferredGlucoseUnitDidChange will be notified once the user completes the health access form. Set to .milligramsPerDeciliter until then + displayGlucosePreference = DisplayGlucosePreference(displayGlucoseUnit: .milligramsPerDeciliter) - completionHandler(error) + self.trustedTimeChecker = trustedTimeChecker + + crashRecoveryManager = CrashRecoveryManager(alertIssuer: alertManager) + alertManager.addAlertResponder(managerIdentifier: crashRecoveryManager.managerIdentifier, alertResponder: crashRecoveryManager) + + if let pumpManagerRawValue = rawPumpManager ?? UserDefaults.appGroup?.legacyPumpManagerRawValue { + pumpManager = pumpManagerFromRawValue(pumpManagerRawValue) + // Update lastPumpEventsReconciliation on DoseStore + if let lastSync = pumpManager?.lastSync { + doseStore.addPumpEvents([], lastReconciliation: lastSync) { _ in } } + if let status = pumpManager?.status { + updatePumpIsAllowingAutomation(status: status) + } + } else { + pumpManager = nil } - } - /** - Read the pump's current state, including reservoir and clock + if let cgmManagerRawValue = rawCGMManager ?? UserDefaults.appGroup?.legacyCGMManagerRawValue { + cgmManager = cgmManagerFromRawValue(cgmManagerRawValue) - - parameter completion: A closure called after the command is complete. This closure takes a single Result argument: - - Success(status, date): The pump status, and the resolved date according to the pump's clock - - Failure(error): An error describing why the command failed - */ - private func readPumpData(_ completion: @escaping (RileyLinkKit.Either<(status: RileyLinkKit.PumpStatus, date: Date), Error>) -> Void) { - guard let device = rileyLinkManager.firstConnectedDevice, let ops = device.ops else { - completion(.failure(LoopError.configurationError)) - return + // Handle case of PumpManager providing CGM + if cgmManager == nil && pumpManagerTypeFromRawValue(cgmManagerRawValue) != nil { + cgmManager = pumpManager as? CGMManager + } } - ops.readPumpStatus { (result) in - switch result { - case .success(let status): - var clock = status.clock - clock.timeZone = ops.pumpState.timeZone + //TODO The instantiation of these non-device related managers should be moved to LoopAppManager, and then LoopAppManager can wire up the connections between them. + statusExtensionManager = ExtensionDataManager(deviceDataManager: self, automaticDosingStatus: automaticDosingStatus) + + loopManager = LoopDataManager( + lastLoopCompleted: ExtensionDataManager.lastLoopCompleted, + basalDeliveryState: pumpManager?.status.basalDeliveryState, + settings: settingsManager.loopSettings, + overrideHistory: overrideHistory, + analyticsServicesManager: analyticsServicesManager, + localCacheDuration: localCacheDuration, + doseStore: doseStore, + glucoseStore: glucoseStore, + carbStore: carbStore, + dosingDecisionStore: dosingDecisionStore, + latestStoredSettingsProvider: settingsManager, + pumpInsulinType: pumpManager?.status.insulinType, + automaticDosingStatus: automaticDosingStatus, + trustedTimeOffset: { trustedTimeChecker.detectedSystemTimeOffset } + ) + cacheStore.delegate = loopManager + loopManager.presetActivationObservers.append(alertManager) + loopManager.presetActivationObservers.append(analyticsServicesManager) + + watchManager = WatchDataManager(deviceManager: self, healthStore: healthStore) + + let remoteDataServicesManager = RemoteDataServicesManager( + alertStore: alertManager.alertStore, + carbStore: carbStore, + doseStore: doseStore, + dosingDecisionStore: dosingDecisionStore, + glucoseStore: glucoseStore, + cgmEventStore: cgmEventStore, + settingsStore: settingsManager.settingsStore, + overrideHistory: overrideHistory, + insulinDeliveryStore: doseStore.insulinDeliveryStore + ) - guard let date = clock.date else { - self.logger.addError("Could not interpret pump clock: \(clock)", fromSource: "RileyLink") - completion(.failure(LoopError.configurationError)) - return + settingsManager.remoteDataServicesManager = remoteDataServicesManager + + servicesManager = ServicesManager( + pluginManager: pluginManager, + alertManager: alertManager, + analyticsServicesManager: analyticsServicesManager, + loggingServicesManager: loggingServicesManager, + remoteDataServicesManager: remoteDataServicesManager, + settingsManager: settingsManager, + servicesManagerDelegate: loopManager, + servicesManagerDosingDelegate: self + ) + + statefulPluginManager = StatefulPluginManager(pluginManager: pluginManager, servicesManager: servicesManager) + + let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore, glucoseStore, carbStore, dosingDecisionStore, doseStore, deviceLog, alertManager.alertStore] + criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, + directory: FileManager.default.exportsDirectoryURL, + historicalDuration: Bundle.main.localCacheDuration) + + loopManager.delegate = self + + alertManager.alertStore.delegate = self + carbStore.delegate = self + doseStore.delegate = self + dosingDecisionStore.delegate = self + glucoseStore.delegate = self + cgmEventStore.delegate = self + doseStore.insulinDeliveryStore.delegate = self + remoteDataServicesManager.delegate = self + + setupPump() + setupCGM() + + cgmStalenessMonitor.$cgmDataIsStale + .combineLatest($cgmHasValidSensorSession) + .map { $0 == false || $1 } + .combineLatest($pumpIsAllowingAutomation) + .map { $0 && $1 } + .receive(on: RunLoop.main) + .removeDuplicates() + .assign(to: \.automaticDosingStatus.isAutomaticDosingAllowed, on: self) + .store(in: &cancellables) + + NotificationCenter.default.addObserver(forName: .HealthStorePreferredGlucoseUnitDidChange, object: healthStore, queue: nil) { [weak self] _ in + guard let self else { + return + } + + Task { @MainActor in + if let unit = await self.healthStore.cachedPreferredUnits(for: .bloodGlucose) { + self.displayGlucosePreference.unitDidChange(to: unit) + self.notifyObserversOfDisplayGlucoseUnitChange(to: unit) } - completion(.success(status: status, date: date)) - case .failure(let error): - self.logger.addError("Failed to fetch pump status: \(error)", fromSource: "RileyLink") - completion(.failure(error)) } } } - /** - Ensures pump data is current by either waking and polling, or ensuring we're listening to sentry packets. - */ - private func assertCurrentPumpData() { - guard let device = rileyLinkManager.firstConnectedDevice else { - return + var availablePumpManagers: [PumpManagerDescriptor] { + var pumpManagers = pluginManager.availablePumpManagers + availableStaticPumpManagers + + pumpManagers = pumpManagers.filter({ pumpManager in + guard !deviceWhitelist.pumpDevices.isEmpty else { + return true + } + + return deviceWhitelist.pumpDevices.contains(pumpManager.identifier) + }) + + return pumpManagers + } + + func setupPumpManager(withIdentifier identifier: String, initialSettings settings: PumpManagerSetupSettings, prefersToSkipUserInteraction: Bool) -> Swift.Result, Error> { + switch setupPumpManagerUI(withIdentifier: identifier, initialSettings: settings, prefersToSkipUserInteraction: prefersToSkipUserInteraction) { + case .failure(let error): + return .failure(error) + case .success(let success): + switch success { + case .userInteractionRequired(let viewController): + return .success(.userInteractionRequired(viewController)) + case .createdAndOnboarded(let pumpManagerUI): + return .success(.createdAndOnboarded(pumpManagerUI)) + } } + } - device.assertIdleListening() + struct UnknownPumpManagerIdentifierError: Error {} - // How long should we wait before we poll for new pump data? - let pumpStatusAgeTolerance = rileyLinkManager.idleListeningEnabled ? TimeInterval(minutes: 11) : TimeInterval(minutes: 4) + func setupPumpManagerUI(withIdentifier identifier: String, initialSettings settings: PumpManagerSetupSettings, prefersToSkipUserInteraction: Bool = false) -> Swift.Result, Error> { + guard let pumpManagerUIType = pumpManagerTypeByIdentifier(identifier) else { + return .failure(UnknownPumpManagerIdentifierError()) + } - // If we don't yet have pump status, or it's old, poll for it. - if doseStore.lastReservoirValue == nil || - doseStore.lastReservoirValue!.startDate.timeIntervalSinceNow <= -pumpStatusAgeTolerance { - readPumpData { (result) in - let nsPumpStatus: NightscoutUploadKit.PumpStatus? - switch result { - case .success(let (status, date)): - self.observeBatteryDuring { - self.latestPumpStatus = status - } + let result = pumpManagerUIType.setupViewController(initialSettings: settings, bluetoothProvider: bluetoothProvider, colorPalette: .default, allowDebugFeatures: FeatureFlags.allowDebugFeatures, prefersToSkipUserInteraction: prefersToSkipUserInteraction, allowedInsulinTypes: allowedInsulinTypes) + + if case .createdAndOnboarded(let pumpManagerUI) = result { + pumpManagerOnboarding(didCreatePumpManager: pumpManagerUI) + pumpManagerOnboarding(didOnboardPumpManager: pumpManagerUI) + } - self.updateReservoirVolume(status.reservoir, at: date, withTimeLeft: nil) - let battery = BatteryStatus(voltage: status.batteryVolts, status: BatteryIndicator(batteryStatus: status.batteryStatus)) + return .success(result) + } + + public func saveUpdatedBasalRateSchedule(_ basalRateSchedule: BasalRateSchedule) { + var therapySettings = self.loopManager.therapySettings + therapySettings.basalRateSchedule = basalRateSchedule + self.saveCompletion(therapySettings: therapySettings) + } + public func pumpManagerTypeByIdentifier(_ identifier: String) -> PumpManagerUI.Type? { + return pluginManager.getPumpManagerTypeByIdentifier(identifier) ?? staticPumpManagersByIdentifier[identifier] + } - nsPumpStatus = NightscoutUploadKit.PumpStatus(clock: date, pumpID: status.pumpID, iob: nil, battery: battery, suspended: status.suspended, bolusing: status.bolusing, reservoir: status.reservoir) - case .failure(let error): - self.troubleshootPumpComms(using: device) - self.nightscoutDataManager.uploadLoopStatus(loopError: error) - nsPumpStatus = nil - } - self.nightscoutDataManager.uploadDeviceStatus(nsPumpStatus) - } + private func pumpManagerTypeFromRawValue(_ rawValue: [String: Any]) -> PumpManager.Type? { + guard let managerIdentifier = rawValue["managerIdentifier"] as? String else { + return nil } + + return pumpManagerTypeByIdentifier(managerIdentifier) } - /// Send a bolus command and handle the result - /// - /// - parameter units: The number of units to deliver - /// - parameter completion: A clsure called after the command is complete. This closure takes a single argument: - /// - error: An error describing why the command failed - func enactBolus(units: Double, completion: @escaping (_ error: Error?) -> Void) { - guard units > 0 else { - completion(nil) - return + func pumpManagerFromRawValue(_ rawValue: [String: Any]) -> PumpManagerUI? { + guard let rawState = rawValue["state"] as? PumpManager.RawStateValue, + let Manager = pumpManagerTypeFromRawValue(rawValue) + else { + return nil } - guard let device = rileyLinkManager.firstConnectedDevice else { - completion(LoopError.connectionError) + return Manager.init(rawState: rawState) as? PumpManagerUI + } + + private func checkPumpDataAndLoop() { + guard !crashRecoveryManager.pendingCrashRecovery else { + self.log.default("Loop paused pending crash recovery acknowledgement.") return } - guard let ops = device.ops else { - completion(LoopError.configurationError) + self.log.default("Asserting current pump data") + guard let pumpManager = pumpManager else { + // Run loop, even if pump is missing, to ensure stored dosing decision + self.loopManager.loop() return } - let setBolus = { - ops.setNormalBolus(units: units) { (error) in - if let error = error { - self.logger.addError(error, fromSource: "Bolus") - completion(LoopError.communicationError) - } else { - self.loopManager.recordBolus(units, at: Date()) - completion(nil) - } - } + pumpManager.ensureCurrentPumpData() { (lastSync) in + self.loopManager.loop() } + } - // If we don't have recent pump data, or the pump was recently rewound, read new pump data before bolusing. - if doseStore.lastReservoirValue == nil || - doseStore.lastReservoirVolumeDrop < 0 || - doseStore.lastReservoirValue!.startDate.timeIntervalSinceNow <= TimeInterval(minutes: -5) - { - readPumpData { (result) in - switch result { - case .success(let (status, date)): - self.doseStore.addReservoirValue(status.reservoir, atDate: date) { (newValue, _, _, error) in - if let error = error { - self.logger.addError(error, fromSource: "Bolus") - completion(error) - } else { - setBolus() - } + private func processCGMReadingResult(_ manager: CGMManager, readingResult: CGMReadingResult, completion: @escaping () -> Void) { + switch readingResult { + case .newData(let values): + loopManager.addGlucoseSamples(values) { result in + if !values.isEmpty { + DispatchQueue.main.async { + self.cgmStalenessMonitor.cgmGlucoseSamplesAvailable(values) } - case .failure(let error): - completion(error) } + completion() } - } else { - setBolus() - } + case .unreliableData: + loopManager.receivedUnreliableCGMReading() + completion() + case .noData: + completion() + case .error(let error): + self.setLastError(error: error) + completion() + } + updatePumpManagerBLEHeartbeatPreference() } - /** - Attempts to fix an extended communication failure between a RileyLink device and the pump + var availableCGMManagers: [CGMManagerDescriptor] { + var availableCGMManagers = pluginManager.availableCGMManagers + availableStaticCGMManagers + if let pumpManagerAsCGMManager = pumpManager as? CGMManager { + availableCGMManagers.append(CGMManagerDescriptor(identifier: pumpManagerAsCGMManager.pluginIdentifier, localizedTitle: pumpManagerAsCGMManager.localizedTitle)) + } + + availableCGMManagers = availableCGMManagers.filter({ cgmManager in + guard !deviceWhitelist.cgmDevices.isEmpty else { + return true + } + + return deviceWhitelist.cgmDevices.contains(cgmManager.identifier) + }) - - parameter device: The RileyLink device - */ - private func troubleshootPumpComms(using device: RileyLinkDevice) { + return availableCGMManagers + } - // How long we should wait before we re-tune the RileyLink - let tuneTolerance = TimeInterval(minutes: 14) + func setupCGMManager(withIdentifier identifier: String, prefersToSkipUserInteraction: Bool = false) -> Swift.Result, Error> { + if let cgmManager = setupCGMManagerFromPumpManager(withIdentifier: identifier) { + return .success(.createdAndOnboarded(cgmManager)) + } - if device.lastTuned == nil || device.lastTuned!.timeIntervalSinceNow <= -tuneTolerance { - device.tunePump { (result) in - switch result { - case .success(let scanResult): - self.logger.addError("Device auto-tuned to \(scanResult.bestFrequency) MHz", fromSource: "RileyLink") - case .failure(let error): - self.logger.addError("Device auto-tune failed with error: \(error)", fromSource: "RileyLink") - } + switch setupCGMManagerUI(withIdentifier: identifier, prefersToSkipUserInteraction: prefersToSkipUserInteraction) { + case .failure(let error): + return .failure(error) + case .success(let success): + switch success { + case .userInteractionRequired(let viewController): + return .success(.userInteractionRequired(viewController)) + case .createdAndOnboarded(let cgmManagerUI): + return .success(.createdAndOnboarded(cgmManagerUI)) } } } - // MARK: - G5 Transmitter - /// The G5 transmitter is a reliable heartbeat by which we can assert the loop state. + struct UnknownCGMManagerIdentifierError: Error {} - // MARK: TransmitterDelegate + fileprivate func setupCGMManagerUI(withIdentifier identifier: String, prefersToSkipUserInteraction: Bool) -> Swift.Result, Error> { + guard let cgmManagerUIType = cgmManagerTypeByIdentifier(identifier) else { + return .failure(UnknownCGMManagerIdentifierError()) + } - func transmitter(_ transmitter: xDripG5.Transmitter, didError error: Error) { - logger.addMessage([ - "error": "\(error)", - "collectedAt": DateFormatter.ISO8601StrictDateFormatter().string(from: Date()) - ], toCollection: "g5" - ) + let result = cgmManagerUIType.setupViewController(bluetoothProvider: bluetoothProvider, displayGlucosePreference: displayGlucosePreference, colorPalette: .default, allowDebugFeatures: FeatureFlags.allowDebugFeatures, prefersToSkipUserInteraction: prefersToSkipUserInteraction) + if case .createdAndOnboarded(let cgmManagerUI) = result { + cgmManagerOnboarding(didCreateCGMManager: cgmManagerUI) + cgmManagerOnboarding(didOnboardCGMManager: cgmManagerUI) + } - assertCurrentPumpData() + return .success(result) } - func transmitter(_ transmitter: xDripG5.Transmitter, didRead glucose: xDripG5.Glucose) { - assertCurrentPumpData() + public func cgmManagerTypeByIdentifier(_ identifier: String) -> CGMManagerUI.Type? { + return pluginManager.getCGMManagerTypeByIdentifier(identifier) ?? staticCGMManagersByIdentifier[identifier] as? CGMManagerUI.Type + } + + public func setupCGMManagerFromPumpManager(withIdentifier identifier: String) -> CGMManager? { + guard identifier == pumpManager?.pluginIdentifier, let cgmManager = pumpManager as? CGMManager else { + return nil + } - guard glucose != latestGlucoseG5 else { - return + // We have a pump that is a CGM! + self.cgmManager = cgmManager + return cgmManager + } + + private func cgmManagerTypeFromRawValue(_ rawValue: [String: Any]) -> CGMManager.Type? { + guard let managerIdentifier = rawValue["managerIdentifier"] as? String else { + return nil } - latestGlucoseG5 = glucose + return cgmManagerTypeByIdentifier(managerIdentifier) + } - guard let glucoseStore = glucoseStore, let quantity = glucose.glucose else { - NotificationCenter.default.post(name: .GlucoseUpdated, object: self) - return + func cgmManagerFromRawValue(_ rawValue: [String: Any]) -> CGMManagerUI? { + guard let rawState = rawValue["state"] as? CGMManager.RawStateValue, + let Manager = cgmManagerTypeFromRawValue(rawValue) + else { + return nil } - let device = HKDevice(name: "xDripG5", manufacturer: "Dexcom", model: "G5 Mobile", hardwareVersion: nil, firmwareVersion: nil, softwareVersion: String(xDripG5VersionNumber), localIdentifier: nil, udiDeviceIdentifier: "00386270000002") - - glucoseStore.addGlucose(quantity, date: glucose.readDate, isDisplayOnly: glucose.isDisplayOnly, device: device) { (success, _, error) -> Void in - if let error = error { - self.logger.addError(error, fromSource: "GlucoseStore") + return Manager.init(rawState: rawState) as? CGMManagerUI + } + + func checkDeliveryUncertaintyState() { + if let pumpManager = pumpManager, pumpManager.status.deliveryIsUncertain { + DispatchQueue.main.async { + self.deliveryUncertaintyAlertManager?.showAlert() } + } + } + func getHealthStoreAuthorization(_ completion: @escaping (HKAuthorizationRequestStatus) -> Void) { + healthStore.getRequestStatusForAuthorization(toShare: shareTypes, read: readTypes) { (authorizationRequestStatus, _) in + completion(authorizationRequestStatus) + } + } + + // Get HealthKit authorization for all of the stores + func authorizeHealthStore(_ completion: @escaping (HKAuthorizationRequestStatus) -> Void) { + // Authorize all types at once for simplicity + healthStore.requestAuthorization(toShare: shareTypes, read: readTypes) { (success, error) in if success { - NotificationCenter.default.post(name: .GlucoseUpdated, object: self) + // Call the individual authorization methods to trigger query creation + self.carbStore.hkSampleStore?.authorizationIsDetermined() + self.doseStore.hkSampleStore?.authorizationIsDetermined() + self.glucoseStore.hkSampleStore?.authorizationIsDetermined() } + + self.getHealthStoreAuthorization(completion) } } +} - public func transmitter(_ transmitter: Transmitter, didReadUnknownData data: Data) { - logger.addMessage([ - "unknownData": data.hexadecimalString, - "collectedAt": DateFormatter.ISO8601StrictDateFormatter().string(from: Date()) - ], toCollection: "g5" - ) - } +private extension DeviceDataManager { + func setupCGM() { + dispatchPrecondition(condition: .onQueue(.main)) - // MARK: G5 data + cgmManager?.cgmManagerDelegate = self + cgmManager?.delegateQueue = queue + reportPluginInitializationComplete() - fileprivate var latestGlucoseG5: xDripG5.Glucose? + glucoseStore.managedDataInterval = cgmManager?.managedDataInterval + glucoseStore.healthKitStorageDelay = cgmManager.map{ type(of: $0).healthKitStorageDelay } ?? 0 - fileprivate var latestGlucoseFromShare: ShareGlucose? + updatePumpManagerBLEHeartbeatPreference() + if let cgmManager = cgmManager { + alertManager?.addAlertResponder(managerIdentifier: cgmManager.pluginIdentifier, + alertResponder: cgmManager) + alertManager?.addAlertSoundVendor(managerIdentifier: cgmManager.pluginIdentifier, + soundVendor: cgmManager) + cgmHasValidSensorSession = cgmManager.cgmManagerStatus.hasValidSensorSession - /** - Attempts to backfill glucose data from the share servers if a G5 connection hasn't been established. - - - parameter completion: An optional closure called after the command is complete. - */ - private func backfillGlucoseFromShareIfNeeded(_ completion: (() -> Void)? = nil) { - // We should have no G4 Share or G5 data, and a configured ShareClient and GlucoseStore. - guard latestGlucoseG4 == nil && latestGlucoseG5 == nil, let shareClient = remoteDataManager.shareClient, let glucoseStore = glucoseStore else { - completion?() - return + analyticsServicesManager.identifyCGMType(cgmManager.pluginIdentifier) } - // If our last glucose was less than 4.5 minutes ago, don't fetch. - if let latestGlucose = glucoseStore.latestGlucose, latestGlucose.startDate.timeIntervalSinceNow > -TimeInterval(minutes: 4.5) { - completion?() - return + if let cgmManagerUI = cgmManager as? CGMManagerUI { + addDisplayGlucoseUnitObserver(cgmManagerUI) } + } - shareClient.fetchLast(6) { (error, glucose) in - guard let glucose = glucose else { - if let error = error { - self.logger.addError(error, fromSource: "ShareClient") - } - completion?() - return - } + func setupPump() { + dispatchPrecondition(condition: .onQueue(.main)) - self.latestGlucoseFromShare = glucose.first + pumpManager?.pumpManagerDelegate = self + pumpManager?.delegateQueue = queue + reportPluginInitializationComplete() - // Ignore glucose values that are up to a minute newer than our previous value, to account for possible time shifting in Share data - let newGlucose = glucose.filterDateRange(glucoseStore.latestGlucose?.startDate.addingTimeInterval(TimeInterval(minutes: 1)), nil).map { - return (quantity: $0.quantity, date: $0.startDate, isDisplayOnly: false) - } + doseStore.device = pumpManager?.status.device + pumpManagerHUDProvider = pumpManager?.hudProvider(bluetoothProvider: bluetoothProvider, colorPalette: .default, allowedInsulinTypes: allowedInsulinTypes) - glucoseStore.addGlucoseValues(newGlucose, device: nil) { (success, _, error) -> Void in - if let error = error { - self.logger.addError(error, fromSource: "GlucoseStore") - } + // Proliferate PumpModel preferences to DoseStore + if let pumpRecordsBasalProfileStartEvents = pumpManager?.pumpRecordsBasalProfileStartEvents { + doseStore.pumpRecordsBasalProfileStartEvents = pumpRecordsBasalProfileStartEvents + } + if let pumpManager = pumpManager { + alertManager?.addAlertResponder(managerIdentifier: pumpManager.pluginIdentifier, + alertResponder: pumpManager) + alertManager?.addAlertSoundVendor(managerIdentifier: pumpManager.pluginIdentifier, + soundVendor: pumpManager) + + deliveryUncertaintyAlertManager = DeliveryUncertaintyAlertManager(pumpManager: pumpManager, alertPresenter: alertPresenter) - if success { - NotificationCenter.default.post(name: .GlucoseUpdated, object: self) - } + analyticsServicesManager.identifyPumpType(pumpManager.pluginIdentifier) - completion?() - } + updatePumpManagerBLEHeartbeatPreference() } } - // MARK: - Share Receiver - - // MARK: ReceiverDelegate - - fileprivate var latestGlucoseG4: GlucoseG4? - - func receiver(_ receiver: Receiver, didReadGlucoseHistory glucoseHistory: [GlucoseG4]) { - assertCurrentPumpData() + func setLastError(error: Error) { + DispatchQueue.main.async { + self.lastError = (date: Date(), error: error) + } + } +} - guard let latest = glucoseHistory.sorted(by: { $0.sequence < $1.sequence }).last, latest != latestGlucoseG4 else { - return +// MARK: - Plugins +extension DeviceDataManager { + func reportPluginInitializationComplete() { + let allActivePlugins = self.allActivePlugins + + for plugin in servicesManager.activeServices { + plugin.initializationComplete(for: allActivePlugins) + } + + for plugin in statefulPluginManager.activeStatefulPlugins { + plugin.initializationComplete(for: allActivePlugins) + } + + for plugin in availableSupports { + plugin.initializationComplete(for: allActivePlugins) + } + + cgmManager?.initializationComplete(for: allActivePlugins) + pumpManager?.initializationComplete(for: allActivePlugins) + } + + var allActivePlugins: [Pluggable] { + var allActivePlugins: [Pluggable] = servicesManager.activeServices + + for plugin in statefulPluginManager.activeStatefulPlugins { + if !allActivePlugins.contains(where: { $0.pluginIdentifier == plugin.pluginIdentifier }) { + allActivePlugins.append(plugin) + } + } + + for plugin in availableSupports { + if !allActivePlugins.contains(where: { $0.pluginIdentifier == plugin.pluginIdentifier }) { + allActivePlugins.append(plugin) + } + } + + if let cgmManager = cgmManager { + if !allActivePlugins.contains(where: { $0.pluginIdentifier == cgmManager.pluginIdentifier }) { + allActivePlugins.append(cgmManager) + } } - latestGlucoseG4 = latest + + if let pumpManager = pumpManager { + if !allActivePlugins.contains(where: { $0.pluginIdentifier == pumpManager.pluginIdentifier }) { + allActivePlugins.append(pumpManager) + } + } + + return allActivePlugins + } +} - guard let glucoseStore = glucoseStore else { +// MARK: - Client API +extension DeviceDataManager { + func enactBolus(units: Double, activationType: BolusActivationType, completion: @escaping (_ error: Error?) -> Void = { _ in }) { + guard let pumpManager = pumpManager else { + completion(LoopError.configurationError(.pumpManager)) return } - // In the event that some of the glucose history was already backfilled from Share, don't overwrite it. - let includeAfter = glucoseStore.latestGlucose?.startDate.addingTimeInterval(TimeInterval(minutes: 1)) + self.loopManager.addRequestedBolus(DoseEntry(type: .bolus, startDate: Date(), value: units, unit: .units, isMutable: true)) { + pumpManager.enactBolus(units: units, activationType: activationType) { (error) in + if let error = error { + self.log.error("%{public}@", String(describing: error)) + switch error { + case .uncertainDelivery: + // Do not generate notification on uncertain delivery error + break + default: + // Do not generate notifications for automatic boluses that fail. + if !activationType.isAutomatic { + NotificationManager.sendBolusFailureNotification(for: error, units: units, at: Date(), activationType: activationType) + } + } + + self.loopManager.bolusRequestFailed(error) { + completion(error) + } + } else { + self.loopManager.bolusConfirmed() { + completion(nil) + } + } + } + // Trigger forecast/recommendation update for remote clients + self.loopManager.updateRemoteRecommendation() + } + } + + func enactBolus(units: Double, activationType: BolusActivationType) async throws { + return try await withCheckedThrowingContinuation { continuation in + enactBolus(units: units, activationType: activationType) { error in + if let error = error { + continuation.resume(throwing: error) + return + } + continuation.resume() + } + } + } - let validGlucose = glucoseHistory.flatMap({ - $0.isStateValid ? $0 : nil - }).filterDateRange(includeAfter, nil).map({ - (quantity: $0.quantity, date: $0.startDate, isDisplayOnly: $0.isDisplayOnly) - }) + var pumpManagerStatus: PumpManagerStatus? { + return pumpManager?.status + } - // "Dexcom G4 Platinum Transmitter (Retail) US" - see https://accessgudid.nlm.nih.gov/devices/search?query=dexcom+g4 - let device = HKDevice(name: "G4ShareSpy", manufacturer: "Dexcom", model: "G4 Share", hardwareVersion: nil, firmwareVersion: nil, softwareVersion: String(G4ShareSpyVersionNumber), localIdentifier: nil, udiDeviceIdentifier: "40386270000048") + var cgmManagerStatus: CGMManagerStatus? { + return cgmManager?.cgmManagerStatus + } - glucoseStore.addGlucoseValues(validGlucose, device: device) { (success, _, error) -> Void in - if let error = error { - self.logger.addError(error, fromSource: "GlucoseStore") + func glucoseDisplay(for glucose: GlucoseSampleValue?) -> GlucoseDisplayable? { + guard let glucose = glucose else { + return cgmManager?.glucoseDisplay + } + + guard FeatureFlags.cgmManagerCategorizeManualGlucoseRangeEnabled else { + // Using Dexcom default glucose thresholds to categorize a glucose range + let urgentLowGlucoseThreshold = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 55) + let lowGlucoseThreshold = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 80) + let highGlucoseThreshold = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 200) + + let glucoseRangeCategory: GlucoseRangeCategory + switch glucose.quantity { + case ...urgentLowGlucoseThreshold: + glucoseRangeCategory = .urgentLow + case urgentLowGlucoseThreshold.. Void)?) { + deviceLog.log(managerIdentifier: manager.pluginIdentifier, deviceIdentifier: deviceIdentifier, type: type, message: message, completion: completion) + } + + var allowDebugFeatures: Bool { + FeatureFlags.allowDebugFeatures // NOTE: DEBUG FEATURES - DEBUG AND TEST ONLY } +} - // MARK: - Configuration +// MARK: - AlertIssuer +extension DeviceDataManager: AlertIssuer { + static let managerIdentifier = "DeviceDataManager" - // MARK: Pump + func issueAlert(_ alert: Alert) { + alertManager?.issueAlert(alert) + } - private var connectedPeripheralIDs: Set = Set(UserDefaults.standard.connectedPeripheralIDs) { - didSet { - UserDefaults.standard.connectedPeripheralIDs = Array(connectedPeripheralIDs) - } + func retractAlert(identifier: Alert.Identifier) { + alertManager?.retractAlert(identifier: identifier) } +} - var pumpID: String? { - get { - return pumpState?.pumpID - } - set { - guard newValue != pumpState?.pumpID else { - return - } +// MARK: - PersistedAlertStore +extension DeviceDataManager: PersistedAlertStore { + func doesIssuedAlertExist(identifier: Alert.Identifier, completion: @escaping (Swift.Result) -> Void) { + precondition(alertManager != nil) + alertManager.doesIssuedAlertExist(identifier: identifier, completion: completion) + } + func lookupAllUnretracted(managerIdentifier: String, completion: @escaping (Swift.Result<[PersistedAlert], Error>) -> Void) { + precondition(alertManager != nil) + alertManager.lookupAllUnretracted(managerIdentifier: managerIdentifier, completion: completion) + } + + func lookupAllUnacknowledgedUnretracted(managerIdentifier: String, completion: @escaping (Swift.Result<[PersistedAlert], Error>) -> Void) { + precondition(alertManager != nil) + alertManager.lookupAllUnacknowledgedUnretracted(managerIdentifier: managerIdentifier, completion: completion) + } - var pumpID = newValue + func recordRetractedAlert(_ alert: Alert, at date: Date) { + precondition(alertManager != nil) + alertManager.recordRetractedAlert(alert, at: date) + } +} - if let pumpID = pumpID, pumpID.characters.count == 6 { - let pumpState = PumpState(pumpID: pumpID, pumpRegion: self.pumpState?.pumpRegion ?? .northAmerica) +// MARK: - CGMManagerDelegate +extension DeviceDataManager: CGMManagerDelegate { + func cgmManagerWantsDeletion(_ manager: CGMManager) { + dispatchPrecondition(condition: .onQueue(queue)) - if let timeZone = self.pumpState?.timeZone { - pumpState.timeZone = timeZone - } + log.default("CGM manager with identifier '%{public}@' wants deletion", manager.pluginIdentifier) - self.pumpState = pumpState - } else { - pumpID = nil - self.pumpState = nil + DispatchQueue.main.async { + if let cgmManagerUI = self.cgmManager as? CGMManagerUI { + self.removeDisplayGlucoseUnitObserver(cgmManagerUI) } + self.cgmManager = nil + self.displayGlucoseUnitObservers.cleanupDeallocatedElements() + self.settingsManager.storeSettings() + } + } - remoteDataManager.nightscoutUploader?.reset() - doseStore.pumpID = pumpID + func cgmManager(_ manager: CGMManager, hasNew readingResult: CGMReadingResult) { + dispatchPrecondition(condition: .onQueue(queue)) + log.default("CGMManager:%{public}@ did update with %{public}@", String(describing: type(of: manager)), String(describing: readingResult)) + processCGMReadingResult(manager, readingResult: readingResult) { + let now = Date() + if case .newData = readingResult, now.timeIntervalSince(self.lastCGMLoopTrigger) > .minutes(4.2) { + self.log.default("Triggering loop from new CGM data at %{public}@", String(describing: now)) + self.lastCGMLoopTrigger = now + self.checkPumpDataAndLoop() + } + } + } - UserDefaults.standard.pumpID = pumpID + func cgmManager(_ manager: LoopKit.CGMManager, hasNew events: [PersistedCgmEvent]) { + Task { + do { + try await cgmEventStore.add(events: events) + } catch { + self.log.error("Error storing cgm events: %{public}@", error.localizedDescription) + } } } - var pumpState: PumpState? { - didSet { - rileyLinkManager.pumpState = pumpState + func startDateToFilterNewData(for manager: CGMManager) -> Date? { + dispatchPrecondition(condition: .onQueue(queue)) + return glucoseStore.latestGlucose?.startDate + } - if let oldValue = oldValue { - NotificationCenter.default.removeObserver(self, name: .PumpStateValuesDidChange, object: oldValue) - } + func cgmManagerDidUpdateState(_ manager: CGMManager) { + dispatchPrecondition(condition: .onQueue(queue)) + rawCGMManager = manager.rawValue + } - if let pumpState = pumpState { - NotificationCenter.default.addObserver(self, selector: #selector(pumpStateValuesDidChange(_:)), name: .PumpStateValuesDidChange, object: pumpState) + func credentialStoragePrefix(for manager: CGMManager) -> String { + // return string unique to this instance of the CGMManager + return UUID().uuidString + } + + func cgmManager(_ manager: CGMManager, didUpdate status: CGMManagerStatus) { + DispatchQueue.main.async { + if self.cgmHasValidSensorSession != status.hasValidSensorSession { + self.cgmHasValidSensorSession = status.hasValidSensorSession } } } +} - @objc private func pumpStateValuesDidChange(_ note: Notification) { - switch note.userInfo?[PumpState.PropertyKey] as? String { - case "timeZone"?: - UserDefaults.standard.pumpTimeZone = pumpState?.timeZone +// MARK: - CGMManagerOnboardingDelegate - if let pumpTimeZone = pumpState?.timeZone { - if let basalRateSchedule = basalRateSchedule { - self.basalRateSchedule = BasalRateSchedule(dailyItems: basalRateSchedule.items, timeZone: pumpTimeZone) - } +extension DeviceDataManager: CGMManagerOnboardingDelegate { + func cgmManagerOnboarding(didCreateCGMManager cgmManager: CGMManagerUI) { + log.default("CGM manager with identifier '%{public}@' created", cgmManager.pluginIdentifier) + self.cgmManager = cgmManager + } - if let carbRatioSchedule = carbRatioSchedule { - self.carbRatioSchedule = CarbRatioSchedule(unit: carbRatioSchedule.unit, dailyItems: carbRatioSchedule.items, timeZone: pumpTimeZone) - } + func cgmManagerOnboarding(didOnboardCGMManager cgmManager: CGMManagerUI) { + precondition(cgmManager.isOnboarded) + log.default("CGM manager with identifier '%{public}@' onboarded", cgmManager.pluginIdentifier) - if let insulinSensitivitySchedule = insulinSensitivitySchedule { - self.insulinSensitivitySchedule = InsulinSensitivitySchedule(unit: insulinSensitivitySchedule.unit, dailyItems: insulinSensitivitySchedule.items, timeZone: pumpTimeZone) - } + DispatchQueue.main.async { + self.refreshDeviceData() + self.settingsManager.storeSettings() + } + } +} - if let glucoseTargetRangeSchedule = glucoseTargetRangeSchedule { - self.glucoseTargetRangeSchedule = GlucoseRangeSchedule(unit: glucoseTargetRangeSchedule.unit, dailyItems: glucoseTargetRangeSchedule.items, workoutRange: glucoseTargetRangeSchedule.workoutRange, timeZone: pumpTimeZone) - } - } - case "pumpModel"?: - if let sentrySupported = pumpState?.pumpModel?.hasMySentry, !sentrySupported { - rileyLinkManager.idleListeningEnabled = false - } +// MARK: - PumpManagerDelegate +extension DeviceDataManager: PumpManagerDelegate { + func pumpManager(_ pumpManager: PumpManager, didAdjustPumpClockBy adjustment: TimeInterval) { + dispatchPrecondition(condition: .onQueue(queue)) + log.default("PumpManager:%{public}@ did adjust pump clock by %fs", String(describing: type(of: pumpManager)), adjustment) - UserDefaults.standard.pumpModelNumber = pumpState?.pumpModel?.rawValue - case "pumpRegion"?: - UserDefaults.standard.pumpRegion = pumpState?.pumpRegion - case "lastHistoryDump"?, "awakeUntil"?: - break - default: - break - } + analyticsServicesManager.pumpTimeDidDrift(adjustment) } - /// The user's preferred method of fetching insulin data from the pump - var preferredInsulinDataSource = UserDefaults.standard.preferredInsulinDataSource ?? .pumpHistory { - didSet { - UserDefaults.standard.preferredInsulinDataSource = preferredInsulinDataSource - } + func pumpManagerDidUpdateState(_ pumpManager: PumpManager) { + dispatchPrecondition(condition: .onQueue(queue)) + log.default("PumpManager:%{public}@ did update state", String(describing: type(of: pumpManager))) + + rawPumpManager = pumpManager.rawValue } - /// The Default battery chemistry is Alkaline - var batteryChemistry = UserDefaults.standard.batteryChemistry ?? .alkaline { - didSet { - UserDefaults.standard.batteryChemistry = batteryChemistry - } + func pumpManager(_ pumpManager: PumpManager, didRequestBasalRateScheduleChange basalRateSchedule: BasalRateSchedule, completion: @escaping (Error?) -> Void) { + saveUpdatedBasalRateSchedule(basalRateSchedule) + completion(nil) } - // MARK: G5 Transmitter - - internal private(set) var transmitter: Transmitter? { - didSet { - transmitter?.delegate = self - enableRileyLinkHeartbeatIfNeeded() - } + func pumpManagerBLEHeartbeatDidFire(_ pumpManager: PumpManager) { + dispatchPrecondition(condition: .onQueue(queue)) + log.default("PumpManager:%{public}@ did fire heartbeat", String(describing: type(of: pumpManager))) + refreshCGM() } - - var transmitterID: String? { - get { - return transmitter?.ID + + private func refreshCGM(_ completion: (() -> Void)? = nil) { + guard let cgmManager = cgmManager else { + completion?() + return } - set { - guard transmitterID != newValue else { return } - if let transmitterID = newValue, transmitterID.characters.count == 6 { - transmitter = Transmitter(ID: transmitterID, passiveModeEnabled: true) - } else { - transmitter = nil + cgmManager.fetchNewDataIfNeeded { (result) in + if case .newData = result { + self.analyticsServicesManager.didFetchNewCGMData() } - UserDefaults.standard.transmitterID = newValue + self.queue.async { + self.processCGMReadingResult(cgmManager, readingResult: result) { + if self.loopManager.lastLoopCompleted == nil || self.loopManager.lastLoopCompleted!.timeIntervalSinceNow < -.minutes(4.2) { + self.log.default("Triggering Loop from refreshCGM()") + self.checkPumpDataAndLoop() + } + completion?() + } + } + } + } + + func refreshDeviceData() { + refreshCGM() { + self.queue.async { + guard let pumpManager = self.pumpManager, pumpManager.isOnboarded else { + return + } + pumpManager.ensureCurrentPumpData(completion: nil) + } } } - // MARK: Loop model inputs + func pumpManagerMustProvideBLEHeartbeat(_ pumpManager: PumpManager) -> Bool { + dispatchPrecondition(condition: .onQueue(queue)) + return pumpManagerMustProvideBLEHeartbeat + } - var basalRateSchedule: BasalRateSchedule? = UserDefaults.standard.basalRateSchedule { - didSet { - doseStore.basalProfile = basalRateSchedule + private var pumpManagerMustProvideBLEHeartbeat: Bool { + /// Controls the management of the RileyLink timer tick, which is a reliably-changing BLE + /// characteristic which can cause the app to wake. For most users, the G5 Transmitter and + /// G4 Receiver are reliable as hearbeats, but users who find their resources extremely constrained + /// due to greedy apps or older devices may choose to always enable the timer by always setting `true` + return !(cgmManager?.providesBLEHeartbeat == true) + } - UserDefaults.standard.basalRateSchedule = basalRateSchedule + func pumpManager(_ pumpManager: PumpManager, didUpdate status: PumpManagerStatus, oldStatus: PumpManagerStatus) { + dispatchPrecondition(condition: .onQueue(queue)) + log.default("PumpManager:%{public}@ did update status: %{public}@", String(describing: type(of: pumpManager)), String(describing: status)) - AnalyticsManager.sharedManager.didChangeBasalRateSchedule() + doseStore.device = status.device + + if let newBatteryValue = status.pumpBatteryChargeRemaining, + let oldBatteryValue = oldStatus.pumpBatteryChargeRemaining, + newBatteryValue - oldBatteryValue >= LoopConstants.batteryReplacementDetectionThreshold { + analyticsServicesManager.pumpBatteryWasReplaced() } - } - var carbRatioSchedule: CarbRatioSchedule? = UserDefaults.standard.carbRatioSchedule { - didSet { - carbStore?.carbRatioSchedule = carbRatioSchedule + if status.basalDeliveryState != oldStatus.basalDeliveryState { + loopManager.basalDeliveryState = status.basalDeliveryState + } - UserDefaults.standard.carbRatioSchedule = carbRatioSchedule + updatePumpIsAllowingAutomation(status: status) + + // Update the pump-schedule based settings + loopManager.setScheduleTimeZone(status.timeZone) + + if status.insulinType != oldStatus.insulinType { + loopManager.pumpInsulinType = status.insulinType + } + + if status.deliveryIsUncertain != oldStatus.deliveryIsUncertain { + DispatchQueue.main.async { + if status.deliveryIsUncertain { + self.deliveryUncertaintyAlertManager?.showAlert() + } else { + self.deliveryUncertaintyAlertManager?.clearAlert() + } + } + } + } - AnalyticsManager.sharedManager.didChangeCarbRatioSchedule() + func updatePumpIsAllowingAutomation(status: PumpManagerStatus) { + if case .tempBasal(let dose) = status.basalDeliveryState, !(dose.automatic ?? true), dose.endDate > Date() { + pumpIsAllowingAutomation = false + } else { + pumpIsAllowingAutomation = true } } - var insulinActionDuration: TimeInterval? = UserDefaults.standard.insulinActionDuration { - didSet { - doseStore.insulinActionDuration = insulinActionDuration + func pumpManagerPumpWasReplaced(_ pumpManager: PumpManager) { + } + + func pumpManagerWillDeactivate(_ pumpManager: PumpManager) { + dispatchPrecondition(condition: .onQueue(queue)) - UserDefaults.standard.insulinActionDuration = insulinActionDuration + log.default("Pump manager with identifier '%{public}@' will deactivate", pumpManager.pluginIdentifier) - if oldValue != insulinActionDuration { - AnalyticsManager.sharedManager.didChangeInsulinActionDuration() - } + DispatchQueue.main.async { + self.pumpManager = nil + self.deliveryUncertaintyAlertManager = nil + self.settingsManager.storeSettings() } } - var insulinSensitivitySchedule: InsulinSensitivitySchedule? = UserDefaults.standard.insulinSensitivitySchedule { - didSet { - carbStore?.insulinSensitivitySchedule = insulinSensitivitySchedule - doseStore.insulinSensitivitySchedule = insulinSensitivitySchedule + func pumpManager(_ pumpManager: PumpManager, didUpdatePumpRecordsBasalProfileStartEvents pumpRecordsBasalProfileStartEvents: Bool) { + dispatchPrecondition(condition: .onQueue(queue)) + log.default("PumpManager:%{public}@ did update pumpRecordsBasalProfileStartEvents to %{public}@", String(describing: type(of: pumpManager)), String(describing: pumpRecordsBasalProfileStartEvents)) + + doseStore.pumpRecordsBasalProfileStartEvents = pumpRecordsBasalProfileStartEvents + } - UserDefaults.standard.insulinSensitivitySchedule = insulinSensitivitySchedule + func pumpManager(_ pumpManager: PumpManager, didError error: PumpManagerError) { + dispatchPrecondition(condition: .onQueue(queue)) + log.error("PumpManager:%{public}@ did error: %{public}@", String(describing: type(of: pumpManager)), String(describing: error)) - AnalyticsManager.sharedManager.didChangeInsulinSensitivitySchedule() - } + setLastError(error: error) } - var glucoseTargetRangeSchedule: GlucoseRangeSchedule? = UserDefaults.standard.glucoseTargetRangeSchedule { - didSet { - UserDefaults.standard.glucoseTargetRangeSchedule = glucoseTargetRangeSchedule + func pumpManager( + _ pumpManager: PumpManager, + hasNewPumpEvents events: [NewPumpEvent], + lastReconciliation: Date?, + replacePendingEvents: Bool, + completion: @escaping (_ error: Error?) -> Void) + { + dispatchPrecondition(condition: .onQueue(queue)) + log.default("PumpManager:%{public}@ hasNewPumpEvents (lastReconciliation = %{public}@)", String(describing: type(of: pumpManager)), String(describing: lastReconciliation)) + + doseStore.addPumpEvents(events, lastReconciliation: lastReconciliation, replacePendingEvents: replacePendingEvents) { (error) in + if let error = error { + self.log.error("Failed to addPumpEvents to DoseStore: %{public}@", String(describing: error)) + } - NotificationCenter.default.post(name: .LoopSettingsUpdated, object: self) + completion(error) - AnalyticsManager.sharedManager.didChangeGlucoseTargetRangeSchedule() + if error == nil { + NotificationCenter.default.post(name: .PumpEventsAdded, object: self, userInfo: nil) + } } } - var workoutModeEnabled: Bool? { - guard let range = glucoseTargetRangeSchedule else { - return nil - } + func pumpManager(_ pumpManager: PumpManager, didReadReservoirValue units: Double, at date: Date, completion: @escaping (_ result: Swift.Result<(newValue: ReservoirValue, lastValue: ReservoirValue?, areStoredValuesContinuous: Bool), Error>) -> Void) { + dispatchPrecondition(condition: .onQueue(queue)) + log.default("PumpManager:%{public}@ did read reservoir value", String(describing: type(of: pumpManager))) - guard let override = range.temporaryOverride else { - return false + loopManager.addReservoirValue(units, at: date) { (result) in + switch result { + case .failure(let error): + self.log.error("Failed to addReservoirValue: %{public}@", String(describing: error)) + completion(.failure(error)) + case .success(let (newValue, lastValue, areStoredValuesContinuous)): + completion(.success((newValue: newValue, lastValue: lastValue, areStoredValuesContinuous: areStoredValuesContinuous))) + } } - - return override.endDate.timeIntervalSinceNow > 0 } - /// Attempts to enable workout glucose targets until the given date, and returns true if successful. - /// TODO: This can live on the schedule itself once its a value type, since didSet would invoke when mutated. - @discardableResult - func enableWorkoutMode(until endDate: Date) -> Bool { - guard let glucoseTargetRangeSchedule = glucoseTargetRangeSchedule else { - return false - } + func startDateToFilterNewPumpEvents(for manager: PumpManager) -> Date { + dispatchPrecondition(condition: .onQueue(queue)) + return doseStore.pumpEventQueryAfterDate + } - _ = glucoseTargetRangeSchedule.setWorkoutOverride(until: endDate) + var automaticDosingEnabled: Bool { + automaticDosingStatus.automaticDosingEnabled + } +} - NotificationCenter.default.post(name: .LoopSettingsUpdated, object: self) +// MARK: - PumpManagerOnboardingDelegate - return true +extension DeviceDataManager: PumpManagerOnboardingDelegate { + func pumpManagerOnboarding(didCreatePumpManager pumpManager: PumpManagerUI) { + log.default("Pump manager with identifier '%{public}@' created", pumpManager.pluginIdentifier) + self.pumpManager = pumpManager } - func disableWorkoutMode() { - glucoseTargetRangeSchedule?.clearOverride() + func pumpManagerOnboarding(didOnboardPumpManager pumpManager: PumpManagerUI) { + precondition(pumpManager.isOnboarded) + log.default("Pump manager with identifier '%{public}@' onboarded", pumpManager.pluginIdentifier) - NotificationCenter.default.post(name: .LoopSettingsUpdated, object: self) + DispatchQueue.main.async { + self.refreshDeviceData() + self.settingsManager.storeSettings() + } } - var maximumBasalRatePerHour: Double? = UserDefaults.standard.maximumBasalRatePerHour { - didSet { - UserDefaults.standard.maximumBasalRatePerHour = maximumBasalRatePerHour + func pumpManagerOnboarding(didPauseOnboarding pumpManager: PumpManagerUI) { + + } +} - AnalyticsManager.sharedManager.didChangeMaximumBasalRate() - } +// MARK: - AlertStoreDelegate +extension DeviceDataManager: AlertStoreDelegate { + func alertStoreHasUpdatedAlertData(_ alertStore: AlertStore) { + remoteDataServicesManager.triggerUpload(for: .alert) } +} - var maximumBolus: Double? = UserDefaults.standard.maximumBolus { - didSet { - UserDefaults.standard.maximumBolus = maximumBolus +// MARK: - CarbStoreDelegate +extension DeviceDataManager: CarbStoreDelegate { + func carbStoreHasUpdatedCarbData(_ carbStore: CarbStore) { + remoteDataServicesManager.triggerUpload(for: .carb) + } - AnalyticsManager.sharedManager.didChangeMaximumBolus() - } + func carbStore(_ carbStore: CarbStore, didError error: CarbStore.CarbStoreError) {} +} + +// MARK: - DoseStoreDelegate +extension DeviceDataManager: DoseStoreDelegate { + func doseStoreHasUpdatedPumpEventData(_ doseStore: DoseStore) { + remoteDataServicesManager.triggerUpload(for: .pumpEvent) } +} - // MARK: - CarbKit +// MARK: - DosingDecisionStoreDelegate +extension DeviceDataManager: DosingDecisionStoreDelegate { + func dosingDecisionStoreHasUpdatedDosingDecisionData(_ dosingDecisionStore: DosingDecisionStore) { + remoteDataServicesManager.triggerUpload(for: .dosingDecision) + } +} - let carbStore: CarbStore? +// MARK: - GlucoseStoreDelegate +extension DeviceDataManager: GlucoseStoreDelegate { + func glucoseStoreHasUpdatedGlucoseData(_ glucoseStore: GlucoseStore) { + remoteDataServicesManager.triggerUpload(for: .glucose) + } +} - // MARK: CarbStoreDelegate +// MARK: - InsulinDeliveryStoreDelegate +extension DeviceDataManager: InsulinDeliveryStoreDelegate { + func insulinDeliveryStoreHasUpdatedDoseData(_ insulinDeliveryStore: InsulinDeliveryStore) { + remoteDataServicesManager.triggerUpload(for: .dose) + } +} - func carbStore(_: CarbStore, didError error: CarbStore.CarbStoreError) { - logger.addError(error, fromSource: "CarbStore") +// MARK: - CgmEventStoreDelegate +extension DeviceDataManager: CgmEventStoreDelegate { + func cgmEventStoreHasUpdatedData(_ cgmEventStore: LoopKit.CgmEventStore) { + remoteDataServicesManager.triggerUpload(for: .cgmEvent) } +} - func carbStore(_ carbStore: CarbStore, hasEntriesNeedingUpload entries: [CarbEntry], withCompletion completionHandler: @escaping (_ uploadedObjects: [String]) -> Void) { - guard let uploader = remoteDataManager.nightscoutUploader else { - completionHandler([]) +// MARK: - TestingPumpManager +extension DeviceDataManager { + func deleteTestingPumpData(completion: ((Error?) -> Void)? = nil) { + guard let testingPumpManager = pumpManager as? TestingPumpManager else { + completion?(nil) return } - let nsCarbEntries = entries.map({ MealBolusNightscoutTreatment(carbEntry: $0)}) + let devicePredicate = HKQuery.predicateForObjects(from: [testingPumpManager.testingDevice]) + let insulinDeliveryStore = doseStore.insulinDeliveryStore + + doseStore.resetPumpData { doseStoreError in + guard doseStoreError == nil else { + completion?(doseStoreError!) + return + } - uploader.upload(nsCarbEntries) { (result) in - switch result { - case .success(let ids): - // Pass new ids back - completionHandler(ids) - case .failure(let error): - self.logger.addError(error, fromSource: "NightscoutUploader") - completionHandler([]) + let insulinSharingDenied = self.healthStore.authorizationStatus(for: HealthKitSampleStore.insulinQuantityType) == .sharingDenied + guard !insulinSharingDenied else { + // only clear cache since access to health kit is denied + insulinDeliveryStore.purgeCachedInsulinDeliveryObjects() { error in + completion?(error) + } + return + } + + insulinDeliveryStore.purgeAllDoseEntries(healthKitPredicate: devicePredicate) { error in + completion?(error) } } } - func carbStore(_ carbStore: CarbStore, hasModifiedEntries entries: [CarbEntry], withCompletion completionHandler: @escaping (_ uploadedObjects: [String]) -> Void) { + func deleteTestingCGMData(completion: ((Error?) -> Void)? = nil) { + guard let testingCGMManager = cgmManager as? TestingCGMManager else { + completion?(nil) + return + } - guard let uploader = remoteDataManager.nightscoutUploader else { - completionHandler([]) + let glucoseSharingDenied = self.healthStore.authorizationStatus(for: HealthKitSampleStore.glucoseType) == .sharingDenied + guard !glucoseSharingDenied else { + // only clear cache since access to health kit is denied + glucoseStore.purgeCachedGlucoseObjects() { error in + completion?(error) + } return } - let nsCarbEntries = entries.map({ MealBolusNightscoutTreatment(carbEntry: $0)}) + let predicate = HKQuery.predicateForObjects(from: [testingCGMManager.testingDevice]) + glucoseStore.purgeAllGlucoseSamples(healthKitPredicate: predicate) { error in + completion?(error) + } + } +} - uploader.modifyTreatments(nsCarbEntries) { (error) in - if let error = error { - self.logger.addError(error, fromSource: "NightscoutUploader") - completionHandler([]) - } else { - completionHandler(entries.map { $0.externalId ?? "" } ) - } +// MARK: - LoopDataManagerDelegate +extension DeviceDataManager: LoopDataManagerDelegate { + func roundBasalRate(unitsPerHour: Double) -> Double { + guard let pumpManager = pumpManager else { + return unitsPerHour } + return pumpManager.roundToSupportedBasalRate(unitsPerHour: unitsPerHour) } - func carbStore(_ carbStore: CarbStore, hasDeletedEntries ids: [String], withCompletion completionHandler: @escaping ([String]) -> Void) { + func roundBolusVolume(units: Double) -> Double { + guard let pumpManager = pumpManager else { + return units + } + + let rounded = pumpManager.roundToSupportedBolusVolume(units: units) + self.log.default("Rounded %{public}@ to %{public}@", String(describing: units), String(describing: rounded)) + + return rounded + } + + func loopDataManager(_ manager: LoopDataManager, estimateBolusDuration units: Double) -> TimeInterval? { + pumpManager?.estimatedDuration(toBolus: units) + } - guard let uploader = remoteDataManager.nightscoutUploader else { - completionHandler([]) + func loopDataManager( + _ manager: LoopDataManager, + didRecommend automaticDose: (recommendation: AutomaticDoseRecommendation, date: Date), + completion: @escaping (LoopError?) -> Void + ) { + guard let pumpManager = pumpManager else { + completion(LoopError.configurationError(.pumpManager)) + return + } + + guard !pumpManager.status.deliveryIsUncertain else { + completion(LoopError.connectionError) return } - uploader.deleteTreatmentsById(ids) { (error) in - if let error = error { - self.logger.addError(error, fromSource: "NightscoutUploader") - completionHandler([]) - } else { - completionHandler(ids) - } + log.default("LoopManager did recommend dose: %{public}@", String(describing: automaticDose.recommendation)) + + crashRecoveryManager.dosingStarted(dose: automaticDose.recommendation) + doseEnactor.enact(recommendation: automaticDose.recommendation, with: pumpManager) { pumpManagerError in + completion(pumpManagerError.map { .pumpManagerError($0) }) + self.crashRecoveryManager.dosingFinished() } - completionHandler([]) } +} - // MARK: - GlucoseKit +extension Notification.Name { + static let PumpManagerChanged = Notification.Name(rawValue: "com.loopKit.notification.PumpManagerChanged") + static let CGMManagerChanged = Notification.Name(rawValue: "com.loopKit.notification.CGMManagerChanged") + static let PumpEventsAdded = Notification.Name(rawValue: "com.loopKit.notification.PumpEventsAdded") +} - let glucoseStore: GlucoseStore? = GlucoseStore() +// MARK: - ServicesManagerDosingDelegate - // MARK: - InsulinKit +extension DeviceDataManager: ServicesManagerDosingDelegate { + + func deliverBolus(amountInUnits: Double) async throws { + try await enactBolus(units: amountInUnits, activationType: .manualNoRecommendation) + } + +} - let doseStore: DoseStore +// MARK: - Critical Event Log Export - // MARK: DoseStoreDelegate +extension DeviceDataManager { + private static var criticalEventLogHistoricalExportBackgroundTaskIdentifier: String { "com.loopkit.background-task.critical-event-log.historical-export" } - func doseStore(_ doseStore: DoseStore, hasEventsNeedingUpload pumpEvents: [PersistedPumpEvent], fromPumpID pumpID: String, withCompletion completionHandler: @escaping (_ uploadedObjects: [NSManagedObjectID]) -> Void) { - guard let uploader = remoteDataManager.nightscoutUploader, let pumpModel = pumpState?.pumpModel else { - completionHandler(pumpEvents.map({ $0.objectID })) - return - } + public static func registerCriticalEventLogHistoricalExportBackgroundTask(_ handler: @escaping (BGProcessingTask) -> Void) -> Bool { + return BGTaskScheduler.shared.register(forTaskWithIdentifier: criticalEventLogHistoricalExportBackgroundTaskIdentifier, using: nil) { handler($0 as! BGProcessingTask) } + } - var objectIDs = [NSManagedObjectID]() - var timestampedPumpEvents = [TimestampedHistoryEvent]() + public func handleCriticalEventLogHistoricalExportBackgroundTask(_ task: BGProcessingTask) { + dispatchPrecondition(condition: .notOnQueue(.main)) - for event in pumpEvents { - objectIDs.append(event.objectID) + scheduleCriticalEventLogHistoricalExportBackgroundTask(isRetry: true) - if let raw = event.raw, raw.count > 0, let type = MinimedKit.PumpEventType(rawValue: raw[0])?.eventType, let pumpEvent = type.init(availableData: raw, pumpModel: pumpModel) { - timestampedPumpEvents.append(TimestampedHistoryEvent(pumpEvent: pumpEvent, date: event.date)) - } + let exporter = criticalEventLogExportManager.createHistoricalExporter() + + task.expirationHandler = { + self.log.default("Invoked critical event log historical export background task expiration handler - cancelling exporter") + exporter.cancel() } - uploader.upload(timestampedPumpEvents, forSource: "loop://\(UIDevice.current.name)", from: pumpModel) { (error) in - if let error = error { - self.logger.addError(error, fromSource: "NightscoutUploadKit") - completionHandler([]) - } else { - completionHandler(objectIDs) + DispatchQueue.global(qos: .background).async { + exporter.export() { error in + if let error = error { + self.log.error("Critical event log historical export errored: %{public}@", String(describing: error)) + } + + self.scheduleCriticalEventLogHistoricalExportBackgroundTask(isRetry: error != nil && !exporter.isCancelled) + task.setTaskCompleted(success: error == nil) + + self.log.default("Completed critical event log historical export background task") } } } - // MARK: - WatchKit + public func scheduleCriticalEventLogHistoricalExportBackgroundTask(isRetry: Bool = false) { + do { + let earliestBeginDate = isRetry ? criticalEventLogExportManager.retryExportHistoricalDate() : criticalEventLogExportManager.nextExportHistoricalDate() + let request = BGProcessingTaskRequest(identifier: Self.criticalEventLogHistoricalExportBackgroundTaskIdentifier) + request.earliestBeginDate = earliestBeginDate + request.requiresExternalPower = true - fileprivate var watchManager: WatchDataManager! - - // MARK: - Status Extension - - fileprivate var statusExtensionManager: StatusExtensionDataManager! + try BGTaskScheduler.shared.submit(request) - // MARK: - Initialization + log.default("Scheduled critical event log historical export background task: %{public}@", ISO8601DateFormatter().string(from: earliestBeginDate)) + } catch let error { + #if IOS_SIMULATOR + log.debug("Failed to schedule critical event log export background task due to running on simulator") + #else + log.error("Failed to schedule critical event log export background task: %{public}@", String(describing: error)) + #endif + } + } - private(set) var loopManager: LoopDataManager! + public func removeExportsDirectory() -> Error? { + let fileManager = FileManager.default + let exportsDirectoryURL = fileManager.exportsDirectoryURL - init() { - let pumpID = UserDefaults.standard.pumpID + guard fileManager.fileExists(atPath: exportsDirectoryURL.path) else { + return nil + } - doseStore = DoseStore( - pumpID: pumpID, - insulinActionDuration: insulinActionDuration, - basalProfile: basalRateSchedule, - insulinSensitivitySchedule: insulinSensitivitySchedule - ) + do { + try fileManager.removeItem(at: exportsDirectoryURL) + } catch let error { + return error + } - carbStore = CarbStore( - defaultAbsorptionTimes: (fast: TimeInterval(hours: 2), medium: TimeInterval(hours: 3), slow: TimeInterval(hours: 4)), - carbRatioSchedule: carbRatioSchedule, - insulinSensitivitySchedule: insulinSensitivitySchedule - ) + return nil + } +} - var idleListeningEnabled = true +// MARK: - Simulated Core Data - if let pumpID = pumpID { - let pumpState = PumpState(pumpID: pumpID, pumpRegion: UserDefaults.standard.pumpRegion ?? .northAmerica) +extension DeviceDataManager { + func generateSimulatedHistoricalCoreData(completion: @escaping (Error?) -> Void) { + guard FeatureFlags.simulatedCoreDataEnabled else { + fatalError("\(#function) should be invoked only when simulated core data is enabled") + } - if let timeZone = UserDefaults.standard.pumpTimeZone { - pumpState.timeZone = timeZone + settingsManager.settingsStore.generateSimulatedHistoricalSettingsObjects() { error in + guard error == nil else { + completion(error) + return } - - if let pumpModelNumber = UserDefaults.standard.pumpModelNumber { - if let model = PumpModel(rawValue: pumpModelNumber) { - pumpState.pumpModel = model - - idleListeningEnabled = model.hasMySentry + self.loopManager.generateSimulatedHistoricalCoreData() { error in + guard error == nil else { + completion(error) + return + } + self.deviceLog.generateSimulatedHistoricalDeviceLogEntries() { error in + guard error == nil else { + completion(error) + return + } + self.alertManager.alertStore.generateSimulatedHistoricalStoredAlerts(completion: completion) } } + } + } - self.pumpState = pumpState + func purgeHistoricalCoreData(completion: @escaping (Error?) -> Void) { + guard FeatureFlags.simulatedCoreDataEnabled else { + fatalError("\(#function) should be invoked only when simulated core data is enabled") } - rileyLinkManager = RileyLinkDeviceManager( - pumpState: self.pumpState, - autoConnectIDs: connectedPeripheralIDs - ) - rileyLinkManager.idleListeningEnabled = idleListeningEnabled + alertManager.alertStore.purgeHistoricalStoredAlerts() { error in + guard error == nil else { + completion(error) + return + } + self.deviceLog.purgeHistoricalDeviceLogEntries() { error in + guard error == nil else { + completion(error) + return + } + self.loopManager.purgeHistoricalCoreData { error in + guard error == nil else { + completion(error) + return + } + self.settingsManager.purgeHistoricalSettingsObjects(completion: completion) + } + } + } + } +} - NotificationCenter.default.addObserver(self, selector: #selector(receivedRileyLinkManagerNotification(_:)), name: nil, object: rileyLinkManager) - NotificationCenter.default.addObserver(self, selector: #selector(receivedRileyLinkPacketNotification(_:)), name: .RileyLinkDeviceDidReceiveIdleMessage, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(receivedRileyLinkTimerTickNotification(_:)), name: .RileyLinkDeviceDidUpdateTimerTick, object: nil) +fileprivate extension FileManager { + var exportsDirectoryURL: URL { + let applicationSupportDirectory = try! url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + return applicationSupportDirectory.appendingPathComponent(Bundle.main.bundleIdentifier!).appendingPathComponent("Exports") + } +} - if let pumpState = pumpState { - NotificationCenter.default.addObserver(self, selector: #selector(pumpStateValuesDidChange(_:)), name: .PumpStateValuesDidChange, object: pumpState) - } +//MARK: - CGMStalenessMonitorDelegate protocol conformance - loopManager = LoopDataManager(deviceDataManager: self) - watchManager = WatchDataManager(deviceDataManager: self) - statusExtensionManager = StatusExtensionDataManager(deviceDataManager: self) - nightscoutDataManager = NightscoutDataManager(deviceDataManager: self) +extension GlucoseStore : CGMStalenessMonitorDelegate { } - carbStore?.delegate = self - carbStore?.syncDelegate = self - doseStore.delegate = self - if UserDefaults.standard.receiverEnabled { - receiver = Receiver() - receiver?.delegate = self +//MARK: TherapySettingsViewModelDelegate +struct CancelTempBasalFailedError: LocalizedError { + let reason: Error? + + var errorDescription: String? { + return String(format: NSLocalizedString("%@%@ was unable to cancel your current temporary basal rate, which is higher than the new Max Basal limit you have set. This may result in higher insulin delivery than desired.\n\nConsider suspending insulin delivery manually and then immediately resuming to enact basal delivery with the new limit in place.", + comment: "Alert text for failing to cancel temp basal (1: reason description, 2: app name)"), + reasonString, Bundle.main.bundleDisplayName) + } + + private var reasonString: String { + let paragraphEnd = ".\n\n" + if let localizedError = reason as? LocalizedError { + let errors = [localizedError.errorDescription, localizedError.failureReason, localizedError.recoverySuggestion].compactMap { $0 } + if !errors.isEmpty { + return errors.joined(separator: ". ") + paragraphEnd + } } + return reason.map { $0.localizedDescription + paragraphEnd } ?? "" + } +} - if let transmitterID = UserDefaults.standard.transmitterID, transmitterID.characters.count == 6 { - transmitter = Transmitter(ID: transmitterID, passiveModeEnabled: true) - transmitter?.delegate = self +//MARK: - RemoteDataServicesManagerDelegate protocol conformance + +extension DeviceDataManager : RemoteDataServicesManagerDelegate { + var shouldSyncToRemoteService: Bool { + guard let cgmManager = cgmManager else { + return true } + return cgmManager.shouldSyncToRemoteService + } +} - enableRileyLinkHeartbeatIfNeeded() +extension DeviceDataManager: TherapySettingsViewModelDelegate { + + func syncBasalRateSchedule(items: [RepeatingScheduleValue], completion: @escaping (Swift.Result) -> Void) { + pumpManager?.syncBasalRateSchedule(items: items, completion: completion) + } + + func syncDeliveryLimits(deliveryLimits: DeliveryLimits, completion: @escaping (Swift.Result) -> Void) { + // FIRST we need to check to make sure if we have to cancel temp basal first + loopManager.maxTempBasalSavePreflight(unitsPerHour: deliveryLimits.maximumBasalRate?.doubleValue(for: .internationalUnitsPerHour)) { [weak self] error in + if let error = error { + completion(.failure(CancelTempBasalFailedError(reason: error))) + } else if let pumpManager = self?.pumpManager { + pumpManager.syncDeliveryLimits(limits: deliveryLimits, completion: completion) + } else { + completion(.success(deliveryLimits)) + } + } + } + + func saveCompletion(therapySettings: TherapySettings) { + + loopManager.mutateSettings { settings in + settings.glucoseTargetRangeSchedule = therapySettings.glucoseTargetRangeSchedule + settings.preMealTargetRange = therapySettings.correctionRangeOverrides?.preMeal + settings.legacyWorkoutTargetRange = therapySettings.correctionRangeOverrides?.workout + settings.suspendThreshold = therapySettings.suspendThreshold + settings.basalRateSchedule = therapySettings.basalRateSchedule + settings.maximumBasalRatePerHour = therapySettings.maximumBasalRatePerHour + settings.maximumBolus = therapySettings.maximumBolus + settings.defaultRapidActingModel = therapySettings.defaultRapidActingModel + settings.carbRatioSchedule = therapySettings.carbRatioSchedule + settings.insulinSensitivitySchedule = therapySettings.insulinSensitivitySchedule + } + } + + func pumpSupportedIncrements() -> PumpSupportedIncrements? { + return pumpManager.map { + PumpSupportedIncrements(basalRates: $0.supportedBasalRates, + bolusVolumes: $0.supportedBolusVolumes, + maximumBolusVolumes: $0.supportedMaximumBolusVolumes, + maximumBasalScheduleEntryCount: $0.maximumBasalScheduleEntryCount) + } } } +extension DeviceDataManager { + func addDisplayGlucoseUnitObserver(_ observer: DisplayGlucoseUnitObserver) { + let queue = DispatchQueue.main + displayGlucoseUnitObservers.insert(observer, queue: queue) + queue.async { + observer.unitDidChange(to: self.displayGlucosePreference.unit) + } + } + + func removeDisplayGlucoseUnitObserver(_ observer: DisplayGlucoseUnitObserver) { + displayGlucoseUnitObservers.removeElement(observer) + } -extension DeviceDataManager: CustomDebugStringConvertible { - var debugDescription: String { - return [ - "## DeviceDataManager", - "receiverEnabled: \(receiverEnabled)", - "latestPumpStatusFromMySentry: \(latestPumpStatusFromMySentry)", - "latestGlucoseG5: \(latestGlucoseG5)", - "latestGlucoseFromShare: \(latestGlucoseFromShare)", - "latestGlucoseG4: \(latestGlucoseG4)", - "pumpState: \(String(reflecting: pumpState))", - "preferredInsulinDataSource: \(preferredInsulinDataSource)", - "transmitterID: \(transmitterID)", - "glucoseTargetRangeSchedule: \(glucoseTargetRangeSchedule?.debugDescription ?? "")", - "workoutModeEnabled: \(workoutModeEnabled)", - "maximumBasalRatePerHour: \(maximumBasalRatePerHour)", - "maximumBolus: \(maximumBolus)", - String(reflecting: rileyLinkManager), - String(reflecting: statusExtensionManager!), - "", - "## NSUserDefaults", - String(reflecting: UserDefaults.standard.dictionaryRepresentation()) - ].joined(separator: "\n") + func notifyObserversOfDisplayGlucoseUnitChange(to displayGlucoseUnit: HKUnit) { + self.displayGlucoseUnitObservers.forEach { + $0.unitDidChange(to: displayGlucoseUnit) + } } } +extension DeviceDataManager: DeviceSupportDelegate { + var availableSupports: [SupportUI] { [cgmManager, pumpManager].compactMap { $0 as? SupportUI } } -extension Notification.Name { - /// Notification posted by the instance when new glucose data was processed - static let GlucoseUpdated = Notification.Name(rawValue: "com.loudnate.Naterade.notification.GlucoseUpdated") + func generateDiagnosticReport(_ completion: @escaping (_ report: String) -> Void) { + self.loopManager.generateDiagnosticReport { (loopReport) in - /// Notification posted by the instance when new pump data was processed - static let PumpStatusUpdated = Notification.Name(rawValue: "com.loudnate.Naterade.notification.PumpStatusUpdated") + let logDurationHours = 84.0 - /// Notification posted by the instance when loop configuration was changed - static let LoopSettingsUpdated = Notification.Name(rawValue: "com.loudnate.Naterade.notification.LoopSettingsUpdated") + self.alertManager.getStoredEntries(startDate: Date() - .hours(logDurationHours)) { (alertReport) in + self.deviceLog.getLogEntries(startDate: Date() - .hours(logDurationHours)) { (result) in + let deviceLogReport: String + switch result { + case .failure(let error): + deviceLogReport = "Error fetching entries: \(error)" + case .success(let entries): + deviceLogReport = entries.map { "* \($0.timestamp) \($0.managerIdentifier) \($0.deviceIdentifier ?? "") \($0.type) \($0.message)" }.joined(separator: "\n") + } + + let report = [ + "## Build Details", + "* appNameAndVersion: \(Bundle.main.localizedNameAndVersion)", + "* profileExpiration: \(BuildDetails.default.profileExpirationString)", + "* gitRevision: \(BuildDetails.default.gitRevision ?? "N/A")", + "* gitBranch: \(BuildDetails.default.gitBranch ?? "N/A")", + "* workspaceGitRevision: \(BuildDetails.default.workspaceGitRevision ?? "N/A")", + "* workspaceGitBranch: \(BuildDetails.default.workspaceGitBranch ?? "N/A")", + "* sourceRoot: \(BuildDetails.default.sourceRoot ?? "N/A")", + "* buildDateString: \(BuildDetails.default.buildDateString ?? "N/A")", + "* xcodeVersion: \(BuildDetails.default.xcodeVersion ?? "N/A")", + "", + "## FeatureFlags", + "\(FeatureFlags)", + "", + alertReport, + "", + "## DeviceDataManager", + "* launchDate: \(self.launchDate)", + "* lastError: \(String(describing: self.lastError))", + "", + "cacheStore: \(String(reflecting: self.cacheStore))", + "", + self.cgmManager != nil ? String(reflecting: self.cgmManager!) : "cgmManager: nil", + "", + self.pumpManager != nil ? String(reflecting: self.pumpManager!) : "pumpManager: nil", + "", + "## Device Communication Log", + deviceLogReport, + "", + String(reflecting: self.watchManager!), + "", + String(reflecting: self.statusExtensionManager!), + "", + loopReport, + ].joined(separator: "\n") + + completion(report) + } + } + } + } } +extension DeviceDataManager: DeviceStatusProvider {} + +extension DeviceDataManager { + var detectedSystemTimeOffset: TimeInterval { trustedTimeChecker.detectedSystemTimeOffset } +} diff --git a/Loop/Managers/DiagnosticLogger+LoopKit.swift b/Loop/Managers/DiagnosticLogger+LoopKit.swift deleted file mode 100644 index f6e9d01925..0000000000 --- a/Loop/Managers/DiagnosticLogger+LoopKit.swift +++ /dev/null @@ -1,76 +0,0 @@ -// -// DiagnosticLogger+LoopKit.swift -// Naterade -// -// Created by Nathan Racklyeft on 3/25/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import Foundation -import HealthKit -import LoopKit - - -extension DiagnosticLogger { - func addError(_ message: String, fromSource source: String) { - let info = [ - "source": source, - "message": message, - "reportedAt": DateFormatter.ISO8601StrictDateFormatter().string(from: Date()) - ] - - addMessage(info, toCollection: "errors") - } - - func addError(_ message: Error, fromSource source: String) { - addError(String(describing: message), fromSource: source) - } - - func addLoopStatus(startDate: Date, endDate: Date, glucose: GlucoseValue, effects: [String: [GlucoseEffect]], error: Error?, prediction: [GlucoseValue], predictionWithRetrospectiveEffect: Double, recommendedTempBasal: LoopDataManager.TempBasalRecommendation?) { - - let dateFormatter = DateFormatter.ISO8601StrictDateFormatter() - let unit = HKUnit.milligramsPerDeciliterUnit() - - var message: [String: Any] = [ - "startDate": dateFormatter.string(from: startDate), - "duration": endDate.timeIntervalSince(startDate), - "glucose": [ - "startDate": dateFormatter.string(from: glucose.startDate), - "value": glucose.quantity.doubleValue(for: unit), - "unit": unit.unitString - ], - "input": effects.reduce([:], { (previous, item) -> [String: Any] in - var input = previous - input[item.0] = item.1.map { - [ - "startDate": dateFormatter.string(from: $0.startDate), - "value": $0.quantity.doubleValue(for: unit), - "unit": unit.unitString - ] - } - return input - }), - "prediction": prediction.map { (value) -> [String: Any] in - [ - "startDate": dateFormatter.string(from: value.startDate), - "value": value.quantity.doubleValue(for: unit), - "unit": unit.unitString - ] - }, - "prediction_retrospect_delta": predictionWithRetrospectiveEffect - ] - - if let error = error { - message["error"] = String(describing: error) - } - - if let recommendedTempBasal = recommendedTempBasal { - message["recommendedTempBasal"] = [ - "rate": recommendedTempBasal.rate, - "minutes": recommendedTempBasal.duration.minutes - ] - } - - addMessage(message, toCollection: "loop") - } -} diff --git a/Loop/Managers/DiagnosticLogger.swift b/Loop/Managers/DiagnosticLogger.swift deleted file mode 100644 index 84d6e707a2..0000000000 --- a/Loop/Managers/DiagnosticLogger.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// DiagnosticLogger.swift -// Naterade -// -// Created by Nathan Racklyeft on 9/10/15. -// Copyright © 2015 Nathan Racklyeft. All rights reserved. -// - -import Foundation - - -final class DiagnosticLogger { - private lazy var isSimulator: Bool = TARGET_OS_SIMULATOR != 0 - - var mLabService: MLabService { - didSet { - try! KeychainManager().setMLabDatabaseName(mLabService.databaseName, APIKey: mLabService.APIKey) - } - } - - init() { - if let (databaseName, APIKey) = KeychainManager().getMLabCredentials() { - mLabService = MLabService(databaseName: databaseName, APIKey: APIKey) - } else { - mLabService = MLabService(databaseName: nil, APIKey: nil) - } - } - - func addMessage(_ message: [String: Any], toCollection collection: String) { - if !isSimulator, - let messageData = try? JSONSerialization.data(withJSONObject: message, options: []), - let task = mLabService.uploadTaskWithData(messageData, inCollection: collection) - { - task.resume() - } else { - NSLog("%@: %@", collection, message) - } - } -} - diff --git a/Loop/Managers/DoseEnactor.swift b/Loop/Managers/DoseEnactor.swift new file mode 100644 index 0000000000..55c782c96c --- /dev/null +++ b/Loop/Managers/DoseEnactor.swift @@ -0,0 +1,61 @@ + // +// DoseEnactor.swift +// Loop +// +// Created by Pete Schwamb on 7/30/21. +// Copyright © 2021 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit + +class DoseEnactor { + + fileprivate let dosingQueue: DispatchQueue = DispatchQueue(label: "com.loopkit.DeviceManagerDosingQueue", qos: .utility) + + private let log = DiagnosticLog(category: "DoseEnactor") + + func enact(recommendation: AutomaticDoseRecommendation, with pumpManager: PumpManager, completion: @escaping (PumpManagerError?) -> Void) { + + dosingQueue.async { + let doseDispatchGroup = DispatchGroup() + + var tempBasalError: PumpManagerError? = nil + var bolusError: PumpManagerError? = nil + + if let basalAdjustment = recommendation.basalAdjustment { + self.log.default("Enacting recommend basal change") + + doseDispatchGroup.enter() + pumpManager.enactTempBasal(unitsPerHour: basalAdjustment.unitsPerHour, for: basalAdjustment.duration, completion: { error in + if let error = error { + tempBasalError = error + } + doseDispatchGroup.leave() + }) + } + + doseDispatchGroup.wait() + + guard tempBasalError == nil else { + completion(tempBasalError) + return + } + + if let bolusUnits = recommendation.bolusUnits, bolusUnits > 0 { + self.log.default("Enacting recommended bolus dose") + doseDispatchGroup.enter() + pumpManager.enactBolus(units: bolusUnits, activationType: .automatic) { (error) in + if let error = error { + bolusError = error + } else { + self.log.default("PumpManager successfully issued bolus command") + } + doseDispatchGroup.leave() + } + } + doseDispatchGroup.wait() + completion(bolusError) + } + } +} diff --git a/Loop/Managers/DoseMath.swift b/Loop/Managers/DoseMath.swift deleted file mode 100644 index a7eec3743e..0000000000 --- a/Loop/Managers/DoseMath.swift +++ /dev/null @@ -1,186 +0,0 @@ -// -// DoseMath.swift -// Naterade -// -// Created by Nathan Racklyeft on 3/8/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import Foundation -import HealthKit -import InsulinKit -import LoopKit - - -struct DoseMath { - /// The allowed precision - static let basalStrokes: Double = 40 - - /** - Calculates the necessary temporary basal rate to transform a glucose value to a target. - - This assumes a constant insulin sensitivity, independent of current glucose or insulin-on-board. - - - parameter currentGlucose: The current glucose - - parameter targetGlucose: The desired glucose - - parameter insulinSensitivity: The insulin sensitivity, in Units of insulin per glucose-unit - - parameter currentBasalRate: The normally-scheduled basal rate - - parameter maxBasalRate: The maximum basal rate, used to constrain the output - - parameter duration: The temporary duration to run the basal - - - returns: The determined basal rate, in Units/hour - */ - private static func calculateTempBasalRateForGlucose(_ currentGlucose: HKQuantity, toTargetGlucose targetGlucose: HKQuantity, insulinSensitivity: HKQuantity, currentBasalRate: Double, maxBasalRate: Double, duration: TimeInterval) -> Double { - let unit = HKUnit.milligramsPerDeciliterUnit() - let doseUnits = (currentGlucose.doubleValue(for: unit) - targetGlucose.doubleValue(for: unit)) / insulinSensitivity.doubleValue(for: unit) - - let rate = min(maxBasalRate, max(0, doseUnits / (duration / TimeInterval(hours: 1)) + currentBasalRate)) - - return round(rate * basalStrokes) / basalStrokes - } - - /** - Recommends a temporary basal rate to conform a glucose prediction timeline to a target range - - Returns nil if the normal scheduled basal, or active temporary basal, is sufficient. - - - parameter glucose: The ascending timeline of predicted glucose values - - parameter date: The date at which the temporary basal rate would start. Defaults to the current date. - - parameter lastTempBasal: The last-set temporary basal - - parameter maxBasalRate: The maximum basal rate, in Units/hour, used to constrain the output - - parameter glucoseTargetRange: The schedule of target glucose ranges - - parameter insulinSensitivity: The schedule of insulin sensitivities, in Units of insulin per glucose-unit - - parameter basalRateSchedule: The schedule of basal rates - - - returns: The recommended basal rate and duration - */ - static func recommendTempBasalFromPredictedGlucose(_ glucose: [GlucoseValue], - atDate date: Date = Date(), - lastTempBasal: DoseEntry?, - maxBasalRate: Double, - glucoseTargetRange: GlucoseRangeSchedule, - insulinSensitivity: InsulinSensitivitySchedule, - basalRateSchedule: BasalRateSchedule - ) -> (rate: Double, duration: TimeInterval)? { - guard glucose.count > 1 else { - return nil - } - - let eventualGlucose = glucose.last! - let minGlucose = glucose.min { $0.quantity < $1.quantity }! - - let eventualGlucoseTargets = glucoseTargetRange.value(at: eventualGlucose.startDate) - let minGlucoseTargets = glucoseTargetRange.value(at: minGlucose.startDate) - let currentSensitivity = insulinSensitivity.quantity(at: date) - let currentScheduledBasalRate = basalRateSchedule.value(at: date) - - var rate: Double? - var duration = TimeInterval(minutes: 30) - - let alwaysLowTempBGThreshold: Double = 55 // mg/dL - - if minGlucose.quantity.doubleValue(for: HKUnit.milligramsPerDeciliterUnit()) <= alwaysLowTempBGThreshold { - rate = 0 - } else if minGlucose.quantity.doubleValue(for: glucoseTargetRange.unit) < minGlucoseTargets.minValue && eventualGlucose.quantity.doubleValue(for: glucoseTargetRange.unit) <= eventualGlucoseTargets.minValue { - let targetGlucose = HKQuantity(unit: glucoseTargetRange.unit, doubleValue: (minGlucoseTargets.minValue + minGlucoseTargets.maxValue) / 2) - rate = calculateTempBasalRateForGlucose(minGlucose.quantity, - toTargetGlucose: targetGlucose, - insulinSensitivity: currentSensitivity, - currentBasalRate: currentScheduledBasalRate, - maxBasalRate: maxBasalRate, - duration: duration - ) - } else if eventualGlucose.quantity.doubleValue(for: glucoseTargetRange.unit) > eventualGlucoseTargets.maxValue { - var adjustedMaxBasalRate = maxBasalRate - if minGlucose.quantity.doubleValue(for: glucoseTargetRange.unit) < minGlucoseTargets.minValue { - adjustedMaxBasalRate = currentScheduledBasalRate - } - - let targetGlucose = HKQuantity(unit: glucoseTargetRange.unit, doubleValue: (eventualGlucoseTargets.minValue + eventualGlucoseTargets.maxValue) / 2) - rate = calculateTempBasalRateForGlucose(eventualGlucose.quantity, - toTargetGlucose: targetGlucose, - insulinSensitivity: currentSensitivity, - currentBasalRate: currentScheduledBasalRate, - maxBasalRate: adjustedMaxBasalRate, - duration: duration - ) - } - - if let determinedRate = rate, determinedRate == currentScheduledBasalRate { - rate = nil - } - - if let lastTempBasal = lastTempBasal, lastTempBasal.unit == .unitsPerHour && lastTempBasal.endDate > date { - if let determinedRate = rate { - // Ignore the dose if the current dose is the same rate and has more than 10 minutes remaining - if determinedRate == lastTempBasal.value && lastTempBasal.endDate.timeIntervalSince(date) > TimeInterval(minutes: 11) { - rate = nil - } - } else { - // If we prefer to not have a dose, cancel the one in progress - rate = 0 - duration = TimeInterval(0) - } - } - - if let rate = rate { - return (rate: rate, duration: duration) - } else { - return nil - } - } - - /** - Recommends a bolus to conform a glucose prediction timeline to a target range - - - parameter glucose: The ascending timeline of predicted glucose values - - parameter date: The date at which the bolus would apply. Defaults to the current date. - - parameter lastTempBasal: The last-set temporary basal - - parameter maxBolus: The maximum bolus, used to constrain the output - - parameter glucoseTargetRange: The schedule of target glucose ranges - - parameter insulinSensitivity: The schedule of insulin sensitivities, in Units of insulin per glucose-unit - - parameter basalRateSchedule: The schedule of basal rates - - - returns: The recommended bolus - */ - static func recommendBolusFromPredictedGlucose(_ glucose: [GlucoseValue], - atDate date: Date = Date(), - lastTempBasal: DoseEntry?, - maxBolus: Double, - glucoseTargetRange: GlucoseRangeSchedule, - insulinSensitivity: InsulinSensitivitySchedule, - basalRateSchedule: BasalRateSchedule - ) -> Double { - guard glucose.count > 1 else { - return 0 - } - - let eventualGlucose = glucose.last! - let minGlucose = glucose.min { $0.quantity < $1.quantity }! - - let eventualGlucoseTargets = glucoseTargetRange.value(at: eventualGlucose.startDate) - // Use between to opt-out of the override. - let minGlucoseTargets = glucoseTargetRange.between(start: minGlucose.startDate, end: minGlucose.startDate).first!.value - - guard minGlucose.quantity.doubleValue(for: glucoseTargetRange.unit) >= minGlucoseTargets.minValue else { - return 0 - } - - let targetGlucose = eventualGlucoseTargets.maxValue - let currentSensitivity = insulinSensitivity.quantity(at: date).doubleValue(for: glucoseTargetRange.unit) - - var doseUnits = (eventualGlucose.quantity.doubleValue(for: glucoseTargetRange.unit) - targetGlucose) / currentSensitivity - - if let lastTempBasal = lastTempBasal, lastTempBasal.unit == .unitsPerHour && lastTempBasal.endDate > date { - let normalBasalRate = basalRateSchedule.value(at: date) - let remainingTime = lastTempBasal.endDate.timeIntervalSince(date) - let remainingUnits = (lastTempBasal.value - normalBasalRate) * remainingTime / TimeInterval(hours: 1) - - doseUnits -= max(0, remainingUnits) - } - - doseUnits = round(doseUnits * 40) / 40 - - return min(maxBolus, max(0, doseUnits)) - } -} diff --git a/Loop/Managers/ExtensionDataManager.swift b/Loop/Managers/ExtensionDataManager.swift new file mode 100644 index 0000000000..9261dcfc43 --- /dev/null +++ b/Loop/Managers/ExtensionDataManager.swift @@ -0,0 +1,185 @@ +// +// ExtensionDataManager.swift +// Loop +// +// Created by Bharat Mediratta on 11/25/16. +// Copyright © 2016 Nathan Racklyeft. All rights reserved. +// + +import HealthKit +import UIKit +import LoopKit + + +final class ExtensionDataManager { + unowned let deviceManager: DeviceDataManager + private let automaticDosingStatus: AutomaticDosingStatus + + init(deviceDataManager: DeviceDataManager, + automaticDosingStatus: AutomaticDosingStatus) + { + self.deviceManager = deviceDataManager + self.automaticDosingStatus = automaticDosingStatus + + NotificationCenter.default.addObserver(self, selector: #selector(notificationReceived(_:)), name: .LoopDataUpdated, object: deviceDataManager.loopManager) + NotificationCenter.default.addObserver(self, selector: #selector(notificationReceived(_:)), name: .PumpManagerChanged, object: nil) + + // Wait until LoopDataManager has had a chance to initialize itself + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.update() + } + } + + fileprivate static var defaults: UserDefaults? { + return UserDefaults.appGroup + } + + static var context: StatusExtensionContext? { + get { + return defaults?.statusExtensionContext + } + set { + defaults?.statusExtensionContext = newValue + } + } + + static var intentExtensionInfo: IntentExtensionInfo? { + get { + return defaults?.intentExtensionInfo + } + set { + defaults?.intentExtensionInfo = newValue + } + } + + static var lastLoopCompleted: Date? { + context?.lastLoopCompleted + } + + @objc private func notificationReceived(_ notification: Notification) { + update() + } + + private func update() { + createStatusContext(glucoseUnit: deviceManager.preferredGlucoseUnit) { (context) in + if let context = context { + ExtensionDataManager.context = context + } + } + + createIntentsContext { (info) in + if let info = info, ExtensionDataManager.intentExtensionInfo?.overridePresetNames != info.overridePresetNames { + ExtensionDataManager.intentExtensionInfo = info + } + } + } + + private func createIntentsContext(_ completion: @escaping (_ context: IntentExtensionInfo?) -> Void) { + let presets = deviceManager.loopManager.settings.overridePresets + let info = IntentExtensionInfo(overridePresetNames: presets.map { $0.name }) + completion(info) + } + + private func createStatusContext(glucoseUnit: HKUnit, _ completionHandler: @escaping (_ context: StatusExtensionContext?) -> Void) { + + let basalDeliveryState = deviceManager.pumpManager?.status.basalDeliveryState + + deviceManager.loopManager.getLoopState { (manager, state) in + let dataManager = self.deviceManager + var context = StatusExtensionContext() + + context.createdAt = Date() + + #if IOS_SIMULATOR + // If we're in the simulator, there's a higher likelihood that we don't have + // a fully configured app. Inject some baseline debug data to let us test the + // experience. This data will be overwritten by actual data below, if available. + context.batteryPercentage = 0.25 + context.netBasal = NetBasalContext( + rate: 2.1, + percentage: 0.6, + start: + Date(timeIntervalSinceNow: -250), + end: Date(timeIntervalSinceNow: .minutes(30)) + ) + context.predictedGlucose = PredictedGlucoseContext( + values: (1...36).map { 89.123 + Double($0 * 5) }, // 3 hours of linear data + unit: HKUnit.milligramsPerDeciliter, + startDate: Date(), + interval: TimeInterval(minutes: 5)) + + let lastLoopCompleted = Date(timeIntervalSinceNow: -TimeInterval(minutes: 0)) + #else + let lastLoopCompleted = manager.lastLoopCompleted + #endif + + context.lastLoopCompleted = lastLoopCompleted + + context.isClosedLoop = self.automaticDosingStatus.automaticDosingEnabled + + context.preMealPresetAllowed = self.automaticDosingStatus.automaticDosingEnabled && manager.settings.preMealTargetRange != nil + context.preMealPresetActive = manager.settings.preMealTargetEnabled() + context.customPresetActive = manager.settings.nonPreMealOverrideEnabled() + + // Drop the first element in predictedGlucose because it is the currentGlucose + // and will have a different interval to the next element + if let predictedGlucose = state.predictedGlucoseIncludingPendingInsulin?.dropFirst(), + predictedGlucose.count > 1 { + let first = predictedGlucose[predictedGlucose.startIndex] + let second = predictedGlucose[predictedGlucose.startIndex.advanced(by: 1)] + context.predictedGlucose = PredictedGlucoseContext( + values: predictedGlucose.map { $0.quantity.doubleValue(for: glucoseUnit) }, + unit: glucoseUnit, + startDate: first.startDate, + interval: second.startDate.timeIntervalSince(first.startDate)) + } + + if let basalDeliveryState = basalDeliveryState, + let basalSchedule = manager.basalRateScheduleApplyingOverrideHistory, + let netBasal = basalDeliveryState.getNetBasal(basalSchedule: basalSchedule, settings: manager.settings) + { + context.netBasal = NetBasalContext(rate: netBasal.rate, percentage: netBasal.percent, start: netBasal.start, end: netBasal.end) + } + + context.batteryPercentage = dataManager.pumpManager?.status.pumpBatteryChargeRemaining + context.reservoirCapacity = dataManager.pumpManager?.pumpReservoirCapacity + + if let glucoseDisplay = dataManager.glucoseDisplay(for: dataManager.glucoseStore.latestGlucose) { + context.glucoseDisplay = GlucoseDisplayableContext( + isStateValid: glucoseDisplay.isStateValid, + stateDescription: glucoseDisplay.stateDescription, + trendType: glucoseDisplay.trendType, + trendRate: glucoseDisplay.trendRate, + isLocal: glucoseDisplay.isLocal, + glucoseRangeCategory: glucoseDisplay.glucoseRangeCategory + ) + } + + if let pumpManagerHUDProvider = dataManager.pumpManagerHUDProvider { + context.pumpManagerHUDViewContext = PumpManagerHUDViewContext(pumpManagerHUDViewRawValue: PumpManagerHUDViewRawValueFromHUDProvider(pumpManagerHUDProvider)) + } + + context.pumpStatusHighlightContext = DeviceStatusHighlightContext(from: dataManager.pumpStatusHighlight) + context.pumpLifecycleProgressContext = DeviceLifecycleProgressContext(from: dataManager.pumpLifecycleProgress) + + context.cgmStatusHighlightContext = DeviceStatusHighlightContext(from: dataManager.cgmStatusHighlight) + context.cgmLifecycleProgressContext = DeviceLifecycleProgressContext(from: dataManager.cgmLifecycleProgress) + + context.carbsOnBoard = state.carbsOnBoard?.quantity.doubleValue(for: .gram()) + + completionHandler(context) + } + } +} + + +extension ExtensionDataManager: CustomDebugStringConvertible { + var debugDescription: String { + return [ + "## StatusExtensionDataManager", + "appGroupName: \(Bundle.main.appGroupSuiteName)", + "statusExtensionContext: \(String(reflecting: ExtensionDataManager.context))", + "" + ].joined(separator: "\n") + } +} diff --git a/Loop/Managers/KeychainManager+Loop.swift b/Loop/Managers/KeychainManager+Loop.swift deleted file mode 100644 index d2e2225c48..0000000000 --- a/Loop/Managers/KeychainManager+Loop.swift +++ /dev/null @@ -1,72 +0,0 @@ -// -// KeychainManager+Loop.swift -// Loop -// -// Created by Nate Racklyeft on 6/26/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import Foundation - - -private let AmplitudeAPIKeyService = "AmplitudeAPIKey" -private let DexcomShareURL = URL(string: "https://share1.dexcom.com")! -private let NightscoutAccount = "NightscoutAPI" - - -extension KeychainManager { - func setAmplitudeAPIKey(_ key: String?) throws { - try replaceGenericPassword(key, forService: AmplitudeAPIKeyService) - } - - func getAmplitudeAPIKey() -> String? { - return try? getGenericPasswordForService(AmplitudeAPIKeyService) - } - - func setDexcomShareUsername(_ username: String?, password: String?) throws { - let credentials: InternetCredentials? - - if let username = username, let password = password { - credentials = InternetCredentials(username: username, password: password, url: DexcomShareURL) - } else { - credentials = nil - } - - try replaceInternetCredentials(credentials, forURL: DexcomShareURL) - } - - func getDexcomShareCredentials() -> (username: String, password: String)? { - do { - let credentials = try getInternetCredentials(url: DexcomShareURL) - - return (username: credentials.username, password: credentials.password) - } catch { - return nil - } - } - - func setNightscoutURL(_ url: URL?, secret: String?) { - let credentials: InternetCredentials? - - if let url = url, let secret = secret { - credentials = InternetCredentials(username: NightscoutAccount, password: secret, url: url) - } else { - credentials = nil - } - - do { - try replaceInternetCredentials(credentials, forAccount: NightscoutAccount) - } catch { - } - } - - func getNightscoutCredentials() -> (url: URL, secret: String)? { - do { - let credentials = try getInternetCredentials(account: NightscoutAccount) - - return (url: credentials.url, secret: credentials.password) - } catch { - return nil - } - } -} diff --git a/Loop/Managers/KeychainManager.swift b/Loop/Managers/KeychainManager.swift deleted file mode 100644 index 75e4205ddd..0000000000 --- a/Loop/Managers/KeychainManager.swift +++ /dev/null @@ -1,290 +0,0 @@ -// -// KeychainManager.swift -// Loop -// -// Created by Nate Racklyeft on 6/26/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import Foundation -import Security - - -enum KeychainManagerError: Error { - case add(OSStatus) - case copy(OSStatus) - case delete(OSStatus) - case unknownResult -} - - -/** - - Influenced by https://github.com/marketplacer/keychain-swift - */ -struct KeychainManager { - typealias Query = [String: NSObject] - - var accessibility: CFString = kSecAttrAccessibleAfterFirstUnlock - - var accessGroup: String? - - struct InternetCredentials { - let username: String - let password: String - let url: URL - } - - // MARK: - Convenience methods - - private func query(by class: CFString) -> Query { - var query: Query = [kSecClass as String: `class`] - - if let accessGroup = accessGroup { - query[kSecAttrAccessGroup as String] = accessGroup as NSObject? - } - - return query - } - - private func queryForGenericPassword(by service: String) -> Query { - var query = self.query(by: kSecClassGenericPassword) - - query[kSecAttrService as String] = service as NSObject? - - return query - } - - private func queryForInternetPassword(account: String? = nil, url: URL? = nil) -> Query { - var query = self.query(by: kSecClassInternetPassword) - - if let account = account { - query[kSecAttrAccount as String] = account as NSObject? - } - - if let url = url, let components = URLComponents(url: url, resolvingAgainstBaseURL: true) { - for (key, value) in components.keychainAttributes { - query[key] = value - } - } - - return query - } - - private func updatedQuery(_ query: Query, withPassword password: String) throws -> Query { - var query = query - - guard let value = password.data(using: String.Encoding.utf8) else { - throw KeychainManagerError.add(errSecDecode) - } - - query[kSecValueData as String] = value as NSObject? - query[kSecAttrAccessible as String] = accessibility - - return query - } - - func delete(_ query: Query) throws { - let statusCode = SecItemDelete(query as CFDictionary) - - guard statusCode == errSecSuccess || statusCode == errSecItemNotFound else { - throw KeychainManagerError.delete(statusCode) - } - } - - // MARK: – Generic Passwords - - func replaceGenericPassword(_ password: String?, forService service: String) throws { - var query = queryForGenericPassword(by: service) - - try delete(query) - - guard let password = password else { - return - } - - query = try updatedQuery(query, withPassword: password) - - let statusCode = SecItemAdd(query as CFDictionary, nil) - - guard statusCode == errSecSuccess else { - throw KeychainManagerError.add(statusCode) - } - } - - func getGenericPasswordForService(_ service: String) throws -> String { - var query = queryForGenericPassword(by: service) - - query[kSecReturnData as String] = kCFBooleanTrue - query[kSecMatchLimit as String] = kSecMatchLimitOne - - var result: AnyObject? - - let statusCode = SecItemCopyMatching(query as CFDictionary, &result) - - guard statusCode == errSecSuccess else { - throw KeychainManagerError.copy(statusCode) - } - - guard let passwordData = result as? Data, let password = String(data: passwordData, encoding: String.Encoding.utf8) else { - throw KeychainManagerError.unknownResult - } - - return password - } - - // MARK – Internet Passwords - - func setInternetPassword(_ password: String, forAccount account: String, atURL url: URL) throws { - var query = try updatedQuery(queryForInternetPassword(account: account, url: url), withPassword: password) - - query[kSecAttrAccount as String] = account as NSObject? - - if let components = URLComponents(url: url, resolvingAgainstBaseURL: true) { - for (key, value) in components.keychainAttributes { - query[key] = value - } - } - - let statusCode = SecItemAdd(query as CFDictionary, nil) - - guard statusCode == errSecSuccess else { - throw KeychainManagerError.add(statusCode) - } - } - - func replaceInternetCredentials(_ credentials: InternetCredentials?, forAccount account: String) throws { - let query = queryForInternetPassword(account: account) - - try delete(query) - - if let credentials = credentials { - try setInternetPassword(credentials.password, forAccount: credentials.username, atURL: credentials.url) - } - } - - func replaceInternetCredentials(_ credentials: InternetCredentials?, forURL url: URL) throws { - let query = queryForInternetPassword(url: url) - - try delete(query) - - if let credentials = credentials { - try setInternetPassword(credentials.password, forAccount: credentials.username, atURL: credentials.url) - } - } - - func getInternetCredentials(account: String? = nil, url: URL? = nil) throws -> InternetCredentials { - var query = queryForInternetPassword(account: account, url: url) - - query[kSecReturnData as String] = kCFBooleanTrue - query[kSecReturnAttributes as String] = kCFBooleanTrue - query[kSecMatchLimit as String] = kSecMatchLimitOne - - var result: AnyObject? - - let statusCode: OSStatus = SecItemCopyMatching(query as CFDictionary, &result) - - guard statusCode == errSecSuccess else { - throw KeychainManagerError.copy(statusCode) - } - - if let result = result as? [AnyHashable: Any], let passwordData = result[kSecValueData as String] as? Data, - let password = String(data: passwordData, encoding: String.Encoding.utf8), - let url = URLComponents(keychainAttributes: result)?.url, - let username = result[kSecAttrAccount as String] as? String - { - return InternetCredentials(username: username, password: password, url: url) - } - - throw KeychainManagerError.unknownResult - } -} - - -private enum SecurityProtocol { - case http - case https - - init?(scheme: String?) { - switch scheme?.lowercased() { - case "http"?: - self = .http - case "https"?: - self = .https - default: - return nil - } - } - - init?(secAttrProtocol: CFString) { - if secAttrProtocol == kSecAttrProtocolHTTP { - self = .http - } else if secAttrProtocol == kSecAttrProtocolHTTPS { - self = .https - } else { - return nil - } - } - - var scheme: String { - switch self { - case .http: - return "http" - case .https: - return "https" - } - } - - var secAttrProtocol: CFString { - switch self { - case .http: - return kSecAttrProtocolHTTP - case .https: - return kSecAttrProtocolHTTPS - } - } -} - - -private extension URLComponents { - init?(keychainAttributes: [AnyHashable: Any]) { - self.init() - - if let secAttProtocol = keychainAttributes[kSecAttrProtocol as String] { - scheme = SecurityProtocol(secAttrProtocol: secAttProtocol as! CFString)?.scheme - } - - host = keychainAttributes[kSecAttrServer as String] as? String - - if let port = keychainAttributes[kSecAttrPort as String] as? NSNumber, port.intValue > 0 { - self.port = port as Int? - } - - if let path = keychainAttributes[kSecAttrPath as String] as? String { - self.path = path - } - } - - var keychainAttributes: [String: NSObject] { - var query: [String: NSObject] = [:] - - if let `protocol` = SecurityProtocol(scheme: scheme) { - query[kSecAttrProtocol as String] = `protocol`.secAttrProtocol - } - - if let host = host { - query[kSecAttrServer as String] = host as NSObject - } - - if let port = port { - query[kSecAttrPort as String] = port as NSObject - } - - if !path.isEmpty { - query[kSecAttrPath as String] = path as NSObject - } - - return query - } -} - diff --git a/Loop/Managers/Live Activity/ChartAxisGenerator.swift b/Loop/Managers/Live Activity/ChartAxisGenerator.swift new file mode 100644 index 0000000000..0fcc3ca80d --- /dev/null +++ b/Loop/Managers/Live Activity/ChartAxisGenerator.swift @@ -0,0 +1,125 @@ +// +// ChartAxisGenerator.swift +// Loop +// +// Created by Bastiaan Verhaar on 12/09/2024. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import Foundation +import HealthKit +import SwiftCharts +import UIKit + +struct ChartAxisGenerator { + private static let yAxisStepSizeMGDLOverride: Double? = FeatureFlags.predictedGlucoseChartClampEnabled ? 40 : nil + private static let range = FeatureFlags.predictedGlucoseChartClampEnabled ? LoopConstants.glucoseChartDefaultDisplayBoundClamped : LoopConstants.glucoseChartDefaultDisplayBound + private static let predictedGlucoseSoftBoundsMinimum = FeatureFlags.predictedGlucoseChartClampEnabled ? HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 40) : nil + + private static let minSegmentCount: Double = 2 + private static let addPaddingSegmentIfEdge = false + private static let axisLabelSettings = ChartLabelSettings(font: .systemFont(ofSize: 14), fontColor: UIColor.secondaryLabel) + + // This logic is copied/ported from generateYAxisValuesUsingLinearSegmentStep + static func getYAxis(points: [Double], isMmol: Bool) -> [Double] { + let unit: HKUnit = isMmol ? .millimolesPerLiter : .milligramsPerDeciliter + + let glucoseDisplayRange = [ + range.lowerBound.doubleValue(for: unit), + range.upperBound.doubleValue(for: unit) + ] + + let actualPoints = points + glucoseDisplayRange + let sortedChartPoints = actualPoints.sorted {(obj1, obj2) in + return obj1 < obj2 + } + + guard let first = sortedChartPoints.first, let lastPar = sortedChartPoints.last else { + print("Trying to generate Y axis without datapoints, returning empty array") + return [] + } + + let maxSegmentCount: Double = glucoseValueBelowSoftBoundsMinimum(first, unit) ? 5 : 4 + + guard lastPar >=~ first else {fatalError("Invalid range generating axis values")} + let multiple: Double = !isMmol ? (yAxisStepSizeMGDLOverride ?? 25) : 1 + + let last = needsToAddOne(lastPar, first) ? lastPar + 1 : lastPar + + /// The first axis value will be less than or equal to the first scalar value, aligned with the desired multiple + var firstValue = first - (first.truncatingRemainder(dividingBy: multiple)) + /// The last axis value will be greater than or equal to the last scalar value, aligned with the desired multiple + let remainder = last.truncatingRemainder(dividingBy: multiple) + var lastValue = remainder == 0 ? last : last + (multiple - remainder) + var segmentSize = multiple + + /// If there should be a padding segment added when a scalar value falls on the first or last axis value, adjust the first and last axis values + if firstValue =~ first && addPaddingSegmentIfEdge { + firstValue = firstValue - segmentSize + } + + // do not allow the first label to be displayed as -0 + while firstValue < 0 && firstValue.rounded() == -0 { + firstValue = firstValue - segmentSize + } + + if lastValue =~ last && addPaddingSegmentIfEdge { + lastValue = lastValue + segmentSize + } + + let distance = lastValue - firstValue + var currentMultiple = multiple + var segmentCount = distance / currentMultiple + var potentialSegmentValues = stride(from: firstValue, to: lastValue, by: currentMultiple) + + /// Find the optimal number of segments and segment width + /// If the number of segments is greater than desired, make each segment wider + /// ensure no label of -0 will be displayed on the axis + while segmentCount > maxSegmentCount || + !potentialSegmentValues.filter({ $0 < 0 && $0.rounded() == -0 }).isEmpty + { + currentMultiple += multiple + segmentCount = distance / currentMultiple + potentialSegmentValues = stride(from: firstValue, to: lastValue, by: currentMultiple) + } + segmentCount = ceil(segmentCount) + + /// Increase the number of segments until there are enough as desired + while segmentCount < minSegmentCount { + segmentCount += 1 + } + segmentSize = currentMultiple + + /// Generate axis values from the first value, segment size and number of segments + let offset = firstValue + return (0...Int(segmentCount)).map {segment in + var scalar = offset + (Double(segment) * segmentSize) + // a value that could be displayed as 0 should truly be 0 to have the zero-line drawn correctly. + if scalar != 0, + scalar.rounded() == 0 + { + scalar = 0 + } + return ChartAxisValueDouble(scalar, labelSettings: axisLabelSettings).scalar + } + } + + private static func needsToAddOne(_ a: Double, _ b: Double) -> Bool { + return fabs(a - b) < Double.ulpOfOne + } + + private static func glucoseValueBelowSoftBoundsMinimum(_ glucoseMinimum: Double, _ unit: HKUnit) -> Bool { + guard let predictedGlucoseSoftBoundsMinimum = predictedGlucoseSoftBoundsMinimum else + { + return false + } + + return HKQuantity(unit: unit, doubleValue: glucoseMinimum) < predictedGlucoseSoftBoundsMinimum + } +} + +fileprivate extension Double { + static func >=~ (a: Double, b: Double) -> Bool { + return a =~ b || a > b + } +} diff --git a/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift b/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift new file mode 100644 index 0000000000..173a46cb86 --- /dev/null +++ b/Loop/Managers/Live Activity/GlucoseActivityAttributes.swift @@ -0,0 +1,139 @@ +// +// LiveActivityAttributes.swift +// LoopUI +// +// Created by Bastiaan Verhaar on 23/06/2024. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import ActivityKit +import Foundation +import LoopKit +import LoopCore + +public struct GlucoseActivityAttributes: ActivityAttributes { + public struct ContentState: Codable, Hashable { + // Meta data + public let date: Date + public let ended: Bool + public let preset: Preset? + public let glucoseRanges: [GlucoseRangeValue] + + // Dynamic island data + public let currentGlucose: Double + public let eventualGlucose: Double? + public let trendType: GlucoseTrend? + public let delta: String + public let isMmol: Bool + + // Loop circle + public let isCloseLoop: Bool + public let lastCompleted: Date? + + // Bottom row + public let bottomRow: [BottomRowItem] + + // Chart view + public let glucoseSamples: [GlucoseSampleAttributes] + public let predicatedGlucose: [Double] + public let predicatedStartDate: Date? + public let predicatedInterval: TimeInterval? + public let yAxisMarks: [Double] + } + + public let mode: LiveActivityMode + public let addPredictiveLine: Bool + public let useLimits: Bool + public let upperLimitChartMmol: Double + public let lowerLimitChartMmol: Double + public let upperLimitChartMg: Double + public let lowerLimitChartMg: Double +} + +public struct Preset: Codable, Hashable { + public let title: String + public let startDate: Date + public let endDate: Date + public let minValue: Double + public let maxValue: Double +} + +public struct GlucoseRangeValue: Identifiable, Codable, Hashable { + public let id: UUID + public let minValue: Double + public let maxValue: Double + public let startDate: Date + public let endDate: Date +} + +public struct BottomRowItem: Codable, Hashable { + public enum BottomRowType: Codable, Hashable { + case generic + case basal + case currentBg + } + + public let type: BottomRowType + + // Generic properties + public let label: String + public let value: String + public let unit: String + + public let trend: GlucoseTrend? + + // Basal properties + public let rate: Double + public let percentage: Double + + private init(type: BottomRowType, label: String?, value: String?, unit: String?, trend: GlucoseTrend?, rate: Double?, percentage: Double?) { + self.type = type + self.label = label ?? "" + self.value = value ?? "" + self.trend = trend + self.unit = unit ?? "" + self.rate = rate ?? 0 + self.percentage = percentage ?? 0 + } + + static func generic(label: String, value: String, unit: String) -> BottomRowItem { + return BottomRowItem( + type: .generic, + label: label, + value: value, + unit: unit, + trend: nil, + rate: nil, + percentage: nil + ) + } + + static func basal(rate: Double, percentage: Double) -> BottomRowItem { + return BottomRowItem( + type: .basal, + label: nil, + value: nil, + unit: nil, + trend: nil, + rate: rate, + percentage: percentage + ) + } + + static func currentBg(label: String, value: String, trend: GlucoseTrend?) -> BottomRowItem { + return BottomRowItem( + type: .currentBg, + label: label, + value: value, + unit: nil, + trend: trend, + rate: nil, + percentage: nil + ) + } +} + +public struct GlucoseSampleAttributes: Codable, Hashable { + public let x: Date + public let y: Double +} diff --git a/Loop/Managers/Live Activity/LiveActivityManager.swift b/Loop/Managers/Live Activity/LiveActivityManager.swift new file mode 100644 index 0000000000..da1d4fdfa7 --- /dev/null +++ b/Loop/Managers/Live Activity/LiveActivityManager.swift @@ -0,0 +1,515 @@ +// +// LiveActivityManaer.swift +// Loop +// +// Created by Bastiaan Verhaar on 24/06/2024. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import LoopKitUI +import LoopKit +import LoopCore +import Foundation +import HealthKit +import ActivityKit + +extension Notification.Name { + static let LiveActivitySettingsChanged = Notification.Name(rawValue: "com.loopKit.notification.LiveActivitySettingsChanged") +} + +@available(iOS 16.2, *) +class LiveActivityManager : LiveActivityManagerProxy { + private let activityInfo = ActivityAuthorizationInfo() + private var activity: Activity? + private let healthStore = HKHealthStore() + + private let glucoseStore: GlucoseStoreProtocol + private let doseStore: DoseStoreProtocol + private var loopSettings: LoopSettings + + private var startDate: Date = Date.now + private var settings: LiveActivitySettings = UserDefaults.standard.liveActivity ?? LiveActivitySettings() + + private let cobFormatter: NumberFormatter = { + let numberFormatter = NumberFormatter() + numberFormatter.numberStyle = .none + return numberFormatter + }() + private let iobFormatter: NumberFormatter = { + let numberFormatter = NumberFormatter() + numberFormatter.numberStyle = .none + numberFormatter.maximumFractionDigits = 1 + numberFormatter.minimumFractionDigits = 1 + return numberFormatter + }() + private let timeFormatter: DateFormatter = { + let dateFormatter = DateFormatter() + dateFormatter.dateStyle = .none + dateFormatter.timeStyle = .short + + return dateFormatter + }() + + init?(glucoseStore: GlucoseStoreProtocol, doseStore: DoseStoreProtocol, loopSettings: LoopSettings) { + guard self.activityInfo.areActivitiesEnabled else { + print("ERROR: Live Activities are not enabled...") + return nil + } + + self.glucoseStore = glucoseStore + self.doseStore = doseStore + self.loopSettings = loopSettings + + // Ensure settings exist + if UserDefaults.standard.liveActivity == nil { + self.settings = LiveActivitySettings() + } + + let nc = NotificationCenter.default + nc.addObserver(self, selector: #selector(self.appMovedToForeground), name: UIApplication.willEnterForegroundNotification, object: nil) + nc.addObserver(self, selector: #selector(self.settingsChanged), name: .LiveActivitySettingsChanged, object: nil) + guard self.settings.enabled else { + return + } + + initEmptyActivity(settings: self.settings) + update() + + Task { + await self.endUnknownActivities() + } + } + + public func update(loopSettings: LoopSettings) { + self.loopSettings = loopSettings + update() + } + + private func update() { + Task { + if self.needsRecreation(), await UIApplication.shared.applicationState == .active { + // activity is no longer visible or old. End it and try to push the update again + await endActivity() + } + + guard let unit = await self.healthStore.cachedPreferredUnits(for: .bloodGlucose) else { + print("ERROR: No unit found...") + return + } + + let isMmol = unit == HKUnit.millimolesPerLiter + await self.endUnknownActivities() + + let statusContext = UserDefaults.appGroup?.statusExtensionContext + let glucoseFormatter = NumberFormatter.glucoseFormatter(for: unit) + + let glucoseSamples = self.getGlucoseSample(unit: unit) + guard let currentGlucose = glucoseSamples.last else { + print("ERROR: No glucose sample found...") + return + } + + let current = currentGlucose.quantity.doubleValue(for: unit) + + var delta: String = "+\(glucoseFormatter.string(from: Double(0)) ?? "")" + if glucoseSamples.count > 1 { + let prevSample = glucoseSamples[glucoseSamples.count - 2] + let deltaValue = current - (prevSample.quantity.doubleValue(for: unit)) + delta = "\(deltaValue < 0 ? "-" : "+")\(glucoseFormatter.string(from: abs(deltaValue)) ?? "??")" + } + + let bottomRow = self.getBottomRow( + currentGlucose: current, + delta: delta, + statusContext: statusContext, + glucoseFormatter: glucoseFormatter + ) + + var predicatedGlucose: [Double] = [] + if let samples = statusContext?.predictedGlucose?.values, settings.addPredictiveLine { + predicatedGlucose = samples + } + + var endDateChart: Date? = nil + if predicatedGlucose.count == 0 { + endDateChart = glucoseSamples.last?.startDate + } else if let predictedGlucose = statusContext?.predictedGlucose { + endDateChart = predictedGlucose.startDate.addingTimeInterval(.hours(4)) + } + + guard let endDateChart = endDateChart else { + return + } + + var presetContext: Preset? = nil + if let override = self.loopSettings.preMealOverride ?? self.loopSettings.scheduleOverride, let start = glucoseSamples.first?.startDate { + presetContext = Preset( + title: override.getTitle(), + startDate: max(override.startDate, start), + endDate: override.duration.isInfinite ? endDateChart : min(override.actualEndDate, endDateChart), + minValue: override.settings.targetRange?.lowerBound.doubleValue(for: unit) ?? 0, + maxValue: override.settings.targetRange?.upperBound.doubleValue(for: unit) ?? 0 + ) + } + + var glucoseRanges: [GlucoseRangeValue] = [] + if let glucoseRangeSchedule = self.loopSettings.glucoseTargetRangeSchedule, let start = glucoseSamples.first?.startDate { + glucoseRanges = getGlucoseRanges( + glucoseRangeSchedule: glucoseRangeSchedule, + presetContext: presetContext, + start: start, + end: endDateChart, + unit: unit + ) + } + + let yAxisPoints = glucoseSamples.map{ item in item.quantity.doubleValue(for: unit) } + predicatedGlucose + let chartYAxis = ChartAxisGenerator.getYAxis( + points: yAxisPoints, + isMmol: unit == HKUnit.millimolesPerLiter + ) + + let state = GlucoseActivityAttributes.ContentState( + date: currentGlucose.startDate, + ended: false, + preset: presetContext, + glucoseRanges: glucoseRanges, + currentGlucose: current, + eventualGlucose: statusContext?.predictedGlucose?.values.last, + trendType: statusContext?.glucoseDisplay?.trendType, + delta: delta, + isMmol: isMmol, + isCloseLoop: statusContext?.isClosedLoop ?? false, + lastCompleted: statusContext?.lastLoopCompleted, + bottomRow: bottomRow, + // In order to prevent maxSize errors, only allow the last 100 samples to be sent + // Will most likely not be an issue, might be an issue for debugging/CGM simulator with 5sec interval + glucoseSamples: glucoseSamples.suffix(100).map { item in + return GlucoseSampleAttributes(x: item.startDate, y: item.quantity.doubleValue(for: unit)) + }, + predicatedGlucose: predicatedGlucose, + predicatedStartDate: statusContext?.predictedGlucose?.startDate, + predicatedInterval: statusContext?.predictedGlucose?.interval, + yAxisMarks: chartYAxis + ) + + await self.activity?.update(ActivityContent( + state: state, + staleDate: Date.now.addingTimeInterval(.hours(1)) + )) + } + } + + @objc private func settingsChanged() { + Task { + let newSettings = UserDefaults.standard.liveActivity ?? LiveActivitySettings() + + // Update live activity if needed + if !newSettings.enabled, let activity = self.activity { + await activity.end(nil, dismissalPolicy: .immediate) + self.activity = nil + + return + } else if newSettings.enabled && self.activity == nil { + initEmptyActivity(settings: newSettings) + + } else if newSettings != self.settings { + await self.activity?.end(nil, dismissalPolicy: .immediate) + self.activity = nil + + initEmptyActivity(settings: newSettings) + } + + self.settings = newSettings + update() + } + } + + @objc private func appMovedToForeground() { + guard self.settings.enabled else { + return + } + + guard let activity = self.activity else { + initEmptyActivity(settings: self.settings) + update() + return + } + + Task { + await activity.end(nil, dismissalPolicy: .immediate) + await self.endUnknownActivities() + self.activity = nil + + initEmptyActivity(settings: self.settings) + update() + } + } + + private func endUnknownActivities() async { + for unknownActivity in Activity.activities + .filter({ self.activity?.id != $0.id }) + { + await unknownActivity.end(nil, dismissalPolicy: .immediate) + } + } + + private func endActivity() async { + let dynamicState = self.activity?.content.state + await self.activity?.end(nil, dismissalPolicy: .immediate) + for unknownActivity in Activity.activities { + await unknownActivity.end(nil, dismissalPolicy: .immediate) + } + + do { + if let dynamicState = dynamicState { + self.activity = try Activity.request( + attributes: GlucoseActivityAttributes( + mode: self.settings.mode, + addPredictiveLine: self.settings.addPredictiveLine, + useLimits: self.settings.useLimits, + upperLimitChartMmol: self.settings.upperLimitChartMmol, + lowerLimitChartMmol: self.settings.lowerLimitChartMmol, + upperLimitChartMg: self.settings.upperLimitChartMg, + lowerLimitChartMg: self.settings.lowerLimitChartMg + ), + content: .init(state: dynamicState, staleDate: nil), + pushType: .token + ) + } + self.startDate = Date.now + } catch { + print("ERROR: Error while ending live activity: \(error.localizedDescription)") + } + } + + private func needsRecreation() -> Bool { + if !self.settings.enabled { + return false + } + + switch activity?.activityState { + case .dismissed, + .ended, + .stale: + return true + case .active: + return -startDate.timeIntervalSinceNow > .hours(1) + default: + return true + } + } + + private func getInsulinOnBoard() -> String { + let updateGroup = DispatchGroup() + var iob = "??" + + updateGroup.enter() + self.doseStore.insulinOnBoard(at: Date.now) { result in + switch (result) { + case .failure: + break + case .success(let iobValue): + iob = self.iobFormatter.string(from: iobValue.value) ?? "??" + break + } + + updateGroup.leave() + } + + _ = updateGroup.wait(timeout: .distantFuture) + return iob + } + + private func getGlucoseSample(unit: HKUnit) -> [StoredGlucoseSample] { + let updateGroup = DispatchGroup() + var samples: [StoredGlucoseSample] = [] + + updateGroup.enter() + + // When in spacious mode, we want to show the predictive line + // In compact mode, we only want to show the history + let timeInterval: TimeInterval = self.settings.addPredictiveLine ? .hours(-2) : .hours(-6) + self.glucoseStore.getGlucoseSamples( + start: Date.now.addingTimeInterval(timeInterval), + end: Date.now + ) { result in + switch (result) { + case .failure: + break + case .success(let data): + samples = data + break + } + + updateGroup.leave() + } + + _ = updateGroup.wait(timeout: .distantFuture) + return samples + } + + private func getGlucoseRanges(glucoseRangeSchedule: GlucoseRangeSchedule, presetContext: Preset?, start: Date, end: Date, unit: HKUnit) -> [GlucoseRangeValue] { + var glucoseRanges: [GlucoseRangeValue] = [] + for item in glucoseRangeSchedule.quantityBetween(start: start, end: end) { + let minValue = item.value.lowerBound.doubleValue(for: unit) + let maxValue = item.value.upperBound.doubleValue(for: unit) + let startDate = max(item.startDate, start) + let endDate = min(item.endDate, end) + + if let presetContext = presetContext { + if presetContext.startDate > startDate, presetContext.endDate < endDate { + // A preset is active during this schedule + glucoseRanges.append(GlucoseRangeValue( + id: UUID(), + minValue: minValue, + maxValue: maxValue, + startDate: startDate, + endDate: presetContext.startDate + )) + glucoseRanges.append(GlucoseRangeValue( + id: UUID(), + minValue: minValue, + maxValue: maxValue, + startDate: presetContext.endDate, + endDate: endDate + )) + } else if presetContext.endDate > startDate, presetContext.endDate < endDate { + // Cut off the start of the glucose target + glucoseRanges.append(GlucoseRangeValue( + id: UUID(), + minValue: minValue, + maxValue: maxValue, + startDate: presetContext.endDate, + endDate: endDate + )) + } else if presetContext.startDate < endDate, presetContext.startDate > startDate { + // Cut off the end of the glucose target + glucoseRanges.append(GlucoseRangeValue( + id: UUID(), + minValue: minValue, + maxValue: maxValue, + startDate: startDate, + endDate: presetContext.startDate + )) + if presetContext.endDate == end { + break + } + } else { + // No overlap with target and override + glucoseRanges.append(GlucoseRangeValue( + id: UUID(), + minValue: minValue, + maxValue: maxValue, + startDate: startDate, + endDate: endDate + )) + } + } else { + glucoseRanges.append(GlucoseRangeValue( + id: UUID(), + minValue: minValue, + maxValue: maxValue, + startDate: startDate, + endDate: endDate + )) + } + } + + return glucoseRanges + } + + private func getBottomRow(currentGlucose: Double, delta: String, statusContext: StatusExtensionContext?, glucoseFormatter: NumberFormatter) -> [BottomRowItem] { + return self.settings.bottomRowConfiguration.map { type in + switch(type) { + case .iob: + return BottomRowItem.generic(label: type.name(), value: getInsulinOnBoard(), unit: "U") + + case .cob: + var cob: String = "0" + if let cobValue = statusContext?.carbsOnBoard { + cob = self.cobFormatter.string(from: cobValue) ?? "??" + } + return BottomRowItem.generic(label: type.name(), value: cob, unit: "g") + + case .basal: + guard let netBasalContext = statusContext?.netBasal else { + return BottomRowItem.basal(rate: 0, percentage: 0) + } + + return BottomRowItem.basal(rate: netBasalContext.rate, percentage: netBasalContext.percentage) + + case .currentBg: + return BottomRowItem.currentBg(label: type.name(), value: "\(glucoseFormatter.string(from: currentGlucose) ?? "??")", trend: statusContext?.glucoseDisplay?.trendType) + + case .eventualBg: + guard let eventual = statusContext?.predictedGlucose?.values.last else { + return BottomRowItem.generic(label: type.name(), value: "??", unit: "") + } + + return BottomRowItem.generic(label: type.name(), value: glucoseFormatter.string(from: eventual) ?? "??", unit: "") + + case .deltaBg: + return BottomRowItem.generic(label: type.name(), value: delta, unit: "") + + case .updatedAt: + return BottomRowItem.generic(label: type.name(), value: timeFormatter.string(from: Date.now), unit: "") + } + } + } + + private func initEmptyActivity(settings: LiveActivitySettings) { + do { + let dynamicState = GlucoseActivityAttributes.ContentState( + date: Date.now, + ended: true, + preset: nil, + glucoseRanges: [], + currentGlucose: 0, + eventualGlucose: nil, + trendType: nil, + delta: "", + isMmol: true, + isCloseLoop: false, + lastCompleted: nil, + bottomRow: [], + glucoseSamples: [], + predicatedGlucose: [], + predicatedStartDate: nil, + predicatedInterval: nil, + yAxisMarks: [] + ) + + self.activity = try Activity.request( + attributes: GlucoseActivityAttributes( + mode: settings.mode, + addPredictiveLine: settings.addPredictiveLine, + useLimits: settings.useLimits, + upperLimitChartMmol: settings.upperLimitChartMmol, + lowerLimitChartMmol: settings.lowerLimitChartMmol, + upperLimitChartMg: settings.upperLimitChartMg, + lowerLimitChartMg: settings.lowerLimitChartMg + ), + content: .init(state: dynamicState, staleDate: nil), + pushType: .token + ) + } catch { + print("ERROR: Error while creating empty live activity: \(error.localizedDescription)") + } + } +} + +extension TemporaryScheduleOverride { + func getTitle() -> String { + switch (self.context) { + case .preset(let preset): + return "\(preset.symbol) \(preset.name)" + case .custom: + return NSLocalizedString("Custom preset", comment: "The title of the cell indicating a generic custom preset is enabled") + case .preMeal: + return NSLocalizedString(" Pre-meal Preset", comment: "Status row title for premeal override enabled (leading space is to separate from symbol)") + case .legacyWorkout: + return "" + } + } +} diff --git a/Loop/Managers/Live Activity/LiveActivityManagerProxy.swift b/Loop/Managers/Live Activity/LiveActivityManagerProxy.swift new file mode 100644 index 0000000000..ed88c92794 --- /dev/null +++ b/Loop/Managers/Live Activity/LiveActivityManagerProxy.swift @@ -0,0 +1,13 @@ +// +// LiveActivityManagerProxy.swift +// Loop +// +// Created by Bastiaan Verhaar on 01/11/2025. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import LoopCore + +protocol LiveActivityManagerProxy { + func update(loopSettings: LoopSettings) +} diff --git a/Loop/Managers/LocalTestingScenariosManager.swift b/Loop/Managers/LocalTestingScenariosManager.swift new file mode 100644 index 0000000000..bd1e7e087a --- /dev/null +++ b/Loop/Managers/LocalTestingScenariosManager.swift @@ -0,0 +1,79 @@ +// +// LocalTestingScenariosManager.swift +// Loop +// +// Created by Michael Pangburn on 4/22/19. +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit +import LoopTestingKit +import OSLog + +final class LocalTestingScenariosManager: TestingScenariosManagerRequirements, DirectoryObserver { + + unowned let deviceManager: DeviceDataManager + unowned let supportManager: SupportManager + + let log = DiagnosticLog(category: "LocalTestingScenariosManager") + + private let fileManager = FileManager.default + private let scenariosSource: URL + private var directoryObservationToken: DirectoryObservationToken? + + private(set) var scenarioURLs: [URL] = [] + var activeScenarioURL: URL? + var activeScenario: TestingScenario? + + weak var delegate: TestingScenariosManagerDelegate? { + didSet { + delegate?.testingScenariosManager(self, didUpdateScenarioURLs: scenarioURLs) + } + } + + var pluginManager: PluginManager { + deviceManager.pluginManager + } + + init(deviceManager: DeviceDataManager, supportManager: SupportManager) { + guard FeatureFlags.scenariosEnabled else { + fatalError("\(#function) should be invoked only when scenarios are enabled") + } + + self.deviceManager = deviceManager + self.supportManager = supportManager + self.scenariosSource = Bundle.main.bundleURL.appendingPathComponent("Scenarios") + + log.debug("Loading testing scenarios from %{public}@", scenariosSource.path) + if !fileManager.fileExists(atPath: scenariosSource.path) { + do { + try fileManager.createDirectory(at: scenariosSource, withIntermediateDirectories: false) + } catch { + log.error("%{public}@", String(describing: error)) + } + } + + directoryObservationToken = observeDirectory(at: scenariosSource) { [weak self] in + self?.reloadScenarioURLs() + } + reloadScenarioURLs() + } + + func fetchScenario(from url: URL, completion: (Result) -> Void) { + let result = Result(catching: { try TestingScenario(source: url) }) + completion(result) + } + + private func reloadScenarioURLs() { + do { + let scenarioURLs = try fileManager.contentsOfDirectory(at: scenariosSource, includingPropertiesForKeys: nil) + .filter { $0.pathExtension == "json" } + self.scenarioURLs = scenarioURLs + delegate?.testingScenariosManager(self, didUpdateScenarioURLs: scenarioURLs) + log.debug("Reloaded scenario URLs") + } catch { + log.error("%{public}@", String(describing: error)) + } + } +} diff --git a/Loop/Managers/LoggingServicesManager.swift b/Loop/Managers/LoggingServicesManager.swift new file mode 100644 index 0000000000..287371aa01 --- /dev/null +++ b/Loop/Managers/LoggingServicesManager.swift @@ -0,0 +1,34 @@ +// +// LoggingServicesManager.swift +// Loop +// +// Created by Darin Krauss on 6/13/19. +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import os.log +import LoopKit + +final class LoggingServicesManager: Logging { + + private var loggingServices = [LoggingService]() + + init() {} + + func addService(_ loggingService: LoggingService) { + loggingServices.append(loggingService) + } + + func restoreService(_ loggingService: LoggingService) { + loggingServices.append(loggingService) + } + + func removeService(_ loggingService: LoggingService) { + loggingServices.removeAll { $0.pluginIdentifier == loggingService.pluginIdentifier } + } + + func log (_ message: StaticString, subsystem: String, category: String, type: OSLogType, _ args: [CVarArg]) { + loggingServices.forEach { $0.log(message, subsystem: subsystem, category: category, type: type, args) } + } + +} diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift new file mode 100644 index 0000000000..b8e23d0bba --- /dev/null +++ b/Loop/Managers/LoopAppManager.swift @@ -0,0 +1,645 @@ +// +// LoopAppManager.swift +// Loop +// +// Created by Darin Krauss on 2/16/21. +// Copyright © 2021 LoopKit Authors. All rights reserved. +// + +import UIKit +import Intents +import Combine +import LoopKit +import LoopKitUI +import MockKit +import HealthKit +import WidgetKit + +#if targetEnvironment(simulator) +enum SimulatorError: Error { + case remoteNotificationsNotAvailable +} +#endif + +public protocol AlertPresenter: AnyObject { + /// Present the alert view controller, with or without animation. + /// - Parameters: + /// - viewControllerToPresent: The alert view controller to present. + /// - animated: Animate the alert view controller presentation or not. + /// - completion: Completion to call once view controller is presented. + func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)?) + + /// Retract any alerts with the given identifier. This includes both pending and delivered alerts. + + /// Dismiss the topmost view controller, presumably the alert view controller. + /// - Parameters: + /// - animated: Animate the alert view controller dismissal or not. + /// - completion: Completion to call once view controller is dismissed. + func dismissTopMost(animated: Bool, completion: (() -> Void)?) + + /// Dismiss an alert, even if it is not the top most alert. + /// - Parameters: + /// - alertToDismiss: The alert to dismiss + /// - animated: Animate the alert view controller dismissal or not. + /// - completion: Completion to call once view controller is dismissed. + func dismissAlert(_ alertToDismiss: UIAlertController, animated: Bool, completion: (() -> Void)?) +} + +public extension AlertPresenter { + func present(_ viewController: UIViewController, animated: Bool) { present(viewController, animated: animated, completion: nil) } + func dismissTopMost(animated: Bool) { dismissTopMost(animated: animated, completion: nil) } + func dismissAlert(_ alertToDismiss: UIAlertController, animated: Bool) { dismissAlert(alertToDismiss, animated: animated, completion: nil) } +} + +protocol WindowProvider: AnyObject { + var window: UIWindow? { get } +} + +class LoopAppManager: NSObject { + private enum State: Int { + case initialize + case checkProtectedDataAvailable + case launchManagers + case launchOnboarding + case launchHomeScreen + case launchComplete + + var next: State { State(rawValue: rawValue + 1) ?? .launchComplete } + } + + private weak var windowProvider: WindowProvider? + private var launchOptions: [UIApplication.LaunchOptionsKey: Any]? + + private var pluginManager: PluginManager! + private var bluetoothStateManager: BluetoothStateManager! + private var alertManager: AlertManager! + private var trustedTimeChecker: TrustedTimeChecker! + private var deviceDataManager: DeviceDataManager! + private var onboardingManager: OnboardingManager! + private var alertPermissionsChecker: AlertPermissionsChecker! + private var supportManager: SupportManager! + private var settingsManager: SettingsManager! + private var loggingServicesManager = LoggingServicesManager() + private var analyticsServicesManager = AnalyticsServicesManager() + private(set) var testingScenariosManager: TestingScenariosManager? + private var resetLoopManager: ResetLoopManager! + private var deeplinkManager: DeeplinkManager! + + private var overrideHistory = UserDefaults.appGroup?.overrideHistory ?? TemporaryScheduleOverrideHistory.init() + + private var state: State = .initialize + + private let log = DiagnosticLog(category: "LoopAppManager") + private let widgetLog = DiagnosticLog(category: "LoopWidgets") + + private let automaticDosingStatus = AutomaticDosingStatus(automaticDosingEnabled: false, isAutomaticDosingAllowed: false) + + lazy private var cancellables = Set() + + func initialize(windowProvider: WindowProvider, launchOptions: [UIApplication.LaunchOptionsKey: Any]?) { + dispatchPrecondition(condition: .onQueue(.main)) + precondition(state == .initialize) + + self.windowProvider = windowProvider + self.launchOptions = launchOptions + + if FeatureFlags.siriEnabled && INPreferences.siriAuthorizationStatus() == .notDetermined { + INPreferences.requestSiriAuthorization { _ in } + } + + registerBackgroundTasks() + + if FeatureFlags.remoteCommandsEnabled { + DispatchQueue.main.async { +#if targetEnvironment(simulator) + self.remoteNotificationRegistrationDidFinish(.failure(SimulatorError.remoteNotificationsNotAvailable)) +#else + UIApplication.shared.registerForRemoteNotifications() +#endif + } + } + self.state = state.next + } + + func launch() { + dispatchPrecondition(condition: .onQueue(.main)) + precondition(isLaunchPending) + + resumeLaunch() + } + + var isLaunchPending: Bool { state == .checkProtectedDataAvailable } + + var isLaunchComplete: Bool { state == .launchComplete } + + private func resumeLaunch() { + if state == .checkProtectedDataAvailable { + checkProtectedDataAvailable() + } + if state == .launchManagers { + launchManagers() + } + if state == .launchOnboarding { + launchOnboarding() + } + if state == .launchHomeScreen { + launchHomeScreen() + } + + askUserToConfirmLoopReset() + } + + private func checkProtectedDataAvailable() { + dispatchPrecondition(condition: .onQueue(.main)) + precondition(state == .checkProtectedDataAvailable) + + guard isProtectedDataAvailable() else { + log.default("Protected data not available; deferring launch...") + return + } + + self.state = state.next + } + + private func launchManagers() { + dispatchPrecondition(condition: .onQueue(.main)) + precondition(state == .launchManagers) + + windowProvider?.window?.tintColor = .loopAccent + OrientationLock.deviceOrientationController = self + UNUserNotificationCenter.current().delegate = self + + resetLoopManager = ResetLoopManager(delegate: self) + + let localCacheDuration = Bundle.main.localCacheDuration + let cacheStore = PersistenceController.controllerInAppGroupDirectory() + + pluginManager = PluginManager() + + + bluetoothStateManager = BluetoothStateManager() + alertManager = AlertManager(alertPresenter: self, + userNotificationAlertScheduler: UserNotificationAlertScheduler(userNotificationCenter: UNUserNotificationCenter.current()), + expireAfter: Bundle.main.localCacheDuration, + bluetoothProvider: bluetoothStateManager, + analyticsServicesManager: analyticsServicesManager) + + alertPermissionsChecker = AlertPermissionsChecker() + alertPermissionsChecker.delegate = alertManager + + trustedTimeChecker = TrustedTimeChecker(alertManager: alertManager) + + settingsManager = SettingsManager(cacheStore: cacheStore, + expireAfter: localCacheDuration, + alertMuter: alertManager.alertMuter) + + deviceDataManager = DeviceDataManager(pluginManager: pluginManager, + alertManager: alertManager, + settingsManager: settingsManager, + loggingServicesManager: loggingServicesManager, + analyticsServicesManager: analyticsServicesManager, + bluetoothProvider: bluetoothStateManager, + alertPresenter: self, + automaticDosingStatus: automaticDosingStatus, + cacheStore: cacheStore, + localCacheDuration: localCacheDuration, + overrideHistory: overrideHistory, + trustedTimeChecker: trustedTimeChecker + ) + settingsManager.deviceStatusProvider = deviceDataManager + settingsManager.displayGlucosePreference = deviceDataManager.displayGlucosePreference + + + overrideHistory.delegate = self + + SharedLogging.instance = loggingServicesManager + + scheduleBackgroundTasks() + + supportManager = SupportManager(pluginManager: pluginManager, + deviceSupportDelegate: deviceDataManager, + servicesManager: deviceDataManager.servicesManager, + alertIssuer: alertManager) + + setWhitelistedDevices() + + onboardingManager = OnboardingManager(pluginManager: pluginManager, + bluetoothProvider: bluetoothStateManager, + deviceDataManager: deviceDataManager, + statefulPluginManager: deviceDataManager.statefulPluginManager, + servicesManager: deviceDataManager.servicesManager, + loopDataManager: deviceDataManager.loopManager, + supportManager: supportManager, + windowProvider: windowProvider, + userDefaults: UserDefaults.appGroup!) + + deeplinkManager = DeeplinkManager(rootViewController: rootViewController) + + for support in supportManager.availableSupports { + if let analyticsService = support as? AnalyticsService { + analyticsServicesManager.addService(analyticsService) + } + support.initializationComplete(for: deviceDataManager.allActivePlugins) + } + + deviceDataManager.onboardingManager = onboardingManager + + // Analytics: user properties + analyticsServicesManager.identifyAppName(Bundle.main.bundleDisplayName) + + if let workspaceGitRevision = BuildDetails.default.workspaceGitRevision { + analyticsServicesManager.identifyWorkspaceGitRevision(workspaceGitRevision) + } + + analyticsServicesManager.identify("Dosing Strategy", value: settingsManager.loopSettings.automaticDosingStrategy.analyticsValue) + let serviceNames = deviceDataManager.servicesManager.activeServices.map { $0.pluginIdentifier } + analyticsServicesManager.identify("Services", array: serviceNames) + + if FeatureFlags.scenariosEnabled { + testingScenariosManager = LocalTestingScenariosManager(deviceManager: deviceDataManager, supportManager: supportManager) + } + + analyticsServicesManager.application(didFinishLaunchingWithOptions: launchOptions) + + + automaticDosingStatus.$isAutomaticDosingAllowed + .combineLatest(deviceDataManager.loopManager.$dosingEnabled) + .map { $0 && $1 } + .assign(to: \.automaticDosingStatus.automaticDosingEnabled, on: self) + .store(in: &cancellables) + + state = state.next + } + + private func launchOnboarding() { + dispatchPrecondition(condition: .onQueue(.main)) + precondition(state == .launchOnboarding) + + onboardingManager.launch { + DispatchQueue.main.async { + self.state = self.state.next + self.resumeLaunch() + } + } + } + + private func launchHomeScreen() { + dispatchPrecondition(condition: .onQueue(.main)) + precondition(state == .launchHomeScreen) + + let storyboard = UIStoryboard(name: "Main", bundle: Bundle(for: Self.self)) + let statusTableViewController = storyboard.instantiateViewController(withIdentifier: "MainStatusViewController") as! StatusTableViewController + statusTableViewController.alertPermissionsChecker = alertPermissionsChecker + statusTableViewController.alertMuter = alertManager.alertMuter + statusTableViewController.automaticDosingStatus = automaticDosingStatus + statusTableViewController.deviceManager = deviceDataManager + statusTableViewController.onboardingManager = onboardingManager + statusTableViewController.supportManager = supportManager + statusTableViewController.testingScenariosManager = testingScenariosManager + bluetoothStateManager.addBluetoothObserver(statusTableViewController) + + var rootNavigationController = rootViewController as? RootNavigationController + if rootNavigationController == nil { + rootNavigationController = RootNavigationController() + rootViewController = rootNavigationController + } + + rootNavigationController?.setViewControllers([statusTableViewController], animated: true) + + deviceDataManager.refreshDeviceData() + + handleRemoteNotificationFromLaunchOptions() + + self.launchOptions = nil + + self.state = state.next + + alertManager.playbackAlertsFromPersistence() + } + + // MARK: - Life Cycle + + func didBecomeActive() { + if let rootViewController = rootViewController { + AppExpirationAlerter.alertIfNeeded(viewControllerToPresentFrom: rootViewController) + } + settingsManager?.didBecomeActive() + deviceDataManager?.didBecomeActive() + alertManager.inferDeliveredLoopNotRunningNotifications() + + widgetLog.default("Refreshing widget. Reason: App didBecomeActive") + WidgetCenter.shared.reloadAllTimelines() + } + + // MARK: - Remote Notification + + func remoteNotificationRegistrationDidFinish(_ result: Result) { + if case .success(let token) = result { + log.default("DeviceToken: %{public}@", token.hexadecimalString) + } + settingsManager.remoteNotificationRegistrationDidFinish(result) + } + + private func handleRemoteNotificationFromLaunchOptions() { + handleRemoteNotification(launchOptions?[.remoteNotification] as? [String: AnyObject]) + } + + @discardableResult + func handleRemoteNotification(_ notification: [String: AnyObject]?) -> Bool { + guard let notification = notification else { + return false + } + deviceDataManager?.servicesManager.handleRemoteNotification(notification) + return true + } + + // MARK: - Deeplinking + + func handle(_ url: URL) -> Bool { + deeplinkManager.handle(url) + } + + // MARK: - Continuity + + func userActivity(_ userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { + if userActivity.activityType == NewCarbEntryIntent.className { + log.default("Restoring %{public}@ intent", userActivity.activityType) + rootViewController?.restoreUserActivityState(.forNewCarbEntry()) + return true + } + + switch userActivity.activityType { + case NSUserActivity.newCarbEntryActivityType, + NSUserActivity.viewLoopStatusActivityType: + log.default("Restoring %{public}@ activity", userActivity.activityType) + if let rootViewController = rootViewController { + restorationHandler([rootViewController]) + } + return true + default: + return false + } + } + + // MARK: - Interface + + private static let defaultSupportedInterfaceOrientations = UIInterfaceOrientationMask.allButUpsideDown + + var supportedInterfaceOrientations = defaultSupportedInterfaceOrientations { + didSet { + if #available(iOS 16.0, *) { + rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations() + } else { + // Fallback on earlier versions + } + } + } + + // MARK: - Background Tasks + + private func registerBackgroundTasks() { + if DeviceDataManager.registerCriticalEventLogHistoricalExportBackgroundTask({ self.deviceDataManager?.handleCriticalEventLogHistoricalExportBackgroundTask($0) }) { + log.debug("Critical event log export background task registered") + } else { + log.error("Critical event log export background task not registered") + } + } + + private func scheduleBackgroundTasks() { + deviceDataManager?.scheduleCriticalEventLogHistoricalExportBackgroundTask() + } + + // MARK: - Private + + private func setWhitelistedDevices() { + var whitelistedCGMs: Set = [] + var whitelistedPumps: Set = [] + + supportManager.availableSupports.forEach { + $0.deviceIdentifierWhitelist.cgmDevices.forEach({ whitelistedCGMs.insert($0) }) + $0.deviceIdentifierWhitelist.pumpDevices.forEach({ whitelistedPumps.insert($0) }) + } + + deviceDataManager.deviceWhitelist = DeviceWhitelist(cgmDevices: Array(whitelistedCGMs), pumpDevices: Array(whitelistedPumps)) + } + + private func isProtectedDataAvailable() -> Bool { + let fileManager = FileManager.default + do { + let documentDirectory = try fileManager.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false) + let fileURL = documentDirectory.appendingPathComponent("protection.test") + guard fileManager.fileExists(atPath: fileURL.path) else { + let contents = Data("unimportant".utf8) + try? contents.write(to: fileURL, options: .completeFileProtectionUntilFirstUserAuthentication) + // If file doesn't exist, we're at first start, which will be user directed. + return true + } + let contents = try? Data(contentsOf: fileURL) + return contents != nil + } catch { + log.error("Could not create after first unlock test file: %@", String(describing: error)) + } + return false + } + + private var rootViewController: UIViewController? { + get { windowProvider?.window?.rootViewController } + set { windowProvider?.window?.rootViewController = newValue } + } +} + +// MARK: - AlertPresenter + +extension LoopAppManager: AlertPresenter { + func present(_ viewControllerToPresent: UIViewController, animated: Bool, completion: (() -> Void)?) { + DispatchQueue.main.async { + self.rootViewController?.topmostViewController.present(viewControllerToPresent, animated: animated, completion: completion) + } + } + + func dismissTopMost(animated: Bool, completion: (() -> Void)?) { + rootViewController?.topmostViewController.dismiss(animated: animated, completion: completion) + } + + func dismissAlert(_ alertToDismiss: UIAlertController, animated: Bool, completion: (() -> Void)?) { + if rootViewController?.topmostViewController == alertToDismiss { + dismissTopMost(animated: animated, completion: completion) + } else { + // check if the alert to dismiss is presenting another alert (and so on) + // calling dismiss() on an alert presenting another alert will only dismiss the presented alert + // (and any other alerts presented by the presented alert) + + // get the stack of presented alerts that would be undesirably dismissed + var presentedAlerts: [UIAlertController] = [] + var currentAlert = alertToDismiss + while let presentedAlert = currentAlert.presentedViewController as? UIAlertController { + presentedAlerts.append(presentedAlert) + currentAlert = presentedAlert + } + + if presentedAlerts.isEmpty { + alertToDismiss.dismiss(animated: animated, completion: completion) + } else { + // Do not animate any of these view transitions, since the alert to dismiss is not at the top of the stack + + // dismiss all the child presented alerts. + // Calling dismiss() on a VC that is presenting an other VC will dismiss the presented VC and all of its child presented VCs + alertToDismiss.dismiss(animated: false) { + // dismiss the desired alert + // Calling dismiss() on a VC that is NOT presenting any other VCs will dismiss said VC + alertToDismiss.dismiss(animated: false) { + // present the child alerts that were undesirably dismissed + var orderedPresentationBlock: (() -> Void)? = nil + for alert in presentedAlerts.reversed() { + if alert == presentedAlerts.last { + orderedPresentationBlock = { + self.present(alert, animated: false, completion: completion) + } + } else { + orderedPresentationBlock = { + self.present(alert, animated: false, completion: orderedPresentationBlock) + } + } + } + orderedPresentationBlock?() + } + } + } + } + } +} + +// MARK: - DeviceOrientationController + +extension LoopAppManager: DeviceOrientationController { + func setDefaultSupportedInferfaceOrientations() { + supportedInterfaceOrientations = Self.defaultSupportedInterfaceOrientations + } +} + +// MARK: - UNUserNotificationCenterDelegate + +extension LoopAppManager: UNUserNotificationCenterDelegate { + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + switch notification.request.identifier { + // TODO: Until these notifications are converted to use the new alert system, they shall still show in the foreground + case LoopNotificationCategory.bolusFailure.rawValue, + LoopNotificationCategory.pumpBatteryLow.rawValue, + LoopNotificationCategory.pumpExpired.rawValue, + LoopNotificationCategory.pumpFault.rawValue, + LoopNotificationCategory.remoteBolus.rawValue, + LoopNotificationCategory.remoteBolusFailure.rawValue, + LoopNotificationCategory.remoteCarbs.rawValue, + LoopNotificationCategory.remoteCarbsFailure.rawValue, + LoopNotificationCategory.missedMeal.rawValue: + completionHandler([.badge, .sound, .list, .banner]) + default: + // For all others, banners are not to be displayed while in the foreground + completionHandler([.badge, .sound, .list]) + } + } + + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { + switch response.actionIdentifier { + case NotificationManager.Action.retryBolus.rawValue: + if let units = response.notification.request.content.userInfo[LoopNotificationUserInfoKey.bolusAmount.rawValue] as? Double, + let startDate = response.notification.request.content.userInfo[LoopNotificationUserInfoKey.bolusStartDate.rawValue] as? Date, + let activationTypeRawValue = response.notification.request.content.userInfo[LoopNotificationUserInfoKey.bolusActivationType.rawValue] as? BolusActivationType.RawValue, + let activationType = BolusActivationType(rawValue: activationTypeRawValue), + startDate.timeIntervalSinceNow >= TimeInterval(minutes: -5) + { + deviceDataManager?.analyticsServicesManager.didRetryBolus() + + deviceDataManager?.enactBolus(units: units, activationType: activationType) { (_) in + DispatchQueue.main.async { + completionHandler() + } + } + return + } + case NotificationManager.Action.acknowledgeAlert.rawValue: + let userInfo = response.notification.request.content.userInfo + if let alertIdentifier = userInfo[LoopNotificationUserInfoKey.alertTypeID.rawValue] as? Alert.AlertIdentifier, + let managerIdentifier = userInfo[LoopNotificationUserInfoKey.managerIDForAlert.rawValue] as? String { + alertManager?.acknowledgeAlert(identifier: Alert.Identifier(managerIdentifier: managerIdentifier, alertIdentifier: alertIdentifier)) + } + case UNNotificationDefaultActionIdentifier: + guard response.notification.request.identifier == LoopNotificationCategory.missedMeal.rawValue else { + break + } + + let carbActivity = NSUserActivity.forNewCarbEntry() + let userInfo = response.notification.request.content.userInfo + + if + let mealTime = userInfo[LoopNotificationUserInfoKey.missedMealTime.rawValue] as? Date, + let carbAmount = userInfo[LoopNotificationUserInfoKey.missedMealCarbAmount.rawValue] as? Double + { + let missedEntry = NewCarbEntry(quantity: HKQuantity(unit: .gram(), + doubleValue: carbAmount), + startDate: mealTime, + foodType: nil, + absorptionTime: nil) + carbActivity.update(from: missedEntry, isMissedMeal: true) + } + + rootViewController?.restoreUserActivityState(carbActivity) + + default: + break + } + + completionHandler() + } + +} + + +// MARK: - UNUserNotificationCenterDelegate + +extension LoopAppManager: TemporaryScheduleOverrideHistoryDelegate { + func temporaryScheduleOverrideHistoryDidUpdate(_ history: TemporaryScheduleOverrideHistory) { + UserDefaults.appGroup?.overrideHistory = history + + deviceDataManager.remoteDataServicesManager.triggerUpload(for: .overrides) + } +} + +extension LoopAppManager: ResetLoopManagerDelegate { + func askUserToConfirmLoopReset() { + resetLoopManager.askUserToConfirmLoopReset() + } + + func presentConfirmationAlert(confirmAction: @escaping (PumpManager?, @escaping () -> Void) -> Void, cancelAction: @escaping () -> Void) { + alertManager.presentLoopResetConfirmationAlert( + confirmAction: { [weak self] completion in + confirmAction(self?.deviceDataManager.pumpManager, completion) + }, + cancelAction: cancelAction + ) + } + + func loopWillReset() { + supportManager.availableSupports.forEach { supportUI in + supportUI.loopWillReset() + } + } + + func loopDidReset() { + supportManager.availableSupports.forEach { supportUI in + supportUI.loopDidReset() + } + } + + func resetTestingData(completion: @escaping () -> Void) { + deviceDataManager.deleteTestingCGMData { [weak deviceDataManager] _ in + deviceDataManager?.deleteTestingPumpData { _ in + completion() + } + } + } + + func presentCouldNotResetLoopAlert(error: Error) { + alertManager.presentCouldNotResetLoopAlert(error: error) + } +} diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 394ebe78cb..c9aef285e8 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -7,693 +7,2198 @@ // import Foundation -import CarbKit +import Combine import HealthKit -import InsulinKit import LoopKit -import MinimedKit -import HealthKit +import LoopCore +import WidgetKit +protocol PresetActivationObserver: AnyObject { + func presetActivated(context: TemporaryScheduleOverride.Context, duration: TemporaryScheduleOverride.Duration) + func presetDeactivated(context: TemporaryScheduleOverride.Context) +} final class LoopDataManager { enum LoopUpdateContext: Int { - case bolus + case insulin case carbs case glucose case preferences - case tempBasal + case loopFinished } + let loopLock = UnfairLock() + static let LoopUpdateContextKey = "com.loudnate.Loop.LoopDataManager.LoopUpdateContext" - typealias TempBasalRecommendation = (recommendedDate: Date, rate: Double, duration: TimeInterval) + private let carbStore: CarbStoreProtocol + + private let mealDetectionManager: MealDetectionManager - private typealias GlucoseChange = (GlucoseValue, GlucoseValue) + private let doseStore: DoseStoreProtocol - unowned let deviceDataManager: DeviceDataManager + let dosingDecisionStore: DosingDecisionStoreProtocol - var dosingEnabled: Bool { - didSet { - UserDefaults.standard.dosingEnabled = dosingEnabled + private let glucoseStore: GlucoseStoreProtocol - notify(forChange: .preferences) - } - } + let latestStoredSettingsProvider: LatestStoredSettingsProvider - var retrospectiveCorrectionEnabled: Bool { - didSet { - UserDefaults.standard.retrospectiveCorrectionEnabled = retrospectiveCorrectionEnabled + weak var delegate: LoopDataManagerDelegate? + + private let logger = DiagnosticLog(category: "LoopDataManager") + private let widgetLog = DiagnosticLog(category: "LoopWidgets") + + private let analyticsServicesManager: AnalyticsServicesManager + + private let trustedTimeOffset: () -> TimeInterval + + private let now: () -> Date + + private let automaticDosingStatus: AutomaticDosingStatus + + lazy private var cancellables = Set() + + // References to registered notification center observers + private var notificationObservers: [Any] = [] + + private var overrideIntentObserver: NSKeyValueObservation? = nil + + var presetActivationObservers: [PresetActivationObserver] = [] + + private var timeBasedDoseApplicationFactor: Double = 1.0 - notify(forChange: .preferences) + private var insulinOnBoard: InsulinValue? + + private var liveActivityManager: LiveActivityManagerProxy? + + deinit { + for observer in notificationObservers { + NotificationCenter.default.removeObserver(observer) } } - init(deviceDataManager: DeviceDataManager) { - self.deviceDataManager = deviceDataManager + init( + lastLoopCompleted: Date?, + basalDeliveryState: PumpManagerStatus.BasalDeliveryState?, + settings: LoopSettings, + overrideHistory: TemporaryScheduleOverrideHistory, + analyticsServicesManager: AnalyticsServicesManager, + localCacheDuration: TimeInterval = .days(1), + doseStore: DoseStoreProtocol, + glucoseStore: GlucoseStoreProtocol, + carbStore: CarbStoreProtocol, + dosingDecisionStore: DosingDecisionStoreProtocol, + latestStoredSettingsProvider: LatestStoredSettingsProvider, + now: @escaping () -> Date = { Date() }, + pumpInsulinType: InsulinType?, + automaticDosingStatus: AutomaticDosingStatus, + trustedTimeOffset: @escaping () -> TimeInterval + ) { + self.analyticsServicesManager = analyticsServicesManager + self.lockedLastLoopCompleted = Locked(lastLoopCompleted) + self.lockedBasalDeliveryState = Locked(basalDeliveryState) + self.lockedSettings = Locked(settings) + self.dosingEnabled = settings.dosingEnabled + + self.overrideHistory = overrideHistory + + let absorptionTimes = LoopCoreConstants.defaultCarbAbsorptionTimes + + self.overrideHistory.relevantTimeWindow = absorptionTimes.slow * 2 + + self.carbStore = carbStore + self.doseStore = doseStore + self.glucoseStore = glucoseStore + + self.dosingDecisionStore = dosingDecisionStore + + self.now = now + + self.latestStoredSettingsProvider = latestStoredSettingsProvider + self.mealDetectionManager = MealDetectionManager( + carbRatioScheduleApplyingOverrideHistory: carbStore.carbRatioScheduleApplyingOverrideHistory, + insulinSensitivityScheduleApplyingOverrideHistory: carbStore.insulinSensitivityScheduleApplyingOverrideHistory, + maximumBolus: settings.maximumBolus + ) + + self.lockedPumpInsulinType = Locked(pumpInsulinType) + + self.automaticDosingStatus = automaticDosingStatus + + self.trustedTimeOffset = trustedTimeOffset + + if #available(iOS 16.2, *) { + self.liveActivityManager = LiveActivityManager( + glucoseStore: self.glucoseStore, + doseStore: self.doseStore, + loopSettings: self.settings + ) + } - dosingEnabled = UserDefaults.standard.dosingEnabled - retrospectiveCorrectionEnabled = UserDefaults.standard.retrospectiveCorrectionEnabled + overrideIntentObserver = UserDefaults.appGroup?.observe(\.intentExtensionOverrideToSet, options: [.new], changeHandler: {[weak self] (defaults, change) in + guard let name = change.newValue??.lowercased(), let appGroup = UserDefaults.appGroup else { + return + } - // Observe changes - let center = NotificationCenter.default + guard let preset = self?.settings.overridePresets.first(where: {$0.name.lowercased() == name}) else { + self?.logger.error("Override Intent: Unable to find override named '%s'", String(describing: name)) + return + } + + self?.logger.default("Override Intent: setting override named '%s'", String(describing: name)) + self?.mutateSettings { settings in + if let oldPreset = settings.scheduleOverride { + if let observers = self?.presetActivationObservers { + for observer in observers { + observer.presetDeactivated(context: oldPreset.context) + } + } + } + + settings.scheduleOverride = preset.createOverride(enactTrigger: .remote("Siri")) + if let observers = self?.presetActivationObservers { + for observer in observers { + observer.presetActivated(context: .preset(preset), duration: preset.duration) + } + } + self?.liveActivityManager?.update(loopSettings: settings) + } + // Remove the override from UserDefaults so we don't set it multiple times + appGroup.intentExtensionOverrideToSet = nil + }) + // Required for device settings in stored dosing decisions + UIDevice.current.isBatteryMonitoringEnabled = true + + // Observe changes notificationObservers = [ - center.addObserver(forName: .GlucoseUpdated, object: deviceDataManager, queue: nil) { (note) -> Void in + NotificationCenter.default.addObserver( + forName: CarbStore.carbEntriesDidChange, + object: self.carbStore, + queue: nil + ) { (note) -> Void in self.dataAccessQueue.async { - self.glucoseMomentumEffect = nil - self.glucoseChange = nil - self.notify(forChange: .glucose) + self.logger.default("Received notification of carb entries changing") + self.liveActivityManager?.update(loopSettings: self.settings) + + self.carbEffect = nil + self.carbsOnBoard = nil + self.recentCarbEntries = nil + self.remoteRecommendationNeedsUpdating = true + self.notify(forChange: .carbs) } }, - center.addObserver(forName: .PumpStatusUpdated, object: deviceDataManager, queue: nil) { (note) -> Void in + NotificationCenter.default.addObserver( + forName: GlucoseStore.glucoseSamplesDidChange, + object: self.glucoseStore, + queue: nil + ) { (note) in self.dataAccessQueue.async { - // Assuming insulin data is never back-dated, we don't need to remove the retrospective glucose effects - self.insulinEffect = nil - self.insulinOnBoard = nil - self.loop() + self.logger.default("Received notification of glucose samples changing") + self.liveActivityManager?.update(loopSettings: self.settings) + + self.glucoseMomentumEffect = nil + self.remoteRecommendationNeedsUpdating = true + + self.notify(forChange: .glucose) } }, - center.addObserver(forName: .CarbEntriesDidUpdate, object: nil, queue: nil) { (note) -> Void in + NotificationCenter.default.addObserver( + forName: nil, + object: self.doseStore, + queue: OperationQueue.main + ) { (note) in self.dataAccessQueue.async { - self.carbEffect = nil - self.carbsOnBoardSeries = nil - self.notify(forChange: .carbs) + self.logger.default("Received notification of dosing changing") + self.liveActivityManager?.update(loopSettings: self.settings) + + self.clearCachedInsulinEffects() + self.remoteRecommendationNeedsUpdating = true + + self.notify(forChange: .insulin) } } ] - } - // Actions - - private func loop() { - NotificationCenter.default.post(name: .LoopRunning, object: self) - - lastLoopError = nil + // Turn off preMeal when going into closed loop off mode + // Cancel any active temp basal when going into closed loop off mode + // The dispatch is necessary in case this is coming from a didSet already on the settings struct. + self.automaticDosingStatus.$automaticDosingEnabled + .removeDuplicates() + .dropFirst() + .receive(on: DispatchQueue.main) + .sink { if !$0 { + self.mutateSettings { settings in + settings.clearOverride(matching: .preMeal) + } + self.cancelActiveTempBasal(for: .automaticDosingDisabled) + } } + .store(in: &cancellables) + } - do { - try self.update() + /// Loop-related settings - if dosingEnabled { + private var lockedSettings: Locked - setRecommendedTempBasal { (success, error) -> Void in - self.lastLoopError = error + var settings: LoopSettings { + lockedSettings.value + } - if let error = error { - self.deviceDataManager.logger.addError(error, fromSource: "TempBasal") - } else { - self.lastLoopCompleted = Date() - } - self.notify(forChange: .tempBasal) - } + func mutateSettings(_ changes: (_ settings: inout LoopSettings) -> Void) { + var oldValue: LoopSettings! + let newValue = lockedSettings.mutate { settings in + oldValue = settings + changes(&settings) + } - // Delay the notification until we know the result of the temp basal - return - } else { - lastLoopCompleted = Date() - } - } catch let error { - lastLoopError = error + guard oldValue != newValue else { + return } - notify(forChange: .tempBasal) - } + var invalidateCachedEffects = false - // References to registered notification center observers - private var notificationObservers: [Any] = [] + dosingEnabled = newValue.dosingEnabled - deinit { - for observer in notificationObservers { - NotificationCenter.default.removeObserver(observer) + if newValue.preMealOverride != oldValue.preMealOverride { + // The prediction isn't actually invalid, but a target range change requires recomputing recommended doses + predictedGlucose = nil + + self.liveActivityManager?.update(loopSettings: newValue) } - } - private func update() throws { - let updateGroup = DispatchGroup() + if newValue.scheduleOverride != oldValue.scheduleOverride { + overrideHistory.recordOverride(settings.scheduleOverride) - if glucoseChange == nil, let glucoseStore = deviceDataManager.glucoseStore { - updateGroup.enter() - glucoseStore.getRecentGlucoseChange { (values, error) in - if let error = error { - self.deviceDataManager.logger.addError(error, fromSource: "GlucoseStore") + if let oldPreset = oldValue.scheduleOverride { + for observer in self.presetActivationObservers { + observer.presetDeactivated(context: oldPreset.context) } - - self.glucoseChange = values - updateGroup.leave() + self.liveActivityManager?.update(loopSettings: newValue) } - } - - if glucoseMomentumEffect == nil { - updateGroup.enter() - updateGlucoseMomentumEffect { (effects, error) in - if error == nil { - self.glucoseMomentumEffect = effects - } else { - self.glucoseMomentumEffect = nil + if let newPreset = newValue.scheduleOverride { + for observer in self.presetActivationObservers { + observer.presetActivated(context: newPreset.context, duration: newPreset.duration) } - updateGroup.leave() + + self.liveActivityManager?.update(loopSettings: newValue) } + + // Invalidate cached effects affected by the override + invalidateCachedEffects = true + + // Update the affected schedules + mealDetectionManager.carbRatioScheduleApplyingOverrideHistory = carbRatioScheduleApplyingOverrideHistory + mealDetectionManager.insulinSensitivityScheduleApplyingOverrideHistory = insulinSensitivityScheduleApplyingOverrideHistory } - if carbEffect == nil { - updateGroup.enter() - updateCarbEffect { (effects, error) in - if error == nil { - self.carbEffect = effects - } else { - self.carbEffect = nil - } - updateGroup.leave() - } + if newValue.insulinSensitivitySchedule != oldValue.insulinSensitivitySchedule { + carbStore.insulinSensitivitySchedule = newValue.insulinSensitivitySchedule + doseStore.insulinSensitivitySchedule = newValue.insulinSensitivitySchedule + mealDetectionManager.insulinSensitivityScheduleApplyingOverrideHistory = insulinSensitivityScheduleApplyingOverrideHistory + invalidateCachedEffects = true + analyticsServicesManager.didChangeInsulinSensitivitySchedule() } - if carbsOnBoardSeries == nil, let carbStore = deviceDataManager.carbStore { - updateGroup.enter() - carbStore.getCarbsOnBoardValues { (values, error) in - if let error = error { - self.deviceDataManager.logger.addError(error, fromSource: "CarbStore") - } + if newValue.basalRateSchedule != oldValue.basalRateSchedule { + doseStore.basalProfile = newValue.basalRateSchedule - self.carbsOnBoardSeries = values - updateGroup.leave() + if let newValue = newValue.basalRateSchedule, let oldValue = oldValue.basalRateSchedule, newValue.items != oldValue.items { + analyticsServicesManager.didChangeBasalRateSchedule() } } - if insulinEffect == nil { - updateGroup.enter() - updateInsulinEffect { (effects, error) in - if error == nil { - self.insulinEffect = effects - } else { - self.insulinEffect = nil - } - updateGroup.leave() - } + if newValue.carbRatioSchedule != oldValue.carbRatioSchedule { + carbStore.carbRatioSchedule = newValue.carbRatioSchedule + mealDetectionManager.carbRatioScheduleApplyingOverrideHistory = carbRatioScheduleApplyingOverrideHistory + invalidateCachedEffects = true + analyticsServicesManager.didChangeCarbRatioSchedule() } - if insulinOnBoard == nil { - updateGroup.enter() - deviceDataManager.doseStore.insulinOnBoardAtDate(Date()) { (value, error) in - if let error = error { - self.deviceDataManager.logger.addError(error, fromSource: "DoseStore") - } - - self.insulinOnBoard = value - updateGroup.leave() + if newValue.defaultRapidActingModel != oldValue.defaultRapidActingModel { + if FeatureFlags.adultChildInsulinModelSelectionEnabled { + doseStore.insulinModelProvider = PresetInsulinModelProvider(defaultRapidActingModel: newValue.defaultRapidActingModel) + } else { + doseStore.insulinModelProvider = PresetInsulinModelProvider(defaultRapidActingModel: nil) } + invalidateCachedEffects = true + analyticsServicesManager.didChangeInsulinModel() } - _ = updateGroup.wait(timeout: DispatchTime.distantFuture) - - if self.retrospectivePredictedGlucose == nil { - do { - try self.updateRetrospectiveGlucoseEffect() - } catch let error { - self.deviceDataManager.logger.addError(error, fromSource: "RetrospectiveGlucose") - } + if newValue.maximumBolus != oldValue.maximumBolus { + mealDetectionManager.maximumBolus = newValue.maximumBolus } - if self.predictedGlucose == nil { - do { - try self.updatePredictedGlucoseAndRecommendedBasal() - } catch let error { - self.deviceDataManager.logger.addError(error, fromSource: "PredictGlucose") - - throw error + if invalidateCachedEffects { + dataAccessQueue.async { + // Invalidate cached effects based on this schedule + self.carbEffect = nil + self.carbsOnBoard = nil + self.clearCachedInsulinEffects() } } - } - - private func notify(forChange context: LoopUpdateContext) { - NotificationCenter.default.post(name: .LoopDataUpdated, - object: self, - userInfo: [type(of: self).LoopUpdateContextKey: context.rawValue] - ) - } - - /** - Retrieves the current state of the loop, calculating - - This operation is performed asynchronously and the completion will be executed on an arbitrary background queue. - - - parameter resultsHandler: A closure called once the values have been retrieved. The closure takes the following arguments: - - predictedGlucose: The calculated timeline of predicted glucose values - - retrospectivePredictedGlucose: The retrospective prediction over a recent period of glucose samples - - recommendedTempBasal: The recommended temp basal based on predicted glucose - - lastTempBasal: The last set temp basal - - lastLoopCompleted: The last date at which a loop completed, from prediction to dose (if dosing is enabled) - - insulinOnBoard Current insulin on board - - carbsOnBoard Current carbs on board - - error: An error in the current state of the loop, or one that happened during the last attempt to loop. - */ - func getLoopStatus(_ resultsHandler: @escaping (_ predictedGlucose: [GlucoseValue]?, _ retrospectivePredictedGlucose: [GlucoseValue]?, _ recommendedTempBasal: TempBasalRecommendation?, _ lastTempBasal: DoseEntry?, _ lastLoopCompleted: Date?, _ insulinOnBoard: InsulinValue?, _ carbsOnBoard: CarbValue?, _ error: Error?) -> Void) { - dataAccessQueue.async { - var error: Error? - - do { - try self.update() - } catch let updateError { - error = updateError - } - - let currentCOB = self.carbsOnBoardSeries?.closestPriorToDate(Date()) - resultsHandler(self.predictedGlucose, self.retrospectivePredictedGlucose, self.recommendedTempBasal, self.lastTempBasal, self.lastLoopCompleted, self.insulinOnBoard, currentCOB, error ?? self.lastLoopError) - } + notify(forChange: .preferences) + analyticsServicesManager.didChangeLoopSettings(from: oldValue, to: newValue) } - func modelPredictedGlucose(using inputs: [PredictionInputEffect], resultsHandler: @escaping (_ predictedGlucose: [GlucoseValue]?, _ error: Error?) -> Void) { - dataAccessQueue.async { - guard let - glucose = self.deviceDataManager.glucoseStore?.latestGlucose - else { - resultsHandler(nil, LoopError.missingDataError("Cannot predict glucose due to missing input data")) - return - } - - var momentum: [GlucoseEffect] = [] - var effects: [[GlucoseEffect]] = [] - - for input in inputs { - switch input { - case .carbs: - if let carbEffect = self.carbEffect { - effects.append(carbEffect) - } - case .insulin: - if let insulinEffect = self.insulinEffect { - effects.append(insulinEffect) - } - case .momentum: - if let momentumEffect = self.glucoseMomentumEffect { - momentum = momentumEffect - } - case .retrospection: - effects.append(self.retrospectiveGlucoseEffect) - } - } - - let prediction = LoopMath.predictGlucose(glucose, momentum: momentum, effects: effects) + @Published private(set) var dosingEnabled: Bool - resultsHandler(prediction, nil) - } - } + let overrideHistory: TemporaryScheduleOverrideHistory - // Calculation + // MARK: - Calculation state - private let dataAccessQueue: DispatchQueue = DispatchQueue(label: "com.loudnate.Naterade.LoopDataManager.dataAccessQueue", qos: .utility) + fileprivate let dataAccessQueue: DispatchQueue = DispatchQueue(label: "com.loudnate.Naterade.LoopDataManager.dataAccessQueue", qos: .utility) private var carbEffect: [GlucoseEffect]? { didSet { predictedGlucose = nil // Carb data may be back-dated, so re-calculate the retrospective glucose. - retrospectivePredictedGlucose = nil + retrospectiveGlucoseDiscrepancies = nil } } - private var carbsOnBoardSeries: [CarbValue]? - private var insulinEffect: [GlucoseEffect]? { - didSet { - if let bolusDate = lastBolus?.date, bolusDate.timeIntervalSinceNow < TimeInterval(minutes: -5) { - lastBolus = nil - } - predictedGlucose = nil + private var insulinEffect: [GlucoseEffect]? + + private var insulinEffectIncludingPendingInsulin: [GlucoseEffect]? { + didSet { + predictedGlucoseIncludingPendingInsulin = nil } } - private var insulinOnBoard: InsulinValue? + private var glucoseMomentumEffect: [GlucoseEffect]? { didSet { predictedGlucose = nil } } - private var glucoseChange: GlucoseChange? { - didSet { - retrospectivePredictedGlucose = nil - } - } - private var predictedGlucose: [GlucoseValue]? { - didSet { - recommendedTempBasal = nil - } - } - private var retrospectivePredictedGlucose: [GlucoseValue]? { - didSet { - retrospectiveGlucoseEffect = [] - } - } + private var retrospectiveGlucoseEffect: [GlucoseEffect] = [] { didSet { predictedGlucose = nil } } - private var recommendedTempBasal: TempBasalRecommendation? - private var lastTempBasal: DoseEntry? - private var lastBolus: (units: Double, date: Date)? - private var lastLoopError: Error? { + /// When combining retrospective glucose discrepancies, extend the window slightly as a buffer. + private let retrospectiveCorrectionGroupingIntervalMultiplier = 1.01 + + private var retrospectiveGlucoseDiscrepancies: [GlucoseEffect]? { didSet { - if lastLoopError != nil { - AnalyticsManager.sharedManager.loopDidError() - } + retrospectiveGlucoseDiscrepanciesSummed = retrospectiveGlucoseDiscrepancies?.combinedSums(of: LoopMath.retrospectiveCorrectionGroupingInterval * retrospectiveCorrectionGroupingIntervalMultiplier) } } - private var lastLoopCompleted: Date? { - didSet { - NotificationManager.scheduleLoopNotRunningNotifications() - AnalyticsManager.sharedManager.loopDidSucceed() + private var retrospectiveGlucoseDiscrepanciesSummed: [GlucoseChange]? + + private var suspendInsulinDeliveryEffect: [GlucoseEffect] = [] + + fileprivate var predictedGlucose: [PredictedGlucoseValue]? { + didSet { + recommendedAutomaticDose = nil + predictedGlucoseIncludingPendingInsulin = nil } } - /// The oldest date that should be used for effect calculation - private var effectStartDate: Date? { - let startDate: Date? + fileprivate var predictedGlucoseIncludingPendingInsulin: [PredictedGlucoseValue]? - if let glucoseStore = deviceDataManager.glucoseStore { - // Fetch glucose effects as far back as we want to make retroactive analysis - startDate = glucoseStore.latestGlucose?.startDate.addingTimeInterval(-glucoseStore.reflectionDataInterval) - } else { - startDate = nil - } + private var recentCarbEntries: [StoredCarbEntry]? + + fileprivate var recommendedAutomaticDose: (recommendation: AutomaticDoseRecommendation, date: Date)? - return startDate + fileprivate var carbsOnBoard: CarbValue? + + var basalDeliveryState: PumpManagerStatus.BasalDeliveryState? { + get { + return lockedBasalDeliveryState.value + } + set { + self.logger.debug("Updating basalDeliveryState to %{public}@", String(describing: newValue)) + lockedBasalDeliveryState.value = newValue + } } + private let lockedBasalDeliveryState: Locked - var lastNetBasal: NetBasal? { + var pumpInsulinType: InsulinType? { get { - guard - let scheduledBasal = deviceDataManager.basalRateSchedule?.between(start: Date(), end: Date()).first - else { - return nil - } - - return NetBasal(lastTempBasal: lastTempBasal, - maxBasal: deviceDataManager.maximumBasalRatePerHour, - scheduledBasal: scheduledBasal) + return lockedPumpInsulinType.value + } + set { + lockedPumpInsulinType.value = newValue } } + private let lockedPumpInsulinType: Locked - private func updateCarbEffect(_ completionHandler: @escaping (_ effects: [GlucoseEffect]?, _ error: Error?) -> Void) { - if let carbStore = deviceDataManager.carbStore { - carbStore.getGlucoseEffects(startDate: effectStartDate) { (effects, error) -> Void in - if let error = error { - self.deviceDataManager.logger.addError(error, fromSource: "CarbStore") - } + fileprivate var lastRequestedBolus: DoseEntry? - completionHandler(effects, error) - } - } else { - completionHandler(nil, LoopError.missingDataError("CarbStore not available")) + /// The last date at which a loop completed, from prediction to dose (if dosing is enabled) + var lastLoopCompleted: Date? { + get { + return lockedLastLoopCompleted.value + } + set { + lockedLastLoopCompleted.value = newValue } } + private let lockedLastLoopCompleted: Locked - private func updateInsulinEffect(_ completionHandler: @escaping (_ effects: [GlucoseEffect]?, _ error: Error?) -> Void) { - deviceDataManager.doseStore.getGlucoseEffects(startDate: effectStartDate) { (effects, error) -> Void in - if let error = error { - self.deviceDataManager.logger.addError(error, fromSource: "DoseStore") - } + fileprivate var lastLoopError: LoopError? - completionHandler(effects, error) + /// A timeline of average velocity of glucose change counteracting predicted insulin effects + fileprivate var insulinCounteractionEffects: [GlucoseEffectVelocity] = [] { + didSet { + carbEffect = nil + carbsOnBoard = nil } } - private func updateGlucoseMomentumEffect(_ completionHandler: @escaping (_ effects: [GlucoseEffect]?, _ error: Error?) -> Void) { - guard let glucoseStore = deviceDataManager.glucoseStore else { - completionHandler(nil, LoopError.missingDataError("GlucoseStore not available")) - return - } - glucoseStore.getRecentMomentumEffect { (effects, error) -> Void in - if let error = error { - self.deviceDataManager.logger.addError(error, fromSource: "GlucoseStore") + // Confined to dataAccessQueue + private var lastIntegralRetrospectiveCorrectionEnabled: Bool? + private var cachedRetrospectiveCorrection: RetrospectiveCorrection? + + var retrospectiveCorrection: RetrospectiveCorrection { + let currentIntegralRetrospectiveCorrectionEnabled = UserDefaults.standard.integralRetrospectiveCorrectionEnabled + + if lastIntegralRetrospectiveCorrectionEnabled != currentIntegralRetrospectiveCorrectionEnabled || cachedRetrospectiveCorrection == nil { + lastIntegralRetrospectiveCorrectionEnabled = currentIntegralRetrospectiveCorrectionEnabled + if currentIntegralRetrospectiveCorrectionEnabled { + cachedRetrospectiveCorrection = IntegralRetrospectiveCorrection(effectDuration: LoopMath.retrospectiveCorrectionEffectDuration) + } else { + cachedRetrospectiveCorrection = StandardRetrospectiveCorrection(effectDuration: LoopMath.retrospectiveCorrectionEffectDuration) } - - completionHandler(effects, error) } + + return cachedRetrospectiveCorrection! } - - /** - Runs the glucose retrospective analysis using the latest effect data. - - *This method should only be called from the `dataAccessQueue`* - */ - private func updateRetrospectiveGlucoseEffect() throws { - guard - let carbEffect = self.carbEffect, - let insulinEffect = self.insulinEffect - else { - self.retrospectivePredictedGlucose = nil - throw LoopError.missingDataError("Cannot retrospect glucose due to missing input data") - } - - guard let change = glucoseChange else { - self.retrospectivePredictedGlucose = nil - return // Expected case for calibrations - } - - // Run a retrospective prediction over the duration of the recorded glucose change, using the current carb and insulin effects - let startDate = change.0.startDate - let endDate = change.1.endDate.addingTimeInterval(TimeInterval(minutes: 5)) - let retrospectivePrediction = LoopMath.predictGlucose(change.0, effects: - carbEffect.filterDateRange(startDate, endDate), - insulinEffect.filterDateRange(startDate, endDate) - ) - - self.retrospectivePredictedGlucose = retrospectivePrediction - - guard let lastGlucose = retrospectivePrediction.last else { return } - let glucoseUnit = HKUnit.milligramsPerDeciliterUnit() - let velocityUnit = glucoseUnit.unitDivided(by: HKUnit.second()) - - let discrepancy = change.1.quantity.doubleValue(for: glucoseUnit) - lastGlucose.quantity.doubleValue(for: glucoseUnit) // mg/dL - let velocity = HKQuantity(unit: velocityUnit, doubleValue: discrepancy / change.1.endDate.timeIntervalSince(change.0.endDate)) - let type = HKQuantityType.quantityType(forIdentifier: HKQuantityTypeIdentifier.bloodGlucose)! - let glucose = HKQuantitySample(type: type, quantity: change.1.quantity, start: change.1.startDate, end: change.1.endDate) - - self.retrospectiveGlucoseEffect = LoopMath.decayEffect(from: glucose, atRate: velocity, for: TimeInterval(minutes: 60)) + + func clearCachedInsulinEffects() { + insulinEffect = nil + insulinEffectIncludingPendingInsulin = nil + predictedGlucose = nil } - /** - Runs the glucose prediction on the latest effect data. - - *This method should only be called from the `dataAccessQueue`* - */ - private func updatePredictedGlucoseAndRecommendedBasal() throws { - guard let - glucose = self.deviceDataManager.glucoseStore?.latestGlucose, - let pumpStatusDate = self.deviceDataManager.doseStore.lastReservoirValue?.startDate - else { - self.predictedGlucose = nil - throw LoopError.missingDataError("Cannot predict glucose due to missing input data") - } + // MARK: - Background task management - let startDate = Date() - let recencyInterval = TimeInterval(minutes: 15) + private var backgroundTask: UIBackgroundTaskIdentifier = .invalid - guard startDate.timeIntervalSince(glucose.startDate) <= recencyInterval && - startDate.timeIntervalSince(pumpStatusDate) <= recencyInterval - else { - self.predictedGlucose = nil - throw LoopError.staleDataError("Glucose Date: \(glucose.startDate) or Pump status date: \(pumpStatusDate) older than \(recencyInterval.minutes) min") + private func startBackgroundTask() { + endBackgroundTask() + backgroundTask = UIApplication.shared.beginBackgroundTask(withName: "PersistenceController save") { + self.endBackgroundTask() } + } - guard let - momentum = self.glucoseMomentumEffect, - let carbEffect = self.carbEffect, - let insulinEffect = self.insulinEffect else - { - self.predictedGlucose = nil - throw LoopError.missingDataError("Cannot predict glucose due to missing effect data") + private func endBackgroundTask() { + if backgroundTask != .invalid { + UIApplication.shared.endBackgroundTask(backgroundTask) + backgroundTask = .invalid } + } - var error: Error? - - let prediction = LoopMath.predictGlucose(glucose, momentum: momentum, effects: carbEffect, insulinEffect) - let predictionWithRetrospectiveEffect = LoopMath.predictGlucose(glucose, momentum: momentum, effects: carbEffect, insulinEffect, retrospectiveGlucoseEffect) - - let predictDiff: Double - - let unit = HKUnit.milligramsPerDeciliterUnit() - if let lastA = prediction.last?.quantity.doubleValue(for: unit), - let lastB = predictionWithRetrospectiveEffect.last?.quantity.doubleValue(for: unit) - { - predictDiff = lastB - lastA - } else { - predictDiff = 0 - } - - defer { - deviceDataManager.logger.addLoopStatus( - startDate: startDate, - endDate: Date(), - glucose: glucose, - effects: [ - "momentum": momentum, - "carbs": carbEffect, - "insulin": insulinEffect, - "retrospective_glucose": retrospectiveGlucoseEffect - ], - error: error, - prediction: prediction, - predictionWithRetrospectiveEffect: predictDiff, - recommendedTempBasal: recommendedTempBasal - ) - } + private func loopDidComplete(date: Date, dosingDecision: StoredDosingDecision, duration: TimeInterval) { + logger.default("Loop completed successfully.") + lastLoopCompleted = date + analyticsServicesManager.loopDidSucceed(duration) + dosingDecisionStore.storeDosingDecision(dosingDecision) {} - self.predictedGlucose = retrospectiveCorrectionEnabled ? predictionWithRetrospectiveEffect : prediction + NotificationCenter.default.post(name: .LoopCompleted, object: self) + } - guard let - maxBasal = deviceDataManager.maximumBasalRatePerHour, - let glucoseTargetRange = deviceDataManager.glucoseTargetRangeSchedule, - let insulinSensitivity = deviceDataManager.insulinSensitivitySchedule, - let basalRates = deviceDataManager.basalRateSchedule - else { - error = LoopError.missingDataError("Loop configuration data not set") - throw error! - } + private func loopDidError(date: Date, error: LoopError, dosingDecision: StoredDosingDecision, duration: TimeInterval) { + logger.error("Loop did error: %{public}@", String(describing: error)) + lastLoopError = error + analyticsServicesManager.loopDidError(error: error) + var dosingDecisionWithError = dosingDecision + dosingDecisionWithError.appendError(error) + dosingDecisionStore.storeDosingDecision(dosingDecisionWithError) {} + } - guard - lastBolus == nil, // Don't recommend changes if a bolus was just set - let predictedGlucose = self.predictedGlucose, - let tempBasal = DoseMath.recommendTempBasalFromPredictedGlucose(predictedGlucose, - lastTempBasal: lastTempBasal, - maxBasalRate: maxBasal, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivity, - basalRateSchedule: basalRates - ) - else { - recommendedTempBasal = nil - return - } + // This is primarily for remote clients displaying a bolus recommendation and forecast + // Should be called after any significant change to forecast input data. - recommendedTempBasal = (recommendedDate: Date(), rate: tempBasal.rate, duration: tempBasal.duration) - } - func addCarbEntryAndRecommendBolus(_ carbEntry: CarbEntry, resultsHandler: @escaping (_ units: Double?, _ error: Error?) -> Void) { - if let carbStore = deviceDataManager.carbStore { - carbStore.addCarbEntry(carbEntry) { (success, _, error) in - self.dataAccessQueue.async { - if success { - self.carbEffect = nil - self.carbsOnBoardSeries = nil + var remoteRecommendationNeedsUpdating: Bool = false - do { - try self.update() + func updateRemoteRecommendation() { + dataAccessQueue.async { + if self.remoteRecommendationNeedsUpdating { + var (dosingDecision, updateError) = self.update(for: .updateRemoteRecommendation) - resultsHandler(try self.recommendBolus(), nil) - } catch let error { - resultsHandler(nil, error) + if let error = updateError { + self.logger.error("Error updating manual bolus recommendation: %{public}@", String(describing: error)) + } else { + do { + if let predictedGlucoseIncludingPendingInsulin = self.predictedGlucoseIncludingPendingInsulin, + let manualBolusRecommendation = try self.recommendManualBolus(forPrediction: predictedGlucoseIncludingPendingInsulin, consideringPotentialCarbEntry: nil) + { + dosingDecision.manualBolusRecommendation = ManualBolusRecommendationWithDate(recommendation: manualBolusRecommendation, date: Date()) + self.logger.debug("Manual bolus rec = %{public}@", String(describing: dosingDecision.manualBolusRecommendation)) + self.dosingDecisionStore.storeDosingDecision(dosingDecision) {} } - } else { - resultsHandler(nil, error) + } catch { + self.logger.error("Error updating manual bolus recommendation: %{public}@", String(describing: error)) } } + self.remoteRecommendationNeedsUpdating = false } - } else { - resultsHandler(nil, LoopError.missingDataError("CarbStore not configured")) } } +} - private func recommendBolus() throws -> Double { - guard let - glucose = self.predictedGlucose, - let maxBolus = self.deviceDataManager.maximumBolus, - let glucoseTargetRange = self.deviceDataManager.glucoseTargetRangeSchedule, - let insulinSensitivity = self.deviceDataManager.insulinSensitivitySchedule, - let basalRates = self.deviceDataManager.basalRateSchedule - else { - throw LoopError.missingDataError("Bolus prediction and configuration data not found") - } +// MARK: Background task management +extension LoopDataManager: PersistenceControllerDelegate { + func persistenceControllerWillSave(_ controller: PersistenceController) { + startBackgroundTask() + } - let recencyInterval = TimeInterval(minutes: 15) + func persistenceControllerDidSave(_ controller: PersistenceController, error: PersistenceController.PersistenceControllerError?) { + endBackgroundTask() + } +} - guard let predictedInterval = glucose.first?.startDate.timeIntervalSinceNow else { - throw LoopError.missingDataError("No glucose data found") - } +// MARK: - Preferences +extension LoopDataManager { - guard abs(predictedInterval) <= recencyInterval else { - throw LoopError.staleDataError("Glucose is \(predictedInterval.minutes) min old") - } + /// The basal rate schedule, applying recent overrides relative to the current moment in time. + var basalRateScheduleApplyingOverrideHistory: BasalRateSchedule? { + return doseStore.basalProfileApplyingOverrideHistory + } - let pendingBolusAmount: Double = lastBolus?.units ?? 0 + /// The carb ratio schedule, applying recent overrides relative to the current moment in time. + var carbRatioScheduleApplyingOverrideHistory: CarbRatioSchedule? { + return carbStore.carbRatioScheduleApplyingOverrideHistory + } - return max(0, DoseMath.recommendBolusFromPredictedGlucose(glucose, - lastTempBasal: self.lastTempBasal, - maxBolus: maxBolus, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivity, - basalRateSchedule: basalRates - ) - pendingBolusAmount) + /// The insulin sensitivity schedule, applying recent overrides relative to the current moment in time. + var insulinSensitivityScheduleApplyingOverrideHistory: InsulinSensitivitySchedule? { + return carbStore.insulinSensitivityScheduleApplyingOverrideHistory } - func getRecommendedBolus(_ resultsHandler: @escaping (_ units: Double?, _ error: Error?) -> Void) { - dataAccessQueue.async { - do { - let units = try self.recommendBolus() - resultsHandler(units, nil) + /// Sets a new time zone for a the schedule-based settings + /// + /// - Parameter timeZone: The time zone + func setScheduleTimeZone(_ timeZone: TimeZone) { + self.mutateSettings { settings in + settings.basalRateSchedule?.timeZone = timeZone + settings.carbRatioSchedule?.timeZone = timeZone + settings.insulinSensitivitySchedule?.timeZone = timeZone + settings.glucoseTargetRangeSchedule?.timeZone = timeZone + } + } +} + + +// MARK: - Intake +extension LoopDataManager { + /// Adds and stores glucose samples + /// + /// - Parameters: + /// - samples: The new glucose samples to store + /// - completion: A closure called once upon completion + /// - result: The stored glucose values + func addGlucoseSamples( + _ samples: [NewGlucoseSample], + completion: ((_ result: Swift.Result<[StoredGlucoseSample], Error>) -> Void)? = nil + ) { + glucoseStore.addGlucoseSamples(samples) { (result) in + self.dataAccessQueue.async { + switch result { + case .success(let samples): + if let endDate = samples.sorted(by: { $0.startDate < $1.startDate }).first?.startDate { + // Prune back any counteraction effects for recomputation + self.insulinCounteractionEffects = self.insulinCounteractionEffects.filter { $0.endDate < endDate } + } + + completion?(.success(samples)) + case .failure(let error): + completion?(.failure(error)) + } + } + } + } + + /// Take actions to address how insulin is delivered when the CGM data is unreliable + /// + /// An active high temp basal (greater than the basal schedule) is cancelled when the CGM data is unreliable. + func receivedUnreliableCGMReading() { + guard case .tempBasal(let tempBasal) = basalDeliveryState, + let scheduledBasalRate = settings.basalRateSchedule?.value(at: now()), + tempBasal.unitsPerHour > scheduledBasalRate else + { + return + } + + // Cancel active high temp basal + cancelActiveTempBasal(for: .unreliableCGMData) + } + + private enum CancelActiveTempBasalReason: String { + case automaticDosingDisabled + case unreliableCGMData + case maximumBasalRateChanged + } + + /// Cancel the active temp basal if it was automatically issued + private func cancelActiveTempBasal(for reason: CancelActiveTempBasalReason) { + guard case .tempBasal(let dose) = basalDeliveryState, (dose.automatic ?? true) else { return } + + dataAccessQueue.async { + self.cancelActiveTempBasal(for: reason, completion: nil) + } + } + + private func cancelActiveTempBasal(for reason: CancelActiveTempBasalReason, completion: ((Error?) -> Void)?) { + dispatchPrecondition(condition: .onQueue(dataAccessQueue)) + + let recommendation = AutomaticDoseRecommendation(basalAdjustment: .cancel) + recommendedAutomaticDose = (recommendation: recommendation, date: now()) + + var dosingDecision = StoredDosingDecision(reason: reason.rawValue) + dosingDecision.settings = StoredDosingDecision.Settings(latestStoredSettingsProvider.latestSettings) + dosingDecision.controllerStatus = UIDevice.current.controllerStatus + dosingDecision.automaticDoseRecommendation = recommendation + + let error = enactRecommendedAutomaticDose() + + dosingDecision.pumpManagerStatus = delegate?.pumpManagerStatus + dosingDecision.cgmManagerStatus = delegate?.cgmManagerStatus + dosingDecision.lastReservoirValue = StoredDosingDecision.LastReservoirValue(doseStore.lastReservoirValue) + + if let error = error { + dosingDecision.appendError(error) + } + self.dosingDecisionStore.storeDosingDecision(dosingDecision) {} + + // Didn't actually run a loop, but this is similar to a loop() in that the automatic dosing + // was updated. + self.notify(forChange: .loopFinished) + completion?(error) + } + + + /// Adds and stores carb data, and recommends a bolus if needed + /// + /// - Parameters: + /// - carbEntry: The new carb value + /// - completion: A closure called once upon completion + /// - result: The bolus recommendation + func addCarbEntry(_ carbEntry: NewCarbEntry, replacing replacingEntry: StoredCarbEntry? = nil, completion: @escaping (_ result: Result) -> Void) { + let addCompletion: (CarbStoreResult) -> Void = { (result) in + self.dataAccessQueue.async { + switch result { + case .success(let storedCarbEntry): + // Remove the active pre-meal target override + self.mutateSettings { settings in + settings.clearOverride(matching: .preMeal) + } + + self.carbEffect = nil + self.carbsOnBoard = nil + completion(.success(storedCarbEntry)) + case .failure(let error): + completion(.failure(error)) + } + } + } + + if let replacingEntry = replacingEntry { + carbStore.replaceCarbEntry(replacingEntry, withEntry: carbEntry, completion: addCompletion) + } else { + carbStore.addCarbEntry(carbEntry, completion: addCompletion) + } + } + + func deleteCarbEntry(_ oldEntry: StoredCarbEntry, completion: @escaping (_ result: CarbStoreResult) -> Void) { + carbStore.deleteCarbEntry(oldEntry) { result in + completion(result) + } + } + + + /// Adds a bolus requested of the pump, but not confirmed. + /// + /// - Parameters: + /// - dose: The DoseEntry representing the requested bolus + /// - completion: A closure that is called after state has been updated + func addRequestedBolus(_ dose: DoseEntry, completion: (() -> Void)?) { + dataAccessQueue.async { + self.logger.debug("addRequestedBolus") + self.lastRequestedBolus = dose + self.notify(forChange: .insulin) + + completion?() + } + } + + /// Notifies the manager that the bolus is confirmed, but not fully delivered. + /// + /// - Parameters: + /// - completion: A closure that is called after state has been updated + func bolusConfirmed(completion: (() -> Void)?) { + self.dataAccessQueue.async { + self.logger.debug("bolusConfirmed") + self.lastRequestedBolus = nil + self.recommendedAutomaticDose = nil + self.clearCachedInsulinEffects() + self.notify(forChange: .insulin) + + completion?() + } + } + + /// Notifies the manager that the bolus failed. + /// + /// - Parameters: + /// - error: An error describing why the bolus request failed + /// - completion: A closure that is called after state has been updated + func bolusRequestFailed(_ error: Error, completion: (() -> Void)?) { + self.dataAccessQueue.async { + self.logger.debug("bolusRequestFailed") + self.lastRequestedBolus = nil + self.clearCachedInsulinEffects() + self.notify(forChange: .insulin) + + completion?() + } + } + + /// Logs a new external bolus insulin dose in the DoseStore and HealthKit + /// + /// - Parameters: + /// - startDate: The date the dose was started at. + /// - value: The number of Units in the dose. + /// - insulinModel: The type of insulin model that should be used for the dose. + func addManuallyEnteredDose(startDate: Date, units: Double, insulinType: InsulinType? = nil) { + let syncIdentifier = Data(UUID().uuidString.utf8).hexadecimalString + let dose = DoseEntry(type: .bolus, startDate: startDate, value: units, unit: .units, syncIdentifier: syncIdentifier, insulinType: insulinType, manuallyEntered: true) + + doseStore.addDoses([dose], from: nil) { (error) in + if error == nil { + self.recommendedAutomaticDose = nil + self.clearCachedInsulinEffects() + self.notify(forChange: .insulin) + } + } + } + + /// Adds and stores a pump reservoir volume + /// + /// - Parameters: + /// - units: The reservoir volume, in units + /// - date: The date of the volume reading + /// - completion: A closure called once upon completion + /// - result: The current state of the reservoir values: + /// - newValue: The new stored value + /// - lastValue: The previous new stored value + /// - areStoredValuesContinuous: Whether the current recent state of the stored reservoir data is considered continuous and reliable for deriving insulin effects after addition of this new value. + func addReservoirValue(_ units: Double, at date: Date, completion: @escaping (_ result: Result<(newValue: ReservoirValue, lastValue: ReservoirValue?, areStoredValuesContinuous: Bool)>) -> Void) { + doseStore.addReservoirValue(units, at: date) { (newValue, previousValue, areStoredValuesContinuous, error) in + if let error = error { + completion(.failure(error)) + } else if let newValue = newValue { + self.dataAccessQueue.async { + self.clearCachedInsulinEffects() + + if let newDoseStartDate = previousValue?.startDate { + // Prune back any counteraction effects for recomputation, after the effect delay + self.insulinCounteractionEffects = self.insulinCounteractionEffects.filterDateRange(nil, newDoseStartDate.addingTimeInterval(.minutes(10))) + } + + completion(.success(( + newValue: newValue, + lastValue: previousValue, + areStoredValuesContinuous: areStoredValuesContinuous + ))) + } + } else { + assertionFailure() + } + } + } + + func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) { + let dosingDecision = StoredDosingDecision(date: date, + reason: bolusDosingDecision.reason.rawValue, + settings: StoredDosingDecision.Settings(latestStoredSettingsProvider.latestSettings), + scheduleOverride: bolusDosingDecision.scheduleOverride, + controllerStatus: UIDevice.current.controllerStatus, + pumpManagerStatus: delegate?.pumpManagerStatus, + cgmManagerStatus: delegate?.cgmManagerStatus, + lastReservoirValue: StoredDosingDecision.LastReservoirValue(doseStore.lastReservoirValue), + historicalGlucose: bolusDosingDecision.historicalGlucose, + originalCarbEntry: bolusDosingDecision.originalCarbEntry, + carbEntry: bolusDosingDecision.carbEntry, + manualGlucoseSample: bolusDosingDecision.manualGlucoseSample, + carbsOnBoard: bolusDosingDecision.carbsOnBoard, + insulinOnBoard: bolusDosingDecision.insulinOnBoard, + glucoseTargetRangeSchedule: bolusDosingDecision.glucoseTargetRangeSchedule, + predictedGlucose: bolusDosingDecision.predictedGlucose, + manualBolusRecommendation: bolusDosingDecision.manualBolusRecommendation, + manualBolusRequested: bolusDosingDecision.manualBolusRequested) + dosingDecisionStore.storeDosingDecision(dosingDecision) {} + } + + // Actions + + /// Runs the "loop" + /// + /// Executes an analysis of the current data, and recommends an adjustment to the current + /// temporary basal rate. + /// + func loop() { + + if let lastLoopCompleted, Date().timeIntervalSince(lastLoopCompleted) < .minutes(2) { + print("Looping too fast!") + } + + let available = loopLock.withLockIfAvailable { + loopInternal() + return true + } + if available == nil { + print("Loop attempted while already looping!") + } + } + + func loopInternal() { + + dataAccessQueue.async { + + // If time was changed to future time, and a loop completed, then time was fixed, lastLoopCompleted will prevent looping + // until the future loop time passes. Fix that here. + if let lastLoopCompleted = self.lastLoopCompleted, Date() < lastLoopCompleted, self.trustedTimeOffset() == 0 { + self.logger.error("Detected future lastLoopCompleted. Restoring.") + self.lastLoopCompleted = Date() + } + + // Partial application factor assumes 5 minute intervals. If our looping intervals are shorter, then this will be adjusted + self.timeBasedDoseApplicationFactor = 1.0 + if let lastLoopCompleted = self.lastLoopCompleted { + let timeSinceLastLoop = max(0, Date().timeIntervalSince(lastLoopCompleted)) + self.timeBasedDoseApplicationFactor = min(1, timeSinceLastLoop/TimeInterval.minutes(5)) + self.logger.default("Looping with timeBasedDoseApplicationFactor = %{public}@", String(describing: self.timeBasedDoseApplicationFactor)) + } + + self.logger.default("Loop running") + NotificationCenter.default.post(name: .LoopRunning, object: self) + + self.lastLoopError = nil + let startDate = self.now() + + var (dosingDecision, error) = self.update(for: .loop) + + if error == nil, self.automaticDosingStatus.automaticDosingEnabled == true { + error = self.enactRecommendedAutomaticDose() + } else { + self.logger.default("Not adjusting dosing during open loop.") + } + + self.finishLoop(startDate: startDate, dosingDecision: dosingDecision, error: error) + } + } + + private func finishLoop(startDate: Date, dosingDecision: StoredDosingDecision, error: LoopError? = nil) { + let date = now() + let duration = date.timeIntervalSince(startDate) + + if let error = error { + loopDidError(date: date, error: error, dosingDecision: dosingDecision, duration: duration) + } else { + loopDidComplete(date: date, dosingDecision: dosingDecision, duration: duration) + } + + logger.default("Loop ended") + notify(forChange: .loopFinished) + + if FeatureFlags.missedMealNotifications { + let samplesStart = now().addingTimeInterval(-MissedMealSettings.maxRecency) + carbStore.getGlucoseEffects(start: samplesStart, end: now(), effectVelocities: insulinCounteractionEffects) {[weak self] result in + guard + let self = self, + case .success((_, let carbEffects)) = result + else { + if case .failure(let error) = result { + self?.logger.error("Failed to fetch glucose effects to check for missed meal: %{public}@", String(describing: error)) + } + return + } + + glucoseStore.getGlucoseSamples(start: samplesStart, end: now()) {[weak self] result in + guard + let self = self, + case .success(let glucoseSamples) = result + else { + if case .failure(let error) = result { + self?.logger.error("Failed to fetch glucose samples to check for missed meal: %{public}@", String(describing: error)) + } + return + } + + self.mealDetectionManager.generateMissedMealNotificationIfNeeded( + glucoseSamples: glucoseSamples, + insulinCounteractionEffects: self.insulinCounteractionEffects, + carbEffects: carbEffects, + pendingAutobolusUnits: self.recommendedAutomaticDose?.recommendation.bolusUnits, + bolusDurationEstimator: { [unowned self] bolusAmount in + return self.delegate?.loopDataManager(self, estimateBolusDuration: bolusAmount) + } + ) + } + } + } + + // 5 second delay to allow stores to cache data before it is read by widget + DispatchQueue.main.asyncAfter(deadline: .now() + 5) { + self.widgetLog.default("Refreshing widget. Reason: Loop completed") + WidgetCenter.shared.reloadAllTimelines() + } + + updateRemoteRecommendation() + } + + fileprivate enum UpdateReason: String { + case loop + case getLoopState + case updateRemoteRecommendation + } + + fileprivate func update(for reason: UpdateReason) -> (StoredDosingDecision, LoopError?) { + dispatchPrecondition(condition: .onQueue(dataAccessQueue)) + + var dosingDecision = StoredDosingDecision(reason: reason.rawValue) + let latestSettings = latestStoredSettingsProvider.latestSettings + dosingDecision.settings = StoredDosingDecision.Settings(latestSettings) + dosingDecision.scheduleOverride = latestSettings.scheduleOverride + dosingDecision.controllerStatus = UIDevice.current.controllerStatus + dosingDecision.pumpManagerStatus = delegate?.pumpManagerStatus + if let pumpStatusHighlight = delegate?.pumpStatusHighlight { + dosingDecision.pumpStatusHighlight = StoredDosingDecision.StoredDeviceHighlight( + localizedMessage: pumpStatusHighlight.localizedMessage, + imageName: pumpStatusHighlight.imageName, + state: pumpStatusHighlight.state) + } + dosingDecision.cgmManagerStatus = delegate?.cgmManagerStatus + dosingDecision.lastReservoirValue = StoredDosingDecision.LastReservoirValue(doseStore.lastReservoirValue) + + let warnings = Locked<[LoopWarning]>([]) + + let updateGroup = DispatchGroup() + + let historicalGlucoseStartDate = Date(timeInterval: -LoopCoreConstants.dosingDecisionHistoricalGlucoseInterval, since: now()) + let inputDataRecencyStartDate = Date(timeInterval: -LoopCoreConstants.inputDataRecencyInterval, since: now()) + + // Fetch glucose effects as far back as we want to make retroactive analysis and historical glucose for dosing decision + var historicalGlucose: [HistoricalGlucoseValue]? + var latestGlucoseDate: Date? + updateGroup.enter() + glucoseStore.getGlucoseSamples(start: min(historicalGlucoseStartDate, inputDataRecencyStartDate), end: nil) { (result) in + switch result { + case .failure(let error): + self.logger.error("Failure getting glucose samples: %{public}@", String(describing: error)) + latestGlucoseDate = nil + warnings.append(.fetchDataWarning(.glucoseSamples(error: error))) + case .success(let samples): + historicalGlucose = samples.filter { $0.startDate >= historicalGlucoseStartDate }.map { HistoricalGlucoseValue(startDate: $0.startDate, quantity: $0.quantity) } + latestGlucoseDate = samples.last?.startDate + } + updateGroup.leave() + } + _ = updateGroup.wait(timeout: .distantFuture) + + guard let lastGlucoseDate = latestGlucoseDate else { + dosingDecision.appendWarnings(warnings.value) + dosingDecision.appendError(.missingDataError(.glucose)) + return (dosingDecision, .missingDataError(.glucose)) + } + + let retrospectiveStart = lastGlucoseDate.addingTimeInterval(-type(of: retrospectiveCorrection).retrospectionInterval) + + let earliestEffectDate = Date(timeInterval: .hours(-24), since: now()) + let nextCounteractionEffectDate = insulinCounteractionEffects.last?.endDate ?? earliestEffectDate + let insulinEffectStartDate = nextCounteractionEffectDate.addingTimeInterval(.minutes(-5)) + + if glucoseMomentumEffect == nil { + updateGroup.enter() + glucoseStore.getRecentMomentumEffect(for: now()) { (result) -> Void in + switch result { + case .failure(let error): + self.logger.error("Failure getting recent momentum effect: %{public}@", String(describing: error)) + self.glucoseMomentumEffect = nil + warnings.append(.fetchDataWarning(.glucoseMomentumEffect(error: error))) + case .success(let effects): + self.glucoseMomentumEffect = effects + } + updateGroup.leave() + } + } + + if insulinEffect == nil || insulinEffect?.first?.startDate ?? .distantFuture > insulinEffectStartDate { + self.logger.debug("Recomputing insulin effects") + updateGroup.enter() + doseStore.getGlucoseEffects(start: insulinEffectStartDate, end: nil, basalDosingEnd: now()) { (result) -> Void in + switch result { + case .failure(let error): + self.logger.error("Could not fetch insulin effects: %{public}@", error.localizedDescription) + self.insulinEffect = nil + warnings.append(.fetchDataWarning(.insulinEffect(error: error))) + case .success(let effects): + self.insulinEffect = effects + } + + updateGroup.leave() + } + } + + if insulinEffectIncludingPendingInsulin == nil { + updateGroup.enter() + doseStore.getGlucoseEffects(start: insulinEffectStartDate, end: nil, basalDosingEnd: nil) { (result) -> Void in + switch result { + case .failure(let error): + self.logger.error("Could not fetch insulin effects including pending insulin: %{public}@", error.localizedDescription) + self.insulinEffectIncludingPendingInsulin = nil + warnings.append(.fetchDataWarning(.insulinEffectIncludingPendingInsulin(error: error))) + case .success(let effects): + self.insulinEffectIncludingPendingInsulin = effects + } + + updateGroup.leave() + } + } + + _ = updateGroup.wait(timeout: .distantFuture) + + if nextCounteractionEffectDate < lastGlucoseDate, let insulinEffect = insulinEffect { + updateGroup.enter() + self.logger.debug("Fetching counteraction effects after %{public}@", String(describing: nextCounteractionEffectDate)) + glucoseStore.getCounteractionEffects(start: nextCounteractionEffectDate, end: nil, to: insulinEffect) { (result) in + switch result { + case .failure(let error): + self.logger.error("Failure getting counteraction effects: %{public}@", String(describing: error)) + warnings.append(.fetchDataWarning(.insulinCounteractionEffect(error: error))) + case .success(let velocities): + self.insulinCounteractionEffects.append(contentsOf: velocities) + } + self.insulinCounteractionEffects = self.insulinCounteractionEffects.filterDateRange(earliestEffectDate, nil) + + updateGroup.leave() + } + + _ = updateGroup.wait(timeout: .distantFuture) + } + + if carbEffect == nil { + updateGroup.enter() + carbStore.getGlucoseEffects( + start: retrospectiveStart, end: nil, + effectVelocities: insulinCounteractionEffects + ) { (result) -> Void in + switch result { + case .failure(let error): + self.logger.error("%{public}@", String(describing: error)) + self.carbEffect = nil + self.recentCarbEntries = nil + warnings.append(.fetchDataWarning(.carbEffect(error: error))) + case .success(let (entries, effects)): + self.carbEffect = effects + self.recentCarbEntries = entries + } + + updateGroup.leave() + } + } + + if carbsOnBoard == nil { + updateGroup.enter() + carbStore.carbsOnBoard(at: now(), effectVelocities: insulinCounteractionEffects) { (result) in + switch result { + case .failure(let error): + switch error { + case .noData: + // when there is no data, carbs on board is set to 0 + self.carbsOnBoard = CarbValue(startDate: Date(), value: 0) + default: + self.carbsOnBoard = nil + warnings.append(.fetchDataWarning(.carbsOnBoard(error: error))) + } + case .success(let value): + self.carbsOnBoard = value + } + updateGroup.leave() + } + } + updateGroup.enter() + doseStore.insulinOnBoard(at: now()) { result in + switch result { + case .failure(let error): + warnings.append(.fetchDataWarning(.insulinOnBoard(error: error))) + case .success(let insulinValue): + self.insulinOnBoard = insulinValue + } + updateGroup.leave() + } + + _ = updateGroup.wait(timeout: .distantFuture) + + if retrospectiveGlucoseDiscrepancies == nil { + do { + try updateRetrospectiveGlucoseEffect() } catch let error { - resultsHandler(nil, error) + logger.error("%{public}@", String(describing: error)) + warnings.append(.fetchDataWarning(.retrospectiveGlucoseEffect(error: error))) + } + } + + do { + try updateSuspendInsulinDeliveryEffect() + } catch let error { + logger.error("%{public}@", String(describing: error)) + } + + dosingDecision.appendWarnings(warnings.value) + + dosingDecision.date = now() + dosingDecision.historicalGlucose = historicalGlucose + dosingDecision.carbsOnBoard = carbsOnBoard + dosingDecision.insulinOnBoard = self.insulinOnBoard + dosingDecision.glucoseTargetRangeSchedule = settings.effectiveGlucoseTargetRangeSchedule() + + // These will be updated by updatePredictedGlucoseAndRecommendedDose, if possible + dosingDecision.predictedGlucose = predictedGlucoseIncludingPendingInsulin + dosingDecision.automaticDoseRecommendation = recommendedAutomaticDose?.recommendation + + // If the glucose prediction hasn't changed, then nothing has changed, so just use pre-existing recommendations + guard predictedGlucose == nil else { + + // If we still have a bolus in progress, then warn (unlikely, but possible if device comms fail) + if lastRequestedBolus != nil, dosingDecision.automaticDoseRecommendation == nil, dosingDecision.manualBolusRecommendation == nil { + dosingDecision.appendWarning(.bolusInProgress) + } + + return (dosingDecision, nil) + } + + return updatePredictedGlucoseAndRecommendedDose(with: dosingDecision) + } + + private func notify(forChange context: LoopUpdateContext) { + NotificationCenter.default.post(name: .LoopDataUpdated, + object: self, + userInfo: [ + type(of: self).LoopUpdateContextKey: context.rawValue + ] + ) + } + + /// Computes amount of insulin from boluses that have been issued and not confirmed, and + /// remaining insulin delivery from temporary basal rate adjustments above scheduled rate + /// that are still in progress. + /// + /// - Returns: The amount of pending insulin, in units + /// - Throws: LoopError.configurationError + private func getPendingInsulin() throws -> Double { + dispatchPrecondition(condition: .onQueue(dataAccessQueue)) + + guard let basalRates = basalRateScheduleApplyingOverrideHistory else { + throw LoopError.configurationError(.basalRateSchedule) + } + + let pendingTempBasalInsulin: Double + let date = now() + + if let basalDeliveryState = basalDeliveryState, case .tempBasal(let lastTempBasal) = basalDeliveryState, lastTempBasal.endDate > date { + let normalBasalRate = basalRates.value(at: date) + let remainingTime = lastTempBasal.endDate.timeIntervalSince(date) + let remainingUnits = (lastTempBasal.unitsPerHour - normalBasalRate) * remainingTime.hours + + pendingTempBasalInsulin = max(0, remainingUnits) + } else { + pendingTempBasalInsulin = 0 + } + + let pendingBolusAmount: Double = lastRequestedBolus?.programmedUnits ?? 0 + + // All outstanding potential insulin delivery + return pendingTempBasalInsulin + pendingBolusAmount + } + + /// - Throws: + /// - LoopError.missingDataError + /// - LoopError.configurationError + /// - LoopError.glucoseTooOld + /// - LoopError.invalidFutureGlucose + /// - LoopError.pumpDataTooOld + fileprivate func predictGlucose( + startingAt startingGlucoseOverride: GlucoseValue? = nil, + using inputs: PredictionInputEffect, + historicalInsulinEffect insulinEffectOverride: [GlucoseEffect]? = nil, + insulinCounteractionEffects insulinCounteractionEffectsOverride: [GlucoseEffectVelocity]? = nil, + historicalCarbEffect carbEffectOverride: [GlucoseEffect]? = nil, + potentialBolus: DoseEntry? = nil, + potentialCarbEntry: NewCarbEntry? = nil, + replacingCarbEntry replacedCarbEntry: StoredCarbEntry? = nil, + includingPendingInsulin: Bool = false, + includingPositiveVelocityAndRC: Bool = true + ) throws -> [PredictedGlucoseValue] { + dispatchPrecondition(condition: .onQueue(dataAccessQueue)) + + guard let glucose = startingGlucoseOverride ?? self.glucoseStore.latestGlucose else { + throw LoopError.missingDataError(.glucose) + } + + let pumpStatusDate = doseStore.lastAddedPumpData + let lastGlucoseDate = glucose.startDate + + guard now().timeIntervalSince(lastGlucoseDate) <= LoopCoreConstants.inputDataRecencyInterval else { + throw LoopError.glucoseTooOld(date: glucose.startDate) + } + + guard lastGlucoseDate.timeIntervalSince(now()) <= LoopCoreConstants.futureGlucoseDataInterval else { + throw LoopError.invalidFutureGlucose(date: lastGlucoseDate) + } + + guard now().timeIntervalSince(pumpStatusDate) <= LoopCoreConstants.inputDataRecencyInterval else { + throw LoopError.pumpDataTooOld(date: pumpStatusDate) + } + + var momentum: [GlucoseEffect] = [] + var retrospectiveGlucoseEffect = self.retrospectiveGlucoseEffect + var effects: [[GlucoseEffect]] = [] + + let insulinCounteractionEffects = insulinCounteractionEffectsOverride ?? self.insulinCounteractionEffects + if inputs.contains(.carbs) { + if let potentialCarbEntry = potentialCarbEntry { + let retrospectiveStart = lastGlucoseDate.addingTimeInterval(-type(of: retrospectiveCorrection).retrospectionInterval) + + if potentialCarbEntry.startDate > lastGlucoseDate || recentCarbEntries?.isEmpty != false, replacedCarbEntry == nil { + // The potential carb effect is independent and can be summed with the existing effect + if let carbEffect = carbEffectOverride ?? self.carbEffect { + effects.append(carbEffect) + } + + let potentialCarbEffect = try carbStore.glucoseEffects( + of: [potentialCarbEntry], + startingAt: retrospectiveStart, + endingAt: nil, + effectVelocities: insulinCounteractionEffects + ) + + effects.append(potentialCarbEffect) + } else { + var recentEntries = self.recentCarbEntries ?? [] + if let replacedCarbEntry = replacedCarbEntry, let index = recentEntries.firstIndex(of: replacedCarbEntry) { + recentEntries.remove(at: index) + } + + // If the entry is in the past or an entry is replaced, DCA and RC effects must be recomputed + var entries = recentEntries.map { NewCarbEntry(quantity: $0.quantity, startDate: $0.startDate, foodType: nil, absorptionTime: $0.absorptionTime) } + entries.append(potentialCarbEntry) + entries.sort(by: { $0.startDate > $1.startDate }) + + let potentialCarbEffect = try carbStore.glucoseEffects( + of: entries, + startingAt: retrospectiveStart, + endingAt: nil, + effectVelocities: insulinCounteractionEffects + ) + + effects.append(potentialCarbEffect) + + retrospectiveGlucoseEffect = computeRetrospectiveGlucoseEffect(startingAt: glucose, carbEffects: potentialCarbEffect) + } + } else if let carbEffect = carbEffectOverride ?? self.carbEffect { + effects.append(carbEffect) + } + } + + if inputs.contains(.insulin) { + let computationInsulinEffect: [GlucoseEffect]? + if insulinEffectOverride != nil { + computationInsulinEffect = insulinEffectOverride + } else { + computationInsulinEffect = includingPendingInsulin ? self.insulinEffectIncludingPendingInsulin : self.insulinEffect + } + + if let insulinEffect = computationInsulinEffect { + effects.append(insulinEffect) + } + + if let potentialBolus = potentialBolus { + guard let sensitivity = insulinSensitivityScheduleApplyingOverrideHistory else { + throw LoopError.configurationError(.insulinSensitivitySchedule) + } + + let earliestEffectDate = Date(timeInterval: .hours(-24), since: now()) + let nextEffectDate = insulinCounteractionEffects.last?.endDate ?? earliestEffectDate + let bolusEffect = [potentialBolus] + .glucoseEffects(insulinModelProvider: doseStore.insulinModelProvider, longestEffectDuration: doseStore.longestEffectDuration, insulinSensitivity: sensitivity) + .filterDateRange(nextEffectDate, nil) + effects.append(bolusEffect) + } + } + + if inputs.contains(.momentum), let momentumEffect = self.glucoseMomentumEffect { + if !includingPositiveVelocityAndRC, let netMomentum = momentumEffect.netEffect(), netMomentum.quantity.doubleValue(for: .milligramsPerDeciliter) > 0 { + momentum = [] + } else { + momentum = momentumEffect + } + } + + if inputs.contains(.retrospection) { + if !includingPositiveVelocityAndRC, let netRC = retrospectiveGlucoseEffect.netEffect(), netRC.quantity.doubleValue(for: .milligramsPerDeciliter) > 0 { + // positive RC is turned off + } else { + effects.append(retrospectiveGlucoseEffect) + } + } + + // Append effect of suspending insulin delivery when selected by the user on the Predicted Glucose screen (for information purposes only) + if inputs.contains(.suspend) { + effects.append(suspendInsulinDeliveryEffect) + } + + var prediction = LoopMath.predictGlucose(startingAt: glucose, momentum: momentum, effects: effects) + + // Dosing requires prediction entries at least as long as the insulin model duration. + // If our prediction is shorter than that, then extend it here. + let finalDate = glucose.startDate.addingTimeInterval(doseStore.longestEffectDuration) + if let last = prediction.last, last.startDate < finalDate { + prediction.append(PredictedGlucoseValue(startDate: finalDate, quantity: last.quantity)) + } + + return prediction + } + + fileprivate func predictGlucoseFromManualGlucose( + _ glucose: NewGlucoseSample, + potentialBolus: DoseEntry?, + potentialCarbEntry: NewCarbEntry?, + replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, + includingPendingInsulin: Bool, + considerPositiveVelocityAndRC: Bool + ) throws -> [PredictedGlucoseValue] { + let retrospectiveStart = glucose.date.addingTimeInterval(-type(of: retrospectiveCorrection).retrospectionInterval) + let earliestEffectDate = Date(timeInterval: .hours(-24), since: now()) + let nextEffectDate = insulinCounteractionEffects.last?.endDate ?? earliestEffectDate + let insulinEffectStartDate = nextEffectDate.addingTimeInterval(.minutes(-5)) + + let updateGroup = DispatchGroup() + let effectCalculationError = Locked(nil) + + var insulinEffect: [GlucoseEffect]? + let basalDosingEnd = includingPendingInsulin ? nil : now() + updateGroup.enter() + doseStore.getGlucoseEffects(start: insulinEffectStartDate, end: nil, basalDosingEnd: basalDosingEnd) { result in + switch result { + case .failure(let error): + effectCalculationError.mutate { $0 = error } + case .success(let effects): + insulinEffect = effects + } + + updateGroup.leave() + } + + updateGroup.wait() + + if let error = effectCalculationError.value { + throw error + } + + var insulinCounteractionEffects = self.insulinCounteractionEffects + if nextEffectDate < glucose.date, let insulinEffect = insulinEffect { + updateGroup.enter() + glucoseStore.getGlucoseSamples(start: nextEffectDate, end: nil) { result in + switch result { + case .failure(let error): + self.logger.error("Failure getting glucose samples: %{public}@", String(describing: error)) + case .success(let samples): + var samples = samples + let manualSample = StoredGlucoseSample(sample: glucose.quantitySample) + let insertionIndex = samples.partitioningIndex(where: { manualSample.startDate < $0.startDate }) + samples.insert(manualSample, at: insertionIndex) + let velocities = self.glucoseStore.counteractionEffects(for: samples, to: insulinEffect) + insulinCounteractionEffects.append(contentsOf: velocities) + } + insulinCounteractionEffects = insulinCounteractionEffects.filterDateRange(earliestEffectDate, nil) + + updateGroup.leave() + } + + updateGroup.wait() + } + + var carbEffect: [GlucoseEffect]? + updateGroup.enter() + carbStore.getGlucoseEffects( + start: retrospectiveStart, end: nil, + effectVelocities: insulinCounteractionEffects + ) { result in + switch result { + case .failure(let error): + effectCalculationError.mutate { $0 = error } + case .success(let (_, effects)): + carbEffect = effects + } + + updateGroup.leave() + } + + updateGroup.wait() + + if let error = effectCalculationError.value { + throw error + } + + return try predictGlucose( + startingAt: glucose.quantitySample, + using: [.insulin, .carbs], + historicalInsulinEffect: insulinEffect, + insulinCounteractionEffects: insulinCounteractionEffects, + historicalCarbEffect: carbEffect, + potentialBolus: potentialBolus, + potentialCarbEntry: potentialCarbEntry, + replacingCarbEntry: replacedCarbEntry, + includingPendingInsulin: true, + includingPositiveVelocityAndRC: considerPositiveVelocityAndRC + ) + } + + fileprivate func recommendBolusForManualGlucose(_ glucose: NewGlucoseSample, consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, considerPositiveVelocityAndRC: Bool) throws -> ManualBolusRecommendation? { + guard lastRequestedBolus == nil else { + // Don't recommend changes if a bolus was just requested. + // Sending additional pump commands is not going to be + // successful in any case. + return nil + } + + let pendingInsulin = try getPendingInsulin() + let shouldIncludePendingInsulin = pendingInsulin > 0 + let prediction = try predictGlucoseFromManualGlucose(glucose, potentialBolus: nil, potentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: shouldIncludePendingInsulin, considerPositiveVelocityAndRC: considerPositiveVelocityAndRC) + return try recommendManualBolus(forPrediction: prediction, consideringPotentialCarbEntry: potentialCarbEntry) + } + + /// - Throws: LoopError.missingDataError + fileprivate func recommendBolus(consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, considerPositiveVelocityAndRC: Bool) throws -> ManualBolusRecommendation? { + guard lastRequestedBolus == nil else { + // Don't recommend changes if a bolus was just requested. + // Sending additional pump commands is not going to be + // successful in any case. + return nil + } + + let pendingInsulin = try getPendingInsulin() + let shouldIncludePendingInsulin = pendingInsulin > 0 + let prediction = try predictGlucose(using: .all, potentialBolus: nil, potentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: shouldIncludePendingInsulin, includingPositiveVelocityAndRC: considerPositiveVelocityAndRC) + return try recommendBolusValidatingDataRecency(forPrediction: prediction, consideringPotentialCarbEntry: potentialCarbEntry) + } + + /// - Throws: + /// - LoopError.missingDataError + /// - LoopError.glucoseTooOld + /// - LoopError.invalidFutureGlucose + /// - LoopError.pumpDataTooOld + /// - LoopError.configurationError + fileprivate func recommendBolusValidatingDataRecency(forPrediction predictedGlucose: [Sample], + consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?) throws -> ManualBolusRecommendation? { + guard let glucose = glucoseStore.latestGlucose else { + throw LoopError.missingDataError(.glucose) + } + + let pumpStatusDate = doseStore.lastAddedPumpData + let lastGlucoseDate = glucose.startDate + + guard now().timeIntervalSince(lastGlucoseDate) <= LoopCoreConstants.inputDataRecencyInterval else { + throw LoopError.glucoseTooOld(date: glucose.startDate) + } + + guard lastGlucoseDate.timeIntervalSince(now()) <= LoopCoreConstants.inputDataRecencyInterval else { + throw LoopError.invalidFutureGlucose(date: lastGlucoseDate) + } + + guard now().timeIntervalSince(pumpStatusDate) <= LoopCoreConstants.inputDataRecencyInterval else { + throw LoopError.pumpDataTooOld(date: pumpStatusDate) + } + + guard glucoseMomentumEffect != nil else { + throw LoopError.missingDataError(.momentumEffect) + } + + guard carbEffect != nil else { + throw LoopError.missingDataError(.carbEffect) + } + + guard insulinEffect != nil else { + throw LoopError.missingDataError(.insulinEffect) + } + + return try recommendManualBolus(forPrediction: predictedGlucose, consideringPotentialCarbEntry: potentialCarbEntry) + } + + /// - Throws: LoopError.configurationError + private func recommendManualBolus(forPrediction predictedGlucose: [Sample], + consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?) throws -> ManualBolusRecommendation? { + guard let glucoseTargetRange = settings.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: potentialCarbEntry != nil) else { + throw LoopError.configurationError(.glucoseTargetRangeSchedule) + } + guard let insulinSensitivity = insulinSensitivityScheduleApplyingOverrideHistory else { + throw LoopError.configurationError(.insulinSensitivitySchedule) + } + guard let maxBolus = settings.maximumBolus else { + throw LoopError.configurationError(.maximumBolus) + } + + guard lastRequestedBolus == nil + else { + // Don't recommend changes if a bolus was just requested. + // Sending additional pump commands is not going to be + // successful in any case. + return nil + } + + let volumeRounder = { (_ units: Double) in + return self.delegate?.roundBolusVolume(units: units) ?? units + } + + let model = doseStore.insulinModelProvider.model(for: pumpInsulinType) + + return predictedGlucose.recommendedManualBolus( + to: glucoseTargetRange, + at: now(), + suspendThreshold: settings.suspendThreshold?.quantity, + sensitivity: insulinSensitivity, + model: model, + pendingInsulin: 0, // Pending insulin is already reflected in the prediction + maxBolus: maxBolus, + volumeRounder: volumeRounder + ) + } + + /// Generates a correction effect based on how large the discrepancy is between the current glucose and its model predicted value. + /// + /// - Throws: LoopError.missingDataError + private func updateRetrospectiveGlucoseEffect() throws { + dispatchPrecondition(condition: .onQueue(dataAccessQueue)) + + // Get carb effects, otherwise clear effect and throw error + guard let carbEffects = self.carbEffect else { + retrospectiveGlucoseDiscrepancies = nil + retrospectiveGlucoseEffect = [] + throw LoopError.missingDataError(.carbEffect) + } + + // Get most recent glucose, otherwise clear effect and throw error + guard let glucose = self.glucoseStore.latestGlucose else { + retrospectiveGlucoseEffect = [] + throw LoopError.missingDataError(.glucose) + } + + // Get timeline of glucose discrepancies + retrospectiveGlucoseDiscrepancies = insulinCounteractionEffects.subtracting(carbEffects, withUniformInterval: carbStore.delta) + + // Calculate retrospective correction + let insulinSensitivity = settings.insulinSensitivitySchedule!.quantity(at: glucose.startDate) + let basalRate = settings.basalRateSchedule!.value(at: glucose.startDate) + let correctionRange = settings.glucoseTargetRangeSchedule!.quantityRange(at: glucose.startDate) + + retrospectiveGlucoseEffect = retrospectiveCorrection.computeEffect( + startingAt: glucose, + retrospectiveGlucoseDiscrepanciesSummed: retrospectiveGlucoseDiscrepanciesSummed, + recencyInterval: LoopCoreConstants.inputDataRecencyInterval, + insulinSensitivity: insulinSensitivity, + basalRate: basalRate, + correctionRange: correctionRange, + retrospectiveCorrectionGroupingInterval: LoopMath.retrospectiveCorrectionGroupingInterval + ) + } + + private func computeRetrospectiveGlucoseEffect(startingAt glucose: GlucoseValue, carbEffects: [GlucoseEffect]) -> [GlucoseEffect] { + + let insulinSensitivity = settings.insulinSensitivitySchedule!.quantity(at: glucose.startDate) + let basalRate = settings.basalRateSchedule!.value(at: glucose.startDate) + let correctionRange = settings.glucoseTargetRangeSchedule!.quantityRange(at: glucose.startDate) + + let retrospectiveGlucoseDiscrepancies = insulinCounteractionEffects.subtracting(carbEffects, withUniformInterval: carbStore.delta) + let retrospectiveGlucoseDiscrepanciesSummed = retrospectiveGlucoseDiscrepancies.combinedSums(of: LoopMath.retrospectiveCorrectionGroupingInterval * retrospectiveCorrectionGroupingIntervalMultiplier) + return retrospectiveCorrection.computeEffect( + startingAt: glucose, + retrospectiveGlucoseDiscrepanciesSummed: retrospectiveGlucoseDiscrepanciesSummed, + recencyInterval: LoopCoreConstants.inputDataRecencyInterval, + insulinSensitivity: insulinSensitivity, + basalRate: basalRate, + correctionRange: correctionRange, + retrospectiveCorrectionGroupingInterval: LoopMath.retrospectiveCorrectionGroupingInterval + ) + } + + /// Generates a glucose prediction effect of suspending insulin delivery over duration of insulin action starting at current date + /// + /// - Throws: LoopError.configurationError + private func updateSuspendInsulinDeliveryEffect() throws { + dispatchPrecondition(condition: .onQueue(dataAccessQueue)) + + // Get settings, otherwise clear effect and throw error + guard + let insulinSensitivity = insulinSensitivityScheduleApplyingOverrideHistory + else { + suspendInsulinDeliveryEffect = [] + throw LoopError.configurationError(.insulinSensitivitySchedule) + } + guard + let basalRateSchedule = basalRateScheduleApplyingOverrideHistory + else { + suspendInsulinDeliveryEffect = [] + throw LoopError.configurationError(.basalRateSchedule) + } + + let insulinModel = doseStore.insulinModelProvider.model(for: pumpInsulinType) + let insulinActionDuration = insulinModel.effectDuration + + let startSuspend = now() + let endSuspend = startSuspend.addingTimeInterval(insulinActionDuration) + + var suspendDoses: [DoseEntry] = [] + let basalItems = basalRateSchedule.between(start: startSuspend, end: endSuspend) + + // Iterate over basal entries during suspension of insulin delivery + for (index, basalItem) in basalItems.enumerated() { + var startSuspendDoseDate: Date + var endSuspendDoseDate: Date + + if index == 0 { + startSuspendDoseDate = startSuspend + } else { + startSuspendDoseDate = basalItem.startDate + } + + if index == basalItems.count - 1 { + endSuspendDoseDate = endSuspend + } else { + endSuspendDoseDate = basalItems[index + 1].startDate } + + let suspendDose = DoseEntry(type: .tempBasal, startDate: startSuspendDoseDate, endDate: endSuspendDoseDate, value: -basalItem.value, unit: DoseUnit.unitsPerHour) + + suspendDoses.append(suspendDose) } + + // Calculate predicted glucose effect of suspending insulin delivery + suspendInsulinDeliveryEffect = suspendDoses.glucoseEffects(insulinModelProvider: doseStore.insulinModelProvider, longestEffectDuration: doseStore.longestEffectDuration, insulinSensitivity: insulinSensitivity).filterDateRange(startSuspend, endSuspend) } - private func setRecommendedTempBasal(_ resultsHandler: @escaping (_ success: Bool, _ error: Error?) -> Void) { - guard let recommendedTempBasal = self.recommendedTempBasal else { - resultsHandler(true, nil) - return + /// Runs the glucose prediction on the latest effect data. + /// + /// - Throws: + /// - LoopError.configurationError + /// - LoopError.glucoseTooOld + /// - LoopError.invalidFutureGlucose + /// - LoopError.missingDataError + /// - LoopError.pumpDataTooOld + private func updatePredictedGlucoseAndRecommendedDose(with dosingDecision: StoredDosingDecision) -> (StoredDosingDecision, LoopError?) { + dispatchPrecondition(condition: .onQueue(dataAccessQueue)) + + var dosingDecision = dosingDecision + + self.logger.debug("Recomputing prediction and recommendations.") + + let startDate = now() + + guard let glucose = glucoseStore.latestGlucose else { + logger.error("Latest glucose missing") + dosingDecision.appendError(.missingDataError(.glucose)) + return (dosingDecision, .missingDataError(.glucose)) } - guard recommendedTempBasal.recommendedDate.timeIntervalSinceNow < TimeInterval(minutes: 5) else { - resultsHandler(false, LoopError.staleDataError("Recommended temp basal is \(recommendedTempBasal.recommendedDate.timeIntervalSinceNow.minutes) min old")) - return + var errors = [LoopError]() + + if startDate.timeIntervalSince(glucose.startDate) > LoopCoreConstants.inputDataRecencyInterval { + errors.append(.glucoseTooOld(date: glucose.startDate)) } - guard let device = self.deviceDataManager.rileyLinkManager.firstConnectedDevice else { - resultsHandler(false, LoopError.connectionError) - return + if glucose.startDate.timeIntervalSince(startDate) > LoopCoreConstants.inputDataRecencyInterval { + errors.append(.invalidFutureGlucose(date: glucose.startDate)) } - guard let ops = device.ops else { - resultsHandler(false, LoopError.configurationError) - return + let pumpStatusDate = doseStore.lastAddedPumpData + + if startDate.timeIntervalSince(pumpStatusDate) > LoopCoreConstants.inputDataRecencyInterval { + errors.append(.pumpDataTooOld(date: pumpStatusDate)) } - ops.setTempBasal(rate: recommendedTempBasal.rate, duration: recommendedTempBasal.duration) { (result) -> Void in - switch result { - case .success(let body): - self.dataAccessQueue.async { - let now = Date() - let endDate = now.addingTimeInterval(body.timeRemaining) - let startDate = endDate.addingTimeInterval(-recommendedTempBasal.duration) + let glucoseTargetRange = settings.effectiveGlucoseTargetRangeSchedule() + if glucoseTargetRange == nil { + errors.append(.configurationError(.glucoseTargetRangeSchedule)) + } + + let basalRateSchedule = basalRateScheduleApplyingOverrideHistory + if basalRateSchedule == nil { + errors.append(.configurationError(.basalRateSchedule)) + } + + let insulinSensitivity = insulinSensitivityScheduleApplyingOverrideHistory + if insulinSensitivity == nil { + errors.append(.configurationError(.insulinSensitivitySchedule)) + } + + if carbRatioScheduleApplyingOverrideHistory == nil { + errors.append(.configurationError(.carbRatioSchedule)) + } + + let maxBasal = settings.maximumBasalRatePerHour + if maxBasal == nil { + errors.append(.configurationError(.maximumBasalRatePerHour)) + } + + let maxBolus = settings.maximumBolus + if maxBolus == nil { + errors.append(.configurationError(.maximumBolus)) + } + + if glucoseMomentumEffect == nil { + errors.append(.missingDataError(.momentumEffect)) + } + + if carbEffect == nil { + errors.append(.missingDataError(.carbEffect)) + } + + if insulinEffect == nil { + errors.append(.missingDataError(.insulinEffect)) + } + + if insulinEffectIncludingPendingInsulin == nil { + errors.append(.missingDataError(.insulinEffectIncludingPendingInsulin)) + } + + if self.insulinOnBoard == nil { + errors.append(.missingDataError(.activeInsulin)) + } + + dosingDecision.appendErrors(errors) + if let error = errors.first { + logger.error("%{public}@", String(describing: error)) + return (dosingDecision, error) + } + + var loopError: LoopError? + do { + let predictedGlucose = try predictGlucose(using: settings.enabledEffects) + self.predictedGlucose = predictedGlucose + let predictedGlucoseIncludingPendingInsulin = try predictGlucose(using: settings.enabledEffects, includingPendingInsulin: true) + self.predictedGlucoseIncludingPendingInsulin = predictedGlucoseIncludingPendingInsulin - self.lastTempBasal = DoseEntry(type: .tempBasal, startDate: startDate, endDate: endDate, value: body.rate, unit: .unitsPerHour) - self.recommendedTempBasal = nil + dosingDecision.predictedGlucose = predictedGlucose - resultsHandler(true, nil) + guard lastRequestedBolus == nil + else { + // Don't recommend changes if a bolus was just requested. + // Sending additional pump commands is not going to be + // successful in any case. + self.logger.debug("Not generating recommendations because bolus request is in progress.") + dosingDecision.appendWarning(.bolusInProgress) + return (dosingDecision, nil) + } + + let rateRounder = { (_ rate: Double) in + return self.delegate?.roundBasalRate(unitsPerHour: rate) ?? rate + } + + let lastTempBasal: DoseEntry? + + if case .some(.tempBasal(let dose)) = basalDeliveryState { + lastTempBasal = dose + } else { + lastTempBasal = nil + } + + let dosingRecommendation: AutomaticDoseRecommendation? + + // automaticDosingIOBLimit calculated from the user entered maxBolus + let automaticDosingIOBLimit = maxBolus! * 2.0 + let iobHeadroom = automaticDosingIOBLimit - self.insulinOnBoard!.value + + switch settings.automaticDosingStrategy { + case .automaticBolus: + let volumeRounder = { (_ units: Double) in + return self.delegate?.roundBolusVolume(units: units) ?? units } - case .failure(let error): - resultsHandler(false, error) + + // Create dosing strategy based on user setting + let applicationFactorStrategy: ApplicationFactorStrategy = UserDefaults.standard.glucoseBasedApplicationFactorEnabled + ? GlucoseBasedApplicationFactorStrategy() + : ConstantApplicationFactorStrategy() + + let correctionRangeSchedule = settings.effectiveGlucoseTargetRangeSchedule() + + let effectiveBolusApplicationFactor = applicationFactorStrategy.calculateDosingFactor( + for: glucose.quantity, + correctionRangeSchedule: correctionRangeSchedule!, + settings: settings + ) + + self.logger.debug(" *** Glucose: %{public}@, effectiveBolusApplicationFactor: %.2f", glucose.quantity.description, effectiveBolusApplicationFactor) + + // If a user customizes maxPartialApplicationFactor > 1; this respects maxBolus + let maxAutomaticBolus = min(iobHeadroom, maxBolus! * min(effectiveBolusApplicationFactor, 1.0)) + + dosingRecommendation = predictedGlucose.recommendedAutomaticDose( + to: glucoseTargetRange!, + at: predictedGlucose[0].startDate, + suspendThreshold: settings.suspendThreshold?.quantity, + sensitivity: insulinSensitivity!, + model: doseStore.insulinModelProvider.model(for: pumpInsulinType), + basalRates: basalRateSchedule!, + maxAutomaticBolus: maxAutomaticBolus, + partialApplicationFactor: effectiveBolusApplicationFactor * self.timeBasedDoseApplicationFactor, + lastTempBasal: lastTempBasal, + volumeRounder: volumeRounder, + rateRounder: rateRounder, + isBasalRateScheduleOverrideActive: settings.scheduleOverride?.isBasalRateScheduleOverriden(at: startDate) == true + ) + case .tempBasalOnly: + + let temp = predictedGlucose.recommendedTempBasal( + to: glucoseTargetRange!, + at: predictedGlucose[0].startDate, + suspendThreshold: settings.suspendThreshold?.quantity, + sensitivity: insulinSensitivity!, + model: doseStore.insulinModelProvider.model(for: pumpInsulinType), + basalRates: basalRateSchedule!, + maxBasalRate: maxBasal!, + additionalActiveInsulinClamp: iobHeadroom, + lastTempBasal: lastTempBasal, + rateRounder: rateRounder, + isBasalRateScheduleOverrideActive: settings.scheduleOverride?.isBasalRateScheduleOverriden(at: startDate) == true + ) + dosingRecommendation = AutomaticDoseRecommendation(basalAdjustment: temp) + } + + if let dosingRecommendation = dosingRecommendation { + self.logger.default("Recommending dose: %{public}@ at %{public}@", String(describing: dosingRecommendation), String(describing: startDate)) + recommendedAutomaticDose = (recommendation: dosingRecommendation, date: startDate) + } else { + self.logger.default("No dose recommended.") + recommendedAutomaticDose = nil + } + dosingDecision.automaticDoseRecommendation = recommendedAutomaticDose?.recommendation + } catch let error { + loopError = error as? LoopError ?? .unknownError(error) + if let loopError = loopError { + logger.error("Error attempting to predict glucose: %{public}@", String(describing: loopError)) + dosingDecision.appendError(loopError) } } + + return (dosingDecision, loopError) } - func enactRecommendedTempBasal(_ resultsHandler: @escaping (_ success: Bool, _ error: Error?) -> Void) { + /// *This method should only be called from the `dataAccessQueue`* + private func enactRecommendedAutomaticDose() -> LoopError? { + dispatchPrecondition(condition: .onQueue(dataAccessQueue)) + + guard let recommendedDose = self.recommendedAutomaticDose else { + return nil + } + + guard abs(recommendedDose.date.timeIntervalSince(now())) < TimeInterval(minutes: 5) else { + return LoopError.recommendationExpired(date: recommendedDose.date) + } + + if case .suspended = basalDeliveryState { + return LoopError.pumpSuspended + } + + let updateGroup = DispatchGroup() + updateGroup.enter() + var delegateError: LoopError? + + delegate?.loopDataManager(self, didRecommend: recommendedDose) { (error) in + delegateError = error + updateGroup.leave() + } + updateGroup.wait() + + if delegateError == nil { + self.recommendedAutomaticDose = nil + } + + return delegateError + } + + /// Ensures that the current temp basal is at or below the proposed max temp basal, and if not, cancel it before proceeding. + /// Calls the completion with `nil` if successful, or an `error` if canceling the active temp basal fails. + func maxTempBasalSavePreflight(unitsPerHour: Double?, completion: @escaping (_ error: Error?) -> Void) { + guard let unitsPerHour = unitsPerHour else { + completion(nil) + return + } dataAccessQueue.async { - self.setRecommendedTempBasal(resultsHandler) + switch self.basalDeliveryState { + case .some(.tempBasal(let dose)): + if dose.unitsPerHour > unitsPerHour { + // Temp basal is higher than proposed rate, so should cancel + self.cancelActiveTempBasal(for: .maximumBasalRateChanged, completion: completion) + } else { + completion(nil) + } + default: + completion(nil) + } } } +} + +/// Describes a view into the loop state +protocol LoopState { + /// The last-calculated carbs on board + var carbsOnBoard: CarbValue? { get } - /** - Informs the loop algorithm of an enacted bolus + /// The last-calculated insulin on board + var insulinOnBoard: InsulinValue? { get } + + /// An error in the current state of the loop, or one that happened during the last attempt to loop. + var error: LoopError? { get } + + /// A timeline of average velocity of glucose change counteracting predicted insulin effects + var insulinCounteractionEffects: [GlucoseEffectVelocity] { get } + + /// The calculated timeline of predicted glucose values + var predictedGlucose: [PredictedGlucoseValue]? { get } + + /// The calculated timeline of predicted glucose values, including the effects of pending insulin + var predictedGlucoseIncludingPendingInsulin: [PredictedGlucoseValue]? { get } + + /// The recommended temp basal based on predicted glucose + var recommendedAutomaticDose: (recommendation: AutomaticDoseRecommendation, date: Date)? { get } + + /// The difference in predicted vs actual glucose over a recent period + var retrospectiveGlucoseDiscrepancies: [GlucoseChange]? { get } + + /// The total corrective glucose effect from retrospective correction + var totalRetrospectiveCorrection: HKQuantity? { get } + + /// Calculates a new prediction from the current data using the specified effect inputs + /// + /// This method is intended for visualization purposes only, not dosing calculation. No validation of input data is done. + /// + /// - Parameter inputs: The effect inputs to include + /// - Parameter potentialBolus: A bolus under consideration for which to include effects in the prediction + /// - Parameter potentialCarbEntry: A carb entry under consideration for which to include effects in the prediction + /// - Parameter replacedCarbEntry: An existing carb entry replaced by `potentialCarbEntry` + /// - Parameter includingPendingInsulin: If `true`, the returned prediction will include the effects of scheduled but not yet delivered insulin + /// - Parameter considerPositiveVelocityAndRC: Positive velocity and positive retrospective correction will not be used if this is false. + /// - Returns: An timeline of predicted glucose values + /// - Throws: LoopError.missingDataError if prediction cannot be computed + func predictGlucose(using inputs: PredictionInputEffect, potentialBolus: DoseEntry?, potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, includingPendingInsulin: Bool, considerPositiveVelocityAndRC: Bool) throws -> [PredictedGlucoseValue] + + /// Calculates a new prediction from a manual glucose entry in the context of a meal entry + /// + /// - Parameter glucose: The unstored manual glucose entry + /// - Parameter potentialBolus: A bolus under consideration for which to include effects in the prediction + /// - Parameter potentialCarbEntry: A carb entry under consideration for which to include effects in the prediction + /// - Parameter replacedCarbEntry: An existing carb entry replaced by `potentialCarbEntry` + /// - Parameter includingPendingInsulin: If `true`, the returned prediction will include the effects of scheduled but not yet delivered insulin + /// - Parameter considerPositiveVelocityAndRC: Positive velocity and positive retrospective correction will not be used if this is false. + /// - Returns: A timeline of predicted glucose values + func predictGlucoseFromManualGlucose( + _ glucose: NewGlucoseSample, + potentialBolus: DoseEntry?, + potentialCarbEntry: NewCarbEntry?, + replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, + includingPendingInsulin: Bool, + considerPositiveVelocityAndRC: Bool + ) throws -> [PredictedGlucoseValue] + + /// Computes the recommended bolus for correcting a glucose prediction, optionally considering a potential carb entry. + /// - Parameter potentialCarbEntry: A carb entry under consideration for which to include effects in the prediction + /// - Parameter replacedCarbEntry: An existing carb entry replaced by `potentialCarbEntry` + /// - Parameter considerPositiveVelocityAndRC: Positive velocity and positive retrospective correction will not be used if this is false. + /// - Returns: A bolus recommendation, or `nil` if not applicable + /// - Throws: LoopError.missingDataError if recommendation cannot be computed + func recommendBolus(consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, considerPositiveVelocityAndRC: Bool) throws -> ManualBolusRecommendation? + + /// Computes the recommended bolus for correcting a glucose prediction derived from a manual glucose entry, optionally considering a potential carb entry. + /// - Parameter glucose: The unstored manual glucose entry + /// - Parameter potentialCarbEntry: A carb entry under consideration for which to include effects in the prediction + /// - Parameter replacedCarbEntry: An existing carb entry replaced by `potentialCarbEntry` + /// - Parameter considerPositiveVelocityAndRC: Positive velocity and positive retrospective correction will not be used if this is false. + /// - Returns: A bolus recommendation, or `nil` if not applicable + /// - Throws: LoopError.configurationError if recommendation cannot be computed + func recommendBolusForManualGlucose(_ glucose: NewGlucoseSample, consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, considerPositiveVelocityAndRC: Bool) throws -> ManualBolusRecommendation? +} + +extension LoopState { + /// Calculates a new prediction from the current data using the specified effect inputs + /// + /// This method is intended for visualization purposes only, not dosing calculation. No validation of input data is done. + /// + /// - Parameter inputs: The effect inputs to include + /// - Parameter includingPendingInsulin: If `true`, the returned prediction will include the effects of scheduled but not yet delivered insulin + /// - Returns: An timeline of predicted glucose values + /// - Throws: LoopError.missingDataError if prediction cannot be computed + func predictGlucose(using inputs: PredictionInputEffect, includingPendingInsulin: Bool = false) throws -> [GlucoseValue] { + try predictGlucose(using: inputs, potentialBolus: nil, potentialCarbEntry: nil, replacingCarbEntry: nil, includingPendingInsulin: includingPendingInsulin, considerPositiveVelocityAndRC: true) + } +} + + +extension LoopDataManager { + private struct LoopStateView: LoopState { + + private let loopDataManager: LoopDataManager + private let updateError: LoopError? + + init(loopDataManager: LoopDataManager, updateError: LoopError?) { + self.loopDataManager = loopDataManager + self.updateError = updateError + } + + var carbsOnBoard: CarbValue? { + dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) + return loopDataManager.carbsOnBoard + } + + var insulinOnBoard: InsulinValue? { + dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) + return loopDataManager.insulinOnBoard + } + + var error: LoopError? { + dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) + return updateError ?? loopDataManager.lastLoopError + } + + var insulinCounteractionEffects: [GlucoseEffectVelocity] { + dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) + return loopDataManager.insulinCounteractionEffects + } + + var predictedGlucose: [PredictedGlucoseValue]? { + dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) + return loopDataManager.predictedGlucose + } + + var predictedGlucoseIncludingPendingInsulin: [PredictedGlucoseValue]? { + dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) + return loopDataManager.predictedGlucoseIncludingPendingInsulin + } + + var recommendedAutomaticDose: (recommendation: AutomaticDoseRecommendation, date: Date)? { + dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) + guard loopDataManager.lastRequestedBolus == nil else { + return nil + } + return loopDataManager.recommendedAutomaticDose + } + + var retrospectiveGlucoseDiscrepancies: [GlucoseChange]? { + dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) + return loopDataManager.retrospectiveGlucoseDiscrepanciesSummed + } + + var totalRetrospectiveCorrection: HKQuantity? { + dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) + return loopDataManager.retrospectiveCorrection.totalGlucoseCorrectionEffect + } - - parameter units: The amount of insulin - - parameter date: The date the bolus was set - */ - func recordBolus(_ units: Double, at date: Date) { + func predictGlucose(using inputs: PredictionInputEffect, potentialBolus: DoseEntry?, potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, includingPendingInsulin: Bool, considerPositiveVelocityAndRC: Bool) throws -> [PredictedGlucoseValue] { + dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) + return try loopDataManager.predictGlucose(using: inputs, potentialBolus: potentialBolus, potentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: includingPendingInsulin, includingPositiveVelocityAndRC: considerPositiveVelocityAndRC) + } + + func predictGlucoseFromManualGlucose( + _ glucose: NewGlucoseSample, + potentialBolus: DoseEntry?, + potentialCarbEntry: NewCarbEntry?, + replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, + includingPendingInsulin: Bool, + considerPositiveVelocityAndRC: Bool + ) throws -> [PredictedGlucoseValue] { + dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) + return try loopDataManager.predictGlucoseFromManualGlucose(glucose, potentialBolus: potentialBolus, potentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: includingPendingInsulin, considerPositiveVelocityAndRC: considerPositiveVelocityAndRC) + } + + func recommendBolus(consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, considerPositiveVelocityAndRC: Bool) throws -> ManualBolusRecommendation? { + dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) + return try loopDataManager.recommendBolus(consideringPotentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, considerPositiveVelocityAndRC: considerPositiveVelocityAndRC) + } + + func recommendBolusForManualGlucose(_ glucose: NewGlucoseSample, consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, considerPositiveVelocityAndRC: Bool) throws -> ManualBolusRecommendation? { + dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) + return try loopDataManager.recommendBolusForManualGlucose(glucose, consideringPotentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, considerPositiveVelocityAndRC: considerPositiveVelocityAndRC) + } + } + + /// Executes a closure with access to the current state of the loop. + /// + /// This operation is performed asynchronously and the closure will be executed on an arbitrary background queue. + /// + /// - Parameter handler: A closure called when the state is ready + /// - Parameter manager: The loop manager + /// - Parameter state: The current state of the manager. This is invalid to access outside of the closure. + func getLoopState(_ handler: @escaping (_ manager: LoopDataManager, _ state: LoopState) -> Void) { dataAccessQueue.async { - self.lastBolus = (units: units, date: date) - self.notify(forChange: .bolus) + let (_, updateError) = self.update(for: .getLoopState) + + handler(self, LoopStateView(loopDataManager: self, updateError: updateError)) + } + } + + func generateSimpleBolusRecommendation(at date: Date, mealCarbs: HKQuantity?, manualGlucose: HKQuantity?) -> BolusDosingDecision? { + + var dosingDecision = BolusDosingDecision(for: .simpleBolus) + + var activeInsulin: Double? = nil + let semaphore = DispatchSemaphore(value: 0) + doseStore.insulinOnBoard(at: Date()) { (result) in + if case .success(let iobValue) = result { + activeInsulin = iobValue.value + dosingDecision.insulinOnBoard = iobValue + } + semaphore.signal() } + semaphore.wait() + + guard let iob = activeInsulin, + let suspendThreshold = settings.suspendThreshold?.quantity, + let carbRatioSchedule = carbStore.carbRatioScheduleApplyingOverrideHistory, + let correctionRangeSchedule = settings.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: mealCarbs != nil), + let sensitivitySchedule = insulinSensitivityScheduleApplyingOverrideHistory + else { + // Settings incomplete; should never get here; remove when therapy settings non-optional + return nil + } + + if let scheduleOverride = settings.scheduleOverride, !scheduleOverride.hasFinished() { + dosingDecision.scheduleOverride = settings.scheduleOverride + } + + dosingDecision.glucoseTargetRangeSchedule = correctionRangeSchedule + + var notice: BolusRecommendationNotice? = nil + if let manualGlucose = manualGlucose { + let glucoseValue = SimpleGlucoseValue(startDate: date, quantity: manualGlucose) + if manualGlucose < suspendThreshold { + notice = .glucoseBelowSuspendThreshold(minGlucose: glucoseValue) + } else { + let correctionRange = correctionRangeSchedule.quantityRange(at: date) + if manualGlucose < correctionRange.lowerBound { + notice = .currentGlucoseBelowTarget(glucose: glucoseValue) + } + } + } + + let bolusAmount = SimpleBolusCalculator.recommendedInsulin( + mealCarbs: mealCarbs, + manualGlucose: manualGlucose, + activeInsulin: HKQuantity.init(unit: .internationalUnit(), doubleValue: iob), + carbRatioSchedule: carbRatioSchedule, + correctionRangeSchedule: correctionRangeSchedule, + sensitivitySchedule: sensitivitySchedule, + at: date) + + dosingDecision.manualBolusRecommendation = ManualBolusRecommendationWithDate(recommendation: ManualBolusRecommendation(amount: bolusAmount.doubleValue(for: .internationalUnit()), pendingInsulin: 0, notice: notice), + date: Date()) + + return dosingDecision } } @@ -702,28 +2207,410 @@ extension LoopDataManager { /// /// This operation is performed asynchronously and the completion will be executed on an arbitrary background queue. /// - /// - parameter completionHandler: A closure called once the report has been generated. The closure takes a single argument of the report string. - func generateDiagnosticReport(_ completionHandler: @escaping (_ report: String) -> Void) { - getLoopStatus { (predictedGlucose, retrospectivePredictedGlucose, recommendedTempBasal, lastTempBasal, lastLoopCompleted, insulinOnBoard, carbsOnBoard, error) in - let report = [ + /// - parameter completion: A closure called once the report has been generated. The closure takes a single argument of the report string. + func generateDiagnosticReport(_ completion: @escaping (_ report: String) -> Void) { + getLoopState { (manager, state) in + + var entries: [String] = [ "## LoopDataManager", - "predictedGlucose: \(predictedGlucose ?? [])", - "retrospectivePredictedGlucose: \(retrospectivePredictedGlucose ?? [])", - "recommendedTempBasal: \(recommendedTempBasal)", - "lastTempBasal: \(lastTempBasal)", - "lastLoopCompleted: \(lastLoopCompleted ?? .distantPast)", - "insulinOnBoard: \(insulinOnBoard)", - "carbsOnBoard: \(carbsOnBoard)", - "error: \(error)" + "settings: \(String(reflecting: manager.settings))", + + "insulinCounteractionEffects: [", + "* GlucoseEffectVelocity(start, end, mg/dL/min)", + manager.insulinCounteractionEffects.reduce(into: "", { (entries, entry) in + entries.append("* \(entry.startDate), \(entry.endDate), \(entry.quantity.doubleValue(for: GlucoseEffectVelocity.unit))\n") + }), + "]", + + "insulinEffect: [", + "* GlucoseEffect(start, mg/dL)", + (manager.insulinEffect ?? []).reduce(into: "", { (entries, entry) in + entries.append("* \(entry.startDate), \(entry.quantity.doubleValue(for: .milligramsPerDeciliter))\n") + }), + "]", + + "carbEffect: [", + "* GlucoseEffect(start, mg/dL)", + (manager.carbEffect ?? []).reduce(into: "", { (entries, entry) in + entries.append("* \(entry.startDate), \(entry.quantity.doubleValue(for: .milligramsPerDeciliter))\n") + }), + "]", + + "predictedGlucose: [", + "* PredictedGlucoseValue(start, mg/dL)", + (state.predictedGlucoseIncludingPendingInsulin ?? []).reduce(into: "", { (entries, entry) in + entries.append("* \(entry.startDate), \(entry.quantity.doubleValue(for: .milligramsPerDeciliter))\n") + }), + "]", + + "integralRetrospectiveCorrectionEnabled: \(UserDefaults.standard.integralRetrospectiveCorrectionEnabled)", + + "retrospectiveGlucoseDiscrepancies: [", + "* GlucoseEffect(start, mg/dL)", + (manager.retrospectiveGlucoseDiscrepancies ?? []).reduce(into: "", { (entries, entry) in + entries.append("* \(entry.startDate), \(entry.quantity.doubleValue(for: .milligramsPerDeciliter))\n") + }), + "]", + + "retrospectiveGlucoseDiscrepanciesSummed: [", + "* GlucoseChange(start, end, mg/dL)", + (manager.retrospectiveGlucoseDiscrepanciesSummed ?? []).reduce(into: "", { (entries, entry) in + entries.append("* \(entry.startDate), \(entry.endDate), \(entry.quantity.doubleValue(for: .milligramsPerDeciliter))\n") + }), + "]", + + "glucoseMomentumEffect: \(manager.glucoseMomentumEffect ?? [])", + "retrospectiveGlucoseEffect: \(manager.retrospectiveGlucoseEffect)", + "recommendedAutomaticDose: \(String(describing: state.recommendedAutomaticDose))", + "lastBolus: \(String(describing: manager.lastRequestedBolus))", + "lastLoopCompleted: \(String(describing: manager.lastLoopCompleted))", + "basalDeliveryState: \(String(describing: manager.basalDeliveryState))", + "carbsOnBoard: \(String(describing: state.carbsOnBoard))", + "insulinOnBoard: \(String(describing: manager.insulinOnBoard))", + "error: \(String(describing: state.error))", + "overrideInUserDefaults: \(String(describing: UserDefaults.appGroup?.intentExtensionOverrideToSet))", + "glucoseBasedApplicationFactorEnabled: \(UserDefaults.standard.glucoseBasedApplicationFactorEnabled)", + "", + String(reflecting: self.retrospectiveCorrection), + "", ] - completionHandler(report.joined(separator: "\n")) + + self.glucoseStore.generateDiagnosticReport { (report) in + entries.append(report) + entries.append("") + + self.carbStore.generateDiagnosticReport { (report) in + entries.append(report) + entries.append("") + + self.doseStore.generateDiagnosticReport { (report) in + entries.append(report) + entries.append("") + + self.mealDetectionManager.generateDiagnosticReport { report in + entries.append(report) + entries.append("") + + UNUserNotificationCenter.current().generateDiagnosticReport { (report) in + entries.append(report) + entries.append("") + + UIDevice.current.generateDiagnosticReport { (report) in + entries.append(report) + entries.append("") + + completion(entries.joined(separator: "\n")) + } + } + } + } + } + } } } } extension Notification.Name { - static let LoopDataUpdated = Notification.Name(rawValue: "com.loudnate.Naterade.notification.LoopDataUpdated") + static let LoopDataUpdated = Notification.Name(rawValue: "com.loopkit.Loop.LoopDataUpdated") + static let LoopRunning = Notification.Name(rawValue: "com.loopkit.Loop.LoopRunning") + static let LoopCompleted = Notification.Name(rawValue: "com.loopkit.Loop.LoopCompleted") +} + +protocol LoopDataManagerDelegate: AnyObject { + + /// Informs the delegate that an immediate basal change is recommended + /// + /// - Parameters: + /// - manager: The manager + /// - basal: The new recommended basal + /// - completion: A closure called once on completion. Will be passed a non-null error if acting on the recommendation fails. + /// - result: The enacted basal + func loopDataManager(_ manager: LoopDataManager, didRecommend automaticDose: (recommendation: AutomaticDoseRecommendation, date: Date), completion: @escaping (LoopError?) -> Void) -> Void + + /// Asks the delegate to round a recommended basal rate to a supported rate + /// + /// - Parameters: + /// - rate: The recommended rate in U/hr + /// - Returns: a supported rate of delivery in Units/hr. The rate returned should not be larger than the passed in rate. + func roundBasalRate(unitsPerHour: Double) -> Double + + /// Asks the delegate to estimate the duration to deliver the bolus. + /// + /// - Parameters: + /// - bolusUnits: size of the bolus in U + /// - Returns: the estimated time it will take to deliver bolus + func loopDataManager(_ manager: LoopDataManager, estimateBolusDuration bolusUnits: Double) -> TimeInterval? + + /// Asks the delegate to round a recommended bolus volume to a supported volume + /// + /// - Parameters: + /// - units: The recommended bolus in U + /// - Returns: a supported bolus volume in U. The volume returned should be the nearest deliverable volume. + func roundBolusVolume(units: Double) -> Double + + /// The pump manager status, if one exists. + var pumpManagerStatus: PumpManagerStatus? { get } + + /// The pump status highlight, if one exists. + var pumpStatusHighlight: DeviceStatusHighlight? { get } + + /// The cgm manager status, if one exists. + var cgmManagerStatus: CGMManagerStatus? { get } +} + +private extension TemporaryScheduleOverride { + func isBasalRateScheduleOverriden(at date: Date) -> Bool { + guard isActive(at: date), let basalRateMultiplier = settings.basalRateMultiplier else { + return false + } + return abs(basalRateMultiplier - 1.0) >= .ulpOfOne + } +} + +private extension StoredDosingDecision.LastReservoirValue { + init?(_ reservoirValue: ReservoirValue?) { + guard let reservoirValue = reservoirValue else { + return nil + } + self.init(startDate: reservoirValue.startDate, unitVolume: reservoirValue.unitVolume) + } +} + +extension ManualBolusRecommendationWithDate { + init?(_ bolusRecommendationDate: (recommendation: ManualBolusRecommendation, date: Date)?) { + guard let bolusRecommendationDate = bolusRecommendationDate else { + return nil + } + self.init(recommendation: bolusRecommendationDate.recommendation, date: bolusRecommendationDate.date) + } +} + +private extension StoredDosingDecision.Settings { + init?(_ settings: StoredSettings?) { + guard let settings = settings else { + return nil + } + self.init(syncIdentifier: settings.syncIdentifier) + } +} + +// MARK: - Simulated Core Data + +extension LoopDataManager { + func generateSimulatedHistoricalCoreData(completion: @escaping (Error?) -> Void) { + guard FeatureFlags.simulatedCoreDataEnabled else { + fatalError("\(#function) should be invoked only when simulated core data is enabled") + } + + guard let glucoseStore = glucoseStore as? GlucoseStore, let carbStore = carbStore as? CarbStore, let doseStore = doseStore as? DoseStore, let dosingDecisionStore = dosingDecisionStore as? DosingDecisionStore else { + fatalError("Mock stores should not be used to generate simulated core data") + } + + glucoseStore.generateSimulatedHistoricalGlucoseObjects() { error in + guard error == nil else { + completion(error) + return + } + carbStore.generateSimulatedHistoricalCarbObjects() { error in + guard error == nil else { + completion(error) + return + } + dosingDecisionStore.generateSimulatedHistoricalDosingDecisionObjects() { error in + guard error == nil else { + completion(error) + return + } + doseStore.generateSimulatedHistoricalPumpEvents(completion: completion) + } + } + } + } + + func purgeHistoricalCoreData(completion: @escaping (Error?) -> Void) { + guard FeatureFlags.simulatedCoreDataEnabled else { + fatalError("\(#function) should be invoked only when simulated core data is enabled") + } + + guard let glucoseStore = glucoseStore as? GlucoseStore, let carbStore = carbStore as? CarbStore, let doseStore = doseStore as? DoseStore, let dosingDecisionStore = dosingDecisionStore as? DosingDecisionStore else { + fatalError("Mock stores should not be used to generate simulated core data") + } + + doseStore.purgeHistoricalPumpEvents() { error in + guard error == nil else { + completion(error) + return + } + dosingDecisionStore.purgeHistoricalDosingDecisionObjects() { error in + guard error == nil else { + completion(error) + return + } + carbStore.purgeHistoricalCarbObjects() { error in + guard error == nil else { + completion(error) + return + } + glucoseStore.purgeHistoricalGlucoseObjects(completion: completion) + } + } + } + } +} + +extension LoopDataManager { + public var therapySettings: TherapySettings { + get { + let settings = settings + return TherapySettings(glucoseTargetRangeSchedule: settings.glucoseTargetRangeSchedule, + correctionRangeOverrides: CorrectionRangeOverrides(preMeal: settings.preMealTargetRange, workout: settings.legacyWorkoutTargetRange), + overridePresets: settings.overridePresets, + maximumBasalRatePerHour: settings.maximumBasalRatePerHour, + maximumBolus: settings.maximumBolus, + suspendThreshold: settings.suspendThreshold, + insulinSensitivitySchedule: settings.insulinSensitivitySchedule, + carbRatioSchedule: settings.carbRatioSchedule, + basalRateSchedule: settings.basalRateSchedule, + defaultRapidActingModel: settings.defaultRapidActingModel) + } + + set { + mutateSettings { settings in + settings.defaultRapidActingModel = newValue.defaultRapidActingModel + settings.insulinSensitivitySchedule = newValue.insulinSensitivitySchedule + settings.carbRatioSchedule = newValue.carbRatioSchedule + settings.basalRateSchedule = newValue.basalRateSchedule + settings.glucoseTargetRangeSchedule = newValue.glucoseTargetRangeSchedule + settings.preMealTargetRange = newValue.correctionRangeOverrides?.preMeal + settings.legacyWorkoutTargetRange = newValue.correctionRangeOverrides?.workout + settings.suspendThreshold = newValue.suspendThreshold + settings.maximumBolus = newValue.maximumBolus + settings.maximumBasalRatePerHour = newValue.maximumBasalRatePerHour + settings.overridePresets = newValue.overridePresets ?? [] + } + } + } +} - static let LoopRunning = Notification.Name(rawValue: "com.loudnate.Naterade.notification.LoopRunning") +extension LoopDataManager: ServicesManagerDelegate { + + //Overrides + + func enactOverride(name: String, duration: TemporaryScheduleOverride.Duration?, remoteAddress: String) async throws { + + guard let preset = settings.overridePresets.first(where: { $0.name == name }) else { + throw EnactOverrideError.unknownPreset(name) + } + + var remoteOverride = preset.createOverride(enactTrigger: .remote(remoteAddress)) + + if let duration { + remoteOverride.duration = duration + } + + await enactOverride(remoteOverride) + } + + + func cancelCurrentOverride() async throws { + await enactOverride(nil) + } + + func enactOverride(_ override: TemporaryScheduleOverride?) async { + mutateSettings { settings in settings.scheduleOverride = override } + } + + enum EnactOverrideError: LocalizedError { + + case unknownPreset(String) + + var errorDescription: String? { + switch self { + case .unknownPreset(let presetName): + return String(format: NSLocalizedString("Unknown preset: %1$@", comment: "Override error description: unknown preset (1: preset name)."), presetName) + } + } + } + + //Carb Entry + + func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws { + + let absorptionTime = absorptionTime ?? carbStore.defaultAbsorptionTimes.medium + if absorptionTime < LoopConstants.minCarbAbsorptionTime || absorptionTime > LoopConstants.maxCarbAbsorptionTime { + throw CarbActionError.invalidAbsorptionTime(absorptionTime) + } + + guard amountInGrams > 0.0 else { + throw CarbActionError.invalidCarbs + } + + guard amountInGrams <= LoopConstants.maxCarbEntryQuantity.doubleValue(for: .gram()) else { + throw CarbActionError.exceedsMaxCarbs + } + + if let startDate = startDate { + let maxStartDate = Date().addingTimeInterval(LoopConstants.maxCarbEntryFutureTime) + let minStartDate = Date().addingTimeInterval(LoopConstants.maxCarbEntryPastTime) + guard startDate <= maxStartDate && startDate >= minStartDate else { + throw CarbActionError.invalidStartDate(startDate) + } + } + + let quantity = HKQuantity(unit: .gram(), doubleValue: amountInGrams) + let candidateCarbEntry = NewCarbEntry(quantity: quantity, startDate: startDate ?? Date(), foodType: foodType, absorptionTime: absorptionTime) + + let _ = try await devliverCarbEntry(candidateCarbEntry) + } + + enum CarbActionError: LocalizedError { + + case invalidAbsorptionTime(TimeInterval) + case invalidStartDate(Date) + case exceedsMaxCarbs + case invalidCarbs + + var errorDescription: String? { + switch self { + case .exceedsMaxCarbs: + return NSLocalizedString("Exceeds maximum allowed carbs", comment: "Carb error description: carbs exceed maximum amount.") + case .invalidCarbs: + return NSLocalizedString("Invalid carb amount", comment: "Carb error description: invalid carb amount.") + case .invalidAbsorptionTime(let absorptionTime): + let absorptionHoursFormatted = Self.numberFormatter.string(from: absorptionTime.hours) ?? "" + return String(format: NSLocalizedString("Invalid absorption time: %1$@ hours", comment: "Carb error description: invalid absorption time. (1: Input duration in hours)."), absorptionHoursFormatted) + case .invalidStartDate(let startDate): + let startDateFormatted = Self.dateFormatter.string(from: startDate) + return String(format: NSLocalizedString("Start time is out of range: %@", comment: "Carb error description: invalid start time is out of range."), startDateFormatted) + } + } + + static var numberFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + return formatter + }() + + static var dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.timeStyle = .medium + return formatter + }() + } + + //Can't add this concurrency wrapper method to LoopKit due to the minimum iOS version + func devliverCarbEntry(_ carbEntry: NewCarbEntry) async throws -> StoredCarbEntry { + return try await withCheckedThrowingContinuation { continuation in + carbStore.addCarbEntry(carbEntry) { result in + switch result { + case .success(let storedCarbEntry): + continuation.resume(returning: storedCarbEntry) + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + } + } diff --git a/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift b/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift new file mode 100644 index 0000000000..a3922a873a --- /dev/null +++ b/Loop/Managers/Missed Meal Detection/MealDetectionManager.swift @@ -0,0 +1,315 @@ +// +// MealDetectionManager.swift +// Loop +// +// Created by Anna Quinlan on 11/28/22. +// Copyright © 2022 LoopKit Authors. All rights reserved. +// + +import Foundation +import HealthKit +import OSLog +import LoopCore +import LoopKit + +enum MissedMealStatus: Equatable { + case hasMissedMeal(startTime: Date, carbAmount: Double) + case noMissedMeal +} + +class MealDetectionManager { + private let log = OSLog(category: "MealDetectionManager") + // All math for meal detection occurs in mg/dL, with settings being converted if in mmol/L + private let unit = HKUnit.milligramsPerDeciliter + + public var carbRatioScheduleApplyingOverrideHistory: CarbRatioSchedule? + public var insulinSensitivityScheduleApplyingOverrideHistory: InsulinSensitivitySchedule? + public var maximumBolus: Double? + + /// The last missed meal notification that was sent + /// Internal for unit testing + var lastMissedMealNotification: MissedMealNotification? = UserDefaults.standard.lastMissedMealNotification { + didSet { + UserDefaults.standard.lastMissedMealNotification = lastMissedMealNotification + } + } + + /// Debug info for missed meal detection + /// Timeline from the most recent check for missed meals + private var lastEvaluatedMissedMealTimeline: [(date: Date, unexpectedDeviation: Double?, mealThreshold: Double?, rateOfChangeThreshold: Double?)] = [] + + /// Timeline from the most recent detection of an missed meal + private var lastDetectedMissedMealTimeline: [(date: Date, unexpectedDeviation: Double?, mealThreshold: Double?, rateOfChangeThreshold: Double?)] = [] + + /// Allows for controlling uses of the system date in unit testing + internal var test_currentDate: Date? + + /// Current date. Will return the unit-test configured date if set, or the current date otherwise. + internal var currentDate: Date { + test_currentDate ?? Date() + } + + internal func currentDate(timeIntervalSinceNow: TimeInterval = 0) -> Date { + return currentDate.addingTimeInterval(timeIntervalSinceNow) + } + + public init( + carbRatioScheduleApplyingOverrideHistory: CarbRatioSchedule?, + insulinSensitivityScheduleApplyingOverrideHistory: InsulinSensitivitySchedule?, + maximumBolus: Double?, + test_currentDate: Date? = nil + ) { + self.carbRatioScheduleApplyingOverrideHistory = carbRatioScheduleApplyingOverrideHistory + self.insulinSensitivityScheduleApplyingOverrideHistory = insulinSensitivityScheduleApplyingOverrideHistory + self.maximumBolus = maximumBolus + self.test_currentDate = test_currentDate + } + + // MARK: Meal Detection + func hasMissedMeal(glucoseSamples: [some GlucoseSampleValue], insulinCounteractionEffects: [GlucoseEffectVelocity], carbEffects: [GlucoseEffect], completion: @escaping (MissedMealStatus) -> Void) { + let delta = TimeInterval(minutes: 5) + + let intervalStart = currentDate(timeIntervalSinceNow: -MissedMealSettings.maxRecency) + let intervalEnd = currentDate(timeIntervalSinceNow: -MissedMealSettings.minRecency) + let now = self.currentDate + + let filteredGlucoseValues = glucoseSamples.filter { intervalStart <= $0.startDate && $0.startDate <= now } + + /// Only try to detect if there's a missed meal if there are no calibration/user-entered BGs, + /// since these can cause large jumps + guard !filteredGlucoseValues.containsUserEntered() else { + completion(.noMissedMeal) + return + } + + let filteredCarbEffects = carbEffects.filterDateRange(intervalStart, now) + + /// Compute how much of the ICE effect we can't explain via our entered carbs + /// Effect caching inspired by `LoopMath.predictGlucose` + var effectValueCache: [Date: Double] = [:] + + /// Carb effects are cumulative, so we have to subtract the previous effect value + var previousEffectValue: Double = filteredCarbEffects.first?.quantity.doubleValue(for: unit) ?? 0 + + /// Counteraction effects only take insulin into account, so we need to account for the carb effects when computing the unexpected deviations + for effect in filteredCarbEffects { + let value = effect.quantity.doubleValue(for: unit) + /// We do `-1 * (value - previousEffectValue)` because this will compute the carb _counteraction_ effect + effectValueCache[effect.startDate] = (effectValueCache[effect.startDate] ?? 0) + -1 * (value - previousEffectValue) + previousEffectValue = value + } + + let processedICE = insulinCounteractionEffects + .filterDateRange(intervalStart, now) + .compactMap { + /// Clamp starts & ends to `intervalStart...now` since our algorithm assumes all effects occur within that interval + let start = max($0.startDate, intervalStart) + let end = min($0.endDate, now) + + guard let effect = $0.effect(from: start, to: end) else { + let item: GlucoseEffect? = nil // FIXME: we get a compiler error if we try to return `nil` directly + return item + } + + return GlucoseEffect(startDate: effect.endDate.dateCeiledToTimeInterval(delta), + quantity: effect.quantity) + } + + for effect in processedICE { + let value = effect.quantity.doubleValue(for: unit) + effectValueCache[effect.startDate] = (effectValueCache[effect.startDate] ?? 0) + value + } + + var unexpectedDeviation: Double = 0 + var mealTime = now + + /// Dates the algorithm uses when computing effects + /// Have the range go from newest -> oldest time + let summationRange = LoopMath.simulationDateRange(from: intervalStart, + to: now, + delta: delta) + .reversed() + + /// Dates the algorithm is allowed to check for the presence of a missed meal + let dateSearchRange = Set(LoopMath.simulationDateRange(from: intervalStart, + to: intervalEnd, + delta: delta)) + + /// Timeline used for debug purposes + var missedMealTimeline: [(date: Date, unexpectedDeviation: Double?, mealThreshold: Double?, rateOfChangeThreshold: Double?)] = [] + + for pastTime in summationRange { + guard let unexpectedEffect = effectValueCache[pastTime] else { + missedMealTimeline.append((pastTime, nil, nil, nil)) + continue + } + + unexpectedDeviation += unexpectedEffect + + guard dateSearchRange.contains(pastTime) else { + /// This time is too recent to check for a missed meal + missedMealTimeline.append((pastTime, unexpectedDeviation, nil, nil)) + continue + } + + /// Find the threshold based on a minimum of `missedMealGlucoseRiseThreshold` of change per minute + let minutesAgo = now.timeIntervalSince(pastTime).minutes + let rateThreshold = MissedMealSettings.glucoseRiseThreshold * minutesAgo + + /// Find the total effect we'd expect to see for a meal with `carbThreshold`-worth of carbs that started at `pastTime` + guard let mealThreshold = self.effectThreshold(mealStart: pastTime, carbsInGrams: MissedMealSettings.minCarbThreshold) else { + continue + } + + missedMealTimeline.append((pastTime, unexpectedDeviation, mealThreshold, rateThreshold)) + + /// Use the higher of the 2 thresholds to ensure noisy CGM data doesn't cause false-positives for more recent times + let effectThreshold = max(rateThreshold, mealThreshold) + + if unexpectedDeviation >= effectThreshold { + mealTime = pastTime + } + } + + self.lastEvaluatedMissedMealTimeline = missedMealTimeline.reversed() + + let mealTimeTooRecent = now.timeIntervalSince(mealTime) < MissedMealSettings.minRecency + guard !mealTimeTooRecent else { + completion(.noMissedMeal) + return + } + + self.lastDetectedMissedMealTimeline = missedMealTimeline.reversed() + + let carbAmount = self.determineCarbs(mealtime: mealTime, unexpectedDeviation: unexpectedDeviation) + completion(.hasMissedMeal(startTime: mealTime, carbAmount: carbAmount ?? MissedMealSettings.minCarbThreshold)) + } + + private func determineCarbs(mealtime: Date, unexpectedDeviation: Double) -> Double? { + var mealCarbs: Double? = nil + + /// Search `carbAmount`s from `minCarbThreshold` to `maxCarbThreshold` in 5-gram increments, + /// seeing if the deviation is at least `carbAmount` of carbs + for carbAmount in stride(from: MissedMealSettings.minCarbThreshold, through: MissedMealSettings.maxCarbThreshold, by: 5) { + if + let modeledCarbEffect = effectThreshold(mealStart: mealtime, carbsInGrams: carbAmount), + unexpectedDeviation >= modeledCarbEffect + { + mealCarbs = carbAmount + } + } + + return mealCarbs + } + + private func effectThreshold(mealStart: Date, carbsInGrams: Double) -> Double? { + guard + let carbRatio = carbRatioScheduleApplyingOverrideHistory?.value(at: mealStart), + let insulinSensitivity = insulinSensitivityScheduleApplyingOverrideHistory?.value(for: unit, at: mealStart) + else { + return nil + } + + return carbsInGrams / carbRatio * insulinSensitivity + } + + // MARK: Notification Generation + /// Searches for any potential missed meals and sends a notification. + /// A missed meal notification can be delivered a maximum of every `MissedMealSettings.maxRecency - MissedMealSettings.minRecency` minutes. + /// + /// - Parameters: + /// - insulinCounteractionEffects: the current insulin counteraction effects that have been observed + /// - carbEffects: the effects of any active carb entries. Must include effects from `currentDate() - MissedMealSettings.maxRecency` until `currentDate()`. + /// - pendingAutobolusUnits: any autobolus units that are still being delivered. Used to delay the missed meal notification to avoid notifying during an autobolus. + /// - bolusDurationEstimator: estimator of bolus duration that takes the units of the bolus as an input. Used to delay the missed meal notification to avoid notifying during an autobolus. + func generateMissedMealNotificationIfNeeded( + glucoseSamples: [some GlucoseSampleValue], + insulinCounteractionEffects: [GlucoseEffectVelocity], + carbEffects: [GlucoseEffect], + pendingAutobolusUnits: Double? = nil, + bolusDurationEstimator: @escaping (Double) -> TimeInterval? + ) { + hasMissedMeal(glucoseSamples: glucoseSamples, insulinCounteractionEffects: insulinCounteractionEffects, carbEffects: carbEffects) {[weak self] status in + self?.manageMealNotifications(for: status, pendingAutobolusUnits: pendingAutobolusUnits, bolusDurationEstimator: bolusDurationEstimator) + } + } + + + // Internal for unit testing + func manageMealNotifications(for status: MissedMealStatus, pendingAutobolusUnits: Double? = nil, bolusDurationEstimator getBolusDuration: (Double) -> TimeInterval?) { + // We should remove expired notifications regardless of whether or not there was a meal + NotificationManager.removeExpiredMealNotifications() + + // Figure out if we should deliver a notification + let now = self.currentDate + let notificationTimeTooRecent = now.timeIntervalSince(lastMissedMealNotification?.deliveryTime ?? .distantPast) < (MissedMealSettings.maxRecency - MissedMealSettings.minRecency) + + guard + case .hasMissedMeal(let startTime, let carbAmount) = status, + !notificationTimeTooRecent, + UserDefaults.standard.missedMealNotificationsEnabled + else { + // No notification needed! + return + } + + var clampedCarbAmount = carbAmount + if + let maxBolus = maximumBolus, + let currentCarbRatio = carbRatioScheduleApplyingOverrideHistory?.quantity(at: now).doubleValue(for: .gram()) + { + let maxAllowedCarbAutofill = maxBolus * currentCarbRatio + clampedCarbAmount = min(clampedCarbAmount, maxAllowedCarbAutofill) + } + + log.debug("Delivering a missed meal notification") + + /// Coordinate the missed meal notification time with any pending autoboluses that `update` may have started + /// so that the user doesn't have to cancel the current autobolus to bolus in response to the missed meal notification + if + let pendingAutobolusUnits, + pendingAutobolusUnits > 0, + let estimatedBolusDuration = getBolusDuration(pendingAutobolusUnits), + estimatedBolusDuration < MissedMealSettings.maxNotificationDelay + { + NotificationManager.sendMissedMealNotification(mealStart: startTime, amountInGrams: clampedCarbAmount, delay: estimatedBolusDuration) + lastMissedMealNotification = MissedMealNotification(deliveryTime: now.advanced(by: estimatedBolusDuration), + carbAmount: clampedCarbAmount) + } else { + NotificationManager.sendMissedMealNotification(mealStart: startTime, amountInGrams: clampedCarbAmount) + lastMissedMealNotification = MissedMealNotification(deliveryTime: now, carbAmount: clampedCarbAmount) + } + } + + // MARK: Logging + + /// Generates a diagnostic report about the current state + /// + /// - parameter completionHandler: A closure called once the report has been generated. The closure takes a single argument of the report string. + func generateDiagnosticReport(_ completionHandler: @escaping (_ report: String) -> Void) { + let report = [ + "## MealDetectionManager", + "", + "* lastMissedMealNotificationTime: \(String(describing: lastMissedMealNotification?.deliveryTime))", + "* lastMissedMealCarbEstimate: \(String(describing: lastMissedMealNotification?.carbAmount))", + "* lastEvaluatedMissedMealTimeline:", + lastEvaluatedMissedMealTimeline.reduce(into: "", { (entries, entry) in + entries.append(" * date: \(entry.date), unexpectedDeviation: \(entry.unexpectedDeviation ?? -1), meal-based threshold: \(entry.mealThreshold ?? -1), change-based threshold: \(entry.rateOfChangeThreshold ?? -1) \n") + }), + "* lastDetectedMissedMealTimeline:", + lastDetectedMissedMealTimeline.reduce(into: "", { (entries, entry) in + entries.append(" * date: \(entry.date), unexpectedDeviation: \(entry.unexpectedDeviation ?? -1), meal-based threshold: \(entry.mealThreshold ?? -1), change-based threshold: \(entry.rateOfChangeThreshold ?? -1) \n") + }) + ] + + completionHandler(report.joined(separator: "\n")) + } +} + +fileprivate extension BidirectionalCollection where Element: GlucoseSampleValue, Index == Int { + /// Returns whether there are any user-entered or calibration points + /// Runtime: O(n) + func containsUserEntered() -> Bool { + return containsCalibrations() || filter({ $0.wasUserEntered }).count != 0 + } +} diff --git a/Loop/Managers/Missed Meal Detection/MissedMealSettings.swift b/Loop/Managers/Missed Meal Detection/MissedMealSettings.swift new file mode 100644 index 0000000000..24ff03a9a8 --- /dev/null +++ b/Loop/Managers/Missed Meal Detection/MissedMealSettings.swift @@ -0,0 +1,25 @@ +// +// MissedMealSettings.swift +// Loop +// +// Created by Anna Quinlan on 11/28/22. +// Copyright © 2022 LoopKit Authors. All rights reserved. +// + +import Foundation + +public struct MissedMealSettings { + /// Minimum grams of unannounced carbs that must be detected for a notification to be delivered + public static let minCarbThreshold: Double = 15 // grams + /// Maximum grams of unannounced carbs that the algorithm will search for + public static let maxCarbThreshold: Double = 80 // grams + /// Minimum threshold for glucose rise over the detection window + static let glucoseRiseThreshold = 2.0 // mg/dL/m + /// Minimum time from now that must have passed for the meal to be detected + public static let minRecency = TimeInterval(minutes: 15) + /// Maximum time from now that a meal can be detected + public static let maxRecency = TimeInterval(hours: 2) + /// Maximum delay allowed in missed meal notification time to avoid + /// notifying the user during an autobolus + public static let maxNotificationDelay = TimeInterval(minutes: 4) +} diff --git a/Loop/Managers/NightscoutDataManager.swift b/Loop/Managers/NightscoutDataManager.swift deleted file mode 100644 index cedb694039..0000000000 --- a/Loop/Managers/NightscoutDataManager.swift +++ /dev/null @@ -1,147 +0,0 @@ -// -// NightscoutDataManager.swift -// Loop -// -// Created by Nate Racklyeft on 8/8/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import Foundation -import NightscoutUploadKit -import CarbKit -import HealthKit -import InsulinKit -import LoopKit - -class NightscoutDataManager { - - unowned let deviceDataManager: DeviceDataManager - - // Last time we uploaded device status - var lastDeviceStatusUpload: Date? - - init(deviceDataManager: DeviceDataManager) { - self.deviceDataManager = deviceDataManager - - NotificationCenter.default.addObserver(self, selector: #selector(loopDataUpdated(_:)), name: .LoopDataUpdated, object: deviceDataManager.loopManager) - } - - @objc func loopDataUpdated(_ note: Notification) { - guard - let rawContext = note.userInfo?[LoopDataManager.LoopUpdateContextKey] as? LoopDataManager.LoopUpdateContext.RawValue, - let context = LoopDataManager.LoopUpdateContext(rawValue: rawContext), - case .tempBasal = context - else { - return - } - - deviceDataManager.loopManager.getLoopStatus { (predictedGlucose, _, recommendedTempBasal, lastTempBasal, _, insulinOnBoard, carbsOnBoard, loopError) in - - self.deviceDataManager.loopManager.getRecommendedBolus { (bolusUnits, getBolusError) in - if let getBolusError = getBolusError { - self.deviceDataManager.logger.addError(getBolusError, fromSource: "NightscoutDataManager") - } - self.uploadLoopStatus(insulinOnBoard, carbsOnBoard: carbsOnBoard, predictedGlucose: predictedGlucose, recommendedTempBasal: recommendedTempBasal, recommendedBolus: bolusUnits, lastTempBasal: lastTempBasal, loopError: loopError ?? getBolusError) - } - } - } - - private var lastTempBasalUploaded: DoseEntry? - - func uploadLoopStatus(_ insulinOnBoard: InsulinValue? = nil, carbsOnBoard: CarbValue? = nil, predictedGlucose: [GlucoseValue]? = nil, recommendedTempBasal: LoopDataManager.TempBasalRecommendation? = nil, recommendedBolus: Double? = nil, lastTempBasal: DoseEntry? = nil, loopError: Error? = nil) { - - guard deviceDataManager.remoteDataManager.nightscoutUploader != nil else { - return - } - - let statusTime = Date() - - let iob: IOBStatus? - - if let insulinOnBoard = insulinOnBoard { - iob = IOBStatus(timestamp: insulinOnBoard.startDate, iob: insulinOnBoard.value) - } else { - iob = nil - } - - let cob: COBStatus? - - if let carbsOnBoard = carbsOnBoard { - cob = COBStatus(cob: carbsOnBoard.quantity.doubleValue(for: HKUnit.gram()), timestamp: carbsOnBoard.startDate) - } else { - cob = nil - } - - let predicted: PredictedBG? - if let predictedGlucose = predictedGlucose, let startDate = predictedGlucose.first?.startDate { - let values = predictedGlucose.map { $0.quantity } - predicted = PredictedBG(startDate: startDate, values: values) - } else { - predicted = nil - } - - let recommended: RecommendedTempBasal? - - if let recommendation = recommendedTempBasal { - recommended = RecommendedTempBasal(timestamp: recommendation.recommendedDate, rate: recommendation.rate, duration: recommendation.duration) - } else { - recommended = nil - } - - let loopEnacted: LoopEnacted? - if let tempBasal = lastTempBasal, tempBasal.unit == .unitsPerHour && - lastTempBasalUploaded?.startDate != tempBasal.startDate { - let duration = tempBasal.endDate.timeIntervalSince(tempBasal.startDate) - loopEnacted = LoopEnacted(rate: tempBasal.value, duration: duration, timestamp: tempBasal.startDate, received: - true) - lastTempBasalUploaded = tempBasal - } else { - loopEnacted = nil - } - - let loopName = Bundle.main.bundleDisplayName - let loopVersion = Bundle.main.shortVersionString - - let loopStatus = LoopStatus(name: loopName, version: loopVersion, timestamp: statusTime, iob: iob, cob: cob, predicted: predicted, recommendedTempBasal: recommended, recommendedBolus: recommendedBolus, enacted: loopEnacted, failureReason: loopError) - - uploadDeviceStatus(nil, loopStatus: loopStatus, includeUploaderStatus: false) - - } - - func getUploaderStatus() -> UploaderStatus { - // Gather UploaderStatus - let uploaderDevice = UIDevice.current - - let battery: Int? - if uploaderDevice.isBatteryMonitoringEnabled { - battery = Int(uploaderDevice.batteryLevel * 100) - } else { - battery = nil - } - return UploaderStatus(name: uploaderDevice.name, timestamp: Date(), battery: battery) - } - - func uploadDeviceStatus(_ pumpStatus: NightscoutUploadKit.PumpStatus? = nil, loopStatus: LoopStatus? = nil, includeUploaderStatus: Bool = true) { - - guard let uploader = deviceDataManager.remoteDataManager.nightscoutUploader else { - return - } - - if pumpStatus == nil && loopStatus == nil && includeUploaderStatus { - // If we're just uploading phone status, limit it to once every 5 minutes - if self.lastDeviceStatusUpload != nil && self.lastDeviceStatusUpload!.timeIntervalSinceNow > -(TimeInterval(minutes: 5)) { - return - } - } - - let uploaderDevice = UIDevice.current - - let uploaderStatus: UploaderStatus? = includeUploaderStatus ? getUploaderStatus() : nil - - // Build DeviceStatus - let deviceStatus = DeviceStatus(device: "loop://\(uploaderDevice.name)", timestamp: Date(), pumpStatus: pumpStatus, uploaderStatus: uploaderStatus, loopStatus: loopStatus) - - self.lastDeviceStatusUpload = Date() - uploader.uploadDeviceStatus(deviceStatus) - } -} diff --git a/Loop/Managers/NotificationManager.swift b/Loop/Managers/NotificationManager.swift index 6667183cd7..996d147047 100644 --- a/Loop/Managers/NotificationManager.swift +++ b/Loop/Managers/NotificationManager.swift @@ -8,185 +8,262 @@ import UIKit import UserNotifications +import LoopKit +import LoopCore - -struct NotificationManager { - enum Category: String { - case BolusFailure - case LoopNotRunning - case PumpBatteryLow - case PumpReservoirEmpty - case PumpReservoirLow - } +enum NotificationManager { enum Action: String { - case RetryBolus - } - - enum UserInfoKey: String { - case BolusAmount - case BolusStartDate + case retryBolus + case acknowledgeAlert } +} +extension NotificationManager { private static var notificationCategories: Set { var categories = [UNNotificationCategory]() let retryBolusAction = UNNotificationAction( - identifier: Action.RetryBolus.rawValue, + identifier: Action.retryBolus.rawValue, title: NSLocalizedString("Retry", comment: "The title of the notification action to retry a bolus command"), options: [] ) categories.append(UNNotificationCategory( - identifier: Category.BolusFailure.rawValue, + identifier: LoopNotificationCategory.bolusFailure.rawValue, actions: [retryBolusAction], intentIdentifiers: [], options: [] )) + + let acknowledgeAlertAction = UNNotificationAction( + identifier: Action.acknowledgeAlert.rawValue, + title: NSLocalizedString("OK", comment: "The title of the notification action to acknowledge a device alert"), + options: .foreground + ) + + categories.append(UNNotificationCategory( + identifier: LoopNotificationCategory.alert.rawValue, + actions: [acknowledgeAlertAction], + intentIdentifiers: [], + options: .customDismissAction + )) return Set(categories) } - static func authorize(delegate: UNUserNotificationCenterDelegate) { - let center = UNUserNotificationCenter.current() + static func getAuthorization(_ completion: @escaping (UNAuthorizationStatus) -> Void) { + UNUserNotificationCenter.current().getNotificationSettings { settings in + completion(settings.authorizationStatus) + } + } - center.delegate = delegate - center.requestAuthorization(options: [.badge, .sound, .alert], completionHandler: { _, _ in }) + static func authorize(_ completion: @escaping (UNAuthorizationStatus) -> Void) { + var authOptions: UNAuthorizationOptions = [.alert, .badge, .sound] + if FeatureFlags.criticalAlertsEnabled { + authOptions.insert(.criticalAlert) + } + + let center = UNUserNotificationCenter.current() + center.requestAuthorization(options: authOptions) { (granted, error) in + UNUserNotificationCenter.current().getNotificationSettings { settings in + completion(settings.authorizationStatus) + guard settings.authorizationStatus == .authorized else { + return + } + } + } center.setNotificationCategories(notificationCategories) } + // MARK: - Notifications - - static func sendBolusFailureNotificationForAmount(_ units: Double, atStartDate startDate: Date) { + + static func sendBolusFailureNotification(for error: PumpManagerError, units: Double, at startDate: Date, activationType: BolusActivationType) { let notification = UNMutableNotificationContent() - notification.title = NSLocalizedString("Bolus", comment: "The notification title for a bolus failure") - notification.body = String(format: NSLocalizedString("%@ U bolus may have failed.", comment: "The notification alert describing a possible bolus failure. The substitution parameter is the size of the bolus in units."), NumberFormatter.localizedString(from: NSNumber(value: units), number: .decimal)) - notification.sound = UNNotificationSound.default() + notification.title = NSLocalizedString("Bolus Issue", comment: "The notification title for a bolus issue") + + let fullStopCharacter = NSLocalizedString(".", comment: "Full stop character") + let sentenceFormat = NSLocalizedString("%1@%2@", comment: "Adds a full-stop to a statement (1: statement, 2: full stop character)") + + let body = [error.errorDescription, error.failureReason, error.recoverySuggestion].compactMap({ $0 }).map({ + // Avoids the double period at the end of a sentence. + $0.hasSuffix(fullStopCharacter) ? $0 : String(format: sentenceFormat, $0, fullStopCharacter) + }).joined(separator: " ") + + notification.body = body + notification.sound = .default if startDate.timeIntervalSinceNow >= TimeInterval(minutes: -5) { - notification.categoryIdentifier = Category.BolusFailure.rawValue + notification.categoryIdentifier = LoopNotificationCategory.bolusFailure.rawValue } notification.userInfo = [ - UserInfoKey.BolusAmount.rawValue: units, - UserInfoKey.BolusStartDate.rawValue: startDate + LoopNotificationUserInfoKey.bolusAmount.rawValue: units, + LoopNotificationUserInfoKey.bolusStartDate.rawValue: startDate, + LoopNotificationUserInfoKey.bolusActivationType.rawValue: activationType.rawValue ] let request = UNNotificationRequest( // Only support 1 bolus notification at once - identifier: Category.BolusFailure.rawValue, + identifier: LoopNotificationCategory.bolusFailure.rawValue, content: notification, trigger: nil ) UNUserNotificationCenter.current().add(request) } + + @MainActor + static func sendRemoteBolusNotification(amount: Double) { + let notification = UNMutableNotificationContent() + let quantityFormatter = QuantityFormatter(for: .internationalUnit()) + guard let amountDescription = quantityFormatter.numberFormatter.string(from: amount) else { + return + } + notification.title = String(format: NSLocalizedString("Remote Bolus Entry: %@ U", comment: "The notification title for a remote bolus. (1: Bolus amount)"), amountDescription) + + let body = "Success!" - // Cancel any previous scheduled notifications in the Loop Not Running category - static func clearPendingNotificationRequests() { - UNUserNotificationCenter.current().removeAllPendingNotificationRequests() - } - - static func scheduleLoopNotRunningNotifications() { - // Give a little extra time for a loop-in-progress to complete - let gracePeriod = TimeInterval(minutes: 0.5) - - for minutes: Double in [20, 40, 60, 120] { - let notification = UNMutableNotificationContent() - let failureInterval = TimeInterval(minutes: minutes) - - let formatter = DateComponentsFormatter() - formatter.maximumUnitCount = 1 - formatter.allowedUnits = [.hour, .minute] - formatter.unitsStyle = .full + notification.body = body + notification.sound = .default - if let failueIntervalString = formatter.string(from: failureInterval)?.localizedLowercase { - notification.body = String(format: NSLocalizedString("Loop has not completed successfully in %@", comment: "The notification alert describing a long-lasting loop failure. The substitution parameter is the time interval since the last loop"), failueIntervalString) - } + let request = UNNotificationRequest( + identifier: LoopNotificationCategory.remoteBolus.rawValue, + content: notification, + trigger: nil + ) - notification.title = NSLocalizedString("Loop Failure", comment: "The notification title for a loop failure") - notification.sound = UNNotificationSound.default() - notification.categoryIdentifier = Category.LoopNotRunning.rawValue - notification.threadIdentifier = Category.LoopNotRunning.rawValue - - let request = UNNotificationRequest( - identifier: "\(Category.LoopNotRunning.rawValue)\(failureInterval)", - content: notification, - trigger: UNTimeIntervalNotificationTrigger( - timeInterval: failureInterval + gracePeriod, - repeats: false - ) - ) - - UNUserNotificationCenter.current().add(request) - } + UNUserNotificationCenter.current().add(request) } - - static func sendPumpBatteryLowNotification() { + + @MainActor + static func sendRemoteBolusFailureNotification(for error: Error, amountInUnits: Double) { let notification = UNMutableNotificationContent() + let quantityFormatter = QuantityFormatter(for: .internationalUnit()) + guard let amountDescription = quantityFormatter.numberFormatter.string(from: amountInUnits) else { + return + } - notification.title = NSLocalizedString("Pump Battery Low", comment: "The notification title for a low pump battery") - notification.body = NSLocalizedString("Change the pump battery immediately", comment: "The notification alert describing a low pump battery") - notification.sound = UNNotificationSound.default() - notification.categoryIdentifier = Category.PumpBatteryLow.rawValue + notification.title = String(format: NSLocalizedString("Remote Bolus Entry: %@ U", comment: "The notification title for a remote failure. (1: Bolus amount)"), amountDescription) + notification.body = error.localizedDescription + notification.sound = .default let request = UNNotificationRequest( - identifier: Category.PumpBatteryLow.rawValue, + identifier: LoopNotificationCategory.remoteBolusFailure.rawValue, content: notification, trigger: nil ) UNUserNotificationCenter.current().add(request) } - - static func sendPumpReservoirEmptyNotification() { + + @MainActor + static func sendRemoteCarbEntryNotification(amountInGrams: Double) { let notification = UNMutableNotificationContent() - notification.title = NSLocalizedString("Pump Reservoir Empty", comment: "The notification title for an empty pump reservoir") - notification.body = NSLocalizedString("Change the pump reservoir now", comment: "The notification alert describing an empty pump reservoir") - notification.sound = UNNotificationSound.default() - notification.categoryIdentifier = Category.PumpReservoirEmpty.rawValue + let leadingBody = remoteCarbEntryNotificationBody(amountInGrams: amountInGrams) + let extraBody = "Success!" + + let body = [leadingBody, extraBody].joined(separator: "\n") + + notification.body = body + notification.sound = .default let request = UNNotificationRequest( - // Not a typo: this should replace any pump reservoir low notifications - identifier: Category.PumpReservoirLow.rawValue, + identifier: LoopNotificationCategory.remoteCarbs.rawValue, content: notification, trigger: nil ) UNUserNotificationCenter.current().add(request) } - - static func sendPumpReservoirLowNotificationForAmount(_ units: Double, andTimeRemaining remaining: TimeInterval?) { + + @MainActor + static func sendRemoteCarbEntryFailureNotification(for error: Error, amountInGrams: Double) { let notification = UNMutableNotificationContent() + + let leadingBody = remoteCarbEntryNotificationBody(amountInGrams: amountInGrams) + let extraBody = error.localizedDescription - notification.title = NSLocalizedString("Pump Reservoir Low", comment: "The notification title for a low pump reservoir") + let body = [leadingBody, extraBody].joined(separator: "\n") + + notification.body = body + notification.sound = .default - let unitsString = NumberFormatter.localizedString(from: NSNumber(value: units), number: .decimal) + let request = UNNotificationRequest( + identifier: LoopNotificationCategory.remoteCarbsFailure.rawValue, + content: notification, + trigger: nil + ) - let intervalFormatter = DateComponentsFormatter() - intervalFormatter.allowedUnits = [.hour, .minute] - intervalFormatter.maximumUnitCount = 1 - intervalFormatter.unitsStyle = .full - intervalFormatter.includesApproximationPhrase = true - intervalFormatter.includesTimeRemainingPhrase = true + UNUserNotificationCenter.current().add(request) + } + + static func sendMissedMealNotification(mealStart: Date, amountInGrams: Double, delay: TimeInterval? = nil) { + let notification = UNMutableNotificationContent() + /// Notifications should expire after the missed meal is no longer relevant + let expirationDate = mealStart.addingTimeInterval(LoopCoreConstants.defaultCarbAbsorptionTimes.slow) - if let remaining = remaining, let timeString = intervalFormatter.string(from: remaining) { - notification.body = String(format: NSLocalizedString("%1$@ U left: %2$@", comment: "Low reservoir alert with time remaining format string. (1: Number of units remaining)(2: approximate time remaining)"), unitsString, timeString) - } else { - notification.body = String(format: NSLocalizedString("%1$@ U left", comment: "Low reservoir alert format string. (1: Number of units remaining)"), unitsString) + notification.title = String(format: NSLocalizedString("Possible Missed Meal", comment: "The notification title for a meal that was possibly not logged in Loop.")) + notification.body = String(format: NSLocalizedString("It looks like you may not have logged a meal you ate. Tap to log it now.", comment: "The notification description for a meal that was possibly not logged in Loop.")) + notification.sound = .default + + notification.userInfo = [ + LoopNotificationUserInfoKey.missedMealTime.rawValue: mealStart, + LoopNotificationUserInfoKey.missedMealCarbAmount.rawValue: amountInGrams, + LoopNotificationUserInfoKey.expirationDate.rawValue: expirationDate + ] + + + var notificationTrigger: UNTimeIntervalNotificationTrigger? = nil + if let delay { + notificationTrigger = UNTimeIntervalNotificationTrigger(timeInterval: delay, repeats: false) } - notification.sound = UNNotificationSound.default() - notification.categoryIdentifier = Category.PumpReservoirLow.rawValue - let request = UNNotificationRequest( - identifier: Category.PumpReservoirLow.rawValue, + /// We use the same `identifier` for all requests so a newer missed meal notification will replace a current one (if it exists) + identifier: LoopNotificationCategory.missedMeal.rawValue, content: notification, - trigger: nil + trigger: notificationTrigger ) UNUserNotificationCenter.current().add(request) } + + static func removeExpiredMealNotifications(now: Date = Date()) { + let notificationCenter = UNUserNotificationCenter.current() + var identifiersToRemove: [String] = [] + + notificationCenter.getDeliveredNotifications { notifications in + for notification in notifications { + let request = notification.request + + guard + request.identifier == LoopNotificationCategory.missedMeal.rawValue, + let expirationDate = request.content.userInfo[LoopNotificationUserInfoKey.expirationDate.rawValue] as? Date, + expirationDate < now + else { + continue + } + + /// The notification is expired: mark it for removal + identifiersToRemove.append(request.identifier) + /// We can break early because all missed meal notifications have the same `identifier`, + /// so there will only ever be 1 outstanding missed meal notification + break + } + + guard identifiersToRemove.count > 0 else { + return + } + + notificationCenter.removeDeliveredNotifications(withIdentifiers: identifiersToRemove) + } + } + + private static func remoteCarbEntryNotificationBody(amountInGrams: Double) -> String { + return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) + } } diff --git a/Loop/Managers/OnboardingManager.swift b/Loop/Managers/OnboardingManager.swift new file mode 100644 index 0000000000..b9f6c8c232 --- /dev/null +++ b/Loop/Managers/OnboardingManager.swift @@ -0,0 +1,554 @@ +// +// OnboardingManager.swift +// Loop +// +// Created by Darin Krauss on 2/19/21. +// Copyright © 2021 LoopKit Authors. All rights reserved. +// + +import os.log +import HealthKit +import LoopKit +import LoopKitUI + +class OnboardingManager { + private let pluginManager: PluginManager + private let bluetoothProvider: BluetoothProvider + private let deviceDataManager: DeviceDataManager + private let statefulPluginManager: StatefulPluginManager + private let servicesManager: ServicesManager + private let loopDataManager: LoopDataManager + private let supportManager: SupportManager + private weak var windowProvider: WindowProvider? + private let userDefaults: UserDefaults + + private let log = OSLog(category: "OnboardingManager") + + @Published public private(set) var isSuspended: Bool { + didSet { userDefaults.onboardingManagerIsSuspended = isSuspended } + } + + @Published public private(set) var isComplete: Bool { + didSet { userDefaults.onboardingManagerIsComplete = isComplete } + } + private var completedOnboardingIdentifiers: [String] = [] { + didSet { userDefaults.onboardingManagerCompletedOnboardingIdentifiers = completedOnboardingIdentifiers } + } + private var activeOnboarding: OnboardingUI? = nil { + didSet { userDefaults.onboardingManagerActiveOnboardingRawValue = activeOnboarding?.rawValue } + } + + private var onboardingCompletion: (() -> Void)? + + init(pluginManager: PluginManager, + bluetoothProvider: BluetoothProvider, + deviceDataManager: DeviceDataManager, + statefulPluginManager: StatefulPluginManager, + servicesManager: ServicesManager, + loopDataManager: LoopDataManager, + supportManager: SupportManager, + windowProvider: WindowProvider?, + userDefaults: UserDefaults = .standard) + { + self.pluginManager = pluginManager + self.bluetoothProvider = bluetoothProvider + self.deviceDataManager = deviceDataManager + self.statefulPluginManager = statefulPluginManager + self.servicesManager = servicesManager + self.loopDataManager = loopDataManager + self.supportManager = supportManager + self.windowProvider = windowProvider + self.userDefaults = userDefaults + + self.isSuspended = userDefaults.onboardingManagerIsSuspended + + self.isComplete = userDefaults.onboardingManagerIsComplete && loopDataManager.therapySettings.isComplete + if !isComplete { + if loopDataManager.therapySettings.isComplete { + self.completedOnboardingIdentifiers = userDefaults.onboardingManagerCompletedOnboardingIdentifiers + } + if let activeOnboardingRawValue = userDefaults.onboardingManagerActiveOnboardingRawValue { + self.activeOnboarding = onboardingFromRawValue(activeOnboardingRawValue) + self.activeOnboarding?.onboardingDelegate = self + } + } + } + + func launch(_ completion: @escaping () -> Void) { + dispatchPrecondition(condition: .onQueue(.main)) + precondition(self.onboardingCompletion == nil) + + self.onboardingCompletion = completion + continueOnboarding() + } + + func resume() { + dispatchPrecondition(condition: .onQueue(.main)) + precondition(self.onboardingCompletion == nil) + + self.onboardingCompletion = { + self.windowProvider?.window?.rootViewController?.dismiss(animated: true, completion: nil) + } + continueOnboarding(allowResume: true) + } + + private func continueOnboarding(allowResume: Bool = false) { + dispatchPrecondition(condition: .onQueue(.main)) + + guard !isComplete else { + authorizeAndComplete() + return + } + guard let onboarding = nextActiveOnboarding else { + authorizeAndComplete() + return + } + guard !isSuspended || allowResume else { + complete() + return + } + + let resuming = isSuspended + self.isSuspended = false + + if !displayOnboarding(onboarding, resuming: resuming) { + completeActiveOnboarding() + } + } + + private var nextActiveOnboarding: OnboardingUI? { + if activeOnboarding == nil { + self.activeOnboarding = nextOnboarding + self.activeOnboarding?.onboardingDelegate = self + } + return activeOnboarding + } + + private var nextOnboarding: OnboardingUI? { + let onboardingIdentifiers = pluginManager.availableOnboardingIdentifiers.filter { !completedOnboardingIdentifiers.contains($0) } + for onboardingIdentifier in onboardingIdentifiers { + guard let onboardingType = onboardingTypeByIdentifier(onboardingIdentifier) else { + continue + } + + let onboarding = onboardingType.createOnboarding() + guard !onboarding.isOnboarded else { + completedOnboardingIdentifiers.append(onboarding.pluginIdentifier) + continue + } + + return onboarding + } + return nil + } + + private func displayOnboarding(_ onboarding: OnboardingUI, resuming: Bool) -> Bool { + var onboardingViewController = onboarding.onboardingViewController(onboardingProvider: self, displayGlucosePreference: deviceDataManager.displayGlucosePreference, colorPalette: .default) + onboardingViewController.cgmManagerOnboardingDelegate = deviceDataManager + onboardingViewController.pumpManagerOnboardingDelegate = deviceDataManager + onboardingViewController.serviceOnboardingDelegate = servicesManager + onboardingViewController.completionDelegate = self + + guard !onboarding.isOnboarded else { + return false + } + + if resuming { + onboardingViewController.isModalInPresentation = true + windowProvider?.window?.rootViewController?.present(onboardingViewController, animated: true, completion: nil) + } else { + windowProvider?.window?.rootViewController = onboardingViewController + } + return true + } + + private func completeActiveOnboarding() { + dispatchPrecondition(condition: .onQueue(.main)) + + if let activeOnboarding = self.activeOnboarding, !isSuspended { + completedOnboardingIdentifiers.append(activeOnboarding.pluginIdentifier) + self.activeOnboarding = nil + } + continueOnboarding() + } + + private func ensureAuthorization(_ completion: @escaping () -> Void) { + ensureNotificationAuthorization { + self.ensureHealthStoreAuthorization { + self.ensureBluetoothAuthorization(completion) + } + } + } + + private func ensureNotificationAuthorization(_ completion: @escaping () -> Void) { + getNotificationAuthorization { authorization in + guard authorization == .notDetermined else { + completion() + return + } + self.authorizeNotification { _ in completion() } + } + } + + private func ensureHealthStoreAuthorization(_ completion: @escaping () -> Void) { + self.authorizeHealthStore { _ in completion() } + } + + private func ensureBluetoothAuthorization(_ completion: @escaping () -> Void) { + guard bluetoothAuthorization == .notDetermined else { + completion() + return + } + authorizeBluetooth { _ in completion() } + } + + private func authorizeAndComplete() { + ensureAuthorization { + DispatchQueue.main.async { + self.isComplete = true + self.complete() + } + } + } + + private func complete() { + dispatchPrecondition(condition: .onQueue(.main)) + + if let completion = onboardingCompletion { + self.onboardingCompletion = nil + completion() + } + } + + // MARK: - State + + private func onboardingFromRawValue(_ rawValue: OnboardingUI.RawValue) -> OnboardingUI? { + guard let onboardingType = onboardingTypeFromRawValue(rawValue), + let rawState = rawValue["state"] as? OnboardingUI.RawState + else { + return nil + } + + return onboardingType.init(rawState: rawState) + } + + private func onboardingTypeFromRawValue(_ rawValue: OnboardingUI.RawValue) -> OnboardingUI.Type? { + guard let identifier = rawValue["onboardingIdentifier"] as? String else { + return nil + } + + return onboardingTypeByIdentifier(identifier) + } + + private func onboardingTypeByIdentifier(_ identifier: String) -> OnboardingUI.Type? { + return pluginManager.getOnboardingTypeByIdentifier(identifier) + } +} + +// MARK: - OnboardingDelegate + +extension OnboardingManager: OnboardingDelegate { + func onboardingDidUpdateState(_ onboarding: OnboardingUI) { + guard onboarding.pluginIdentifier == activeOnboarding?.pluginIdentifier else { return } + userDefaults.onboardingManagerActiveOnboardingRawValue = onboarding.rawValue + } + + func onboarding(_ onboarding: OnboardingUI, hasNewTherapySettings therapySettings: TherapySettings) { + guard onboarding.pluginIdentifier == activeOnboarding?.pluginIdentifier else { return } + loopDataManager.therapySettings = therapySettings + } + + func onboarding(_ onboarding: OnboardingUI, hasNewDosingEnabled dosingEnabled: Bool) { + guard onboarding.pluginIdentifier == activeOnboarding?.pluginIdentifier else { return } + loopDataManager.mutateSettings { settings in + settings.dosingEnabled = dosingEnabled + } + } + + func onboardingDidSuspend(_ onboarding: OnboardingUI) { + log.debug("OnboardingUI %@ did suspend", onboarding.pluginIdentifier) + guard onboarding.pluginIdentifier == activeOnboarding?.pluginIdentifier else { return } + self.isSuspended = true + } +} + +// MARK: - CompletionDelegate + +extension OnboardingManager: CompletionDelegate { + func completionNotifyingDidComplete(_ object: CompletionNotifying) { + DispatchQueue.main.async { + guard let activeOnboarding = self.activeOnboarding else { + return + } + + self.log.debug("completionNotifyingDidComplete during activeOnboarding", activeOnboarding.pluginIdentifier) + + // The `completionNotifyingDidComplete` callback can be called by an onboarding plugin to signal that the user is done with + // the onboarding UI, like when pausing, so the onboarding UI can be dismissed. This doesn't necessarily mean that the + // onboarding is finished/complete. So we check to see if onboarding is finished here before calling `completeActiveOnboarding` + if activeOnboarding.isOnboarded { + self.completeActiveOnboarding() + } + + self.complete() + } + } +} + +// MARK: - NotificationAuthorizationProvider + +extension OnboardingManager: NotificationAuthorizationProvider { + func getNotificationAuthorization(_ completion: @escaping (NotificationAuthorization) -> Void) { + NotificationManager.getAuthorization { completion(NotificationAuthorization($0)) } + } + + func authorizeNotification(_ completion: @escaping (NotificationAuthorization) -> Void) { + NotificationManager.authorize{ completion(NotificationAuthorization($0)) } + } +} + +// MARK: - HealthStoreAuthorizationProvider + +extension OnboardingManager: HealthStoreAuthorizationProvider { + func getHealthStoreAuthorization(_ completion: @escaping (HealthStoreAuthorization) -> Void) { + deviceDataManager.getHealthStoreAuthorization { completion(HealthStoreAuthorization($0)) } + } + + func authorizeHealthStore(_ completion: @escaping (HealthStoreAuthorization) -> Void) { + deviceDataManager.authorizeHealthStore { completion(HealthStoreAuthorization($0)) } + } +} + +// MARK: - BluetoothProvider + +extension OnboardingManager: BluetoothProvider { + var bluetoothAuthorization: BluetoothAuthorization { bluetoothProvider.bluetoothAuthorization } + + var bluetoothState: BluetoothState { bluetoothProvider.bluetoothState } + + func authorizeBluetooth(_ completion: @escaping (BluetoothAuthorization) -> Void) { bluetoothProvider.authorizeBluetooth(completion) } + + func addBluetoothObserver(_ observer: BluetoothObserver, queue: DispatchQueue) { bluetoothProvider.addBluetoothObserver(observer, queue: queue) } + + func removeBluetoothObserver(_ observer: BluetoothObserver) { bluetoothProvider.removeBluetoothObserver(observer) } +} + +// MARK: - CGMManagerProvider + +extension OnboardingManager: CGMManagerProvider { + var activeCGMManager: CGMManager? { deviceDataManager.cgmManager } + + var availableCGMManagers: [CGMManagerDescriptor] { deviceDataManager.availableCGMManagers } + + func imageForCGMManager(withIdentifier identifier: String) -> UIImage? { + guard let cgmManagerType = deviceDataManager.cgmManagerTypeByIdentifier(identifier) else { + return nil + } + return cgmManagerType.onboardingImage + } + + func onboardCGMManager(withIdentifier identifier: String, prefersToSkipUserInteraction: Bool) -> Swift.Result, Error> { + guard let cgmManager = deviceDataManager.cgmManager else { + return deviceDataManager.setupCGMManager(withIdentifier: identifier, prefersToSkipUserInteraction: prefersToSkipUserInteraction) + } + guard cgmManager.pluginIdentifier == identifier else { + return .failure(OnboardingError.invalidState) + } + + if cgmManager.isOnboarded { + return .success(.createdAndOnboarded(cgmManager)) + } + + guard let cgmManagerUI = cgmManager as? CGMManagerUI else { + return .failure(OnboardingError.invalidState) + } + + return .success(.userInteractionRequired(cgmManagerUI.settingsViewController(bluetoothProvider: self, displayGlucosePreference: deviceDataManager.displayGlucosePreference, colorPalette: .default, allowDebugFeatures: FeatureFlags.allowDebugFeatures))) + } +} + +// MARK: - PumpManagerProvider + +extension OnboardingManager: PumpManagerProvider { + var activePumpManager: PumpManager? { deviceDataManager.pumpManager } + + var availablePumpManagers: [PumpManagerDescriptor] { deviceDataManager.availablePumpManagers } + + func imageForPumpManager(withIdentifier identifier: String) -> UIImage? { + guard let pumpManagerType = deviceDataManager.pumpManagerTypeByIdentifier(identifier) else { + return nil + } + return pumpManagerType.onboardingImage + } + + func supportedIncrementsForPumpManager(withIdentifier identifier: String) -> PumpSupportedIncrements? { + guard let pumpManagerType = deviceDataManager.pumpManagerTypeByIdentifier(identifier) else { + return nil + } + return PumpSupportedIncrements(basalRates: pumpManagerType.onboardingSupportedBasalRates, + bolusVolumes: pumpManagerType.onboardingSupportedBolusVolumes, + maximumBolusVolumes: pumpManagerType.onboardingSupportedMaximumBolusVolumes, + maximumBasalScheduleEntryCount: pumpManagerType.onboardingMaximumBasalScheduleEntryCount) + } + + func onboardPumpManager(withIdentifier identifier: String, initialSettings settings: PumpManagerSetupSettings, prefersToSkipUserInteraction: Bool) -> Swift.Result, Error> { + guard let pumpManager = deviceDataManager.pumpManager else { + return deviceDataManager.setupPumpManager(withIdentifier: identifier, initialSettings: settings, prefersToSkipUserInteraction: prefersToSkipUserInteraction) + } + guard pumpManager.pluginIdentifier == identifier else { + return .failure(OnboardingError.invalidState) + } + + if pumpManager.isOnboarded { + return .success(.createdAndOnboarded(pumpManager)) + } + + return .success(.userInteractionRequired(pumpManager.settingsViewController(bluetoothProvider: self, colorPalette: .default, allowDebugFeatures: FeatureFlags.allowDebugFeatures, allowedInsulinTypes: deviceDataManager.allowedInsulinTypes))) + } +} + +// MARK: - StatefulPluggableProvider + +extension OnboardingManager: StatefulPluggableProvider { + func statefulPlugin(withIdentifier identifier: String) -> StatefulPluggable? { + statefulPluginManager.statefulPlugin(withIdentifier: identifier) } +} + +// MARK: - ServiceProvider + +extension OnboardingManager: ServiceProvider { + var activeServices: [Service] { servicesManager.activeServices } + + var availableServices: [ServiceDescriptor] { servicesManager.availableServices } + + func onboardService(withIdentifier identifier: String) -> Swift.Result, Error> { + guard let service = activeServices.first(where: { $0.pluginIdentifier == identifier }) else { + return servicesManager.setupService(withIdentifier: identifier) + } + + if service.isOnboarded { + return .success(.createdAndOnboarded(service)) + } + + guard let serviceUI = service as? ServiceUI else { + return .failure(OnboardingError.invalidState) + } + + return .success(.userInteractionRequired(serviceUI.settingsViewController(colorPalette: .default))) + } +} + +// MARK: - TherapySettingsProvider + +extension OnboardingManager: TherapySettingsProvider { + var onboardingTherapySettings: TherapySettings { + return loopDataManager.therapySettings + } +} + +// MARK: - OnboardingProvider + +extension OnboardingManager: OnboardingProvider { + var allowDebugFeatures: Bool { FeatureFlags.allowDebugFeatures } // NOTE: DEBUG FEATURES - DEBUG AND TEST ONLY +} + +// MARK: - SupportProvider + +extension OnboardingManager: SupportProvider { + var availableSupports: [SupportUI] { supportManager.availableSupports } +} + +// MARK: - OnboardingUI + +fileprivate extension OnboardingUI { + typealias RawValue = [String: Any] + + var rawValue: RawValue { + return [ + "onboardingIdentifier": pluginIdentifier, + "state": rawState + ] + } +} + +// MARK: - OnboardingError + +enum OnboardingError: LocalizedError { + case invalidState + + var errorDescription: String? { + switch self { + case .invalidState: + return NSLocalizedString("An unexpected onboarding error state occurred.", comment: "Invalid onboarding state") + } + } +} + +// MARK: - UserDefaults + +fileprivate extension UserDefaults { + private enum Key: String { + case onboardingManagerIsSuspended = "com.loopkit.Loop.OnboardingManager.IsSuspended" + case onboardingManagerIsComplete = "com.loopkit.Loop.OnboardingManager.IsComplete" + case onboardingManagerCompletedOnboardingIdentifiers = "com.loopkit.Loop.OnboardingManager.CompletedOnboardingIdentifiers" + case onboardingManagerActiveOnboardingRawValue = "com.loopkit.Loop.OnboardingManager.ActiveOnboardingRawValue" + } + + var onboardingManagerIsSuspended: Bool { + get { bool(forKey: Key.onboardingManagerIsSuspended.rawValue) } + set { set(newValue, forKey: Key.onboardingManagerIsSuspended.rawValue) } + } + + var onboardingManagerIsComplete: Bool { + get { bool(forKey: Key.onboardingManagerIsComplete.rawValue) } + set { set(newValue, forKey: Key.onboardingManagerIsComplete.rawValue) } + } + + var onboardingManagerCompletedOnboardingIdentifiers: [String] { + get { array(forKey: Key.onboardingManagerCompletedOnboardingIdentifiers.rawValue) as? [String] ?? [] } + set { set(newValue, forKey: Key.onboardingManagerCompletedOnboardingIdentifiers.rawValue) } + } + + var onboardingManagerActiveOnboardingRawValue: OnboardingUI.RawValue? { + get { object(forKey: Key.onboardingManagerActiveOnboardingRawValue.rawValue) as? OnboardingUI.RawValue } + set { set(newValue, forKey: Key.onboardingManagerActiveOnboardingRawValue.rawValue) } + } +} + +// MARK: - NotificationAuthorization + +extension NotificationAuthorization { + init(_ authorization: UNAuthorizationStatus) { + switch authorization { + case .notDetermined: + self = .notDetermined + case .denied: + self = .denied + case .authorized: + self = .authorized + case .provisional: + self = .provisional + case .ephemeral: + self = .denied + @unknown default: + self = .notDetermined + } + } +} + +// MARK: - HealthStoreAuthorization + +extension HealthStoreAuthorization { + init(_ authorization: HKAuthorizationRequestStatus) { + switch authorization { + case .unknown: + self = .notDetermined + case .shouldRequest: + self = .notDetermined + case .unnecessary: + self = .determined + @unknown default: + self = .notDetermined + } + } +} diff --git a/Loop/Managers/RemoteDataManager.swift b/Loop/Managers/RemoteDataManager.swift deleted file mode 100644 index 631ff5e68a..0000000000 --- a/Loop/Managers/RemoteDataManager.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// RemoteDataManager.swift -// Loop -// -// Created by Nate Racklyeft on 6/29/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import Foundation -import NightscoutUploadKit -import ShareClient - - -final class RemoteDataManager { - - var nightscoutUploader: NightscoutUploader? { - return nightscoutService.uploader - } - - var nightscoutService: NightscoutService { - didSet { - keychain.setNightscoutURL(nightscoutService.siteURL, secret: nightscoutService.APISecret) - UIDevice.current.isBatteryMonitoringEnabled = true - } - } - - var shareClient: ShareClient? { - return shareService.client - } - - var shareService: ShareService { - didSet { - try! keychain.setDexcomShareUsername(shareService.username, password: shareService.password) - } - } - - private let keychain = KeychainManager() - - init() { - if let (username, password) = keychain.getDexcomShareCredentials() { - shareService = ShareService(username: username, password: password) - } else { - shareService = ShareService(username: nil, password: nil) - } - - if let (siteURL, APISecret) = keychain.getNightscoutCredentials() { - nightscoutService = NightscoutService(siteURL: siteURL, APISecret: APISecret) - UIDevice.current.isBatteryMonitoringEnabled = true - } else { - nightscoutService = NightscoutService(siteURL: nil, APISecret: nil) - } - } -} diff --git a/Loop/Managers/RemoteDataServicesManager.swift b/Loop/Managers/RemoteDataServicesManager.swift new file mode 100644 index 0000000000..bf21376bc3 --- /dev/null +++ b/Loop/Managers/RemoteDataServicesManager.swift @@ -0,0 +1,647 @@ +// +// RemoteDataServicesManager.swift +// Loop +// +// Created by Nate Racklyeft on 6/29/16. +// Copyright © 2016 Nathan Racklyeft. All rights reserved. +// + +import os.log +import Foundation +import LoopKit + +enum RemoteDataType: String, CaseIterable { + case alert = "Alert" + case carb = "Carb" + case dose = "Dose" + case dosingDecision = "DosingDecision" + case glucose = "Glucose" + case pumpEvent = "PumpEvent" + case cgmEvent = "CgmEvent" + case settings = "Settings" + case overrides = "Overrides" + + var debugDescription: String { + return self.rawValue + } +} + +// Each service can upload each type in parallel. But no more than one task for each +// service & type combination should be running concurrently. +struct UploadTaskKey: Hashable { + let serviceIdentifier: String + let remoteDataType: RemoteDataType + + var queueName: String { + return "com.loopkit.Loop.RemoteDataServicesManager.\(serviceIdentifier).\(remoteDataType.rawValue)DispatchQueue" + } +} + +final class RemoteDataServicesManager { + + public typealias RawState = [String: Any] + + public weak var delegate: RemoteDataServicesManagerDelegate? + + private var lock = UnfairLock() + + + // RemoteDataServices + + private var unlockedRemoteDataServices = [RemoteDataService]() + + func addService(_ remoteDataService: RemoteDataService) { + lock.withLock { + unlockedRemoteDataServices.append(remoteDataService) + } + uploadExistingData(to: remoteDataService) + } + + func restoreService(_ remoteDataService: RemoteDataService) { + lock.withLock { + unlockedRemoteDataServices.append(remoteDataService) + } + } + + func removeService(_ remoteDataService: RemoteDataService) { + lock.withLock { + unlockedRemoteDataServices.removeAll { $0.pluginIdentifier == remoteDataService.pluginIdentifier } + } + clearQueryAnchors(for: remoteDataService) + } + + private var remoteDataServices: [RemoteDataService] { + return lock.withLock { unlockedRemoteDataServices } + } + + + // Dispatch Queues for each Service/DataType + + private var unlockedDispatchQueues = [UploadTaskKey: DispatchQueue]() + + + private func dispatchQueue(for remoteDataService: RemoteDataService, withRemoteDataType remoteDataType: RemoteDataType) -> DispatchQueue { + let key = UploadTaskKey(serviceIdentifier: remoteDataService.pluginIdentifier, remoteDataType: remoteDataType) + return dispatchQueue(key) + } + + private func dispatchQueue(_ key: UploadTaskKey) -> DispatchQueue { + return lock.withLock { + if let dispatchQueue = self.unlockedDispatchQueues[key] { + return dispatchQueue + } + + let dispatchQueue = DispatchQueue(label: key.queueName, qos: .utility) + self.unlockedDispatchQueues[key] = dispatchQueue + return dispatchQueue + } + } + + private var lockedFailedUploads: Locked> + + var failedUploads: [UploadTaskKey] { + Array(lockedFailedUploads.value) + } + + func uploadFailed(_ key: UploadTaskKey) { + lockedFailedUploads.mutate { failedUploads in + failedUploads.insert(key) + } + log.debug("RemoteDataType %{public}@ upload failed", key.remoteDataType.rawValue) + } + + func uploadSucceeded(_ key: UploadTaskKey) { + lockedFailedUploads.mutate { failedUploads in + failedUploads.remove(key) + } + } + + let uploadGroup = DispatchGroup() + + private let log = OSLog(category: "RemoteDataServicesManager") + + private let alertStore: AlertStore + + private let carbStore: CarbStore + + private let doseStore: DoseStore + + private let dosingDecisionStore: DosingDecisionStore + + private let glucoseStore: GlucoseStore + + private let cgmEventStore: CgmEventStore + + private let insulinDeliveryStore: InsulinDeliveryStore + + private let settingsStore: SettingsStore + + private let overrideHistory: TemporaryScheduleOverrideHistory + + init( + alertStore: AlertStore, + carbStore: CarbStore, + doseStore: DoseStore, + dosingDecisionStore: DosingDecisionStore, + glucoseStore: GlucoseStore, + cgmEventStore: CgmEventStore, + settingsStore: SettingsStore, + overrideHistory: TemporaryScheduleOverrideHistory, + insulinDeliveryStore: InsulinDeliveryStore + ) { + self.alertStore = alertStore + self.carbStore = carbStore + self.doseStore = doseStore + self.dosingDecisionStore = dosingDecisionStore + self.glucoseStore = glucoseStore + self.cgmEventStore = cgmEventStore + self.insulinDeliveryStore = insulinDeliveryStore + self.settingsStore = settingsStore + self.overrideHistory = overrideHistory + self.lockedFailedUploads = Locked([]) + } + + private func uploadExistingData(to remoteDataService: RemoteDataService) { + uploadAlertData(to: remoteDataService) + uploadCarbData(to: remoteDataService) + uploadDoseData(to: remoteDataService) + uploadDosingDecisionData(to: remoteDataService) + uploadGlucoseData(to: remoteDataService) + uploadPumpEventData(to: remoteDataService) + uploadSettingsData(to: remoteDataService) + } + + private func clearQueryAnchors(for remoteDataService: RemoteDataService) { + for remoteDataType in RemoteDataType.allCases { + dispatchQueue(for: remoteDataService, withRemoteDataType: remoteDataType).async { + UserDefaults.appGroup?.deleteQueryAnchor(for: remoteDataService, withRemoteDataType: remoteDataType) + } + } + } + + func triggerUpload(for triggeringType: RemoteDataType) { + let uploadTypes = [triggeringType] + failedUploads.map { $0.remoteDataType } + + log.debug("RemoteDataType %{public}@ triggering uploads for: %{public}@", triggeringType.rawValue, String(describing: uploadTypes.map { $0.debugDescription})) + + for type in uploadTypes { + switch type { + case .alert: + remoteDataServices.forEach { self.uploadAlertData(to: $0) } + case .carb: + remoteDataServices.forEach { self.uploadCarbData(to: $0) } + case .dose: + remoteDataServices.forEach { self.uploadDoseData(to: $0) } + case .dosingDecision: + remoteDataServices.forEach { self.uploadDosingDecisionData(to: $0) } + case .glucose: + remoteDataServices.forEach { self.uploadGlucoseData(to: $0) } + case .pumpEvent: + remoteDataServices.forEach { self.uploadPumpEventData(to: $0) } + case .cgmEvent: + remoteDataServices.forEach { self.uploadCgmEventData(to: $0) } + case .settings: + remoteDataServices.forEach { self.uploadSettingsData(to: $0) } + case .overrides: + remoteDataServices.forEach { self.uploadTemporaryOverrideData(to: $0) } + } + } + } + + func triggerUpload(for triggeringType: RemoteDataType, completion: @escaping () -> Void) { + triggerUpload(for: triggeringType) + self.uploadGroup.notify(queue: DispatchQueue.main) { + completion() + } + } + + func triggerUpload(for triggeringType: RemoteDataType) async { + return await withCheckedContinuation { continuation in + triggerUpload(for: triggeringType) { + continuation.resume(returning: ()) + } + } + } +} + +extension RemoteDataServicesManager { + private func uploadAlertData(to remoteDataService: RemoteDataService) { + uploadGroup.enter() + + let key = UploadTaskKey(serviceIdentifier: remoteDataService.pluginIdentifier, remoteDataType: .alert) + + dispatchQueue(key).async { + let semaphore = DispatchSemaphore(value: 0) + let queryAnchor = UserDefaults.appGroup?.getQueryAnchor(for: remoteDataService, withRemoteDataType: .alert) ?? AlertStore.QueryAnchor() + + self.alertStore.executeAlertQuery(fromQueryAnchor: queryAnchor, limit: remoteDataService.alertDataLimit ?? Int.max) { result in + switch result { + case .failure(let error): + self.log.error("Error querying alert data: %{public}@", String(describing: error)) + semaphore.signal() + case .success(let queryAnchor, let data): + remoteDataService.uploadAlertData(data) { result in + switch result { + case .failure(let error): + self.log.error("Error synchronizing alert data: %{public}@", String(describing: error)) + self.uploadFailed(key) + case .success: + UserDefaults.appGroup?.setQueryAnchor(for: remoteDataService, withRemoteDataType: .alert, queryAnchor) + self.uploadSucceeded(key) + } + semaphore.signal() + } + } + } + semaphore.wait() + self.uploadGroup.leave() + } + } +} + +extension RemoteDataServicesManager { + private func uploadCarbData(to remoteDataService: RemoteDataService) { + uploadGroup.enter() + + let key = UploadTaskKey(serviceIdentifier: remoteDataService.pluginIdentifier, remoteDataType: .carb) + + dispatchQueue(key).async { + let semaphore = DispatchSemaphore(value: 0) + let previousQueryAnchor = UserDefaults.appGroup?.getQueryAnchor(for: remoteDataService, withRemoteDataType: .carb) ?? CarbStore.QueryAnchor() + var continueUpload = false + + self.carbStore.executeCarbQuery(fromQueryAnchor: previousQueryAnchor, limit: remoteDataService.carbDataLimit ?? Int.max) { result in + switch result { + case .failure(let error): + self.log.error("Error querying carb data: %{public}@", String(describing: error)) + semaphore.signal() + case .success(let queryAnchor, let created, let updated, let deleted): + remoteDataService.uploadCarbData(created: created, updated: updated, deleted: deleted) { result in + switch result { + case .failure(let error): + self.log.error("Error synchronizing carb data: %{public}@", String(describing: error)) + self.uploadFailed(key) + case .success: + UserDefaults.appGroup?.setQueryAnchor(for: remoteDataService, withRemoteDataType: .carb, queryAnchor) + continueUpload = queryAnchor != previousQueryAnchor + self.uploadSucceeded(key) + } + semaphore.signal() + } + } + } + + semaphore.wait() + self.uploadGroup.leave() + + if continueUpload { + self.uploadCarbData(to: remoteDataService) + } + } + } +} + +extension RemoteDataServicesManager { + private func uploadDoseData(to remoteDataService: RemoteDataService) { + uploadGroup.enter() + + let key = UploadTaskKey(serviceIdentifier: remoteDataService.pluginIdentifier, remoteDataType: .dose) + + dispatchQueue(key).async { + let semaphore = DispatchSemaphore(value: 0) + let previousQueryAnchor = UserDefaults.appGroup?.getQueryAnchor(for: remoteDataService, withRemoteDataType: .dose) ?? InsulinDeliveryStore.QueryAnchor() + var continueUpload = false + + self.insulinDeliveryStore.executeDoseQuery(fromQueryAnchor: previousQueryAnchor, limit: remoteDataService.doseDataLimit ?? Int.max) { result in + switch result { + case .failure(let error): + self.log.error("Error querying dose data: %{public}@", String(describing: error)) + semaphore.signal() + case .success(let queryAnchor, let created, let deleted): + remoteDataService.uploadDoseData(created: created, deleted: deleted) { result in + switch result { + case .failure(let error): + self.log.error("Error synchronizing dose data: %{public}@", String(describing: error)) + self.uploadFailed(key) + case .success: + UserDefaults.appGroup?.setQueryAnchor(for: remoteDataService, withRemoteDataType: .dose, queryAnchor) + continueUpload = queryAnchor != previousQueryAnchor + self.uploadSucceeded(key) + } + semaphore.signal() + } + } + } + + semaphore.wait() + self.uploadGroup.leave() + + if continueUpload { + self.uploadDoseData(to: remoteDataService) + } + } + } +} + +extension RemoteDataServicesManager { + private func uploadDosingDecisionData(to remoteDataService: RemoteDataService) { + uploadGroup.enter() + + let key = UploadTaskKey(serviceIdentifier: remoteDataService.pluginIdentifier, remoteDataType: .dosingDecision) + + dispatchQueue(key).async { + let semaphore = DispatchSemaphore(value: 0) + let previousQueryAnchor = UserDefaults.appGroup?.getQueryAnchor(for: remoteDataService, withRemoteDataType: .dosingDecision) ?? DosingDecisionStore.QueryAnchor() + var continueUpload = false + + self.dosingDecisionStore.executeDosingDecisionQuery(fromQueryAnchor: previousQueryAnchor, limit: remoteDataService.dosingDecisionDataLimit ?? Int.max) { result in + switch result { + case .failure(let error): + self.log.error("Error querying dosing decision data: %{public}@", String(describing: error)) + semaphore.signal() + case .success(let queryAnchor, let data): + remoteDataService.uploadDosingDecisionData(data) { result in + switch result { + case .failure(let error): + self.log.error("Error synchronizing dosing decision data: %{public}@", String(describing: error)) + self.uploadFailed(key) + case .success: + UserDefaults.appGroup?.setQueryAnchor(for: remoteDataService, withRemoteDataType: .dosingDecision, queryAnchor) + continueUpload = queryAnchor != previousQueryAnchor + self.uploadSucceeded(key) + } + semaphore.signal() + } + } + } + + semaphore.wait() + self.uploadGroup.leave() + + if continueUpload { + self.uploadDosingDecisionData(to: remoteDataService) + } + } + } +} + +extension RemoteDataServicesManager { + private func uploadGlucoseData(to remoteDataService: RemoteDataService) { + + if delegate?.shouldSyncToRemoteService == false { + return + } + + uploadGroup.enter() + + let key = UploadTaskKey(serviceIdentifier: remoteDataService.pluginIdentifier, remoteDataType: .glucose) + + dispatchQueue(key).async { + let semaphore = DispatchSemaphore(value: 0) + let previousQueryAnchor = UserDefaults.appGroup?.getQueryAnchor(for: remoteDataService, withRemoteDataType: .glucose) ?? GlucoseStore.QueryAnchor() + var continueUpload = false + + self.glucoseStore.executeGlucoseQuery(fromQueryAnchor: previousQueryAnchor, limit: remoteDataService.glucoseDataLimit ?? Int.max) { result in + switch result { + case .failure(let error): + self.log.error("Error querying glucose data: %{public}@", String(describing: error)) + semaphore.signal() + case .success(let queryAnchor, let data): + remoteDataService.uploadGlucoseData(data) { result in + switch result { + case .failure(let error): + self.log.error("Error synchronizing glucose data: %{public}@", String(describing: error)) + self.uploadFailed(key) + case .success: + UserDefaults.appGroup?.setQueryAnchor(for: remoteDataService, withRemoteDataType: .glucose, queryAnchor) + continueUpload = queryAnchor != previousQueryAnchor + self.uploadSucceeded(key) + } + semaphore.signal() + } + } + } + + semaphore.wait() + self.uploadGroup.leave() + + if continueUpload { + self.uploadGlucoseData(to: remoteDataService) + } + } + } +} + +extension RemoteDataServicesManager { + private func uploadPumpEventData(to remoteDataService: RemoteDataService) { + uploadGroup.enter() + + let key = UploadTaskKey(serviceIdentifier: remoteDataService.pluginIdentifier, remoteDataType: .pumpEvent) + + dispatchQueue(for: remoteDataService, withRemoteDataType: .pumpEvent).async { + let semaphore = DispatchSemaphore(value: 0) + let previousQueryAnchor = UserDefaults.appGroup?.getQueryAnchor(for: remoteDataService, withRemoteDataType: .pumpEvent) ?? DoseStore.QueryAnchor() + var continueUpload = false + + self.doseStore.executePumpEventQuery(fromQueryAnchor: previousQueryAnchor, limit: remoteDataService.pumpEventDataLimit ?? Int.max) { result in + switch result { + case .failure(let error): + self.log.error("Error querying pump event data: %{public}@", String(describing: error)) + semaphore.signal() + case .success(let queryAnchor, let data): + remoteDataService.uploadPumpEventData(data) { result in + switch result { + case .failure(let error): + self.log.error("Error synchronizing pump event data: %{public}@", String(describing: error)) + self.uploadFailed(key) + case .success: + UserDefaults.appGroup?.setQueryAnchor(for: remoteDataService, withRemoteDataType: .pumpEvent, queryAnchor) + continueUpload = queryAnchor != previousQueryAnchor + self.uploadSucceeded(key) + } + semaphore.signal() + } + } + } + + semaphore.wait() + self.uploadGroup.leave() + + if continueUpload { + self.uploadPumpEventData(to: remoteDataService) + } + } + } +} + +extension RemoteDataServicesManager { + private func uploadSettingsData(to remoteDataService: RemoteDataService) { + uploadGroup.enter() + + let key = UploadTaskKey(serviceIdentifier: remoteDataService.pluginIdentifier, remoteDataType: .settings) + + dispatchQueue(for: remoteDataService, withRemoteDataType: .settings).async { + let semaphore = DispatchSemaphore(value: 0) + let previousQueryAnchor = UserDefaults.appGroup?.getQueryAnchor(for: remoteDataService, withRemoteDataType: .settings) ?? SettingsStore.QueryAnchor() + var continueUpload = false + + self.settingsStore.executeSettingsQuery(fromQueryAnchor: previousQueryAnchor, limit: remoteDataService.settingsDataLimit ?? Int.max) { result in + switch result { + case .failure(let error): + self.log.error("Error querying settings data: %{public}@", String(describing: error)) + semaphore.signal() + case .success(let queryAnchor, let data): + remoteDataService.uploadSettingsData(data) { result in + switch result { + case .failure(let error): + self.log.error("Error synchronizing settings data: %{public}@", String(describing: error)) + self.uploadFailed(key) + case .success: + UserDefaults.appGroup?.setQueryAnchor(for: remoteDataService, withRemoteDataType: .settings, queryAnchor) + continueUpload = queryAnchor != previousQueryAnchor + self.uploadSucceeded(key) + } + semaphore.signal() + } + } + } + + semaphore.wait() + self.uploadGroup.leave() + + if continueUpload { + self.uploadSettingsData(to: remoteDataService) + } + } + } +} + +extension RemoteDataServicesManager { + private func uploadTemporaryOverrideData(to remoteDataService: RemoteDataService) { + uploadGroup.enter() + + let key = UploadTaskKey(serviceIdentifier: remoteDataService.pluginIdentifier, remoteDataType: .overrides) + + dispatchQueue(for: remoteDataService, withRemoteDataType: .overrides).async { + let semaphore = DispatchSemaphore(value: 0) + + let queryAnchor = UserDefaults.appGroup?.getQueryAnchor(for: remoteDataService, withRemoteDataType: .overrides) ?? TemporaryScheduleOverrideHistory.QueryAnchor() + + let (overrides, deletedOverrides, newAnchor) = self.overrideHistory.queryByAnchor(queryAnchor) + + remoteDataService.uploadTemporaryOverrideData(updated: overrides, deleted: deletedOverrides) { result in + switch result { + case .failure(let error): + self.log.error("Error synchronizing temporary override data: %{public}@", String(describing: error)) + self.uploadFailed(key) + case .success: + UserDefaults.appGroup?.setQueryAnchor(for: remoteDataService, withRemoteDataType: .overrides, newAnchor) + self.uploadSucceeded(key) + } + semaphore.signal() + } + + semaphore.wait() + self.uploadGroup.leave() + } + } +} + +extension RemoteDataServicesManager { + private func uploadCgmEventData(to remoteDataService: RemoteDataService) { + uploadGroup.enter() + + let key = UploadTaskKey(serviceIdentifier: remoteDataService.pluginIdentifier, remoteDataType: .pumpEvent) + + dispatchQueue(for: remoteDataService, withRemoteDataType: .cgmEvent).async { + let semaphore = DispatchSemaphore(value: 0) + let previousQueryAnchor = UserDefaults.appGroup?.getQueryAnchor(for: remoteDataService, withRemoteDataType: .cgmEvent) ?? CgmEventStore.QueryAnchor() + var continueUpload = false + + self.cgmEventStore.executeCgmEventQuery(fromQueryAnchor: previousQueryAnchor) { result in + switch result { + case .failure(let error): + self.log.error("Error querying cgm event data: %{public}@", String(describing: error)) + semaphore.signal() + case .success(let queryAnchor, let data): + remoteDataService.uploadCgmEventData(data) { result in + switch result { + case .failure(let error): + self.log.error("Error synchronizing cgm event data: %{public}@", String(describing: error)) + self.uploadFailed(key) + case .success: + UserDefaults.appGroup?.setQueryAnchor(for: remoteDataService, withRemoteDataType: .cgmEvent, queryAnchor) + continueUpload = queryAnchor != previousQueryAnchor + self.uploadSucceeded(key) + } + semaphore.signal() + } + } + } + + semaphore.wait() + self.uploadGroup.leave() + + if continueUpload { + self.uploadPumpEventData(to: remoteDataService) + } + } + } +} + +//Remote Commands +extension RemoteDataServicesManager { + + public func remoteNotificationWasReceived(_ notification: [String: AnyObject]) async throws { + let service = try serviceForPushNotification(notification) + return try await service.remoteNotificationWasReceived(notification) + } + + func serviceForPushNotification(_ notification: [String: AnyObject]) throws -> RemoteDataService { + let defaultServiceIdentifier = "NightscoutService" + let serviceIdentifier = notification["serviceIdentifier"] as? String ?? defaultServiceIdentifier + guard let service = remoteDataServices.first(where: {$0.pluginIdentifier == serviceIdentifier}) else { + throw RemoteDataServicesManagerCommandError.unsupportedServiceIdentifier(serviceIdentifier) + } + return service + } + + enum RemoteDataServicesManagerCommandError: LocalizedError { + case unsupportedServiceIdentifier(String) + + var errorDescription: String? { + switch self { + case .unsupportedServiceIdentifier(let serviceIdentifier): + return String(format: NSLocalizedString("Unsupported Notification Service: %1$@", comment: "Error message when a service can't be found to handle a push notification. (1: Service Identifier)"), serviceIdentifier) + } + } + } +} + +protocol RemoteDataServicesManagerDelegate: AnyObject { + var shouldSyncToRemoteService: Bool {get} +} + + +fileprivate extension UserDefaults { + + private func queryAnchorKey(for remoteDataService: RemoteDataService, withRemoteDataType remoteDataType: RemoteDataType) -> String { + return "com.loopkit.Loop.RemoteDataServicesManager.\(remoteDataService.pluginIdentifier).\(remoteDataType.rawValue)QueryAnchor" + } + + func getQueryAnchor(for remoteDataService: RemoteDataService, withRemoteDataType remoteDataType: RemoteDataType) -> T? where T: RawRepresentable, T.RawValue == [String: Any] { + guard let rawQueryAnchor = dictionary(forKey: queryAnchorKey(for: remoteDataService, withRemoteDataType: remoteDataType)) else { + return nil + } + return T.init(rawValue: rawQueryAnchor) + } + + func setQueryAnchor(for remoteDataService: RemoteDataService, withRemoteDataType remoteDataType: RemoteDataType, _ queryAnchor: T) where T: RawRepresentable, T.RawValue == [String: Any] { + set(queryAnchor.rawValue, forKey: queryAnchorKey(for: remoteDataService, withRemoteDataType: remoteDataType)) + } + + func deleteQueryAnchor(for remoteDataService: RemoteDataService, withRemoteDataType remoteDataType: RemoteDataType) { + removeObject(forKey: queryAnchorKey(for: remoteDataService, withRemoteDataType: remoteDataType)) + } + +} diff --git a/Loop/Managers/ResetLoopManager.swift b/Loop/Managers/ResetLoopManager.swift new file mode 100644 index 0000000000..fe3f1710a1 --- /dev/null +++ b/Loop/Managers/ResetLoopManager.swift @@ -0,0 +1,126 @@ +// +// ResetLoopManager.swift +// Loop +// +// Created by Cameron Ingham on 5/18/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import LoopKit + +protocol ResetLoopManagerDelegate: AnyObject { + func loopWillReset() + func loopDidReset() + + func resetTestingData(completion: @escaping () -> Void) + + func presentConfirmationAlert( + confirmAction: @escaping (_ pumpManager: PumpManager?, _ completion: @escaping () -> Void) -> Void, + cancelAction: @escaping () -> Void + ) + + func presentCouldNotResetLoopAlert(error: Error) +} + +class ResetLoopManager { + + private weak var delegate: ResetLoopManagerDelegate? + + private var loopIsAlreadyReset: Bool = false + private var resetAlertPresented: Bool = false + + init(delegate: ResetLoopManagerDelegate?) { + self.delegate = delegate + + checkIfLoopIsAlreadyReset() + } + + func askUserToConfirmLoopReset() { + if loopIsAlreadyReset { + UserDefaults.appGroup?.userRequestedLoopReset = false + } + + if UserDefaults.appGroup?.userRequestedLoopReset == true && !resetAlertPresented { + resetAlertPresented = true + + delegate?.presentConfirmationAlert( + confirmAction: { [weak self] pumpManager, completion in + self?.resetAlertPresented = false + + guard let pumpManager else { + self?.resetLoop { + completion() + } + return + } + + pumpManager.prepareForDeactivation() { [weak self] error in + guard let error = error else { + self?.resetLoop() { + completion() + } + return + } + + self?.delegate?.presentCouldNotResetLoopAlert(error: error) + } + }, cancelAction: { [weak self] in + self?.resetAlertPresented = false + UserDefaults.appGroup?.userRequestedLoopReset = false + } + ) + } + + checkIfLoopIsAlreadyReset() + } + + private func checkIfLoopIsAlreadyReset() { + let fileManager = FileManager.default + + guard let url = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else { + return + } + + guard let hasReset = try? fileManager.contentsOfDirectory(atPath: url.path).count <= 1 else { + return + } + + loopIsAlreadyReset = hasReset + } + + private func resetLoop(completion: @escaping () -> Void) { + delegate?.loopWillReset() + + delegate?.resetTestingData { [weak self] in + self?.resetLoopDocuments() + self?.resetLoopUserDefaults() + self?.delegate?.loopDidReset() + completion() + } + } + + private func resetLoopUserDefaults() { + // Store values to persist + let allowDebugFeatures = UserDefaults.appGroup?.allowDebugFeatures + + // Wipe away whole domain + UserDefaults.appGroup?.removePersistentDomain(forName: Bundle.main.appGroupSuiteName) + + // Restore values to persist + UserDefaults.appGroup?.allowDebugFeatures = allowDebugFeatures ?? false + } + + private func resetLoopDocuments() { + guard let directoryURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: Bundle.main.appGroupSuiteName) else { + preconditionFailure("Could not get a container directory URL. Please ensure App Groups are set up correctly in entitlements.") + } + + let documents: URL = directoryURL.appendingPathComponent("com.loopkit.LoopKit", isDirectory: true) + try? FileManager.default.removeItem(at: documents) + + guard let localDocuments = try? FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) else { + preconditionFailure("Could not get a documents directory URL.") + } + try? FileManager.default.removeItem(at: localDocuments) + } +} diff --git a/Loop/Managers/Service.swift b/Loop/Managers/Service.swift new file mode 100644 index 0000000000..9f4b2f0eee --- /dev/null +++ b/Loop/Managers/Service.swift @@ -0,0 +1,32 @@ +// +// Service.swift +// Loop +// +// Created by Darin Krauss on 5/17/19. +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import LoopKit +import LoopKitUI +import MockKit + +let staticServices: [Service.Type] = [MockService.self] + +let staticServicesByIdentifier: [String: Service.Type] = staticServices.reduce(into: [:]) { (map, Type) in + map[Type.pluginIdentifier] = Type +} + +let availableStaticServices = staticServices.map { (Type) -> ServiceDescriptor in + return ServiceDescriptor(identifier: Type.pluginIdentifier, localizedTitle: Type.localizedTitle) +} + +func ServiceFromRawValue(_ rawValue: [String: Any]) -> Service? { + guard let serviceIdentifier = rawValue["serviceIdentifier"] as? String, + let rawState = rawValue["state"] as? Service.RawStateValue, + let ServiceType = staticServicesByIdentifier[serviceIdentifier] + else { + return nil + } + + return ServiceType.init(rawState: rawState) +} diff --git a/Loop/Managers/ServicesManager.swift b/Loop/Managers/ServicesManager.swift new file mode 100644 index 0000000000..2393ceb073 --- /dev/null +++ b/Loop/Managers/ServicesManager.swift @@ -0,0 +1,414 @@ +// +// ServicesManager.swift +// Loop +// +// Created by Darin Krauss on 5/22/19. +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import os.log +import LoopKit +import LoopKitUI +import LoopCore +import Combine + +class ServicesManager { + + private let pluginManager: PluginManager + + private let alertManager: AlertManager + + let analyticsServicesManager: AnalyticsServicesManager + + let loggingServicesManager: LoggingServicesManager + + let remoteDataServicesManager: RemoteDataServicesManager + + let settingsManager: SettingsManager + + weak var servicesManagerDelegate: ServicesManagerDelegate? + weak var servicesManagerDosingDelegate: ServicesManagerDosingDelegate? + + private var services = [Service]() + + private let servicesLock = UnfairLock() + + private let log = OSLog(category: "ServicesManager") + + lazy private var cancellables = Set() + + @PersistedProperty(key: "Services") + var rawServices: [Service.RawValue]? + + init( + pluginManager: PluginManager, + alertManager: AlertManager, + analyticsServicesManager: AnalyticsServicesManager, + loggingServicesManager: LoggingServicesManager, + remoteDataServicesManager: RemoteDataServicesManager, + settingsManager: SettingsManager, + servicesManagerDelegate: ServicesManagerDelegate, + servicesManagerDosingDelegate: ServicesManagerDosingDelegate + ) { + self.pluginManager = pluginManager + self.alertManager = alertManager + self.analyticsServicesManager = analyticsServicesManager + self.loggingServicesManager = loggingServicesManager + self.remoteDataServicesManager = remoteDataServicesManager + self.settingsManager = settingsManager + self.servicesManagerDelegate = servicesManagerDelegate + self.servicesManagerDosingDelegate = servicesManagerDosingDelegate + restoreState() + } + + public var availableServices: [ServiceDescriptor] { + return pluginManager.availableServices + availableStaticServices + } + + func setupService(withIdentifier identifier: String) -> Swift.Result, Error> { + switch setupServiceUI(withIdentifier: identifier) { + case .failure(let error): + return .failure(error) + case .success(let success): + switch success { + case .userInteractionRequired(let viewController): + return .success(.userInteractionRequired(viewController)) + case .createdAndOnboarded(let serviceUI): + return .success(.createdAndOnboarded(serviceUI)) + } + } + } + + struct UnknownServiceIdentifierError: Error {} + + fileprivate func setupServiceUI(withIdentifier identifier: String) -> Swift.Result, Error> { + guard let serviceUIType = serviceUITypeByIdentifier(identifier) else { + return .failure(UnknownServiceIdentifierError()) + } + + let result = serviceUIType.setupViewController(colorPalette: .default, pluginHost: self) + if case .createdAndOnboarded(let serviceUI) = result { + serviceOnboarding(didCreateService: serviceUI) + serviceOnboarding(didOnboardService: serviceUI) + } + + return .success(result) + } + + func serviceUITypeByIdentifier(_ identifier: String) -> ServiceUI.Type? { + return pluginManager.getServiceTypeByIdentifier(identifier) ?? staticServicesByIdentifier[identifier] as? ServiceUI.Type + } + + private func serviceTypeFromRawValue(_ rawValue: Service.RawStateValue) -> Service.Type? { + guard let identifier = rawValue["serviceIdentifier"] as? String else { + return nil + } + + return serviceUITypeByIdentifier(identifier) + } + + private func serviceFromRawValue(_ rawValue: Service.RawStateValue) -> Service? { + guard let serviceType = serviceTypeFromRawValue(rawValue), + let rawState = rawValue["state"] as? Service.RawStateValue + else { + return nil + } + + return serviceType.init(rawState: rawState) + } + + public var activeServices: [Service] { + return servicesLock.withLock { services } + } + + public func addActiveService(_ service: Service) { + servicesLock.withLock { + service.serviceDelegate = self + service.stateDelegate = self + + services.append(service) + + if let analyticsService = service as? AnalyticsService { + analyticsServicesManager.addService(analyticsService) + } + if let loggingService = service as? LoggingService { + loggingServicesManager.addService(loggingService) + } + if let remoteDataService = service as? RemoteDataService { + remoteDataServicesManager.addService(remoteDataService) + } + + saveState() + } + } + + public func removeActiveService(_ service: Service) { + servicesLock.withLock { + if let remoteDataService = service as? RemoteDataService { + remoteDataServicesManager.removeService(remoteDataService) + } + if let loggingService = service as? LoggingService { + loggingServicesManager.removeService(loggingService) + } + if let analyticsService = service as? AnalyticsService { + analyticsServicesManager.removeService(analyticsService) + } + + services.removeAll { $0.pluginIdentifier == service.pluginIdentifier } + + service.serviceDelegate = nil + service.stateDelegate = nil + + saveState() + } + } + + private func saveState() { + rawServices = services.compactMap { $0.rawValue } + UserDefaults.appGroup?.clearLegacyServicesState() + } + + private func restoreState() { + let rawServices = rawServices ?? UserDefaults.appGroup?.legacyServicesState ?? [] + rawServices.forEach { rawValue in + if let service = serviceFromRawValue(rawValue) { + service.serviceDelegate = self + service.stateDelegate = self + + services.append(service) + + if let analyticsService = service as? AnalyticsService { + analyticsServicesManager.restoreService(analyticsService) + } + if let loggingService = service as? LoggingService { + loggingServicesManager.restoreService(loggingService) + } + if let remoteDataService = service as? RemoteDataService { + remoteDataServicesManager.restoreService(remoteDataService) + } + } + } + } + + func handleRemoteNotification(_ notification: [String: AnyObject]) { + Task { + log.default("Remote Notification: Handling notification %{public}@", notification) + + guard FeatureFlags.remoteCommandsEnabled else { + log.error("Remote Notification: Remote Commands not enabled.") + return + } + + let backgroundTask = await beginBackgroundTask(name: "Handle Remote Notification") + do { + try await remoteDataServicesManager.remoteNotificationWasReceived(notification) + } catch { + log.error("Remote Notification: Error: %{public}@", String(describing: error)) + } + + await endBackgroundTask(backgroundTask) + log.default("Remote Notification: Finished handling") + } + } + + private func beginBackgroundTask(name: String) async -> UIBackgroundTaskIdentifier? { + var backgroundTask: UIBackgroundTaskIdentifier? + backgroundTask = await UIApplication.shared.beginBackgroundTask(withName: name) { + guard let backgroundTask = backgroundTask else {return} + Task { + await UIApplication.shared.endBackgroundTask(backgroundTask) + } + + self.log.error("Background Task Expired: %{public}@", name) + } + + return backgroundTask + } + + private func endBackgroundTask(_ backgroundTask: UIBackgroundTaskIdentifier?) async { + guard let backgroundTask else {return} + await UIApplication.shared.endBackgroundTask(backgroundTask) + } +} + +public protocol ServicesManagerDosingDelegate: AnyObject { + func deliverBolus(amountInUnits: Double) async throws +} + +public protocol ServicesManagerDelegate: AnyObject { + func enactOverride(name: String, duration: TemporaryScheduleOverride.Duration?, remoteAddress: String) async throws + func cancelCurrentOverride() async throws + func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws +} + +// MARK: - StatefulPluggableDelegate +extension ServicesManager: StatefulPluggableDelegate { + func pluginDidUpdateState(_ plugin: StatefulPluggable) { + saveState() + } + + func pluginWantsDeletion(_ plugin: StatefulPluggable) { + guard let service = plugin as? Service else { return } + log.default("Service with identifier '%{public}@' deleted", service.pluginIdentifier) + removeActiveService(service) + } +} + +// MARK: - ServiceDelegate + +extension ServicesManager: ServiceDelegate { + var hostIdentifier: String { + return "com.loopkit.Loop" + } + + var hostVersion: String { + var semanticVersion = Bundle.main.shortVersionString + + while semanticVersion.split(separator: ".").count < 3 { + semanticVersion += ".0" + } + + semanticVersion += "+\(Bundle.main.version)" + + return semanticVersion + } + + func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws { + + var duration: TemporaryScheduleOverride.Duration? = nil + if let durationTime = durationTime { + + guard durationTime <= LoopConstants.maxOverrideDurationTime else { + throw OverrideActionError.durationExceedsMax(LoopConstants.maxOverrideDurationTime) + } + + guard durationTime >= 0 else { + throw OverrideActionError.negativeDuration + } + + if durationTime == 0 { + duration = .indefinite + } else { + duration = .finite(durationTime) + } + } + + try await servicesManagerDelegate?.enactOverride(name: name, duration: duration, remoteAddress: remoteAddress) + await remoteDataServicesManager.triggerUpload(for: .overrides) + } + + enum OverrideActionError: LocalizedError { + + case durationExceedsMax(TimeInterval) + case negativeDuration + + var errorDescription: String? { + switch self { + case .durationExceedsMax(let maxDurationTime): + return String(format: NSLocalizedString("Duration exceeds: %1$.1f hours", comment: "Override error description: duration exceed max (1: max duration in hours)."), maxDurationTime.hours) + case .negativeDuration: + return String(format: NSLocalizedString("Negative duration not allowed", comment: "Override error description: negative duration error.")) + } + } + } + + func cancelRemoteOverride() async throws { + try await servicesManagerDelegate?.cancelCurrentOverride() + await remoteDataServicesManager.triggerUpload(for: .overrides) + } + + func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws { + do { + try await servicesManagerDelegate?.deliverCarbs(amountInGrams: amountInGrams, absorptionTime: absorptionTime, foodType: foodType, startDate: startDate) + await NotificationManager.sendRemoteCarbEntryNotification(amountInGrams: amountInGrams) + await remoteDataServicesManager.triggerUpload(for: .carb) + analyticsServicesManager.didAddCarbs(source: "Remote", amount: amountInGrams) + } catch { + await NotificationManager.sendRemoteCarbEntryFailureNotification(for: error, amountInGrams: amountInGrams) + throw error + } + } + + func deliverRemoteBolus(amountInUnits: Double) async throws { + do { + + guard amountInUnits > 0 else { + throw BolusActionError.invalidBolus + } + + guard let maxBolusAmount = settingsManager.loopSettings.maximumBolus else { + throw BolusActionError.missingMaxBolus + } + + guard amountInUnits <= maxBolusAmount else { + throw BolusActionError.exceedsMaxBolus + } + + try await servicesManagerDosingDelegate?.deliverBolus(amountInUnits: amountInUnits) + await NotificationManager.sendRemoteBolusNotification(amount: amountInUnits) + await remoteDataServicesManager.triggerUpload(for: .dose) + analyticsServicesManager.didBolus(source: "Remote", units: amountInUnits) + } catch { + await NotificationManager.sendRemoteBolusFailureNotification(for: error, amountInUnits: amountInUnits) + throw error + } + } + + enum BolusActionError: LocalizedError { + + case invalidBolus + case missingMaxBolus + case exceedsMaxBolus + + var errorDescription: String? { + switch self { + case .invalidBolus: + return NSLocalizedString("Invalid Bolus Amount", comment: "Bolus error description: invalid bolus amount.") + case .missingMaxBolus: + return NSLocalizedString("Missing maximum allowed bolus in settings", comment: "Bolus error description: missing maximum bolus in settings.") + case .exceedsMaxBolus: + return NSLocalizedString("Exceeds maximum allowed bolus in settings", comment: "Bolus error description: bolus exceeds maximum bolus in settings.") + } + } + } +} + +extension ServicesManager: AlertIssuer { + func issueAlert(_ alert: Alert) { + alertManager.issueAlert(alert) + } + + func retractAlert(identifier: Alert.Identifier) { + alertManager.retractAlert(identifier: identifier) + } +} + +// MARK: - ServiceOnboardingDelegate + +extension ServicesManager: ServiceOnboardingDelegate { + func serviceOnboarding(didCreateService service: Service) { + log.default("Service with identifier '%{public}@' created", service.pluginIdentifier) + addActiveService(service) + } + + func serviceOnboarding(didOnboardService service: Service) { + precondition(service.isOnboarded) + log.default("Service with identifier '%{public}@' onboarded", service.pluginIdentifier) + } +} + +extension ServicesManager { + var availableSupports: [SupportUI] { activeServices.compactMap { $0 as? SupportUI } } +} + +// Service extension for rawValue +extension Service { + typealias RawValue = [String: Any] + + var rawValue: RawValue { + return [ + "serviceIdentifier": pluginIdentifier, + "state": rawState + ] + } +} diff --git a/Loop/Managers/SettingsManager.swift b/Loop/Managers/SettingsManager.swift new file mode 100644 index 0000000000..e3fdb60bf7 --- /dev/null +++ b/Loop/Managers/SettingsManager.swift @@ -0,0 +1,249 @@ +// +// SettingsManager.swift +// Loop +// +// Created by Pete Schwamb on 2/27/22. +// Copyright © 2022 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit +import UserNotifications +import UIKit +import HealthKit +import Combine +import LoopCore +import LoopKitUI +import os.log + + +protocol DeviceStatusProvider { + var pumpManagerStatus: PumpManagerStatus? { get } + var cgmManagerStatus: CGMManagerStatus? { get } +} + +class SettingsManager { + + let settingsStore: SettingsStore + + var remoteDataServicesManager: RemoteDataServicesManager? + + var deviceStatusProvider: DeviceStatusProvider? + + var alertMuter: AlertMuter + + var displayGlucosePreference: DisplayGlucosePreference? + + public var latestSettings: StoredSettings + + private var remoteNotificationRegistrationResult: Swift.Result? + + private var cancellables: Set = [] + + private let log = OSLog(category: "SettingsManager") + + init(cacheStore: PersistenceController, expireAfter: TimeInterval, alertMuter: AlertMuter) + { + settingsStore = SettingsStore(store: cacheStore, expireAfter: expireAfter) + self.alertMuter = alertMuter + + if let storedSettings = settingsStore.latestSettings { + latestSettings = storedSettings + } else { + log.default("SettingsStore has no latestSettings: initializing empty StoredSettings.") + latestSettings = StoredSettings() + } + + settingsStore.delegate = self + + // Migrate old settings from UserDefaults + if var legacyLoopSettings = UserDefaults.appGroup?.legacyLoopSettings { + log.default("Migrating settings from UserDefaults") + legacyLoopSettings.insulinSensitivitySchedule = UserDefaults.appGroup?.legacyInsulinSensitivitySchedule + legacyLoopSettings.basalRateSchedule = UserDefaults.appGroup?.legacyBasalRateSchedule + legacyLoopSettings.carbRatioSchedule = UserDefaults.appGroup?.legacyCarbRatioSchedule + legacyLoopSettings.defaultRapidActingModel = .rapidActingAdult + + storeSettings(newLoopSettings: legacyLoopSettings) + + UserDefaults.appGroup?.removeLegacyLoopSettings() + } + + NotificationCenter.default + .publisher(for: .LoopDataUpdated) + .receive(on: DispatchQueue.main) + .sink { [weak self] note in + let context = note.userInfo?[LoopDataManager.LoopUpdateContextKey] as! LoopDataManager.LoopUpdateContext.RawValue + if case .preferences = LoopDataManager.LoopUpdateContext(rawValue: context), let loopDataManager = note.object as? LoopDataManager { + self?.storeSettings(newLoopSettings: loopDataManager.settings) + } + } + .store(in: &cancellables) + + self.alertMuter.$configuration + .sink { [weak self] alertMuterConfiguration in + guard var notificationSettings = self?.latestSettings.notificationSettings else { return } + let newTemporaryMuteAlertsSetting = NotificationSettings.TemporaryMuteAlertSetting(enabled: alertMuterConfiguration.shouldMute, duration: alertMuterConfiguration.duration) + if notificationSettings.temporaryMuteAlertsSetting != newTemporaryMuteAlertsSetting { + notificationSettings.temporaryMuteAlertsSetting = newTemporaryMuteAlertsSetting + self?.storeSettings(notificationSettings: notificationSettings) + } + } + .store(in: &cancellables) + } + + var loopSettings: LoopSettings { + get { + return LoopSettings( + dosingEnabled: latestSettings.dosingEnabled, + glucoseTargetRangeSchedule: latestSettings.glucoseTargetRangeSchedule, + insulinSensitivitySchedule: latestSettings.insulinSensitivitySchedule, + basalRateSchedule: latestSettings.basalRateSchedule, + carbRatioSchedule: latestSettings.carbRatioSchedule, + preMealTargetRange: latestSettings.preMealTargetRange, + legacyWorkoutTargetRange: latestSettings.workoutTargetRange, + overridePresets: latestSettings.overridePresets, + scheduleOverride: latestSettings.scheduleOverride, + preMealOverride: latestSettings.preMealOverride, + maximumBasalRatePerHour: latestSettings.maximumBasalRatePerHour, + maximumBolus: latestSettings.maximumBolus, + suspendThreshold: latestSettings.suspendThreshold, + automaticDosingStrategy: latestSettings.automaticDosingStrategy, + defaultRapidActingModel: latestSettings.defaultRapidActingModel?.presetForRapidActingInsulin) + } + } + + private func mergeSettings(newLoopSettings: LoopSettings? = nil, notificationSettings: NotificationSettings? = nil, deviceToken: String? = nil) -> StoredSettings + { + let newLoopSettings = newLoopSettings ?? loopSettings + let newNotificationSettings = notificationSettings ?? settingsStore.latestSettings?.notificationSettings + + return StoredSettings(date: Date(), + dosingEnabled: newLoopSettings.dosingEnabled, + glucoseTargetRangeSchedule: newLoopSettings.glucoseTargetRangeSchedule, + preMealTargetRange: newLoopSettings.preMealTargetRange, + workoutTargetRange: newLoopSettings.legacyWorkoutTargetRange, + overridePresets: newLoopSettings.overridePresets, + scheduleOverride: newLoopSettings.scheduleOverride, + preMealOverride: newLoopSettings.preMealOverride, + maximumBasalRatePerHour: newLoopSettings.maximumBasalRatePerHour, + maximumBolus: newLoopSettings.maximumBolus, + suspendThreshold: newLoopSettings.suspendThreshold, + deviceToken: deviceToken, + insulinType: deviceStatusProvider?.pumpManagerStatus?.insulinType, + defaultRapidActingModel: newLoopSettings.defaultRapidActingModel.map(StoredInsulinModel.init), + basalRateSchedule: newLoopSettings.basalRateSchedule, + insulinSensitivitySchedule: newLoopSettings.insulinSensitivitySchedule, + carbRatioSchedule: newLoopSettings.carbRatioSchedule, + notificationSettings: newNotificationSettings, + controllerDevice: UIDevice.current.controllerDevice, + cgmDevice: deviceStatusProvider?.cgmManagerStatus?.device, + pumpDevice: deviceStatusProvider?.pumpManagerStatus?.device, + bloodGlucoseUnit: displayGlucosePreference?.unit, + automaticDosingStrategy: newLoopSettings.automaticDosingStrategy) + } + + func storeSettings(newLoopSettings: LoopSettings? = nil, notificationSettings: NotificationSettings? = nil) { + + var deviceTokenStr: String? + + if case .success(let deviceToken) = remoteNotificationRegistrationResult { + deviceTokenStr = deviceToken.hexadecimalString + } + + let mergedSettings = mergeSettings(newLoopSettings: newLoopSettings, notificationSettings: notificationSettings, deviceToken: deviceTokenStr) + + if latestSettings == mergedSettings { + // Skipping unchanged settings store + return + } + + latestSettings = mergedSettings + + if remoteNotificationRegistrationResult == nil && FeatureFlags.remoteCommandsEnabled { + // remote notification registration not finished + return + } + + if latestSettings.insulinSensitivitySchedule == nil { + log.default("Saving settings with no ISF schedule.") + } + + settingsStore.storeSettings(latestSettings) { error in + if let error = error { + self.log.error("Error storing settings: %{public}@", error.localizedDescription) + } + } + } + + func storeSettingsCheckingNotificationPermissions() { + UNUserNotificationCenter.current().getNotificationSettings() { notificationSettings in + DispatchQueue.main.async { + guard let latestSettings = self.settingsStore.latestSettings else { + return + } + + let temporaryMuteAlertSetting = NotificationSettings.TemporaryMuteAlertSetting(enabled: self.alertMuter.configuration.shouldMute, duration: self.alertMuter.configuration.duration) + let notificationSettings = NotificationSettings(notificationSettings, temporaryMuteAlertsSetting: temporaryMuteAlertSetting) + + if notificationSettings != latestSettings.notificationSettings + { + self.storeSettings(notificationSettings: notificationSettings) + } + } + } + } + + func didBecomeActive () { + storeSettingsCheckingNotificationPermissions() + } + + func remoteNotificationRegistrationDidFinish(_ result: Swift.Result) { + self.remoteNotificationRegistrationResult = result + storeSettings() + } + + func purgeHistoricalSettingsObjects(completion: @escaping (Error?) -> Void) { + settingsStore.purgeHistoricalSettingsObjects(completion: completion) + } +} + +// MARK: - SettingsStoreDelegate +extension SettingsManager: SettingsStoreDelegate { + func settingsStoreHasUpdatedSettingsData(_ settingsStore: SettingsStore) { + remoteDataServicesManager?.triggerUpload(for: .settings) + } +} + +private extension NotificationSettings { + + init(_ notificationSettings: UNNotificationSettings, temporaryMuteAlertsSetting: TemporaryMuteAlertSetting) { + let timeSensitiveSetting: NotificationSettings.NotificationSetting + let scheduledDeliverySetting: NotificationSettings.NotificationSetting + + if #available(iOS 15.0, *) { + timeSensitiveSetting = NotificationSettings.NotificationSetting(notificationSettings.timeSensitiveSetting) + scheduledDeliverySetting = NotificationSettings.NotificationSetting(notificationSettings.scheduledDeliverySetting) + } else { + timeSensitiveSetting = .unknown + scheduledDeliverySetting = .unknown + } + + self.init(authorizationStatus: NotificationSettings.AuthorizationStatus(notificationSettings.authorizationStatus), + soundSetting: NotificationSettings.NotificationSetting(notificationSettings.soundSetting), + badgeSetting: NotificationSettings.NotificationSetting(notificationSettings.badgeSetting), + alertSetting: NotificationSettings.NotificationSetting(notificationSettings.alertSetting), + notificationCenterSetting: NotificationSettings.NotificationSetting(notificationSettings.notificationCenterSetting), + lockScreenSetting: NotificationSettings.NotificationSetting(notificationSettings.lockScreenSetting), + carPlaySetting: NotificationSettings.NotificationSetting(notificationSettings.carPlaySetting), + alertStyle: NotificationSettings.AlertStyle(notificationSettings.alertStyle), + showPreviewsSetting: NotificationSettings.ShowPreviewsSetting(notificationSettings.showPreviewsSetting), + criticalAlertSetting: NotificationSettings.NotificationSetting(notificationSettings.criticalAlertSetting), + providesAppNotificationSettings: notificationSettings.providesAppNotificationSettings, + announcementSetting: NotificationSettings.NotificationSetting(notificationSettings.announcementSetting), + timeSensitiveSetting: timeSensitiveSetting, + scheduledDeliverySetting: scheduledDeliverySetting, + temporaryMuteAlertsSetting: temporaryMuteAlertsSetting + ) + } +} diff --git a/Loop/Managers/SharedLogging.swift b/Loop/Managers/SharedLogging.swift new file mode 100644 index 0000000000..36a95816f8 --- /dev/null +++ b/Loop/Managers/SharedLogging.swift @@ -0,0 +1,15 @@ +// +// SharedLogging.swift +// Loop +// +// Created by Bill Gestrich on 5/21/22. +// Copyright © 2022 LoopKit Authors. All rights reserved. +// +import LoopKit + +public class SharedLogging { + + /// A shared, global instance of Logging. + public static var instance: Logging? + +} diff --git a/Loop/Managers/SleepStore.swift b/Loop/Managers/SleepStore.swift new file mode 100644 index 0000000000..723a4f6496 --- /dev/null +++ b/Loop/Managers/SleepStore.swift @@ -0,0 +1,116 @@ +// +// SleepStore.swift +// Loop +// +// Created by Anna Quinlan on 12/28/19. +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import Foundation +import HealthKit +import os.log + +enum SleepStoreResult { + case success(T) + case failure(SleepStoreError) +} + +enum SleepStoreError: Error { + case noMatchingBedtime + case unknownReturnConfiguration + case noSleepDataAvailable + case queryError(String) // String is description of error +} + +class SleepStore { + var healthStore: HKHealthStore + + private let log = OSLog(category: "SleepStore") + + public init(healthStore: HKHealthStore) { + self.healthStore = healthStore + } + + func getAverageSleepStartTime(sampleLimit: Int = 30, _ completion: @escaping (_ result: SleepStoreResult) -> Void) { + let inBedPredicate = HKQuery.predicateForCategorySamples( + with: .equalTo, + value: HKCategoryValueSleepAnalysis.inBed.rawValue + ) + + getAverageSleepStartTime(matching: inBedPredicate, sampleLimit: sampleLimit) { (result) in + switch result { + case .success(_): + completion(result) + case .failure(let error): + switch error { + case SleepStoreError.noSleepDataAvailable: + // if there were no .inBed samples, check if there are any .asleep samples that could be used to estimate bedtime + let asleepPredicate = HKQuery.predicateForCategorySamples( + with: .equalTo, + value: HKCategoryValueSleepAnalysis.asleep.rawValue + ) + self.getAverageSleepStartTime(matching: asleepPredicate, sampleLimit: sampleLimit, completion) + default: + // otherwise, call completion + completion(result) + } + } + + } + } + + fileprivate func getAverageSleepStartTime(matching predicate: NSPredicate, sampleLimit: Int, _ completion: @escaping (_ result: SleepStoreResult) -> Void) { + let sleepType = HKObjectType.categoryType(forIdentifier: HKCategoryTypeIdentifier.sleepAnalysis)! + + // get more-recent values first + let sortByDate = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false) + + let query = HKSampleQuery(sampleType: sleepType, predicate: predicate, limit: sampleLimit, sortDescriptors: [sortByDate]) { (query, samples, error) in + + if let error = error { + self.log.error("Error fetching sleep data: %{public}@", String(describing: error)) + completion(.failure(SleepStoreError.queryError(error.localizedDescription))) + } else if let samples = samples as? [HKCategorySample] { + guard !samples.isEmpty else { + completion(.failure(SleepStoreError.noSleepDataAvailable)) + return + } + + // find the average hour and minute components from the sleep start times + let average = samples.reduce(0, { (base, sample) in + if let metadata = sample.metadata, let timezoneStr = metadata[HKMetadataKeyTimeZone] as? String, let timezone = NSTimeZone(name: timezoneStr) { + return base + sample.startDate.timeOfDayInSeconds(sampleTimeZone: timezone as TimeZone) + } else { + // default to the current timezone if the sample does not contain one in its metadata + return base + sample.startDate.timeOfDayInSeconds(sampleTimeZone: Calendar.current.timeZone) + } + }) / samples.count + + let averageHour = average / 3600 + let averageMinute = average % 3600 / 60 + + // find the next time that the user will go to bed, based on the averages we've computed + guard let bedtime = Calendar.current.nextDate(after: Date(), matching: DateComponents(hour: averageHour, minute: averageMinute), matchingPolicy: .nextTime), bedtime.timeIntervalSinceNow <= .hours(24) else { + completion(.failure(SleepStoreError.noMatchingBedtime)) + return + } + completion(.success(bedtime)) + } else { + completion(.failure(SleepStoreError.unknownReturnConfiguration)) + } + } + healthStore.execute(query) + } +} + +extension Date { + fileprivate func timeOfDayInSeconds(sampleTimeZone: TimeZone) -> Int { + var calendar = Calendar.current + calendar.timeZone = sampleTimeZone + + let dateComponents = calendar.dateComponents([.hour, .minute, .second], from: self) + let dateSeconds = dateComponents.hour! * 3600 + dateComponents.minute! * 60 + dateComponents.second! + + return dateSeconds + } +} diff --git a/Loop/Managers/StatefulPluggable.swift b/Loop/Managers/StatefulPluggable.swift new file mode 100644 index 0000000000..ab1be4754d --- /dev/null +++ b/Loop/Managers/StatefulPluggable.swift @@ -0,0 +1,20 @@ +// +// StatefulPluggable.swift +// Loop +// +// Created by Nathaniel Hamming on 2023-09-13. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import LoopKit + +extension StatefulPluggable { + typealias RawValue = [String: Any] + + var rawValue: RawValue { + return [ + "statefulPluginIdentifier": pluginIdentifier, + "state": rawState + ] + } +} diff --git a/Loop/Managers/StatefulPluginManager.swift b/Loop/Managers/StatefulPluginManager.swift new file mode 100644 index 0000000000..22fc035b0c --- /dev/null +++ b/Loop/Managers/StatefulPluginManager.swift @@ -0,0 +1,125 @@ +// +// StatefulPluginManager.swift +// Loop +// +// Created by Nathaniel Hamming on 2023-09-06. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import LoopKit +import LoopKitUI +import LoopCore +import Combine + +class StatefulPluginManager: StatefulPluggableProvider { + + private let pluginManager: PluginManager + + private let servicesManager: ServicesManager + + private var statefulPlugins = [StatefulPluggable]() + + private let statefulPluginLock = UnfairLock() + + @PersistedProperty(key: "StatefulPlugins") + var rawStatefulPlugins: [StatefulPluggable.RawStateValue]? + + init(pluginManager: PluginManager, + servicesManager: ServicesManager) + { + self.pluginManager = pluginManager + self.servicesManager = servicesManager + restoreState() + } + + public var availableStatefulPluginIdentifiers: [String] { + return pluginManager.availableStatefulPluginIdentifiers + } + + func statefulPlugin(withIdentifier identifier: String) -> StatefulPluggable? { + for plugin in statefulPlugins { + if plugin.pluginIdentifier == identifier { + return plugin + } + } + + return setupStatefulPlugin(withIdentifier: identifier) + } + + func statefulPluginType(withIdentifier identifier: String) -> StatefulPluggable.Type? { + pluginManager.getStatefulPluginTypeByIdentifier(identifier) + } + + func setupStatefulPlugin(withIdentifier identifier: String) -> StatefulPluggable? { + guard let statefulPluinType = pluginManager.getStatefulPluginTypeByIdentifier(identifier) else { return nil } + + // init without raw value + let statefulPlugin = statefulPluinType.init(rawState: [:]) + statefulPlugin?.initializationComplete(for: servicesManager.activeServices) + addActiveStatefulPlugin(statefulPlugin) + + return statefulPlugin + } + + private func statefulPluginTypeFromRawValue(_ rawValue: StatefulPluggable.RawStateValue) -> StatefulPluggable.Type? { + guard let identifier = rawValue["statefulPluginIdentifier"] as? String else { + return nil + } + + return statefulPluginType(withIdentifier: identifier) + } + + private func statefulPluginFromRawValue(_ rawValue: StatefulPluggable.RawStateValue) -> StatefulPluggable? { + guard let statefulPluginType = statefulPluginTypeFromRawValue(rawValue), + let rawState = rawValue["state"] as? StatefulPluggable.RawStateValue + else { + return nil + } + + return statefulPluginType.init(rawState: rawState) + } + + public var activeStatefulPlugins: [StatefulPluggable] { + return statefulPluginLock.withLock { statefulPlugins } + } + + public func addActiveStatefulPlugin(_ statefulPlugin: StatefulPluggable?) { + guard let statefulPlugin = statefulPlugin else { return } + statefulPluginLock.withLock { + statefulPlugin.stateDelegate = self + statefulPlugins.append(statefulPlugin) + saveState() + } + } + + public func removeActiveStatefulPlugin(_ statefulPlugin: StatefulPluggable) { + statefulPluginLock.withLock { + statefulPlugins.removeAll { $0.pluginIdentifier == statefulPlugin.pluginIdentifier } + saveState() + } + } + + private func saveState() { + rawStatefulPlugins = statefulPlugins.compactMap { $0.rawValue } + } + + private func restoreState() { + let rawStatefulPlugins = rawStatefulPlugins ?? [] + rawStatefulPlugins.forEach { rawValue in + if let statefulPlugin = statefulPluginFromRawValue(rawValue) { + statefulPlugin.initializationComplete(for: servicesManager.activeServices) + statefulPlugins.append(statefulPlugin) + } + } + } +} + +extension StatefulPluginManager: StatefulPluggableDelegate { + func pluginDidUpdateState(_ plugin: StatefulPluggable) { + saveState() + } + + func pluginWantsDeletion(_ plugin: LoopKit.StatefulPluggable) { + removeActiveStatefulPlugin(plugin) + } +} diff --git a/Loop/Managers/StatusChartManager.swift b/Loop/Managers/StatusChartManager.swift deleted file mode 100644 index f708e75f1f..0000000000 --- a/Loop/Managers/StatusChartManager.swift +++ /dev/null @@ -1,753 +0,0 @@ -// -// Chart.swift -// Naterade -// -// Created by Nathan Racklyeft on 2/19/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import Foundation - -import CarbKit -import GlucoseKit -import HealthKit -import InsulinKit -import LoopKit -import SwiftCharts - - -final class StatusChartsManager { - - // MARK: - Configuration - - private lazy var chartSettings: ChartSettings = { - let chartSettings = ChartSettings() - chartSettings.top = 12 - chartSettings.bottom = 0 - chartSettings.trailing = 8 - chartSettings.axisTitleLabelsToLabelsSpacing = 0 - chartSettings.labelsToAxisSpacingX = 6 - chartSettings.labelsWidthY = 30 - - return chartSettings - }() - - /// The amount of horizontal space reserved for fixed margins - var fixedHorizontalMargin: CGFloat { - return chartSettings.leading + chartSettings.trailing + (chartSettings.labelsWidthY ?? 0) + chartSettings.labelsToAxisSpacingY - } - - private lazy var dateFormatter: DateFormatter = { - let timeFormatter = DateFormatter() - timeFormatter.dateStyle = .none - timeFormatter.timeStyle = .short - - return timeFormatter - }() - - private lazy var doseFormatter: NumberFormatter = { - let numberFormatter = NumberFormatter() - numberFormatter.numberStyle = .decimal - numberFormatter.minimumFractionDigits = 2 - numberFormatter.maximumFractionDigits = 2 - - return numberFormatter - }() - - private lazy var integerFormatter: NumberFormatter = { - let numberFormatter = NumberFormatter() - numberFormatter.numberStyle = .none - numberFormatter.maximumFractionDigits = 0 - - return numberFormatter - }() - - private lazy var axisLineColor = UIColor.clear - - private lazy var axisLabelSettings: ChartLabelSettings = ChartLabelSettings(font: UIFont.preferredFont(forTextStyle: UIFontTextStyle.caption1), fontColor: UIColor.secondaryLabelColor) - - private lazy var guideLinesLayerSettings: ChartGuideLinesLayerSettings = ChartGuideLinesLayerSettings(linesColor: UIColor.gridColor) - - var panGestureRecognizer: UIPanGestureRecognizer? - - // MARK: - Data - - var startDate = Date() - - var glucoseUnit: HKUnit = HKUnit.milligramsPerDeciliterUnit() { - didSet { - if glucoseUnit != oldValue { - // Regenerate the glucose display points - let oldRange = glucoseDisplayRange - glucoseDisplayRange = oldRange - } - } - } - - var glucoseTargetRangeSchedule: GlucoseRangeSchedule? - - var glucoseValues: [GlucoseValue] = [] { - didSet { - let unitString = glucoseUnit.glucoseUnitDisplayString - let glucoseFormatter = NumberFormatter.glucoseFormatter(for: glucoseUnit) - glucosePoints = glucoseValues.map { - return ChartPoint( - x: ChartAxisValueDate(date: $0.startDate, formatter: dateFormatter), - y: ChartAxisValueDoubleUnit($0.quantity.doubleValue(for: glucoseUnit), unitString: unitString, formatter: glucoseFormatter) - ) - } - } - } - - var glucoseDisplayRange: (min: HKQuantity, max: HKQuantity)? { - didSet { - if let range = glucoseDisplayRange { - glucoseDisplayRangePoints = [ - ChartPoint(x: ChartAxisValue(scalar: 0), y: ChartAxisValueDouble(range.min.doubleValue(for: glucoseUnit))), - ChartPoint(x: ChartAxisValue(scalar: 0), y: ChartAxisValueDouble(range.max.doubleValue(for: glucoseUnit))) - ] - } else { - glucoseDisplayRangePoints = [] - } - } - } - - var predictedGlucoseValues: [GlucoseValue] = [] { - didSet { - let unitString = glucoseUnit.glucoseUnitDisplayString - let glucoseFormatter = NumberFormatter.glucoseFormatter(for: glucoseUnit) - - predictedGlucosePoints = predictedGlucoseValues.map { - return ChartPoint( - x: ChartAxisValueDate(date: $0.startDate, formatter: dateFormatter), - y: ChartAxisValueDoubleUnit($0.quantity.doubleValue(for: glucoseUnit), unitString: unitString, formatter: glucoseFormatter) - ) - } - } - } - - var alternatePredictedGlucoseValues: [GlucoseValue] = [] { - didSet { - let unitString = glucoseUnit.glucoseUnitDisplayString - let glucoseFormatter = NumberFormatter.glucoseFormatter(for: glucoseUnit) - - alternatePredictedGlucosePoints = alternatePredictedGlucoseValues.map { - return ChartPoint( - x: ChartAxisValueDate(date: $0.startDate, formatter: dateFormatter), - y: ChartAxisValueDoubleUnit($0.quantity.doubleValue(for: glucoseUnit), unitString: unitString, formatter: glucoseFormatter) - ) - } - } - } - - var iobValues: [InsulinValue] = [] { - didSet { - iobPoints = iobValues.map { - return ChartPoint( - x: ChartAxisValueDate(date: $0.startDate, formatter: dateFormatter), - y: ChartAxisValueDoubleUnit($0.value, unitString: "U", formatter: doseFormatter) - ) - } - } - } - - var cobValues: [CarbValue] = [] { - didSet { - let unit = HKUnit.gram() - let unitString = unit.unitString - - cobPoints = cobValues.map { - ChartPoint( - x: ChartAxisValueDate(date: $0.startDate, formatter: dateFormatter), - y: ChartAxisValueDoubleUnit($0.quantity.doubleValue(for: unit), unitString: unitString, formatter: integerFormatter) - ) - } - } - } - - var doseEntries: [DoseEntry] = [] { - didSet { - var basalDosePoints = [ChartPoint]() - var bolusDosePoints = [ChartPoint]() - var allDosePoints = [ChartPoint]() - - for entry in doseEntries { - switch entry.unit { - case .unitsPerHour: - // TODO: Display the DateInterval - let startX = ChartAxisValueDate(date: entry.startDate, formatter: dateFormatter) - let endX = ChartAxisValueDate(date: entry.endDate, formatter: dateFormatter) - let zero = ChartAxisValueInt(0) - let value = ChartAxisValueDoubleLog(actualDouble: entry.value, unitString: "U/hour", formatter: doseFormatter) - - basalDosePoints += [ - ChartPoint(x: startX, y: zero), - ChartPoint(x: startX, y: value), - ChartPoint(x: endX, y: value), - ChartPoint(x: endX, y: zero) - ] - - if entry.value != 0 { - allDosePoints += [ - ChartPoint(x: startX, y: value), - ChartPoint(x: endX, y: value) - ] - } - case .units: - let x = ChartAxisValueDate(date: entry.startDate, formatter: dateFormatter) - let y = ChartAxisValueDoubleLog(actualDouble: entry.value, unitString: "U", formatter: doseFormatter) - - let point = ChartPoint(x: x, y: y) - bolusDosePoints.append(point) - allDosePoints.append(point) - } - } - - self.basalDosePoints = basalDosePoints - self.bolusDosePoints = bolusDosePoints - self.allDosePoints = allDosePoints - } - } - - // MARK: - State - - private var glucosePoints: [ChartPoint] = [] { - didSet { - glucoseChart = nil - xAxisValues = nil - } - } - - private var glucoseDisplayRangePoints: [ChartPoint] = [] { - didSet { - glucoseChart = nil - } - } - - /// The chart points for predicted glucose - var predictedGlucosePoints: [ChartPoint] = [] { - didSet { - glucoseChart = nil - xAxisValues = nil - } - } - - /// The chart points for alternate predicted glucose - var alternatePredictedGlucosePoints: [ChartPoint]? - - private var targetGlucosePoints: [ChartPoint] = [] { - didSet { - glucoseChart = nil - } - } - - private var targetOverridePoints: [ChartPoint] = [] { - didSet { - glucoseChart = nil - } - } - - private var targetOverrideDurationPoints: [ChartPoint] = [] { - didSet { - glucoseChart = nil - } - } - - /// The chart points for IOB - var iobPoints: [ChartPoint] = [] { - didSet { - iobChart = nil - xAxisValues = nil - } - } - - /// The minimum range to display for insulin values. - private let iobDisplayRangePoints: [ChartPoint] = [0, 1].map { - return ChartPoint( - x: ChartAxisValue(scalar: 0), - y: ChartAxisValueInt($0) - ) - } - - /// The chart points for COB - var cobPoints: [ChartPoint] = [] { - didSet { - cobChart = nil - xAxisValues = nil - } - } - - /// The minimum range to display for COB values. - private var cobDisplayRangePoints: [ChartPoint] = [0, 10].map { - return ChartPoint( - x: ChartAxisValue(scalar: 0), - y: ChartAxisValueInt($0) - ) - } - - private var basalDosePoints: [ChartPoint] = [] { - didSet { - doseChart = nil - xAxisValues = nil - } - } - - private var bolusDosePoints: [ChartPoint] = [] { - didSet { - doseChart = nil - xAxisValues = nil - } - } - - private var allDosePoints: [ChartPoint] = [] - - private var xAxisValues: [ChartAxisValue]? { - didSet { - if let xAxisValues = xAxisValues, xAxisValues.count > 1 { - xAxisModel = ChartAxisModel(axisValues: xAxisValues, lineColor: axisLineColor) - } else { - xAxisModel = nil - } - } - } - - private var xAxisModel: ChartAxisModel? - - private var glucoseChart: Chart? - - private var iobChart: Chart? - - private var cobChart: Chart? - - private var doseChart: Chart? - - private var glucoseChartCache: ChartPointsTouchHighlightLayerViewCache? - - private var iobChartCache: ChartPointsTouchHighlightLayerViewCache? - - private var cobChartCache: ChartPointsTouchHighlightLayerViewCache? - - private var doseChartCache: ChartPointsTouchHighlightLayerViewCache? - - // MARK: - Generators - - func glucoseChartWithFrame(_ frame: CGRect) -> Chart? { - if let chart = glucoseChart, chart.frame != frame { - self.glucoseChart = nil - } - - if glucoseChart == nil { - glucoseChart = generateGlucoseChartWithFrame(frame) - } - - return glucoseChart - } - - private func generateGlucoseChartWithFrame(_ frame: CGRect) -> Chart? { - guard let xAxisModel = xAxisModel else { - return nil - } - - let points = glucosePoints + predictedGlucosePoints + targetGlucosePoints + targetOverridePoints + glucoseDisplayRangePoints - - guard points.count > 1 else { - return nil - } - - let yAxisValues = ChartAxisValuesGenerator.generateYAxisValuesWithChartPoints(points, - minSegmentCount: 2, - maxSegmentCount: 4, - multiple: glucoseUnit.glucoseUnitYAxisSegmentSize, - axisValueGenerator: { - ChartAxisValueDouble($0, labelSettings: self.axisLabelSettings) - }, - addPaddingSegmentIfEdge: false - ) - - let yAxisModel = ChartAxisModel(axisValues: yAxisValues, lineColor: axisLineColor) - - let coordsSpace = ChartCoordsSpaceLeftBottomSingleAxis(chartSettings: chartSettings, chartFrame: frame, xModel: xAxisModel, yModel: yAxisModel) - - let (xAxis, yAxis, innerFrame) = (coordsSpace.xAxis, coordsSpace.yAxis, coordsSpace.chartInnerFrame) - - // The glucose targets - var targetLayer: ChartPointsAreaLayer? = nil - - if targetGlucosePoints.count > 1 { - let alpha: CGFloat = targetOverridePoints.count > 1 ? 0.15 : 0.3 - - targetLayer = ChartPointsAreaLayer(xAxis: xAxis, yAxis: yAxis, innerFrame: innerFrame, chartPoints: targetGlucosePoints, areaColor: UIColor.glucoseTintColor.withAlphaComponent(alpha), animDuration: 0, animDelay: 0, addContainerPoints: false) - } - - var targetOverrideLayer: ChartPointsAreaLayer? = nil - - if targetOverridePoints.count > 1 { - targetOverrideLayer = ChartPointsAreaLayer(xAxis: xAxis, yAxis: yAxis, innerFrame: innerFrame, chartPoints: targetOverridePoints, areaColor: UIColor.glucoseTintColor.withAlphaComponent(0.3), animDuration: 0, animDelay: 0, addContainerPoints: false) - } - - var targetOverrideDurationLayer: ChartPointsAreaLayer? = nil - - if targetOverrideDurationPoints.count > 1 { - targetOverrideDurationLayer = ChartPointsAreaLayer(xAxis: xAxis, yAxis: yAxis, innerFrame: innerFrame, chartPoints: targetOverrideDurationPoints, areaColor: UIColor.glucoseTintColor.withAlphaComponent(0.3), animDuration: 0, animDelay: 0, addContainerPoints: false) - } - - let gridLayer = ChartGuideLinesLayer(xAxis: xAxis, yAxis: yAxis, innerFrame: innerFrame, axis: .xAndY, settings: guideLinesLayerSettings, onlyVisibleX: true, onlyVisibleY: false) - - let circles = ChartPointsScatterCirclesLayer(xAxis: xAxis, yAxis: yAxis, innerFrame: innerFrame, chartPoints: glucosePoints, displayDelay: 0, itemSize: CGSize(width: 4, height: 4), itemFillColor: UIColor.glucoseTintColor) - - var alternatePrediction: ChartLayer? - - if let altPoints = alternatePredictedGlucosePoints, altPoints.count > 1 { - // TODO: Bug in ChartPointsLineLayer requires a non-zero animation to draw the dash pattern - let lineModel = ChartLineModel(chartPoints: altPoints, lineColor: UIColor.glucoseTintColor, lineWidth: 2, animDuration: 0.0001, animDelay: 0, dashPattern: [6, 5]) - - alternatePrediction = ChartPointsLineLayer(xAxis: xAxis, yAxis: yAxis, innerFrame: innerFrame, lineModels: [lineModel]) - } - - var prediction: ChartLayer? - - if predictedGlucosePoints.count > 1 { - let lineColor = (alternatePrediction == nil) ? UIColor.glucoseTintColor : UIColor.secondaryLabelColor - - // TODO: Bug in ChartPointsLineLayer requires a non-zero animation to draw the dash pattern - let lineModel = ChartLineModel( - chartPoints: predictedGlucosePoints, - lineColor: lineColor, - lineWidth: 1, - animDuration: 0.0001, - animDelay: 0, - dashPattern: [6, 5] - ) - - prediction = ChartPointsLineLayer(xAxis: xAxis, yAxis: yAxis, innerFrame: innerFrame, lineModels: [lineModel]) - } - - glucoseChartCache = ChartPointsTouchHighlightLayerViewCache( - xAxis: xAxis, - yAxis: yAxis, - innerFrame: innerFrame, - chartPoints: glucosePoints + (alternatePredictedGlucosePoints ?? predictedGlucosePoints), - tintColor: UIColor.glucoseTintColor, - labelCenterY: chartSettings.top, - gestureRecognizer: panGestureRecognizer - ) - - let layers: [ChartLayer?] = [ - gridLayer, - targetLayer, - targetOverrideLayer, - targetOverrideDurationLayer, - xAxis, - yAxis, - glucoseChartCache?.highlightLayer, - prediction, - alternatePrediction, - circles - ] - - return Chart(frame: frame, layers: layers.flatMap { $0 }) - } - - func iobChartWithFrame(_ frame: CGRect) -> Chart? { - if let chart = iobChart, chart.frame != frame { - self.iobChart = nil - } - - if iobChart == nil { - iobChart = generateIOBChartWithFrame(frame) - } - - return iobChart - } - - private func generateIOBChartWithFrame(_ frame: CGRect) -> Chart? { - guard let xAxisModel = xAxisModel else { - return nil - } - - var containerPoints = iobPoints - - // Create a container line at 0 - if let first = iobPoints.first { - containerPoints.insert(ChartPoint(x: first.x, y: ChartAxisValueInt(0)), at: 0) - } - - if let last = iobPoints.last { - containerPoints.append(ChartPoint(x: last.x, y: ChartAxisValueInt(0))) - } - - let yAxisValues = ChartAxisValuesGenerator.generateYAxisValuesWithChartPoints(iobPoints + iobDisplayRangePoints, minSegmentCount: 2, maxSegmentCount: 3, multiple: 0.5, axisValueGenerator: { ChartAxisValueDouble($0, labelSettings: self.axisLabelSettings) }, addPaddingSegmentIfEdge: false) - - let yAxisModel = ChartAxisModel(axisValues: yAxisValues, lineColor: axisLineColor) - - let coordsSpace = ChartCoordsSpaceLeftBottomSingleAxis(chartSettings: chartSettings, chartFrame: frame, xModel: xAxisModel, yModel: yAxisModel) - - let (xAxis, yAxis, innerFrame) = (coordsSpace.xAxis, coordsSpace.yAxis, coordsSpace.chartInnerFrame) - - // The IOB area - let lineModel = ChartLineModel(chartPoints: iobPoints, lineColor: UIColor.IOBTintColor, lineWidth: 2, animDuration: 0, animDelay: 0) - let iobLine = ChartPointsLineLayer(xAxis: xAxis, yAxis: yAxis, innerFrame: innerFrame, lineModels: [lineModel]) - - let iobArea: ChartPointsAreaLayer? - - if containerPoints.count > 1 { - iobArea = ChartPointsAreaLayer(xAxis: xAxis, yAxis: yAxis, innerFrame: innerFrame, chartPoints: containerPoints, areaColor: UIColor.IOBTintColor.withAlphaComponent(0.5), animDuration: 0, animDelay: 0, addContainerPoints: false) - } else { - iobArea = nil - } - - // Grid lines - let gridLayer = ChartGuideLinesLayer(xAxis: xAxis, yAxis: yAxis, innerFrame: innerFrame, axis: .xAndY, settings: guideLinesLayerSettings, onlyVisibleX: true, onlyVisibleY: false) - - // 0-line - let dummyZeroChartPoint = ChartPoint(x: ChartAxisValueDouble(0), y: ChartAxisValueDouble(0)) - let zeroGuidelineLayer = ChartPointsViewsLayer(xAxis: xAxis, yAxis: yAxis, innerFrame: innerFrame, chartPoints: [dummyZeroChartPoint], viewGenerator: {(chartPointModel, layer, chart) -> UIView? in - let width: CGFloat = 0.5 - let viewFrame = CGRect(x: innerFrame.origin.x, y: chartPointModel.screenLoc.y - width / 2, width: innerFrame.size.width, height: width) - - let v = UIView(frame: viewFrame) - v.backgroundColor = UIColor.IOBTintColor - return v - }) - - iobChartCache = ChartPointsTouchHighlightLayerViewCache( - xAxis: xAxis, - yAxis: yAxis, - innerFrame: innerFrame, - chartPoints: iobPoints, - tintColor: UIColor.IOBTintColor, - labelCenterY: chartSettings.top, - gestureRecognizer: panGestureRecognizer - ) - - let layers: [ChartLayer?] = [ - gridLayer, - xAxis, - yAxis, - zeroGuidelineLayer, - iobChartCache?.highlightLayer, - iobArea, - iobLine, - ] - - return Chart(frame: frame, layers: layers.flatMap { $0 }) - } - - func cobChartWithFrame(_ frame: CGRect) -> Chart? { - if let chart = cobChart, chart.frame != frame { - self.cobChart = nil - } - - if cobChart == nil { - cobChart = generateCOBChartWithFrame(frame) - } - - return cobChart - } - - private func generateCOBChartWithFrame(_ frame: CGRect) -> Chart? { - guard let xAxisModel = xAxisModel else { - return nil - } - - var containerPoints = cobPoints - - // Create a container line at 0 - if let first = cobPoints.first { - containerPoints.insert(ChartPoint(x: first.x, y: ChartAxisValueInt(0)), at: 0) - } - - if let last = cobPoints.last { - containerPoints.append(ChartPoint(x: last.x, y: ChartAxisValueInt(0))) - } - - let yAxisValues = ChartAxisValuesGenerator.generateYAxisValuesWithChartPoints(cobPoints + cobDisplayRangePoints, minSegmentCount: 2, maxSegmentCount: 3, multiple: 10, axisValueGenerator: { ChartAxisValueDouble($0, labelSettings: self.axisLabelSettings) }, addPaddingSegmentIfEdge: false) - - let yAxisModel = ChartAxisModel(axisValues: yAxisValues, lineColor: axisLineColor) - - let coordsSpace = ChartCoordsSpaceLeftBottomSingleAxis(chartSettings: chartSettings, chartFrame: frame, xModel: xAxisModel, yModel: yAxisModel) - - let (xAxis, yAxis, innerFrame) = (coordsSpace.xAxis, coordsSpace.yAxis, coordsSpace.chartInnerFrame) - - // The COB area - let lineModel = ChartLineModel(chartPoints: cobPoints, lineColor: UIColor.COBTintColor, lineWidth: 2, animDuration: 0, animDelay: 0) - let cobLine = ChartPointsLineLayer(xAxis: xAxis, yAxis: yAxis, innerFrame: innerFrame, lineModels: [lineModel]) - - let cobArea: ChartPointsAreaLayer? - - if containerPoints.count > 0 { - cobArea = ChartPointsAreaLayer(xAxis: xAxis, yAxis: yAxis, innerFrame: innerFrame, chartPoints: containerPoints, areaColor: UIColor.COBTintColor.withAlphaComponent(0.5), animDuration: 0, animDelay: 0, addContainerPoints: false) - } else { - cobArea = nil - } - - // Grid lines - let gridLayer = ChartGuideLinesLayer(xAxis: xAxis, yAxis: yAxis, innerFrame: innerFrame, axis: .xAndY, settings: guideLinesLayerSettings, onlyVisibleX: true, onlyVisibleY: false) - - - cobChartCache = ChartPointsTouchHighlightLayerViewCache( - xAxis: xAxis, - yAxis: yAxis, - innerFrame: innerFrame, - chartPoints: cobPoints, - tintColor: UIColor.COBTintColor, - labelCenterY: chartSettings.top, - gestureRecognizer: panGestureRecognizer - ) - - let layers: [ChartLayer?] = [ - gridLayer, - xAxis, - yAxis, - cobChartCache?.highlightLayer, - cobArea, - cobLine - ] - - return Chart(frame: frame, layers: layers.flatMap { $0 }) - } - - func doseChartWithFrame(_ frame: CGRect) -> Chart? { - if let chart = doseChart, chart.frame != frame { - self.doseChart = nil - } - - if doseChart == nil { - doseChart = generateDoseChartWithFrame(frame) - } - - return doseChart - } - - private func generateDoseChartWithFrame(_ frame: CGRect) -> Chart? { - guard let xAxisModel = xAxisModel else { - return nil - } - - let yAxisValues = ChartAxisValuesGenerator.generateYAxisValuesWithChartPoints(basalDosePoints + bolusDosePoints + iobDisplayRangePoints, minSegmentCount: 2, maxSegmentCount: 3, multiple: log10(2) / 2, axisValueGenerator: { ChartAxisValueDoubleLog(screenLocDouble: $0, formatter: self.integerFormatter, labelSettings: self.axisLabelSettings) }, addPaddingSegmentIfEdge: true) - - let yAxisModel = ChartAxisModel(axisValues: yAxisValues, lineColor: axisLineColor) - - let coordsSpace = ChartCoordsSpaceLeftBottomSingleAxis(chartSettings: chartSettings, chartFrame: frame, xModel: xAxisModel, yModel: yAxisModel) - - let (xAxis, yAxis, innerFrame) = (coordsSpace.xAxis, coordsSpace.yAxis, coordsSpace.chartInnerFrame) - - // The dose area - let lineModel = ChartLineModel(chartPoints: basalDosePoints, lineColor: UIColor.doseTintColor, lineWidth: 2, animDuration: 0, animDelay: 0) - let doseLine = ChartPointsLineLayer(xAxis: xAxis, yAxis: yAxis, innerFrame: innerFrame, lineModels: [lineModel]) - - let doseArea: ChartPointsAreaLayer? - - if basalDosePoints.count > 1 { - doseArea = ChartPointsAreaLayer(xAxis: xAxis, yAxis: yAxis, innerFrame: innerFrame, chartPoints: basalDosePoints, areaColor: UIColor.doseTintColor.withAlphaComponent(0.5), animDuration: 0, animDelay: 0, addContainerPoints: false) - } else { - doseArea = nil - } - - let bolusLayer: ChartPointsScatterDownTrianglesLayer? - - if bolusDosePoints.count > 0 { - bolusLayer = ChartPointsScatterDownTrianglesLayer(xAxis: xAxis, yAxis: yAxis, innerFrame: innerFrame, chartPoints: bolusDosePoints, displayDelay: 0, itemSize: CGSize(width: 12, height: 12), itemFillColor: UIColor.doseTintColor) - } else { - bolusLayer = nil - } - - // Grid lines - let gridLayer = ChartGuideLinesLayer(xAxis: xAxis, yAxis: yAxis, innerFrame: innerFrame, axis: .xAndY, settings: guideLinesLayerSettings, onlyVisibleX: true, onlyVisibleY: false) - - // 0-line - let dummyZeroChartPoint = ChartPoint(x: ChartAxisValueDouble(0), y: ChartAxisValueDouble(0)) - let zeroGuidelineLayer = ChartPointsViewsLayer(xAxis: xAxis, yAxis: yAxis, innerFrame: innerFrame, chartPoints: [dummyZeroChartPoint], viewGenerator: {(chartPointModel, layer, chart) -> UIView? in - let width: CGFloat = 1 - let viewFrame = CGRect(x: innerFrame.origin.x, y: chartPointModel.screenLoc.y - width / 2, width: innerFrame.size.width, height: width) - - let v = UIView(frame: viewFrame) - v.backgroundColor = UIColor.doseTintColor - return v - }) - - doseChartCache = ChartPointsTouchHighlightLayerViewCache( - xAxis: xAxis, - yAxis: yAxis, - innerFrame: innerFrame, - chartPoints: allDosePoints, - tintColor: UIColor.doseTintColor, - labelCenterY: chartSettings.top, - gestureRecognizer: panGestureRecognizer - ) - - let layers: [ChartLayer?] = [ - gridLayer, - xAxis, - yAxis, - zeroGuidelineLayer, - doseChartCache?.highlightLayer, - doseArea, - doseLine, - bolusLayer - ] - - return Chart(frame: frame, layers: layers.flatMap { $0 }) - } - - private func generateXAxisValues() { - let timeFormatter = DateFormatter() - timeFormatter.dateFormat = "h a" - - let points = [ - ChartPoint(x: ChartAxisValueDate(date: startDate, formatter: timeFormatter), y: ChartAxisValue(scalar: 0)), - ChartPoint(x: ChartAxisValueDate(date: startDate.addingTimeInterval(TimeInterval(hours: 4)), formatter: timeFormatter), y: ChartAxisValue(scalar: 0)), - glucosePoints.last, - predictedGlucosePoints.last, - iobPoints.last, - cobPoints.last, - basalDosePoints.last - ].flatMap { $0 } - - guard points.count > 1 else { - self.xAxisValues = [] - return - } - - let xAxisValues = ChartAxisValuesGenerator.generateXAxisValuesWithChartPoints(points, minSegmentCount: 4, maxSegmentCount: 10, multiple: TimeInterval(hours: 1), axisValueGenerator: { - ChartAxisValueDate(date: ChartAxisValueDate.dateFromScalar($0), formatter: timeFormatter, labelSettings: self.axisLabelSettings) - }, addPaddingSegmentIfEdge: false) - xAxisValues.first?.hidden = true - xAxisValues.last?.hidden = true - - self.xAxisValues = xAxisValues - } - - func prerender() { - glucoseChart = nil - iobChart = nil - cobChart = nil - - generateXAxisValues() - - if let xAxisValues = xAxisValues, xAxisValues.count > 1, - let targets = glucoseTargetRangeSchedule { - targetGlucosePoints = ChartPoint.pointsForGlucoseRangeSchedule(targets, xAxisValues: xAxisValues) - - if let override = targets.temporaryOverride { - targetOverridePoints = ChartPoint.pointsForGlucoseRangeScheduleOverride(override, xAxisValues: xAxisValues) - - targetOverrideDurationPoints = ChartPoint.pointsForGlucoseRangeScheduleOverrideDuration(override, xAxisValues: xAxisValues) - } else { - targetOverridePoints = [] - targetOverrideDurationPoints = [] - } - } - } -} - - -private extension HKUnit { - var glucoseUnitYAxisSegmentSize: Double { - if self == HKUnit.milligramsPerDeciliterUnit() { - return 25 - } else { - return 1 - } - } -} diff --git a/Loop/Managers/StatusChartsManager.swift b/Loop/Managers/StatusChartsManager.swift new file mode 100644 index 0000000000..79ec51ad62 --- /dev/null +++ b/Loop/Managers/StatusChartsManager.swift @@ -0,0 +1,138 @@ +// +// StatusChartsManager.swift +// Loop +// +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit +import LoopUI +import LoopKitUI +import SwiftCharts + + +class StatusChartsManager: ChartsManager { + enum ChartIndex: Int, CaseIterable { + case glucose + case iob + case dose + case cob + } + + let glucose: PredictedGlucoseChart + let iob: IOBChart + let dose: DoseChart + let cob: COBChart + + init(colors: ChartColorPalette, settings: ChartSettings, traitCollection: UITraitCollection) { + let glucose = PredictedGlucoseChart(predictedGlucoseBounds: FeatureFlags.predictedGlucoseChartClampEnabled ? .default : nil, + yAxisStepSizeMGDLOverride: FeatureFlags.predictedGlucoseChartClampEnabled ? 40 : nil) + let iob = IOBChart() + let dose = DoseChart() + let cob = COBChart() + self.glucose = glucose + self.iob = iob + self.dose = dose + self.cob = cob + + super.init(colors: colors, settings: settings, charts: ChartIndex.allCases.map({ (index) -> ChartProviding in + switch index { + case .glucose: + return glucose + case .iob: + return iob + case .dose: + return dose + case .cob: + return cob + } + }), traitCollection: traitCollection) + } +} + +extension StatusChartsManager { + func setGlucoseValues(_ glucoseValues: [GlucoseValue]) { + glucose.setGlucoseValues(glucoseValues) + invalidateChart(atIndex: ChartIndex.glucose.rawValue) + } + + func setPredictedGlucoseValues(_ glucoseValues: [GlucoseValue]) { + glucose.setPredictedGlucoseValues(glucoseValues) + invalidateChart(atIndex: ChartIndex.glucose.rawValue) + } + + func setAlternatePredictedGlucoseValues(_ glucoseValues: [GlucoseValue]) { + glucose.setAlternatePredictedGlucoseValues(glucoseValues) + invalidateChart(atIndex: ChartIndex.glucose.rawValue) + } + + func glucoseChart(withFrame frame: CGRect) -> Chart? { + return chart(atIndex: ChartIndex.glucose.rawValue, frame: frame) + } + + var targetGlucoseSchedule: GlucoseRangeSchedule? { + get { + return glucose.targetGlucoseSchedule + } + set { + glucose.targetGlucoseSchedule = newValue + invalidateChart(atIndex: ChartIndex.glucose.rawValue) + } + } + + var preMealOverride: TemporaryScheduleOverride? { + get { + return glucose.preMealOverride + } + set { + glucose.preMealOverride = newValue + invalidateChart(atIndex: ChartIndex.glucose.rawValue) + } + } + + var scheduleOverride: TemporaryScheduleOverride? { + get { + return glucose.scheduleOverride + } + set { + glucose.scheduleOverride = newValue + invalidateChart(atIndex: ChartIndex.glucose.rawValue) + } + } +} + +extension StatusChartsManager { + func setIOBValues(_ iobValues: [InsulinValue]) { + iob.setIOBValues(iobValues) + invalidateChart(atIndex: ChartIndex.iob.rawValue) + } + + func iobChart(withFrame frame: CGRect) -> Chart? { + return chart(atIndex: ChartIndex.iob.rawValue, frame: frame) + } +} + + +extension StatusChartsManager { + func setDoseEntries(_ doseEntries: [DoseEntry]) { + dose.doseEntries = doseEntries + invalidateChart(atIndex: ChartIndex.dose.rawValue) + } + + func doseChart(withFrame frame: CGRect) -> Chart? { + return chart(atIndex: ChartIndex.dose.rawValue, frame: frame) + } +} + + +extension StatusChartsManager { + func setCOBValues(_ cobValues: [CarbValue]) { + cob.setCOBValues(cobValues) + invalidateChart(atIndex: ChartIndex.cob.rawValue) + } + + func cobChart(withFrame frame: CGRect) -> Chart? { + return chart(atIndex: ChartIndex.cob.rawValue, frame: frame) + } +} diff --git a/Loop/Managers/StatusExtensionDataManager.swift b/Loop/Managers/StatusExtensionDataManager.swift deleted file mode 100644 index 130480ea02..0000000000 --- a/Loop/Managers/StatusExtensionDataManager.swift +++ /dev/null @@ -1,115 +0,0 @@ -// -// StatusExtensionDataManager.swift -// Loop -// -// Created by Bharat Mediratta on 11/25/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import HealthKit -import UIKit -import CarbKit -import LoopKit - -final class StatusExtensionDataManager { - unowned let dataManager: DeviceDataManager - - init(deviceDataManager: DeviceDataManager) { - self.dataManager = deviceDataManager - - NotificationCenter.default.addObserver(self, selector: #selector(update(_:)), name: .LoopDataUpdated, object: deviceDataManager.loopManager) - } - - fileprivate var defaults: UserDefaults? { - return UserDefaults(suiteName: Bundle.main.appGroupSuiteName) - } - - @objc private func update(_ notification: Notification) { - - self.dataManager.glucoseStore?.preferredUnit() { (unit, error) in - if error == nil, let unit = unit { - self.createContext(unit) { (context) in - if let context = context { - self.defaults?.statusExtensionContext = context - } - } - } - } - } - - private func createContext(_ preferredUnit: HKUnit, _ completionHandler: @escaping (_ context: StatusExtensionContext?) -> Void) { - guard let glucoseStore = self.dataManager.glucoseStore else { - completionHandler(nil) - return - } - - dataManager.loopManager.getLoopStatus { - (predictedGlucose, _, recommendedTempBasal, lastTempBasal, lastLoopCompleted, _, _, error) in - - if error != nil { - // TODO: unclear how to handle the error here properly. - completionHandler(nil) - } - - let dataManager = self.dataManager - let context = StatusExtensionContext() - - #if IOS_SIMULATOR - // If we're in the simulator, there's a higher likelihood that we don't have - // a fully configured app. Inject some baseline debug data to let us test the - // experience. This data will be overwritten by actual data below, if available. - context.batteryPercentage = 0.25 - context.reservoir = ReservoirContext(startDate: Date(), unitVolume: 42.5, capacity: 200) - context.netBasal = NetBasalContext(rate: 2.1, percentage: 0.6, startDate: Date() - TimeInterval(250)) - context.eventualGlucose = HKQuantity(unit: HKUnit.milligramsPerDeciliterUnit(), doubleValue: 89.123) - .doubleValue(for: preferredUnit) - #endif - - context.preferredUnitString = preferredUnit.unitString - context.loop = LoopContext( - dosingEnabled: dataManager.loopManager.dosingEnabled, - lastCompleted: lastLoopCompleted) - - if let glucose = glucoseStore.latestGlucose { - // It's possible that the unit came in nil and we defaulted to mg/dL. To account for that case, - // convert the latest glucose to those units just to be sure. - context.latestGlucose = GlucoseContext( - quantity: glucose.quantity.doubleValue(for: preferredUnit), - startDate: glucose.startDate, - sensor: dataManager.sensorInfo) - } - - if let lastNetBasal = dataManager.loopManager.lastNetBasal { - context.netBasal = NetBasalContext(rate: lastNetBasal.rate, percentage: lastNetBasal.percent, startDate: lastNetBasal.startDate) - } - - if let reservoir = dataManager.doseStore.lastReservoirValue, - let capacity = dataManager.pumpState?.pumpModel?.reservoirCapacity { - context.reservoir = ReservoirContext( - startDate: reservoir.startDate, - unitVolume: reservoir.unitVolume, - capacity: capacity) - } - - if let batteryPercentage = dataManager.pumpBatteryChargeRemaining { - context.batteryPercentage = batteryPercentage - } - - if let lastPoint = predictedGlucose?.last { - context.eventualGlucose = lastPoint.quantity.doubleValue(for: preferredUnit) - } - - completionHandler(context) - } - } -} - - -extension StatusExtensionDataManager: CustomDebugStringConvertible { - var debugDescription: String { - return [ - "## StatusExtensionDataManager", - "statusExtensionContext: \(String(reflecting: defaults?.statusExtensionContext))" - ].joined(separator: "\n") - } -} diff --git a/Loop/Managers/Store Protocols/CarbStoreProtocol.swift b/Loop/Managers/Store Protocols/CarbStoreProtocol.swift new file mode 100644 index 0000000000..a7ffef2e5e --- /dev/null +++ b/Loop/Managers/Store Protocols/CarbStoreProtocol.swift @@ -0,0 +1,56 @@ +// +// CarbStoreProtocol.swift +// Loop +// +// Created by Anna Quinlan on 8/17/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import LoopKit +import HealthKit + +protocol CarbStoreProtocol: AnyObject { + + var preferredUnit: HKUnit! { get } + + var delegate: CarbStoreDelegate? { get set } + + // MARK: Settings + var carbRatioSchedule: CarbRatioSchedule? { get set } + + var insulinSensitivitySchedule: InsulinSensitivitySchedule? { get set } + + var insulinSensitivityScheduleApplyingOverrideHistory: InsulinSensitivitySchedule? { get } + + var carbRatioScheduleApplyingOverrideHistory: CarbRatioSchedule? { get } + + var maximumAbsorptionTimeInterval: TimeInterval { get } + + var delta: TimeInterval { get } + + var defaultAbsorptionTimes: CarbStore.DefaultAbsorptionTimes { get } + + // MARK: Data Management + func replaceCarbEntry(_ oldEntry: StoredCarbEntry, withEntry newEntry: NewCarbEntry, completion: @escaping (_ result: CarbStoreResult) -> Void) + + func addCarbEntry(_ entry: NewCarbEntry, completion: @escaping (_ result: CarbStoreResult) -> Void) + + func getCarbStatus(start: Date, end: Date?, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (_ result: CarbStoreResult<[CarbStatus]>) -> Void) + + func generateDiagnosticReport(_ completion: @escaping (_ report: String) -> Void) + + // MARK: COB & Effect Generation + func getGlucoseEffects(start: Date, end: Date?, effectVelocities: [GlucoseEffectVelocity], completion: @escaping(_ result: CarbStoreResult<(entries: [StoredCarbEntry], effects: [GlucoseEffect])>) -> Void) + + func glucoseEffects(of samples: [Sample], startingAt start: Date, endingAt end: Date?, effectVelocities: [GlucoseEffectVelocity]) throws -> [GlucoseEffect] + + func getCarbsOnBoardValues(start: Date, end: Date?, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (_ result: CarbStoreResult<[CarbValue]>) -> Void) + + func carbsOnBoard(at date: Date, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (_ result: CarbStoreResult) -> Void) + + func getTotalCarbs(since start: Date, completion: @escaping (_ result: CarbStoreResult) -> Void) + + func deleteCarbEntry(_ entry: StoredCarbEntry, completion: @escaping (_ result: CarbStoreResult) -> Void) +} + +extension CarbStore: CarbStoreProtocol { } diff --git a/Loop/Managers/Store Protocols/DoseStoreProtocol.swift b/Loop/Managers/Store Protocols/DoseStoreProtocol.swift new file mode 100644 index 0000000000..dd21ea2a1f --- /dev/null +++ b/Loop/Managers/Store Protocols/DoseStoreProtocol.swift @@ -0,0 +1,61 @@ +// +// DoseStoreProtocol.swift +// Loop +// +// Created by Anna Quinlan on 8/17/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import LoopKit +import HealthKit + +protocol DoseStoreProtocol: AnyObject { + // MARK: settings + var basalProfile: LoopKit.BasalRateSchedule? { get set } + + var insulinModelProvider: InsulinModelProvider { get set } + + var longestEffectDuration: TimeInterval { get set } + + var insulinSensitivitySchedule: LoopKit.InsulinSensitivitySchedule? { get set } + + var basalProfileApplyingOverrideHistory: BasalRateSchedule? { get } + + // MARK: store information + var lastReservoirValue: LoopKit.ReservoirValue? { get } + + var lastAddedPumpData: Date { get } + + var delegate: DoseStoreDelegate? { get set } + + var device: HKDevice? { get set } + + var pumpRecordsBasalProfileStartEvents: Bool { get set } + + var pumpEventQueryAfterDate: Date { get } + + // MARK: dose management + func addPumpEvents(_ events: [NewPumpEvent], lastReconciliation: Date?, replacePendingEvents: Bool, completion: @escaping (_ error: DoseStore.DoseStoreError?) -> Void) + + func addReservoirValue(_ unitVolume: Double, at date: Date, completion: @escaping (_ value: ReservoirValue?, _ previousValue: ReservoirValue?, _ areStoredValuesContinuous: Bool, _ error: DoseStore.DoseStoreError?) -> Void) + + func getNormalizedDoseEntries(start: Date, end: Date?, completion: @escaping (_ result: DoseStoreResult<[DoseEntry]>) -> Void) + + func executePumpEventQuery(fromQueryAnchor queryAnchor: DoseStore.QueryAnchor?, limit: Int, completion: @escaping (DoseStore.PumpEventQueryResult) -> Void) + + func generateDiagnosticReport(_ completion: @escaping (_ report: String) -> Void) + + func addDoses(_ doses: [DoseEntry], from device: HKDevice?, completion: @escaping (_ error: Error?) -> Void) + + // MARK: IOB and insulin effect + func insulinOnBoard(at date: Date, completion: @escaping (_ result: DoseStoreResult) -> Void) + + func getGlucoseEffects(start: Date, end: Date?, basalDosingEnd: Date?, completion: @escaping (_ result: DoseStoreResult<[GlucoseEffect]>) -> Void) + + func getInsulinOnBoardValues(start: Date, end: Date? , basalDosingEnd: Date?, completion: @escaping (_ result: DoseStoreResult<[InsulinValue]>) -> Void) + + func getTotalUnitsDelivered(since startDate: Date, completion: @escaping (_ result: DoseStoreResult) -> Void) + +} + +extension DoseStore: DoseStoreProtocol { } diff --git a/Loop/Managers/Store Protocols/DosingDecisionStoreProtocol.swift b/Loop/Managers/Store Protocols/DosingDecisionStoreProtocol.swift new file mode 100644 index 0000000000..6ff38926f9 --- /dev/null +++ b/Loop/Managers/Store Protocols/DosingDecisionStoreProtocol.swift @@ -0,0 +1,15 @@ +// +// DosingDecisionStoreProtocol.swift +// Loop +// +// Created by Anna Quinlan on 8/19/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import LoopKit + +protocol DosingDecisionStoreProtocol: AnyObject { + func storeDosingDecision(_ dosingDecision: StoredDosingDecision, completion: @escaping () -> Void) +} + +extension DosingDecisionStore: DosingDecisionStoreProtocol { } diff --git a/Loop/Managers/Store Protocols/GlucoseStoreProtocol.swift b/Loop/Managers/Store Protocols/GlucoseStoreProtocol.swift new file mode 100644 index 0000000000..adde73c4c7 --- /dev/null +++ b/Loop/Managers/Store Protocols/GlucoseStoreProtocol.swift @@ -0,0 +1,39 @@ +// +// GlucoseStoreProtocol.swift +// Loop +// +// Created by Anna Quinlan on 8/17/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import LoopKit +import HealthKit + +protocol GlucoseStoreProtocol: AnyObject { + + var latestGlucose: GlucoseSampleValue? { get } + + var delegate: GlucoseStoreDelegate? { get set } + + var managedDataInterval: TimeInterval? { get set } + + // MARK: Sample Management + func addGlucoseSamples(_ samples: [NewGlucoseSample], completion: @escaping (_ result: Result<[StoredGlucoseSample], Error>) -> Void) + + func getGlucoseSamples(start: Date?, end: Date?, completion: @escaping (_ result: Result<[StoredGlucoseSample], Error>) -> Void) + + func generateDiagnosticReport(_ completion: @escaping (_ report: String) -> Void) + + func purgeAllGlucoseSamples(healthKitPredicate: NSPredicate, completion: @escaping (Error?) -> Void) + + func executeGlucoseQuery(fromQueryAnchor queryAnchor: GlucoseStore.QueryAnchor?, limit: Int, completion: @escaping (GlucoseStore.GlucoseQueryResult) -> Void) + + // MARK: Effect Calculation + func getRecentMomentumEffect(for date: Date?, _ completion: @escaping (_ result: Result<[GlucoseEffect], Error>) -> Void) + + func getCounteractionEffects(start: Date, end: Date?, to effects: [GlucoseEffect], _ completion: @escaping (_ effects: Result<[GlucoseEffectVelocity], Error>) -> Void) + + func counteractionEffects(for samples: [Sample], to effects: [GlucoseEffect]) -> [GlucoseEffectVelocity] +} + +extension GlucoseStore: GlucoseStoreProtocol { } diff --git a/Loop/Managers/Store Protocols/LatestStoredSettingsProvider.swift b/Loop/Managers/Store Protocols/LatestStoredSettingsProvider.swift new file mode 100644 index 0000000000..72ead59cbc --- /dev/null +++ b/Loop/Managers/Store Protocols/LatestStoredSettingsProvider.swift @@ -0,0 +1,15 @@ +// +// LatestStoredSettingsProvider.swift +// Loop +// +// Created by Anna Quinlan on 8/19/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import LoopKit + +protocol LatestStoredSettingsProvider: AnyObject { + var latestSettings: StoredSettings { get } +} + +extension SettingsManager: LatestStoredSettingsProvider { } diff --git a/Loop/Managers/SupportManager.swift b/Loop/Managers/SupportManager.swift new file mode 100644 index 0000000000..18f3df912d --- /dev/null +++ b/Loop/Managers/SupportManager.swift @@ -0,0 +1,352 @@ +// +// SupportManager.swift +// Loop +// +// Created by Rick Pasetto on 9/8/21. +// Copyright © 2021 LoopKit Authors. All rights reserved. +// + +import Combine +import Foundation +import LoopKit +import LoopKitUI +import SwiftUI + +public protocol DeviceSupportDelegate { + var availableSupports: [SupportUI] { get } + var pumpManagerStatus: LoopKit.PumpManagerStatus? { get } + var cgmManagerStatus: LoopKit.CGMManagerStatus? { get } + + func generateDiagnosticReport(_ completion: @escaping (_ report: String) -> Void) +} + +public final class SupportManager { + + private lazy var log = DiagnosticLog(category: "SupportManager") + + private var supports = Locked<[String: SupportUI]>([:]) + + private var identifierWithHighestVersionUpdate: String? { + get { + return UserDefaults.appGroup?.identifierWithHighestVersionUpdate + } + set { + UserDefaults.appGroup?.identifierWithHighestVersionUpdate = newValue + } + } + + private let alertIssuer: AlertIssuer + private let deviceSupportDelegate: DeviceSupportDelegate + private let pluginManager: PluginManager + private let staticSupportTypes: [SupportUI.Type] + private let staticSupportTypesByIdentifier: [String: SupportUI.Type] + + lazy private var cancellables = Set() + + init(pluginManager: PluginManager, + deviceSupportDelegate: DeviceSupportDelegate, + servicesManager: ServicesManager? = nil, + staticSupportTypes: [SupportUI.Type]? = nil, + alertIssuer: AlertIssuer) { + + self.alertIssuer = alertIssuer + self.deviceSupportDelegate = deviceSupportDelegate + self.pluginManager = pluginManager + self.staticSupportTypes = [] + staticSupportTypesByIdentifier = self.staticSupportTypes.reduce(into: [:]) { (map, type) in + map[type.pluginIdentifier] = type + } + + restoreState() + + // Any supports that we don't have state for, we still initialize. + let existingIds = supports.value.keys + let remainingSupportBundles = pluginManager.pluginBundles.filter { bundle in + guard bundle.isSupportPlugin || bundle.isAppExtension else { + return false + } + guard let identifier = bundle.object(forInfoDictionaryKey: LoopPluginBundleKey.supportIdentifier.rawValue) as? String else { + return false + } + return !existingIds.contains(identifier) + } + + + for bundle in remainingSupportBundles { + do { + if let support = try bundle.loadAndInstantiateSupport() { + log.debug("Loaded support plugin: %{public}@", support.pluginIdentifier) + addSupport(support) + } + } catch { + log.error("Error loading plugin: %{public}@", String(describing: error)) + } + } + + let availablePluginSupports = [SupportUI]() + let availableDeviceSupports = deviceSupportDelegate.availableSupports + let availableServiceSupports = servicesManager?.availableSupports ?? [SupportUI]() + let staticSupports = self.staticSupportTypes.map { $0.init(rawState: [:]) }.compactMap { $0 } + let allSupports = availablePluginSupports + availableDeviceSupports + availableServiceSupports + staticSupports + allSupports.forEach { + addSupport($0) + } + + // Perform a check every foreground entry and every loop + NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification) + .sink { [weak self] _ in + self?.performCheck() + } + .store(in: &cancellables) + + NotificationCenter.default.publisher(for: .LoopCompleted) + .sink { [weak self] _ in + self?.performCheck() + } + .store(in: &cancellables) + } +} + +// MARK: Manage list of Supports +extension SupportManager { + func addSupport(_ support: SupportUI) { + supports.mutate { + if $0[support.pluginIdentifier] == nil { + $0[support.pluginIdentifier] = support + support.delegate = self + } + } + } + + func restoreSupport(_ support: SupportUI) { + addSupport(support) + } + + func removeSupport(_ support: SupportUI) { + supports.mutate { + $0[support.pluginIdentifier] = nil + support.delegate = self + } + } + + var availableSupports: [SupportUI] { + return Array(supports.value.values) + } +} + +// MARK: Version checking +extension SupportManager { + func performCheck() { + Task { @MainActor in + let versionUpdate = await checkVersion() + self.notify(versionUpdate) + } + } + + private func notify(_ versionUpdate: VersionUpdate) { + if versionUpdate.softwareUpdateAvailable { + NotificationCenter.default.post(name: .SoftwareUpdateAvailable, object: versionUpdate) + } + } + + func checkVersion() async -> VersionUpdate { + + let results = await withTaskGroup(of: (VersionUpdate?,String).self) { group in + var updatesFromServices = [(VersionUpdate?,String)]() + + supports.value.values.forEach { support in + group.addTask { + return (await support.checkVersion(bundleIdentifier: Bundle.main.bundleIdentifier!, currentVersion: Bundle.main.shortVersionString), support.pluginIdentifier) + } + } + + for await update in group { + updatesFromServices.append(update) + } + + return updatesFromServices + } + + self.saveState() + let (identifierWithHighestVersionUpdate, aggregatedVersionUpdate) = self.aggregate(results: results) + self.identifierWithHighestVersionUpdate = identifierWithHighestVersionUpdate + return aggregatedVersionUpdate + } + + private func aggregate(results: [(VersionUpdate?,String)]) -> (String?, VersionUpdate) { + var aggregatedVersionUpdate = VersionUpdate.default + var identifierWithHighestVersionUpdate: String? + results.forEach { versionUpdate, identifier in + if let versionUpdate { + if versionUpdate > aggregatedVersionUpdate { + aggregatedVersionUpdate = versionUpdate + identifierWithHighestVersionUpdate = identifier + } + } else { + self.log.error("Version check failed for %{public}@", identifier) + } + } + return (identifierWithHighestVersionUpdate, aggregatedVersionUpdate) + } + +} + +// MARK: UI +extension SupportManager { + func softwareUpdateView(guidanceColors: GuidanceColors) -> AnyView? { + // This is the SupportUI that gave the last "highest" VersionUpdate, or `nil` if there is none + let lastHighestVersionCheckUI = identifierWithHighestVersionUpdate.flatMap { supports.value[$0] } + + return lastHighestVersionCheckUI?.softwareUpdateView( + bundleIdentifier: Bundle.main.bundleIdentifier!, + currentVersion: Bundle.main.shortVersionString, + guidanceColors: guidanceColors, + openAppStore: openAppStore) + } + + func openAppStore() { + if let appStoreURLString = Bundle.main.appStoreURL, + let appStoreURL = URL(string: appStoreURLString) { + UIApplication.shared.open(appStoreURL) + } + } +} + +// MARK: SupportUIDelegate +extension SupportManager: SupportUIDelegate { + public func openURL(url: URL) { + UIApplication.shared.open(url) + } + + public var pumpStatus: LoopKit.PumpManagerStatus? { + deviceSupportDelegate.pumpManagerStatus + } + + public var cgmStatus: LoopKit.CGMManagerStatus? { + deviceSupportDelegate.cgmManagerStatus + } + + private var branchNameIfNotReleaseBranch: String? { + return BuildDetails.default.workspaceGitBranch.filter { branch in + return branch != "" && + branch != "main" && + branch != "master" && + !branch.starts(with: "release/") + } + } + + public var localizedAppNameAndVersion: String { + if let branch = branchNameIfNotReleaseBranch { + return Bundle.main.localizedNameAndVersion + " (\(branch))" + } + return Bundle.main.localizedNameAndVersion + } + + public func generateIssueReport(completion: @escaping (String) -> Void) { + deviceSupportDelegate.generateDiagnosticReport(completion) + } + + public func issueAlert(_ alert: LoopKit.Alert) { + alertIssuer.issueAlert(alert) + } + + public func retractAlert(identifier: LoopKit.Alert.Identifier) { + alertIssuer.retractAlert(identifier: identifier) + } + +} + +// MARK: Private functions +extension SupportManager { + + private func saveState() { + UserDefaults.appGroup?.supportsState = availableSupports.compactMap { $0.rawValue } + } + + private func restoreState() { + UserDefaults.appGroup?.supportsState.forEach { rawValue in + if let support = supportFromRawValue(rawValue) { + restoreSupport(support) + } + } + } + + private func supportFromRawValue(_ rawValue: SupportUI.RawStateValue) -> SupportUI? { + guard let supportType = supportTypeFromRawValue(rawValue), + let rawState = rawValue["state"] as? SupportUI.RawStateValue + else { + return nil + } + + return supportType.init(rawState: rawState) + } + + private func supportTypeFromRawValue(_ rawValue: [String: Any]) -> SupportUI.Type? { + guard let supportIdentifier = rawValue["supportIdentifier"] as? String, + let supportType = pluginManager.getSupportUITypeByIdentifier(supportIdentifier) ?? staticSupportTypesByIdentifier[supportIdentifier] + else { + return nil + } + + return supportType + } + +} + +fileprivate extension Result where Success == VersionUpdate? { + var value: VersionUpdate { + switch self { + case .failure: return .noUpdateNeeded + case .success(let val): return val ?? .noUpdateNeeded + } + } +} + +fileprivate extension UserDefaults { + private enum Key: String { + case supportsState = "com.loopkit.Loop.supportsState" + case identifierWithHighestVersionUpdate = "com.loopkit.Loop.identifierWithHighestVersionUpdate" + } + + var identifierWithHighestVersionUpdate: String? { + get { + return object(forKey: Key.identifierWithHighestVersionUpdate.rawValue) as? String + } + set { + set(newValue, forKey: Key.identifierWithHighestVersionUpdate.rawValue) + } + } + + var supportsState: [SupportUI.RawStateValue] { + get { + return array(forKey: Key.supportsState.rawValue) as? [[String: Any]] ?? [] + } + set { + set(newValue, forKey: Key.supportsState.rawValue) + } + } + +} + +extension SupportUI { + var rawValue: RawStateValue { + return [ + "supportIdentifier": Self.pluginIdentifier, + "state": rawState + ] + } + +} + +extension Bundle { + fileprivate func loadAndInstantiateSupport() throws -> SupportUI? { + try loadAndReturnError() + + guard let principalClass = principalClass as? NSObject.Type, + let supportUIPlugin = principalClass.init() as? SupportUIPlugin else { + return nil + } + + return supportUIPlugin.support + } +} diff --git a/Loop/Managers/TestingScenariosManager.swift b/Loop/Managers/TestingScenariosManager.swift new file mode 100644 index 0000000000..b71e357433 --- /dev/null +++ b/Loop/Managers/TestingScenariosManager.swift @@ -0,0 +1,386 @@ +// +// TestingScenariosManager.swift +// Loop +// +// Created by Michael Pangburn on 4/20/19. +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import LoopKit +import LoopTestingKit +import LoopKitUI + +protocol TestingScenariosManagerDelegate: AnyObject { + func testingScenariosManager(_ manager: TestingScenariosManager, didUpdateScenarioURLs scenarioURLs: [URL]) +} + +protocol TestingScenariosManager: AnyObject { + var delegate: TestingScenariosManagerDelegate? { get set } + var activeScenarioURL: URL? { get } + var scenarioURLs: [URL] { get } + var supportManager: SupportManager { get } + func loadScenario(from url: URL, completion: @escaping (Error?) -> Void) + func loadScenario(from url: URL, advancedByLoopIterations iterations: Int, completion: @escaping (Error?) -> Void) + func loadScenario(from url: URL, rewoundByLoopIterations iterations: Int, completion: @escaping (Error?) -> Void) + func stepActiveScenarioBackward(completion: @escaping (Error?) -> Void) + func stepActiveScenarioForward(completion: @escaping (Error?) -> Void) +} + +/// Describes the requirements necessary to implement TestingScenariosManager +protocol TestingScenariosManagerRequirements: TestingScenariosManager { + var deviceManager: DeviceDataManager { get } + var activeScenarioURL: URL? { get set } + var activeScenario: TestingScenario? { get set } + var log: DiagnosticLog { get } + func fetchScenario(from url: URL, completion: @escaping (Result) -> Void) +} + +// MARK: - TestingScenarioManager requirement implementations + +extension TestingScenariosManagerRequirements { + func loadScenario(from url: URL, completion: @escaping (Error?) -> Void) { + loadScenario( + from: url, + loadingVia: self.loadScenario(_:completion:), + successLogMessage: "Loaded scenario from \(url.lastPathComponent)", + completion: completion + ) + } + + func loadScenario(from url: URL, advancedByLoopIterations iterations: Int, completion: @escaping (Error?) -> Void) { + loadScenario( + from: url, + loadingVia: { self.loadScenario($0, advancedByLoopIterations: iterations, completion: $1) }, + successLogMessage: "Loaded scenario from \(url.lastPathComponent), advancing \(iterations) loop iterations", + completion: completion + ) + } + + func loadScenario(from url: URL, rewoundByLoopIterations iterations: Int, completion: @escaping (Error?) -> Void) { + loadScenario( + from: url, + loadingVia: { self.loadScenario($0, rewoundByLoopIterations: iterations, completion: $1) }, + successLogMessage: "Loaded scenario from \(url.lastPathComponent), rewinding \(iterations) loop iterations", + completion: completion + ) + } + + func stepActiveScenarioBackward(completion: @escaping (Error?) -> Void) { + guard let activeScenario = activeScenario else { + completion(nil) + return + } + + loadScenario(activeScenario, rewoundByLoopIterations: 1) { error in + if error == nil { + self.log.debug("Active scenario stepped backward") + } + completion(error) + } + } + + func stepActiveScenarioForward(completion: @escaping (Error?) -> Void) { + guard let activeScenario = activeScenario else { + completion(nil) + return + } + + loadScenario(activeScenario, advancedByLoopIterations: 1) { error in + if error == nil { + self.log.debug("Active scenario stepped forward") + } + completion(error) + } + } +} + +// MARK: - Implementation details + +private enum ScenarioLoadingError: LocalizedError { + case noTestingCGMManagerEnabled + case noTestingPumpManagerEnabled + + var errorDescription: String? { + switch self { + case .noTestingCGMManagerEnabled: + return "Testing CGM manager must be enabled to load CGM scenarios" + case .noTestingPumpManagerEnabled: + return "Testing pump manager must be enabled to load pump scenarios" + } + } +} + +extension TestingScenariosManagerRequirements { + private func loadScenario( + from url: URL, + loadingVia load: @escaping ( + _ scenario: TestingScenario, + _ completion: @escaping (Error?) -> Void + ) -> Void, + successLogMessage: String, + completion: @escaping (Error?) -> Void + ) { + fetchScenario(from: url) { result in + switch result { + case .success(let scenario): + load(scenario) { error in + if error == nil { + self.activeScenarioURL = url + self.log.debug("@{public}%", successLogMessage) + } + completion(error) + } + case .failure(let error): + completion(error) + } + } + } + + private func loadScenario(_ scenario: TestingScenario, advancedByLoopIterations iterations: Int, completion: @escaping (Error?) -> Void) { + assert(iterations >= 0) + + guard iterations > 0 else { + loadScenario(scenario, completion: completion) + return + } + + stepForward(scenario) { advanced in + self.loadScenario(advanced) { error in + guard error == nil else { + completion(error!) + return + } + self.loadScenario(advanced, advancedByLoopIterations: iterations - 1, completion: completion) + } + } + } + + private func stepForward(_ scenario: TestingScenario, completion: @escaping (TestingScenario) -> Void) { + deviceManager.loopManager.getLoopState { _, state in + var scenario = scenario + guard let recommendedDose = state.recommendedAutomaticDose?.recommendation else { + scenario.stepForward(by: .minutes(5)) + completion(scenario) + return + } + + if let basalAdjustment = recommendedDose.basalAdjustment { + scenario.stepForward(unitsPerHour: basalAdjustment.unitsPerHour, duration: basalAdjustment.duration) + } + completion(scenario) + } + } + + private func loadScenario(_ scenario: TestingScenario, rewoundByLoopIterations iterations: Int, completion: @escaping (Error?) -> Void) { + assert(iterations > 0) + + let offset = Double(iterations) * .minutes(5) + var scenario = scenario + scenario.stepBackward(by: offset) + loadScenario(scenario, completion: completion) + } + + private func loadScenario(_ scenario: TestingScenario, completion: @escaping (Error?) -> Void) { + guard FeatureFlags.scenariosEnabled else { + fatalError("\(#function) should be invoked only when scenarios are enabled") + } + + func bail(with error: Error) { + activeScenarioURL = nil + log.error("%{public}@", String(describing: error)) + completion(error) + } + + let instance = scenario.instantiate() + + var testingCGMManager: TestingCGMManager? + var testingPumpManager: TestingPumpManager? + + if instance.hasCGMData { + if let cgmManager = deviceManager.cgmManager as? TestingCGMManager { + if instance.shouldReloadManager?.cgm == true { + testingCGMManager = reloadCGMManager(withIdentifier: cgmManager.pluginIdentifier) + } else { + testingCGMManager = cgmManager + } + } else { + bail(with: ScenarioLoadingError.noTestingCGMManagerEnabled) + return + } + } + + if instance.hasPumpData { + if let pumpManager = deviceManager.pumpManager as? TestingPumpManager { + if instance.shouldReloadManager?.pump == true { + testingPumpManager = reloadPumpManager(withIdentifier: pumpManager.pluginIdentifier) + } else { + testingPumpManager = pumpManager + } + } else { + bail(with: ScenarioLoadingError.noTestingPumpManagerEnabled) + return + } + } + + wipeExistingData { error in + guard error == nil else { + bail(with: error!) + return + } + + self.deviceManager.carbStore.addCarbEntries(instance.carbEntries) { result in + switch result { + case .success(_): + testingPumpManager?.reservoirFillFraction = 1.0 + testingPumpManager?.injectPumpEvents(instance.pumpEvents) + testingCGMManager?.injectGlucoseSamples(instance.pastGlucoseSamples, futureSamples: instance.futureGlucoseSamples) + self.activeScenario = scenario + completion(nil) + case .failure(let error): + bail(with: error) + } + } + } + + instance.deviceActions.forEach { [testingCGMManager, testingPumpManager] action in + if testingCGMManager?.pluginIdentifier == action.managerIdentifier { + testingCGMManager?.trigger(action: action) + } else if testingPumpManager?.pluginIdentifier == action.managerIdentifier { + testingPumpManager?.trigger(action: action) + } + } + } + + private func reloadPumpManager(withIdentifier pumpManagerIdentifier: String) -> TestingPumpManager { + deviceManager.pumpManager = nil + guard let maximumBasalRate = deviceManager.loopManager.settings.maximumBasalRatePerHour, + let maxBolus = deviceManager.loopManager.settings.maximumBolus, + let basalSchedule = deviceManager.loopManager.settings.basalRateSchedule else + { + fatalError("Failed to reload pump manager. Missing initial settings") + } + let initialSettings = PumpManagerSetupSettings(maxBasalRateUnitsPerHour: maximumBasalRate, + maxBolusUnits: maxBolus, + basalSchedule: basalSchedule) + let result = deviceManager.setupPumpManager(withIdentifier: pumpManagerIdentifier, + initialSettings: initialSettings, + prefersToSkipUserInteraction: true) + switch result { + case .success(let setupUIResult): + switch setupUIResult { + case .createdAndOnboarded(let pumpManager): + return pumpManager as! TestingPumpManager + default: + fatalError("Failed to reload pump manager. UI interaction required for setup") + } + default: + fatalError("Failed to reload pump manager. Setup failed") + } + } + + private func reloadCGMManager(withIdentifier cgmManagerIdentifier: String) -> TestingCGMManager { + deviceManager.cgmManager = nil + let result = deviceManager.setupCGMManager(withIdentifier: cgmManagerIdentifier, prefersToSkipUserInteraction: true) + switch result { + case .success(let setupUIResult): + switch setupUIResult { + case .createdAndOnboarded(let cgmManager): + return cgmManager as! TestingCGMManager + default: + fatalError("Failed to reload CGM manager. UI interaction required for setup") + } + default: + fatalError("Failed to reload CGM manager. Setup failed") + } + } + + private func wipeExistingData(completion: @escaping (Error?) -> Void) { + guard FeatureFlags.scenariosEnabled else { + fatalError("\(#function) should be invoked only when scenarios are enabled") + } + + deviceManager.deleteTestingPumpData { error in + guard error == nil else { + completion(error!) + return + } + + self.deviceManager.deleteTestingCGMData { error in + guard error == nil else { + completion(error!) + return + } + + self.deviceManager.carbStore.deleteAllCarbEntries() { error in + guard error == nil else { + completion(error!) + return + } + + self.deviceManager.alertManager.alertStore.purge(before: Date(), completion: completion) + } + } + } + } +} + + +private extension CarbStore { + /// Errors if adding any individual entry errors. + func addCarbEntries(_ entries: [NewCarbEntry], completion: @escaping (CarbStoreResult<[StoredCarbEntry]>) -> Void) { + addCarbEntries(entries[...], completion: completion) + } + + private func addCarbEntries(_ entries: ArraySlice, completion: @escaping (CarbStoreResult<[StoredCarbEntry]>) -> Void) { + guard let entry = entries.first else { + completion(.success([])) + return + } + + addCarbEntry(entry) { individualResult in + switch individualResult { + case .success(let entry): + let remainder = entries.dropFirst() + self.addCarbEntries(remainder) { collectiveResult in + switch collectiveResult { + case .success(let entries): + completion(.success([entry] + entries)) + case .failure(let error): + completion(.failure(error)) + } + } + case .failure(let error): + completion(.failure(error)) + } + } + } + + /// Errors if getting carb entries errors, or if deleting any individual entry errors. + func deleteAllCarbEntries(completion: @escaping (CarbStoreError?) -> Void) { + getCarbEntries() { result in + switch result { + case .success(let entries): + self.deleteCarbEntries(entries[...], completion: completion) + case .failure(let error): + completion(error) + } + } + } + + private func deleteCarbEntries(_ entries: ArraySlice, completion: @escaping (CarbStoreError?) -> Void) { + guard let entry = entries.first else { + completion(nil) + return + } + + deleteCarbEntry(entry) { result in + switch result { + case .success(_): + let remainder = entries.dropFirst() + self.deleteCarbEntries(remainder, completion: completion) + case .failure(let error): + completion(error) + } + } + } +} diff --git a/Loop/Managers/TrustedTimeChecker.swift b/Loop/Managers/TrustedTimeChecker.swift new file mode 100644 index 0000000000..4d627b9f8f --- /dev/null +++ b/Loop/Managers/TrustedTimeChecker.swift @@ -0,0 +1,96 @@ +// +// TrustedTimeChecker.swift +// Loop +// +// Created by Rick Pasetto on 10/14/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import LoopKit +import TrueTime +import UIKit + +fileprivate extension UserDefaults { + private enum Key: String { + case detectedSystemTimeOffset = "com.loopkit.Loop.DetectedSystemTimeOffset" + } + + var detectedSystemTimeOffset: TimeInterval? { + get { + return object(forKey: Key.detectedSystemTimeOffset.rawValue) as? TimeInterval + } + set { + set(newValue, forKey: Key.detectedSystemTimeOffset.rawValue) + } + } +} + +class TrustedTimeChecker { + private let acceptableTimeDelta = TimeInterval.seconds(120) + + // For NTP time checking + private var ntpClient: TrueTimeClient + private weak var alertManager: AlertManager? + private lazy var log = DiagnosticLog(category: "TrustedTimeChecker") + + var detectedSystemTimeOffset: TimeInterval { + didSet { + UserDefaults.standard.detectedSystemTimeOffset = detectedSystemTimeOffset + } + } + + init(alertManager: AlertManager? = nil) { + ntpClient = TrueTimeClient.sharedInstance + #if DEBUG + if ntpClient.responds(to: #selector(setter: TrueTimeClient.logCallback)) { + ntpClient.logCallback = { _ in } // TrueTimeClient is a bit chatty in DEBUG build. This squelches all of its logging. + } + #endif + ntpClient.start() + self.alertManager = alertManager + self.detectedSystemTimeOffset = UserDefaults.standard.detectedSystemTimeOffset ?? 0 + NotificationCenter.default.addObserver(forName: UIApplication.willEnterForegroundNotification, + object: nil, queue: nil) { [weak self] _ in self?.checkTrustedTime() } + NotificationCenter.default.addObserver(forName: .LoopRunning, + object: nil, queue: nil) { [weak self] _ in self?.checkTrustedTime() } + checkTrustedTime() + } + + private func checkTrustedTime() { + ntpClient.fetchIfNeeded(completion: { [weak self] result in + guard let self = self else { return } + switch result { + case let .success(referenceTime): + let deviceNow = Date() + let ntpNow = referenceTime.now() + let timeDelta = ntpNow.timeIntervalSince(deviceNow) + + if abs(timeDelta) > self.acceptableTimeDelta { + self.log.default("applicationSignificantTimeChange: ntpNow = %@, deviceNow = %@", ntpNow.debugDescription, deviceNow.debugDescription) + self.detectedSystemTimeOffset = timeDelta + self.issueTimeChangedAlert() + } else { + self.detectedSystemTimeOffset = 0 + self.retractTimeChangedAlert() + } + case let .failure(error): + self.log.error("applicationSignificantTimeChange: Error getting NTP time: %@", error.localizedDescription) + } + }) + } + + private var alertIdentifier: Alert.Identifier { + Alert.Identifier(managerIdentifier: "Loop", alertIdentifier: "significantTimeChange") + } + + private func issueTimeChangedAlert() { + let alertTitle = String(format: NSLocalizedString("%1$@ Time Settings Need Attention", comment: "Time change alert title"), UIDevice.current.model) + let alertBody = String(format: NSLocalizedString("Your %1$@’s time has been changed. %2$@ needs accurate time records to make predictions about your glucose and adjust your insulin accordingly.\n\nCheck in your %1$@ Settings (General / Date & Time) and verify that 'Set Automatically' is turned ON. Failure to resolve could lead to serious under-delivery or over-delivery of insulin.", comment: "Time change alert body. (1: app name)"), UIDevice.current.model, Bundle.main.bundleDisplayName) + let content = Alert.Content(title: alertTitle, body: alertBody, acknowledgeActionButtonLabel: NSLocalizedString("OK", comment: "Alert acknowledgment OK button")) + alertManager?.issueAlert(Alert(identifier: alertIdentifier, foregroundContent: content, backgroundContent: content, trigger: .immediate)) + } + + private func retractTimeChangedAlert() { + alertManager?.retractAlert(identifier: alertIdentifier) + } +} diff --git a/Loop/Managers/WatchDataManager.swift b/Loop/Managers/WatchDataManager.swift index b00c038fb0..bac60b71dc 100644 --- a/Loop/Managers/WatchDataManager.swift +++ b/Loop/Managers/WatchDataManager.swift @@ -9,192 +9,527 @@ import HealthKit import UIKit import WatchConnectivity -import CarbKit import LoopKit -import xDripG5 +import LoopCore +final class WatchDataManager: NSObject { -final class WatchDataManager: NSObject, WCSessionDelegate { - - unowned let deviceDataManager: DeviceDataManager - - init(deviceDataManager: DeviceDataManager) { - self.deviceDataManager = deviceDataManager + private unowned let deviceManager: DeviceDataManager + + init(deviceManager: DeviceDataManager, healthStore: HKHealthStore) { + self.deviceManager = deviceManager + self.sleepStore = SleepStore(healthStore: healthStore) + self.lastBedtimeQuery = UserDefaults.appGroup?.lastBedtimeQuery ?? .distantPast + self.bedtime = UserDefaults.appGroup?.bedtime super.init() - NotificationCenter.default.addObserver(self, selector: #selector(updateWatch(_:)), name: .LoopDataUpdated, object: deviceDataManager.loopManager) + NotificationCenter.default.addObserver(self, selector: #selector(updateWatch(_:)), name: .LoopDataUpdated, object: deviceManager.loopManager) + NotificationCenter.default.addObserver(self, selector: #selector(sendSupportedBolusVolumesIfNeeded), name: .PumpManagerChanged, object: deviceManager) watchSession?.delegate = self watchSession?.activate() } + private let log = DiagnosticLog(category: "WatchDataManager") + private var watchSession: WCSession? = { if WCSession.isSupported() { - return WCSession.default() + return WCSession.default } else { return nil } }() + private var lastSentSettings: LoopSettings? + private var lastSentBolusVolumes: [Double]? + + private var contextDosingDecisions: [Date: BolusDosingDecision] { + get { lockedContextDosingDecisions.value } + set { lockedContextDosingDecisions.value = newValue } + } + private var lockedContextDosingDecisions: Locked<[Date: BolusDosingDecision]> = Locked([:]) + + private let contextDosingDecisionExpirationDuration: TimeInterval = -.minutes(5) + + let sleepStore: SleepStore + + var lastBedtimeQuery: Date { + didSet { + UserDefaults.appGroup?.lastBedtimeQuery = lastBedtimeQuery + } + } + + var bedtime: Date? { + didSet { + UserDefaults.appGroup?.bedtime = bedtime + } + } + + private func updateBedtimeIfNeeded() { + let now = Date() + let lastUpdateInterval = now.timeIntervalSince(lastBedtimeQuery) + + guard lastUpdateInterval >= TimeInterval(hours: 24) else { + // increment the bedtime by 1 day if it's before the current time, but we don't need to make another HealthKit query yet + if let bedtime = bedtime, bedtime < now { + let calendar = Calendar.current + let hourComponent = calendar.component(.hour, from: bedtime) + let minuteComponent = calendar.component(.minute, from: bedtime) + + if let newBedtime = calendar.nextDate(after: now, matching: DateComponents(hour: hourComponent, minute: minuteComponent), matchingPolicy: .nextTime) { + self.bedtime = newBedtime + } + } + + return + } + + sleepStore.getAverageSleepStartTime() { (result) in + + self.lastBedtimeQuery = now + + switch result { + case .success(let bedtime): + self.bedtime = bedtime + case .failure: + self.bedtime = nil + } + } + } + @objc private func updateWatch(_ notification: Notification) { guard - let rawContext = notification.userInfo?[LoopDataManager.LoopUpdateContextKey] as? LoopDataManager.LoopUpdateContext.RawValue, - let context = LoopDataManager.LoopUpdateContext(rawValue: rawContext), - case .tempBasal = context, - let session = watchSession + let rawUpdateContext = notification.userInfo?[LoopDataManager.LoopUpdateContextKey] as? LoopDataManager.LoopUpdateContext.RawValue, + let updateContext = LoopDataManager.LoopUpdateContext(rawValue: rawUpdateContext) else { return } - switch session.activationState { - case .notActivated, .inactive: - session.activate() - case .activated: - createWatchContext { (context) in - if let context = context { - self.sendWatchContext(context) - } - } + // Any update context should trigger a watch update + sendWatchContextIfNeeded() + + if case .preferences = updateContext { + sendSettingsIfNeeded() } } private var lastComplicationContext: WatchContext? private let minTrendDrift: Double = 20 - private lazy var minTrendUnit = HKUnit.milligramsPerDeciliterUnit() + private lazy var minTrendUnit = HKUnit.milligramsPerDeciliter - private func sendWatchContext(_ context: WatchContext) { - if let session = watchSession, session.isPaired && session.isWatchAppInstalled { + private func sendSettingsIfNeeded() { + let settings = deviceManager.loopManager.settings - let complicationShouldUpdate: Bool + guard let session = watchSession, session.isPaired, session.isWatchAppInstalled else { + return + } - if let lastContext = lastComplicationContext, - let lastGlucose = lastContext.glucose, let lastGlucoseDate = lastContext.glucoseDate, - let newGlucose = context.glucose, let newGlucoseDate = context.glucoseDate - { - let enoughTimePassed = newGlucoseDate.timeIntervalSince(lastGlucoseDate as Date).minutes >= 30 - let enoughTrendDrift = abs(newGlucose.doubleValue(for: minTrendUnit) - lastGlucose.doubleValue(for: minTrendUnit)) >= minTrendDrift + guard case .activated = session.activationState else { + session.activate() + return + } - complicationShouldUpdate = enoughTimePassed || enoughTrendDrift - } else { - complicationShouldUpdate = true - } + guard settings != lastSentSettings else { + log.default("Skipping settings transfer due to no changes") + return + } - if session.isComplicationEnabled && complicationShouldUpdate { - session.transferCurrentComplicationUserInfo(context.rawValue) - lastComplicationContext = context - } else { - do { - try session.updateApplicationContext(context.rawValue) - } catch let error { - deviceDataManager.logger.addError(error, fromSource: "WCSession") - } + lastSentSettings = settings + + // clear any old pending settings transfers + for transfer in session.outstandingUserInfoTransfers { + if (transfer.userInfo["name"] as? String) == LoopSettingsUserInfo.name { + log.default("Cancelling old setings transfer") + transfer.cancel() } } + + let userInfo = LoopSettingsUserInfo(settings: settings).rawValue + log.default("Transferring LoopSettingsUserInfo: %{public}@", userInfo) + session.transferUserInfo(userInfo) + } + + @objc private func sendSupportedBolusVolumesIfNeeded() { + guard + let volumes = deviceManager.pumpManager?.supportedBolusVolumes, + let session = watchSession, + session.isPaired, + session.isWatchAppInstalled + else { + return + } + + guard case .activated = session.activationState else { + session.activate() + return + } + + guard volumes != lastSentBolusVolumes else { + log.default("Skipping bolus volumes transfer due to no changes") + return + } + + lastSentBolusVolumes = volumes + + log.default("Transferring supported bolus volumes") + session.transferUserInfo(SupportedBolusVolumesUserInfo(supportedBolusVolumes: volumes).rawValue) + } + + private func sendWatchContextIfNeeded() { + guard let session = watchSession, session.isPaired, session.isWatchAppInstalled else { + return + } + + guard case .activated = session.activationState else { + session.activate() + return + } + + createWatchContext { (context) in + self.sendWatchContext(context) + } } - private func createWatchContext(_ completionHandler: @escaping (_ context: WatchContext?) -> Void) { + private func sendWatchContext(_ context: WatchContext) { + guard let session = watchSession, session.isPaired, session.isWatchAppInstalled else { + return + } - guard let glucoseStore = self.deviceDataManager.glucoseStore else { - completionHandler(nil) + guard case .activated = session.activationState else { + session.activate() return } - let glucose = deviceDataManager.glucoseStore?.latestGlucose - let reservoir = deviceDataManager.doseStore.lastReservoirValue - let maxBolus = deviceDataManager.maximumBolus + let complicationShouldUpdate: Bool + updateBedtimeIfNeeded() - deviceDataManager.loopManager.getLoopStatus { (predictedGlucose, _, recommendedTempBasal, lastTempBasal, lastLoopCompleted, _, _, error) in - let eventualGlucose = predictedGlucose?.last + if let lastContext = lastComplicationContext, + let lastGlucose = lastContext.glucose, let lastGlucoseDate = lastContext.glucoseDate, + let newGlucose = context.glucose, let newGlucoseDate = context.glucoseDate + { + let enoughTimePassed = newGlucoseDate.timeIntervalSince(lastGlucoseDate) >= session.complicationUserInfoTransferInterval(bedtime: bedtime) + let enoughTrendDrift = abs(newGlucose.doubleValue(for: minTrendUnit) - lastGlucose.doubleValue(for: minTrendUnit)) >= minTrendDrift - self.deviceDataManager.loopManager.getRecommendedBolus { (units, error) in - glucoseStore.preferredUnit { (unit, error) in - let context = WatchContext(glucose: glucose, eventualGlucose: eventualGlucose, glucoseUnit: unit) - context.reservoir = reservoir?.unitVolume + complicationShouldUpdate = enoughTimePassed || enoughTrendDrift + } else { + complicationShouldUpdate = true + } - context.loopLastRunDate = lastLoopCompleted - context.recommendedBolusDose = units - context.maxBolus = maxBolus + if session.isComplicationEnabled && complicationShouldUpdate { + log.default("transferCurrentComplicationUserInfo") + session.transferCurrentComplicationUserInfo(context.rawValue) + lastComplicationContext = context + } else { + do { + log.default("updateApplicationContext") + try session.updateApplicationContext(context.rawValue) + } catch let error { + log.error("%{public}@", String(describing: error)) + } + } + } + + private func createWatchContext(recommendingBolusFor potentialCarbEntry: NewCarbEntry? = nil, _ completion: @escaping (_ context: WatchContext) -> Void) { + var dosingDecision = BolusDosingDecision(for: .watchBolus) + + let loopManager = deviceManager.loopManager! - if let trend = self.deviceDataManager.sensorInfo?.trendType { - context.glucoseTrendRawValue = trend.rawValue + let glucose = deviceManager.glucoseStore.latestGlucose + let reservoir = deviceManager.doseStore.lastReservoirValue + let basalDeliveryState = deviceManager.pumpManager?.status.basalDeliveryState + + loopManager.getLoopState { (manager, state) in + let updateGroup = DispatchGroup() + + let carbsOnBoard = state.carbsOnBoard + + let context = WatchContext(glucose: glucose, glucoseUnit: self.deviceManager.preferredGlucoseUnit) + context.reservoir = reservoir?.unitVolume + context.loopLastRunDate = manager.lastLoopCompleted + context.cob = carbsOnBoard?.quantity.doubleValue(for: HKUnit.gram()) + + if let glucoseDisplay = self.deviceManager.glucoseDisplay(for: glucose) { + context.glucoseTrend = glucoseDisplay.trendType + context.glucoseTrendRate = glucoseDisplay.trendRate + } + + dosingDecision.carbsOnBoard = carbsOnBoard + + context.cgmManagerState = self.deviceManager.cgmManager?.rawValue + + let settings = self.deviceManager.loopManager.settings + + context.isClosedLoop = settings.dosingEnabled + + context.potentialCarbEntry = potentialCarbEntry + if let recommendedBolus = try? state.recommendBolus(consideringPotentialCarbEntry: potentialCarbEntry, replacingCarbEntry: nil, considerPositiveVelocityAndRC: FeatureFlags.usePositiveMomentumAndRCForManualBoluses) + { + context.recommendedBolusDose = recommendedBolus.amount + dosingDecision.manualBolusRecommendation = ManualBolusRecommendationWithDate(recommendation: recommendedBolus, + date: Date()) + } + + var historicalGlucose: [HistoricalGlucoseValue]? + if let glucose = glucose { + updateGroup.enter() + let historicalGlucoseStartDate = Date(timeIntervalSinceNow: -LoopCoreConstants.dosingDecisionHistoricalGlucoseInterval) + self.deviceManager.glucoseStore.getGlucoseSamples(start: min(historicalGlucoseStartDate, glucose.startDate), end: nil) { (result) in + var sample: StoredGlucoseSample? + switch result { + case .failure(let error): + self.log.error("Failure getting glucose samples: %{public}@", String(describing: error)) + sample = nil + case .success(let samples): + sample = samples.last + historicalGlucose = samples.filter { $0.startDate >= historicalGlucoseStartDate }.map { HistoricalGlucoseValue(startDate: $0.startDate, quantity: $0.quantity) } } + context.glucose = sample?.quantity + context.glucoseDate = sample?.startDate + context.glucoseIsDisplayOnly = sample?.isDisplayOnly + context.glucoseWasUserEntered = sample?.wasUserEntered + context.glucoseSyncIdentifier = sample?.syncIdentifier + updateGroup.leave() + } + } - completionHandler(context) + var insulinOnBoard: InsulinValue? + updateGroup.enter() + self.deviceManager.doseStore.insulinOnBoard(at: Date()) { (result) in + switch result { + case .success(let iobValue): + context.iob = iobValue.value + insulinOnBoard = iobValue + case .failure: + context.iob = nil } + updateGroup.leave() + } + + _ = updateGroup.wait(timeout: .distantFuture) + + dosingDecision.historicalGlucose = historicalGlucose + dosingDecision.insulinOnBoard = insulinOnBoard + + if let basalDeliveryState = basalDeliveryState, + let basalSchedule = manager.basalRateScheduleApplyingOverrideHistory, + let netBasal = basalDeliveryState.getNetBasal(basalSchedule: basalSchedule, settings: manager.settings) + { + context.lastNetTempBasalDose = netBasal.rate + } + + if let predictedGlucose = state.predictedGlucoseIncludingPendingInsulin { + // Drop the first element in predictedGlucose because it is the current glucose + let filteredPredictedGlucose = predictedGlucose.dropFirst() + if filteredPredictedGlucose.count > 0 { + context.predictedGlucose = WatchPredictedGlucose(values: Array(filteredPredictedGlucose)) + } + } + + dosingDecision.predictedGlucose = state.predictedGlucoseIncludingPendingInsulin ?? state.predictedGlucose + + var preMealOverride = settings.preMealOverride + if preMealOverride?.hasFinished() == true { + preMealOverride = nil + } + + var scheduleOverride = settings.scheduleOverride + if scheduleOverride?.hasFinished() == true { + scheduleOverride = nil + } + + dosingDecision.scheduleOverride = scheduleOverride + + if scheduleOverride != nil || preMealOverride != nil { + dosingDecision.glucoseTargetRangeSchedule = settings.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: potentialCarbEntry != nil) + } else { + dosingDecision.glucoseTargetRangeSchedule = settings.glucoseTargetRangeSchedule } + + // Remove any expired context dosing decisions and add new + self.contextDosingDecisions = self.contextDosingDecisions.filter { (date, _) in date.timeIntervalSinceNow > self.contextDosingDecisionExpirationDuration } + self.contextDosingDecisions[context.creationDate] = dosingDecision + + completion(context) } } - private func addCarbEntryFromWatchMessage(_ message: [String: Any], completionHandler: ((_ units: Double?) -> Void)? = nil) { - if let carbStore = deviceDataManager.carbStore, let carbEntry = CarbEntryUserInfo(rawValue: message) { - let newEntry = NewCarbEntry( - quantity: HKQuantity(unit: carbStore.preferredUnit, doubleValue: carbEntry.value), - startDate: carbEntry.startDate, - foodType: nil, - absorptionTime: carbEntry.absorptionTimeType.absorptionTimeFromDefaults(carbStore.defaultAbsorptionTimes) - ) + private func addCarbEntryAndBolusFromWatchMessage(_ message: [String: Any]) { + guard let bolus = SetBolusUserInfo(rawValue: message as SetBolusUserInfo.RawValue) else { + log.error("Could not enact bolus from from unknown message: %{public}@", String(describing: message)) + return + } - deviceDataManager.loopManager.addCarbEntryAndRecommendBolus(newEntry) { (units, error) in - if let error = error { - self.deviceDataManager.logger.addError(error, fromSource: error is CarbStore.CarbStoreError ? "CarbStore" : "Bolus") - } else { - AnalyticsManager.sharedManager.didAddCarbsFromWatch(carbEntry.value) + // Prevent any delayed messages from enacting. + guard bolus.startDate.timeIntervalSinceNow > -30 else { + log.error("Could not enact expired bolus from watch: %{public}@", String(describing: message)) + return + } + + var dosingDecision: BolusDosingDecision + if let contextDate = bolus.contextDate, let contextDosingDecision = contextDosingDecisions[contextDate] { + dosingDecision = contextDosingDecision + } else { + dosingDecision = BolusDosingDecision(for: .watchBolus) // The user saved without waiting for recommendation (no bolus) + } + + func enactBolus() { + dosingDecision.manualBolusRequested = bolus.value + deviceManager.loopManager.storeManualBolusDosingDecision(dosingDecision, withDate: bolus.startDate) + + guard bolus.value > 0 else { + // Ensure active carbs is updated in the absence of a bolus + sendWatchContextIfNeeded() + return + } + + deviceManager.enactBolus(units: bolus.value, activationType: bolus.activationType) { (error) in + if error == nil { + self.deviceManager.analyticsServicesManager.didBolus(source: "Watch", units: bolus.value) } - completionHandler?(units) + // When we've successfully started the bolus, send a new context with our new prediction + self.sendWatchContextIfNeeded() + + self.deviceManager.loopManager.updateRemoteRecommendation() + } + } + + if let carbEntry = bolus.carbEntry { + deviceManager.loopManager.addCarbEntry(carbEntry) { (result) in + switch result { + case .success(let storedCarbEntry): + dosingDecision.carbEntry = storedCarbEntry + self.deviceManager.analyticsServicesManager.didAddCarbs(source: "Watch", amount: storedCarbEntry.quantity.doubleValue(for: .gram())) + enactBolus() + case .failure(let error): + self.log.error("%{public}@", String(describing: error)) + } } } else { - completionHandler?(nil) + dosingDecision.carbEntry = nil + enactBolus() } } +} - // MARK: WCSessionDelegate - func session(_ session: WCSession, didReceiveMessage message: [String : Any], replyHandler: @escaping ([String: Any]) -> Void) { +extension WatchDataManager: WCSessionDelegate { + func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { switch message["name"] as? String { - case CarbEntryUserInfo.name?: - addCarbEntryFromWatchMessage(message) { (units) in - replyHandler(BolusSuggestionUserInfo(recommendedBolus: units ?? 0, maxBolus: self.deviceDataManager.maximumBolus).rawValue) + case PotentialCarbEntryUserInfo.name?: + if let potentialCarbEntry = PotentialCarbEntryUserInfo(rawValue: message)?.carbEntry { + self.createWatchContext(recommendingBolusFor: potentialCarbEntry) { (context) in + replyHandler(context.rawValue) + } + } else { + log.error("Could not recommend bolus from from unknown message: %{public}@", String(describing: message)) + replyHandler([:]) } case SetBolusUserInfo.name?: - if let bolus = SetBolusUserInfo(rawValue: message as SetBolusUserInfo.RawValue) { - self.deviceDataManager.enactBolus(units: bolus.value) { (error) in - if error != nil { - NotificationManager.sendBolusFailureNotificationForAmount(bolus.value, atStartDate: bolus.startDate) - } else { - AnalyticsManager.sharedManager.didSetBolusFromWatch(bolus.value) - } + // Add carbs if applicable; start the bolus and reply when it's successfully requested + addCarbEntryAndBolusFromWatchMessage(message) - replyHandler([:]) + // Reply immediately + replyHandler([:]) + case LoopSettingsUserInfo.name?: + if let watchSettings = LoopSettingsUserInfo(rawValue: message)?.settings { + // So far we only support watch changes of temporary schedule overrides + var loopSettings = deviceManager.loopManager.settings + loopSettings.preMealOverride = watchSettings.preMealOverride + loopSettings.scheduleOverride = watchSettings.scheduleOverride + + // Prevent re-sending these updated settings back to the watch + lastSentSettings = loopSettings + deviceManager.loopManager.mutateSettings { settings in + settings = loopSettings + } + } + + // Since target range affects recommended bolus, send back a new one + createWatchContext { (context) in + replyHandler(context.rawValue) + } + case CarbBackfillRequestUserInfo.name?: + if let userInfo = CarbBackfillRequestUserInfo(rawValue: message) { + deviceManager.carbStore.getSyncCarbObjects(start: userInfo.startDate) { (result) in + switch result { + case .failure(let error): + self.log.error("%{public}@", String(describing: error)) + replyHandler([:]) + case .success(let objects): + replyHandler(WatchHistoricalCarbs(objects: objects).rawValue) + } + } + } else { + replyHandler([:]) + } + case GlucoseBackfillRequestUserInfo.name?: + if let userInfo = GlucoseBackfillRequestUserInfo(rawValue: message) { + deviceManager.glucoseStore.getSyncGlucoseSamples(start: userInfo.startDate.addingTimeInterval(1)) { (result) in + switch result { + case .failure(let error): + self.log.error("Failure getting sync glucose objects: %{public}@", String(describing: error)) + replyHandler([:]) + case .success(let samples): + replyHandler(WatchHistoricalGlucose(samples: samples).rawValue) + } } } else { replyHandler([:]) } + case WatchContextRequestUserInfo.name?: + self.createWatchContext { (context) in + // Send back the updated prediction and recommended bolus + replyHandler(context.rawValue) + } default: replyHandler([:]) } } - func session(_ session: WCSession, didReceiveUserInfo userInfo: [String : Any]) { - addCarbEntryFromWatchMessage(userInfo) + func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) { + assertionFailure("We currently don't expect any userInfo messages transferred from the watch side") } func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { switch activationState { case .activated: if let error = error { - deviceDataManager.logger.addError(error, fromSource: "WCSession") + log.error("%{public}@", String(describing: error)) + } else { + sendSettingsIfNeeded() + sendWatchContextIfNeeded() + sendSupportedBolusVolumesIfNeeded() } case .inactive, .notActivated: break + @unknown default: + break } } func session(_ session: WCSession, didFinish userInfoTransfer: WCSessionUserInfoTransfer, error: Error?) { if let error = error { - deviceDataManager.logger.addError(error, fromSource: "WCSession") + log.error("%{public}@", String(describing: error)) + + // This might be useless, as userInfoTransfer.userInfo seems to be nil when error is non-nil. + switch userInfoTransfer.userInfo["name"] as? String { + case nil: + lastSentSettings = nil + sendSettingsIfNeeded() + lastSentBolusVolumes = nil + sendSupportedBolusVolumesIfNeeded() + case LoopSettingsUserInfo.name: + lastSentSettings = nil + sendSettingsIfNeeded() + case SupportedBolusVolumesUserInfo.name: + lastSentBolusVolumes = nil + sendSupportedBolusVolumesIfNeeded() + default: + break + } } } @@ -203,9 +538,79 @@ final class WatchDataManager: NSObject, WCSessionDelegate { } func sessionDidDeactivate(_ session: WCSession) { - watchSession = WCSession.default() + lastSentSettings = nil + watchSession = WCSession.default watchSession?.delegate = self watchSession?.activate() } + func sessionReachabilityDidChange(_ session: WCSession) { + sendSettingsIfNeeded() + sendSupportedBolusVolumesIfNeeded() + } +} + + +extension WatchDataManager { + override var debugDescription: String { + var items = [ + "## WatchDataManager", + "lastSentSettings: \(String(describing: lastSentSettings))", + "lastComplicationContext: \(String(describing: lastComplicationContext))", + "lastBedtimeQuery: \(String(describing: lastBedtimeQuery))", + "bedtime: \(String(describing: bedtime))", + "complicationUserInfoTransferInterval: \(round(watchSession?.complicationUserInfoTransferInterval(bedtime: bedtime).minutes ?? 0)) min" + ] + + if let session = watchSession { + items.append(String(reflecting: session)) + } else { + items.append(contentsOf: [ + "watchSession: nil" + ]) + } + + return items.joined(separator: "\n") + } + +} + +extension WCSession { + open override var debugDescription: String { + return [ + "\(self)", + "* hasContentPending: \(hasContentPending)", + "* isComplicationEnabled: \(isComplicationEnabled)", + "* isPaired: \(isPaired)", + "* isReachable: \(isReachable)", + "* isWatchAppInstalled: \(isWatchAppInstalled)", + "* outstandingFileTransfers: \(outstandingFileTransfers)", + "* outstandingUserInfoTransfers: \(outstandingUserInfoTransfers)", + "* receivedApplicationContext: \(receivedApplicationContext)", + "* remainingComplicationUserInfoTransfers: \(remainingComplicationUserInfoTransfers)", + "* watchDirectoryURL: \(watchDirectoryURL?.absoluteString ?? "nil")", + ].joined(separator: "\n") + } + + fileprivate func complicationUserInfoTransferInterval(bedtime: Date?) -> TimeInterval { + let now = Date() + let timeUntilRefresh: TimeInterval + + if let midnight = Calendar.current.nextDate(after: now, matching: DateComponents(hour: 0), matchingPolicy: .nextTime) { + // we can have a more frequent refresh rate if we only refresh when it's likely the user is awake (based on HealthKit sleep data) + if let nextBedtime = bedtime { + let timeUntilBedtime = nextBedtime.timeIntervalSince(now) + // if bedtime is before the current time or more than 24 hours away, use midnight instead + timeUntilRefresh = (0.. TimeInterval { - switch self { - case .fast: - return defaults.fast - case .medium: - return defaults.medium - case .slow: - return defaults.slow - } - } -} diff --git a/Loop/Models/ApplicationFactorStrategy.swift b/Loop/Models/ApplicationFactorStrategy.swift new file mode 100644 index 0000000000..bf67935c4e --- /dev/null +++ b/Loop/Models/ApplicationFactorStrategy.swift @@ -0,0 +1,20 @@ +// +// ApplicationFactorStrategy.swift +// Loop +// +// Created by Jonas Björkert on 2023-06-03. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation +import HealthKit +import LoopKit +import LoopCore + +protocol ApplicationFactorStrategy { + func calculateDosingFactor( + for glucose: HKQuantity, + correctionRangeSchedule: GlucoseRangeSchedule, + settings: LoopSettings + ) -> Double +} diff --git a/Loop/Models/AutomaticDosingStatus.swift b/Loop/Models/AutomaticDosingStatus.swift new file mode 100644 index 0000000000..ae3930c122 --- /dev/null +++ b/Loop/Models/AutomaticDosingStatus.swift @@ -0,0 +1,21 @@ +// +// AutomaticDosingStatus.swift +// Loop +// +// Created by Nathaniel Hamming on 2021-05-28. +// Copyright © 2021 LoopKit Authors. All rights reserved. +// + +import Foundation + +class AutomaticDosingStatus { + @Published var automaticDosingEnabled: Bool + @Published var isAutomaticDosingAllowed: Bool + + init(automaticDosingEnabled: Bool, + isAutomaticDosingAllowed: Bool) + { + self.automaticDosingEnabled = automaticDosingEnabled + self.isAutomaticDosingAllowed = isAutomaticDosingAllowed + } +} diff --git a/Loop/Models/BolusDosingDecision.swift b/Loop/Models/BolusDosingDecision.swift new file mode 100644 index 0000000000..9d63905858 --- /dev/null +++ b/Loop/Models/BolusDosingDecision.swift @@ -0,0 +1,34 @@ +// +// BolusDosingDecision.swift +// Loop +// +// Created by Darin Krauss on 10/1/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import LoopKit + +struct BolusDosingDecision { + enum Reason: String { + case normalBolus + case simpleBolus + case watchBolus + } + + var reason: Reason + var scheduleOverride: TemporaryScheduleOverride? + var historicalGlucose: [HistoricalGlucoseValue]? + var originalCarbEntry: StoredCarbEntry? + var carbEntry: StoredCarbEntry? + var manualGlucoseSample: StoredGlucoseSample? + var carbsOnBoard: CarbValue? + var insulinOnBoard: InsulinValue? + var glucoseTargetRangeSchedule: GlucoseRangeSchedule? + var predictedGlucose: [PredictedGlucoseValue]? + var manualBolusRecommendation: ManualBolusRecommendationWithDate? + var manualBolusRequested: Double? + + init(for reason: Reason) { + self.reason = reason + } +} diff --git a/Loop/Models/ChartAxisValueDoubleLog.swift b/Loop/Models/ChartAxisValueDoubleLog.swift deleted file mode 100644 index fc213af2e5..0000000000 --- a/Loop/Models/ChartAxisValueDoubleLog.swift +++ /dev/null @@ -1,56 +0,0 @@ -// -// ChartAxisValueDoubleLog.swift -// Naterade -// -// Created by Nathan Racklyeft on 2/29/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import UIKit -import SwiftCharts - - -final class ChartAxisValueDoubleLog: ChartAxisValueDoubleScreenLoc { - - let unitString: String? - - init(actualDouble: Double, unitString: String? = nil, formatter: NumberFormatter, labelSettings: ChartLabelSettings = ChartLabelSettings()) { - let screenLocDouble: Double - - switch actualDouble { - case let x where x < 0: - screenLocDouble = -log(-x + 1) - case let x where x > 0: - screenLocDouble = log(x + 1) - default: // 0 - screenLocDouble = 0 - } - - self.unitString = unitString - - super.init(screenLocDouble: screenLocDouble, actualDouble: actualDouble, formatter: formatter, labelSettings: labelSettings) - } - - init(screenLocDouble: Double, formatter: NumberFormatter, labelSettings: ChartLabelSettings = ChartLabelSettings()) { - let actualDouble: Double - - switch screenLocDouble { - case let x where x < 0: - actualDouble = -pow(M_E, -x) + 1 - case let x where x > 0: - actualDouble = pow(M_E, x) - 1 - default: // 0 - actualDouble = 0 - } - - self.unitString = nil - - super.init(screenLocDouble: screenLocDouble, actualDouble: actualDouble, formatter: formatter, labelSettings: labelSettings) - } - - override var description: String { - let suffix = unitString != nil ? " \(unitString!)" : "" - - return super.description + suffix - } -} diff --git a/Loop/Models/ChartAxisValueDoubleUnit.swift b/Loop/Models/ChartAxisValueDoubleUnit.swift deleted file mode 100644 index 72073a5090..0000000000 --- a/Loop/Models/ChartAxisValueDoubleUnit.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// ChartAxisValueDoubleUnit.swift -// Loop -// -// Created by Nate Racklyeft on 7/16/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import UIKit -import SwiftCharts - - -final class ChartAxisValueDoubleUnit: ChartAxisValueDouble { - let unitString: String - - init(_ double: Double, unitString: String, formatter: NumberFormatter) { - self.unitString = unitString - - super.init(double, formatter: formatter) - } - - init(_ double: Double, unitString: String) { - self.unitString = unitString - - super.init(double) - } - - override var description: String { - return "\(super.description) \(unitString)" - } -} diff --git a/Loop/Models/ConstantApplicationFactorStrategy.swift b/Loop/Models/ConstantApplicationFactorStrategy.swift new file mode 100644 index 0000000000..e13c40c42e --- /dev/null +++ b/Loop/Models/ConstantApplicationFactorStrategy.swift @@ -0,0 +1,23 @@ +// +// ConstantDosingStrategy.swift +// Loop +// +// Created by Jonas Björkert on 2023-06-03. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation +import HealthKit +import LoopKit +import LoopCore + +struct ConstantApplicationFactorStrategy: ApplicationFactorStrategy { + func calculateDosingFactor( + for glucose: HKQuantity, + correctionRangeSchedule: GlucoseRangeSchedule, + settings: LoopSettings + ) -> Double { + // The original strategy uses a constant dosing factor. + return LoopConstants.bolusPartialApplicationFactor + } +} diff --git a/Loop/Models/CrashRecoveryManager.swift b/Loop/Models/CrashRecoveryManager.swift new file mode 100644 index 0000000000..e0f0e6f260 --- /dev/null +++ b/Loop/Models/CrashRecoveryManager.swift @@ -0,0 +1,75 @@ +// +// CrashRecoveryManager.swift +// Loop +// +// Created by Pete Schwamb on 9/17/22. +// Copyright © 2022 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit + +class CrashRecoveryManager { + + private let log = DiagnosticLog(category: "CrashRecoveryManager") + + let managerIdentifier = "CrashRecoveryManager" + + private let crashAlertIdentifier = "CrashAlert" + + var doseRecoveredFromCrash: AutomaticDoseRecommendation? + + let alertIssuer: AlertIssuer + + var pendingCrashRecovery: Bool { + return doseRecoveredFromCrash != nil + } + + init(alertIssuer: AlertIssuer) { + self.alertIssuer = alertIssuer + + doseRecoveredFromCrash = UserDefaults.appGroup?.inFlightAutomaticDose + + if doseRecoveredFromCrash != nil { + issueCrashAlert() + } + } + + func dosingStarted(dose: AutomaticDoseRecommendation) { + UserDefaults.appGroup?.inFlightAutomaticDose = dose + } + + func dosingFinished() { + UserDefaults.appGroup?.inFlightAutomaticDose = nil + } + + private func issueCrashAlert() { + let title = NSLocalizedString("Loop Crashed", comment: "Title for crash recovery alert") + let modalBody = NSLocalizedString("Oh no! Loop crashed while dosing, and insulin adjustments have been paused until this dialog is closed. Dosing history may not be accurate. Please review Insulin Delivery charts, and monitor your blood glucose carefully.", comment: "Modal body for crash recovery alert") + let modalContent = Alert.Content(title: title, + body: modalBody, + acknowledgeActionButtonLabel: NSLocalizedString("Continue", comment: "Default alert dismissal")) + let notificationBody = NSLocalizedString("Insulin adjustments have been disabled!", comment: "Notification body for crash recovery alert") + let notificationContent = Alert.Content(title: title, + body: notificationBody, + acknowledgeActionButtonLabel: NSLocalizedString("Continue", comment: "Default alert dismissal")) + + let identifier = Alert.Identifier(managerIdentifier: managerIdentifier, alertIdentifier: crashAlertIdentifier) + + let alert = Alert(identifier: identifier, + foregroundContent: modalContent, + backgroundContent: notificationContent, + trigger: .immediate, + interruptionLevel: .critical) + + self.alertIssuer.issueAlert(alert) + } +} + +extension CrashRecoveryManager: AlertResponder { + func acknowledgeAlert(alertIdentifier: LoopKit.Alert.AlertIdentifier, completion: @escaping (Error?) -> Void) { + UserDefaults.appGroup?.inFlightAutomaticDose = nil + doseRecoveredFromCrash = nil + } +} + diff --git a/Loop/Models/Glucose.swift b/Loop/Models/Glucose.swift deleted file mode 100644 index ddc1c6848a..0000000000 --- a/Loop/Models/Glucose.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// GlucoseRxMessage.swift -// Loop -// -// Created by Nathan Racklyeft on 5/30/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import Foundation -import LoopUI -import xDripG5 - - -extension Glucose: SensorDisplayable { - public var isStateValid: Bool { - return state == .ok && status == .ok - } - - public var stateDescription: String { - let status: String - switch self.status { - case .ok: - status = "" - case .lowBattery: - status = NSLocalizedString(" Low Battery", comment: "The description of a low G5 transmitter battery with a leading space") - case .unknown(let value): - status = String(format: "%02x", value) - } - - return String(format: "%1$@ %2$@", String(describing: state), status) - } - - public var trendType: GlucoseTrend? { - guard trend < Int(Int8.max) else { - return nil - } - - switch trend { - case let x where x <= -30: - return .downDownDown - case let x where x <= -20: - return .downDown - case let x where x <= -10: - return .down - case let x where x < 10: - return .flat - case let x where x < 20: - return .up - case let x where x < 30: - return .upUp - default: - return .upUpUp - } - } - - public var isLocal: Bool { - return true - } -} diff --git a/Loop/Models/GlucoseBasedApplicationFactorStrategy.swift b/Loop/Models/GlucoseBasedApplicationFactorStrategy.swift new file mode 100644 index 0000000000..41caa3d773 --- /dev/null +++ b/Loop/Models/GlucoseBasedApplicationFactorStrategy.swift @@ -0,0 +1,42 @@ +// +// GlucoseBasedApplicationFactorStrategy.swift +// Loop +// +// Created by Jonas Björkert on 2023-06-03. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation +import HealthKit +import LoopKit +import LoopCore + +struct GlucoseBasedApplicationFactorStrategy: ApplicationFactorStrategy { + static let minPartialApplicationFactor = 0.20 // min fraction of correction when glucose > minGlucoseSlidingScale + static let maxPartialApplicationFactor = 0.80 // max fraction of correction when glucose > maxGlucoseSlidingScale + // set minGlucoseSlidingScale based on user setting for correction range + // use mg/dL for calculations + static let minGlucoseDeltaSlidingScale = 10.0 // mg/dL + static let maxGlucoseSlidingScale = 200.0 // mg/dL + + func calculateDosingFactor( + for glucose: HKQuantity, + correctionRangeSchedule: GlucoseRangeSchedule, + settings: LoopSettings + ) -> Double { + // Calculate current glucose and lower bound target + let currentGlucose = glucose.doubleValue(for: .milligramsPerDeciliter) + let correctionRange = correctionRangeSchedule.quantityRange(at: Date()) + let lowerBoundTarget = correctionRange.lowerBound.doubleValue(for: .milligramsPerDeciliter) + + // Calculate minimum glucose sliding scale and scaling fraction + let minGlucoseSlidingScale = GlucoseBasedApplicationFactorStrategy.minGlucoseDeltaSlidingScale + lowerBoundTarget + let scalingFraction = (GlucoseBasedApplicationFactorStrategy.maxPartialApplicationFactor - GlucoseBasedApplicationFactorStrategy.minPartialApplicationFactor) / (GlucoseBasedApplicationFactorStrategy.maxGlucoseSlidingScale - minGlucoseSlidingScale) + let scalingGlucose = max(currentGlucose - minGlucoseSlidingScale, 0.0) + + // Calculate effectiveBolusApplicationFactor + let effectiveBolusApplicationFactor = min(GlucoseBasedApplicationFactorStrategy.minPartialApplicationFactor + scalingGlucose * scalingFraction, GlucoseBasedApplicationFactorStrategy.maxPartialApplicationFactor) + + return effectiveBolusApplicationFactor + } +} diff --git a/Loop/Models/GlucoseDisplay.swift b/Loop/Models/GlucoseDisplay.swift new file mode 100644 index 0000000000..119e06d096 --- /dev/null +++ b/Loop/Models/GlucoseDisplay.swift @@ -0,0 +1,59 @@ +// +// GlucoseDisplay.swift +// Loop +// +// Created by Nathaniel Hamming on 2020-09-22. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import Foundation +import HealthKit +import LoopKit + +struct GlucoseDisplay: GlucoseDisplayable { + let isStateValid: Bool + let trendType: GlucoseTrend? + let trendRate: HKQuantity? + let isLocal: Bool + var glucoseRangeCategory: GlucoseRangeCategory? + + init(isStateValid: Bool, + trendType: GlucoseTrend?, + trendRate: HKQuantity?, + isLocal: Bool, + glucoseRangeCategory: GlucoseRangeCategory?) + { + self.isStateValid = isStateValid + self.trendType = trendType + self.trendRate = trendRate + self.isLocal = isLocal + self.glucoseRangeCategory = glucoseRangeCategory + } + + init?(_ glucoseDisplayable: GlucoseDisplayable?) { + guard let glucoseDisplayable = glucoseDisplayable else { + return nil + } + self.isStateValid = glucoseDisplayable.isStateValid + self.trendType = glucoseDisplayable.trendType + self.trendRate = glucoseDisplayable.trendRate + self.isLocal = glucoseDisplayable.isLocal + self.glucoseRangeCategory = glucoseDisplayable.glucoseRangeCategory + } +} + +struct ManualGlucoseDisplay: GlucoseDisplayable { + let isStateValid: Bool + let trendType: GlucoseTrend? + let trendRate: HKQuantity? + let isLocal: Bool + let glucoseRangeCategory: GlucoseRangeCategory? + + init(glucoseRangeCategory: GlucoseRangeCategory?) { + isStateValid = true + trendType = nil + trendRate = nil + isLocal = true + self.glucoseRangeCategory = glucoseRangeCategory + } +} diff --git a/Loop/Models/GlucoseEffectVelocity.swift b/Loop/Models/GlucoseEffectVelocity.swift new file mode 100644 index 0000000000..9557f2fd50 --- /dev/null +++ b/Loop/Models/GlucoseEffectVelocity.swift @@ -0,0 +1,38 @@ +// +// GlucoseEffectVelocity.swift +// Loop +// +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +import HealthKit +import LoopKit + + +extension GlucoseEffectVelocity: RawRepresentable { + public typealias RawValue = [String: Any] + + static let unit = HKUnit.milligramsPerDeciliter.unitDivided(by: .minute()) + + public init?(rawValue: RawValue) { + guard let startDate = rawValue["startDate"] as? Date, + let doubleValue = rawValue["doubleValue"] as? Double + else { + return nil + } + + self.init( + startDate: startDate, + endDate: rawValue["endDate"] as? Date ?? startDate, + quantity: HKQuantity(unit: type(of: self).unit, doubleValue: doubleValue) + ) + } + + public var rawValue: RawValue { + return [ + "startDate": startDate, + "endDate": endDate, + "doubleValue": quantity.doubleValue(for: type(of: self).unit) + ] + } +} diff --git a/Loop/Models/GlucoseG4.swift b/Loop/Models/GlucoseG4.swift deleted file mode 100644 index de8b64f0ac..0000000000 --- a/Loop/Models/GlucoseG4.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// GlucoseG4.swift -// Loop -// -// Created by Mark Wilson on 7/21/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import Foundation -import G4ShareSpy -import HealthKit -import LoopKit -import LoopUI - - -extension GlucoseG4: GlucoseValue { - public var quantity: HKQuantity { - return HKQuantity(unit: HKUnit.milligramsPerDeciliterUnit(), doubleValue: Double(glucose)) - } - - public var startDate: Date { - return time - } -} - - -extension GlucoseG4: SensorDisplayable { - public var isStateValid: Bool { - return glucose >= 20 - } - - public var stateDescription: String { - if isStateValid { - return NSLocalizedString("OK", comment: "Sensor state description for the valid state") - } else { - return NSLocalizedString("Needs Attention", comment: "Sensor state description for the non-valid state") - } - } - - public var trendType: GlucoseTrend? { - return GlucoseTrend(rawValue: Int(trend)) - } - - public var isLocal: Bool { - return true - } -} diff --git a/Loop/Models/InsulinDataSource.swift b/Loop/Models/InsulinDataSource.swift deleted file mode 100644 index 618c178604..0000000000 --- a/Loop/Models/InsulinDataSource.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// InsulinDataSource.swift -// Loop -// -// Created by Nathan Racklyeft on 6/10/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import Foundation - - -enum InsulinDataSource: Int, CustomStringConvertible { - case pumpHistory = 0 - case reservoir - - var description: String { - switch self { - case .pumpHistory: - return NSLocalizedString("Event History", comment: "Describing the pump history insulin data source") - case .reservoir: - return NSLocalizedString("Reservoir", comment: "Describing the reservoir insulin data source") - } - } -} diff --git a/Loop/Models/LoopConstants.swift b/Loop/Models/LoopConstants.swift new file mode 100644 index 0000000000..fb69c8275f --- /dev/null +++ b/Loop/Models/LoopConstants.swift @@ -0,0 +1,75 @@ +// +// LoopConstants.swift +// Loop +// +// Created by Pete Schwamb on 10/7/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import Foundation +import HealthKit +import LoopKit +import UIKit + +enum LoopConstants { + + // Input field bounds + + static let maxCarbEntryQuantity = HKQuantity(unit: .gram(), doubleValue: 250) // cannot exceed this value + + static let warningCarbEntryQuantity = HKQuantity(unit: .gram(), doubleValue: 99) // user is warned above this value + + static let validManualGlucoseEntryRange = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 10)...HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 600) + + static let minCarbAbsorptionTime = TimeInterval(minutes: 30) + static let maxCarbAbsorptionTime = TimeInterval(hours: 8) + + static let maxCarbEntryPastTime = TimeInterval(hours: (-12)) + static let maxCarbEntryFutureTime = TimeInterval(hours: 1) + + static let maxOverrideDurationTime = TimeInterval(hours: 24) + + // MARK - Display settings + + static let minimumChartWidthPerHour: CGFloat = 50 + + static let statusChartMinimumHistoryDisplay: TimeInterval = .hours(1) + + static let glucoseChartDefaultDisplayBound = + HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 100)...HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 175) + + static let glucoseChartDefaultDisplayRangeWide = + HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 60)...HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 200) + + static let glucoseChartDefaultDisplayBoundClamped = + HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 80)...HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 240) + + + // Compile time configuration + + static let retrospectiveCorrectionEnabled = true + + // Percentage of recommended dose to apply as bolus when using automatic bolus dosing strategy + static let bolusPartialApplicationFactor = 0.4 + + /// Loop completion aging category limits + static let completionFreshLimit = TimeInterval(minutes: 6) + static let completionAgingLimit = TimeInterval(minutes: 16) + static let completionStaleLimit = TimeInterval(hours: 12) + + static let batteryReplacementDetectionThreshold = 0.5 + + static let defaultWatchCarbPickerValue = 15 // grams + + static let defaultWatchBolusPickerValue = 1.0 // % + + /// Missed Meal warning constants + static let missedMealWarningGlucoseRiseThreshold = 3.0 // mg/dL/m + static let missedMealWarningGlucoseRecencyWindow = TimeInterval(minutes: 20) + static let missedMealWarningVelocitySampleMinDuration = TimeInterval(minutes: 12) + + // Bolus calculator warning thresholds + static let simpleBolusCalculatorMinGlucoseBolusRecommendation = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 70) + static let simpleBolusCalculatorMinGlucoseMealBolusRecommendation = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 55) + static let simpleBolusCalculatorGlucoseWarningLimit = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 70) +} diff --git a/Loop/Models/LoopError.swift b/Loop/Models/LoopError.swift index 2bfc990403..015d5cc05c 100644 --- a/Loop/Models/LoopError.swift +++ b/Loop/Models/LoopError.swift @@ -6,20 +6,206 @@ // Copyright © 2016 Nathan Racklyeft. All rights reserved. // +import Foundation +import LoopKit -enum LoopError: Error { - // Failure during device communication - case communicationError +enum ConfigurationErrorDetail: String, Codable { + case pumpManager + case basalRateSchedule + case carbRatioSchedule + case glucoseTargetRangeSchedule + case insulinSensitivitySchedule + case maximumBasalRatePerHour + case maximumBolus + + func localized() -> String { + switch self { + case .pumpManager: + return NSLocalizedString("Pump Manager", comment: "Details for configuration error when pump manager is missing") + case .basalRateSchedule: + return NSLocalizedString("Basal Rate Schedule", comment: "Details for configuration error when basal rate schedule is missing") + case .carbRatioSchedule: + return NSLocalizedString("Carb Ratio Schedule", comment: "Details for configuration error when carb ratio schedule is missing") + case .glucoseTargetRangeSchedule: + return NSLocalizedString("Glucose Target Range Schedule", comment: "Details for configuration error when glucose target range schedule is missing") + case .insulinSensitivitySchedule: + return NSLocalizedString("Insulin Sensitivity Schedule", comment: "Details for configuration error when insulin sensitivity schedule is missing") + case .maximumBasalRatePerHour: + return NSLocalizedString("Maximum Basal Rate Per Hour", comment: "Details for configuration error when maximum basal rate per hour is missing") + case .maximumBolus: + return NSLocalizedString("Maximum Bolus", comment: "Details for configuration error when maximum bolus is missing") + } + } +} +enum MissingDataErrorDetail: String, Codable { + case glucose + case momentumEffect + case carbEffect + case insulinEffect + case activeInsulin + case insulinEffectIncludingPendingInsulin + + var localizedDetail: String { + switch self { + case .glucose: + return NSLocalizedString("Glucose data not available", comment: "Description of error when glucose data is missing") + case .momentumEffect: + return NSLocalizedString("Momentum effects", comment: "Details for missing data error when momentum effects are missing") + case .carbEffect: + return NSLocalizedString("Carb effects", comment: "Details for missing data error when carb effects are missing") + case .insulinEffect: + return NSLocalizedString("Insulin effects", comment: "Details for missing data error when insulin effects are missing") + case .activeInsulin: + return NSLocalizedString("Active Insulin", comment: "Details for missing data error when active insulin amount is missing") + case .insulinEffectIncludingPendingInsulin: + return NSLocalizedString("Insulin effects", comment: "Details for missing data error when insulin effects including pending insulin are missing") + } + } + + var localizedRecovery: String? { + switch self { + case .glucose: + return NSLocalizedString("Check your CGM data source", comment: "Recovery suggestion when glucose data is missing") + case .momentumEffect: + return nil + case .carbEffect: + return nil + case .insulinEffect, .activeInsulin, .insulinEffectIncludingPendingInsulin: + return nil + } + } +} + +enum LoopError: Error { // Missing or unexpected configuration values - case configurationError + case configurationError(ConfigurationErrorDetail) // No connected devices, or failure during device connection case connectionError - // Missing required data to perform an action - case missingDataError(String) + // Missing data required to perform an action + case missingDataError(MissingDataErrorDetail) + + // Glucose data is too old to perform action + case glucoseTooOld(date: Date) + + // Glucose data is in the future + case invalidFutureGlucose(date: Date) + + // Pump data is too old to perform action + case pumpDataTooOld(date: Date) + + // Recommendation Expired + case recommendationExpired(date: Date) + + // Pump Suspended + case pumpSuspended + + // Pump Manager Error + case pumpManagerError(PumpManagerError) + + // Some other error + case unknownError(Error) +} + +extension LoopError { + var issue: StoredDosingDecision.Issue { + return StoredDosingDecision.Issue(id: issueId, details: issueDetails) + } + + var issueId: String { + switch self { + case .configurationError: + return "configurationError" + case .connectionError: + return "connectionError" + case .missingDataError: + return "missingDataError" + case .glucoseTooOld: + return "glucoseTooOld" + case .invalidFutureGlucose: + return "invalidFutureGlucose" + case .pumpDataTooOld: + return "pumpDataTooOld" + case .recommendationExpired: + return "recommendationExpired" + case .pumpSuspended: + return "pumpSuspended" + case .pumpManagerError: + return "pumpManagerError" + case .unknownError: + return "unknownError" + } + } + + var issueDetails: [String: String] { + var details: [String: String] = [:] + switch self { + case .configurationError(let detail): + details["detail"] = detail.rawValue + case .missingDataError(let detail): + details["detail"] = detail.rawValue + case .glucoseTooOld(let date): + details["date"] = StoredDosingDecisionIssue.description(for: date) + case .invalidFutureGlucose(let date): + details["date"] = StoredDosingDecisionIssue.description(for: date) + case .pumpDataTooOld(let date): + details["date"] = StoredDosingDecisionIssue.description(for: date) + case .recommendationExpired(let date): + details["date"] = StoredDosingDecisionIssue.description(for: date) + case .pumpManagerError(let pumpManagerError): + details = pumpManagerError.issueDetails + case .unknownError(let error): + details["error"] = StoredDosingDecisionIssue.description(for: error) + default: + break + } + return details + } +} + +extension LoopError: LocalizedError { + + public var recoverySuggestion: String? { + switch self { + case .missingDataError(let detail): + return detail.localizedRecovery + default: + return nil + } + } + + public var errorDescription: String? { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.minute] + formatter.unitsStyle = .full - // Out-of-date required data to perform an action - case staleDataError(String) + switch self { + case .configurationError(let details): + return String(format: NSLocalizedString("Configuration Error: %1$@", comment: "The error message displayed for configuration errors. (1: configuration error details)"), details.localized()) + case .connectionError: + return NSLocalizedString("No connected devices, or failure during device connection", comment: "The error message displayed for device connection errors.") + case .missingDataError(let details): + return String(format: NSLocalizedString("Missing data: %1$@", comment: "The error message for missing data. (1: missing data details)"), details.localizedDetail) + case .glucoseTooOld(let date): + let minutes = formatter.string(from: -date.timeIntervalSinceNow) ?? "" + return String(format: NSLocalizedString("Glucose data is %1$@ old", comment: "The error message when glucose data is too old to be used. (1: glucose data age in minutes)"), minutes) + case .invalidFutureGlucose(let date): + let minutes = formatter.string(from: -date.timeIntervalSinceNow) ?? "" + return String(format: NSLocalizedString("Invalid glucose reading with a timestamp that is %1$@ in the future", comment: "The error message when glucose data is in the future. (1: glucose data time in future in minutes)"), minutes) + case .pumpDataTooOld(let date): + let minutes = formatter.string(from: -date.timeIntervalSinceNow) ?? "" + return String(format: NSLocalizedString("Pump data is %1$@ old", comment: "The error message when pump data is too old to be used. (1: pump data age in minutes)"), minutes) + case .recommendationExpired(let date): + let minutes = formatter.string(from: -date.timeIntervalSinceNow) ?? "" + return String(format: NSLocalizedString("Recommendation expired: %1$@ old", comment: "The error message when a recommendation has expired. (1: age of recommendation in minutes)"), minutes) + case .pumpSuspended: + return NSLocalizedString("Pump Suspended. Automatic dosing is disabled.", comment: "The error message displayed for pumpSuspended errors.") + case .pumpManagerError(let pumpManagerError): + return String(format: NSLocalizedString("Pump Manager Error: %1$@", comment: "The error message displayed for pump manager errors. (1: pump manager error)"), pumpManagerError.errorDescription!) + case .unknownError(let error): + return String(format: NSLocalizedString("Unknown Error: %1$@", comment: "The error message displayed for unknown errors. (1: unknown error)"), error.localizedDescription) + } + } } diff --git a/Loop/Models/LoopSettings+Loop.swift b/Loop/Models/LoopSettings+Loop.swift new file mode 100644 index 0000000000..e4952934cb --- /dev/null +++ b/Loop/Models/LoopSettings+Loop.swift @@ -0,0 +1,20 @@ +// +// LoopSettings+Loop.swift +// Loop +// +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopCore + +// MARK: - Static configuration +extension LoopSettings { + var enabledEffects: PredictionInputEffect { + var inputs = PredictionInputEffect.all + if !LoopConstants.retrospectiveCorrectionEnabled { + inputs.remove(.retrospection) + } + return inputs + } +} diff --git a/Loop/Models/LoopWarning.swift b/Loop/Models/LoopWarning.swift new file mode 100644 index 0000000000..45439b3c55 --- /dev/null +++ b/Loop/Models/LoopWarning.swift @@ -0,0 +1,99 @@ +// +// LoopWarning.swift +// Loop +// +// Created by Darin Krauss on 10/22/21. +// Copyright © 2021 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit + +enum FetchDataWarningDetail { + case glucoseSamples(error: Error) + case glucoseMomentumEffect(error: Error) + case insulinEffect(error: Error) + case insulinEffectIncludingPendingInsulin(error: Error) + case insulinCounteractionEffect(error: Error) + case carbEffect(error: Error) + case carbsOnBoard(error: Error) + case insulinOnBoard(error: Error) + case retrospectiveGlucoseEffect(error: Error) +} + +extension FetchDataWarningDetail { + var issueId: String { + switch self { + case .glucoseSamples: + return "glucoseSamples" + case .glucoseMomentumEffect: + return "glucoseMomentumEffect" + case .insulinEffect: + return "insulinEffect" + case .insulinEffectIncludingPendingInsulin: + return "insulinEffectIncludingPendingInsulin" + case .insulinCounteractionEffect: + return "insulinCounteractionEffect" + case .carbEffect: + return "carbEffect" + case .carbsOnBoard: + return "carbsOnBoard" + case .insulinOnBoard: + return "insulinOnBoard" + case .retrospectiveGlucoseEffect: + return "retrospectiveGlucoseEffect" + } + } + + var issueDetails: [String: String] { + var details = ["detail": issueId] + switch self { + case .glucoseSamples(let error), + .glucoseMomentumEffect(let error), + .insulinEffect(let error), + .insulinEffectIncludingPendingInsulin(let error), + .insulinCounteractionEffect(let error), + .carbEffect(let error), + .carbsOnBoard(let error), + .insulinOnBoard(let error), + .retrospectiveGlucoseEffect(let error): + details["error"] = StoredDosingDecisionIssue.description(for: error) + } + return details + } +} + +enum LoopWarning { + case fetchDataWarning(FetchDataWarningDetail) + case bolusInProgress +} + +extension LoopWarning { + var issue: StoredDosingDecision.Issue { + return StoredDosingDecision.Issue(id: issueId, details: issueDetails) + } + + var issueId: String { + switch self { + case .fetchDataWarning: + return "fetchDataWarning" + case .bolusInProgress: + return "bolusInProgress" + } + } + + var issueDetails: [String: String] { + var details: [String: String] = [:] + switch self { + case .fetchDataWarning(let detail): + details = detail.issueDetails + default: + break + } + return details + } +} + +extension Locked where T == [LoopWarning] { + func append(_ loopWarning: LoopWarning) { mutate { $0.append(loopWarning) } } +} diff --git a/Loop/Models/ManualBolusRecommendation.swift b/Loop/Models/ManualBolusRecommendation.swift new file mode 100644 index 0000000000..c1ad01125a --- /dev/null +++ b/Loop/Models/ManualBolusRecommendation.swift @@ -0,0 +1,75 @@ +// +// BolusRecommendation.swift +// Loop +// +// Created by Pete Schwamb on 1/2/17. +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit +import HealthKit + + +extension BolusRecommendationNotice { + public func description(using unit: HKUnit) -> String { + switch self { + case .glucoseBelowSuspendThreshold(minGlucose: let minGlucose): + let glucoseFormatter = NumberFormatter.glucoseFormatter(for: unit) + let bgStr = glucoseFormatter.string(from: minGlucose.quantity, unit: unit)! + return String(format: NSLocalizedString("Predicted glucose of %1$@ is below your glucose safety limit setting.", comment: "Notice message when recommending bolus when BG is below the glucose safety limit. (1: glucose value)"), bgStr) + case .currentGlucoseBelowTarget(glucose: let glucose): + let glucoseFormatter = NumberFormatter.glucoseFormatter(for: unit) + let bgStr = glucoseFormatter.string(from: glucose.quantity, unit: unit)! + return String(format: NSLocalizedString("Current glucose of %1$@ is below correction range.", comment: "Message when offering bolus recommendation even though bg is below range. (1: glucose value)"), bgStr) + case .predictedGlucoseBelowTarget(minGlucose: let minGlucose), .allGlucoseBelowTarget(minGlucose: let minGlucose): + let timeFormatter = DateFormatter() + timeFormatter.dateStyle = .none + timeFormatter.timeStyle = .short + let time = timeFormatter.string(from: minGlucose.startDate) + + let glucoseFormatter = NumberFormatter.glucoseFormatter(for: unit) + let minBGStr = glucoseFormatter.string(from: minGlucose.quantity, unit: unit)! + return String(format: NSLocalizedString("Predicted glucose at %1$@ is %2$@.", comment: "Message when offering bolus recommendation even though bg is below range and minBG is in future. (1: glucose time)(2: glucose number)"), time, minBGStr) + case .predictedGlucoseInRange: + return NSLocalizedString("Predicted glucose is in range.", comment: "Notice when predicted glucose for bolus recommendation is in range") + } + } +} + +extension BolusRecommendationNotice: Equatable { + public static func ==(lhs: BolusRecommendationNotice, rhs: BolusRecommendationNotice) -> Bool { + switch (lhs, rhs) { + case (.glucoseBelowSuspendThreshold, .glucoseBelowSuspendThreshold): + return true + + case (.currentGlucoseBelowTarget, .currentGlucoseBelowTarget): + return true + + case (let .predictedGlucoseBelowTarget(minGlucose1), let .predictedGlucoseBelowTarget(minGlucose2)): + // GlucoseValue is not equatable + return + minGlucose1.startDate == minGlucose2.startDate && + minGlucose1.endDate == minGlucose2.endDate && + minGlucose1.quantity == minGlucose2.quantity + + case (.predictedGlucoseInRange, .predictedGlucoseInRange): + return true + + default: + return false + } + } +} + + +extension ManualBolusRecommendation: Comparable { + public static func ==(lhs: ManualBolusRecommendation, rhs: ManualBolusRecommendation) -> Bool { + return lhs.amount == rhs.amount + } + + public static func <(lhs: ManualBolusRecommendation, rhs: ManualBolusRecommendation) -> Bool { + return lhs.amount < rhs.amount + } +} + diff --git a/Loop/Models/MySentryPumpStatusMessageBody.swift b/Loop/Models/MySentryPumpStatusMessageBody.swift deleted file mode 100644 index f1f14dd392..0000000000 --- a/Loop/Models/MySentryPumpStatusMessageBody.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// MySentryPumpStatusMessageBody.swift -// Loop -// -// Created by Nate Racklyeft on 7/28/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import LoopKit -import LoopUI -import MinimedKit - - -extension MySentryPumpStatusMessageBody: SensorDisplayable { - public var isStateValid: Bool { - switch glucose { - case .active, .off: - return true - default: - return false - } - } - - public var trendType: LoopUI.GlucoseTrend? { - guard case .active = glucose else { - return nil - } - - switch glucoseTrend { - case .down: - return .down - case .downDown: - return .downDown - case .up: - return .up - case .upUp: - return .upUp - case .flat: - return .flat - } - } - - public var isLocal: Bool { - return true - } - - var batteryPercentage: Int { - return batteryRemainingPercent - } -} diff --git a/Loop/Models/NetBasal.swift b/Loop/Models/NetBasal.swift index d9f3007bf7..ff11e9e064 100644 --- a/Loop/Models/NetBasal.swift +++ b/Loop/Models/NetBasal.swift @@ -7,18 +7,42 @@ // import Foundation -import InsulinKit import LoopKit +/// Max basal should generally be set, but in those cases where it isn't just use 3.0U/hr as a default top of scale, so we can show *something*. +fileprivate let defaultMaxBasalForScale = 3.0 + struct NetBasal { let rate: Double let percent: Double - let startDate: Date - + let start: Date + let end: Date? + + init(suspendedAt: Date, maxBasal: Double?, scheduledBasal: AbsoluteScheduleValue) { + rate = -scheduledBasal.value + start = suspendedAt + end = nil + + if rate < 0 { + percent = rate / scheduledBasal.value + } else { + percent = rate / ((maxBasal ?? defaultMaxBasalForScale) - scheduledBasal.value) + } + } + + init(scheduledRateStartedAt: Date) { + rate = 0 + start = scheduledRateStartedAt + end = nil + percent = 0 + } + init(lastTempBasal: DoseEntry?, maxBasal: Double?, scheduledBasal: AbsoluteScheduleValue) { - if let lastTempBasal = lastTempBasal, lastTempBasal.endDate > Date(), let maxBasal = maxBasal { - rate = lastTempBasal.value - scheduledBasal.value - startDate = lastTempBasal.startDate + if let lastTempBasal = lastTempBasal, lastTempBasal.endDate > Date() { + let maxBasal = maxBasal ?? defaultMaxBasalForScale + rate = lastTempBasal.unitsPerHour - scheduledBasal.value + start = lastTempBasal.startDate + end = lastTempBasal.endDate if rate < 0 { percent = rate / scheduledBasal.value @@ -30,12 +54,12 @@ struct NetBasal { percent = 0 if let lastTempBasal = lastTempBasal, lastTempBasal.endDate > scheduledBasal.startDate { - startDate = lastTempBasal.endDate + start = lastTempBasal.endDate + end = scheduledBasal.endDate } else { - startDate = scheduledBasal.startDate + start = scheduledBasal.startDate + end = scheduledBasal.endDate } } } - } - diff --git a/Loop/Models/PredictionInputEffect.swift b/Loop/Models/PredictionInputEffect.swift index 1b257e3d51..45fb5ea0c7 100644 --- a/Loop/Models/PredictionInputEffect.swift +++ b/Loop/Models/PredictionInputEffect.swift @@ -10,35 +10,48 @@ import Foundation import HealthKit -enum PredictionInputEffect { - case carbs - case insulin - case momentum - case retrospection +struct PredictionInputEffect: OptionSet { + let rawValue: Int - var localizedTitle: String { + static let carbs = PredictionInputEffect(rawValue: 1 << 0) + static let insulin = PredictionInputEffect(rawValue: 1 << 1) + static let momentum = PredictionInputEffect(rawValue: 1 << 2) + static let retrospection = PredictionInputEffect(rawValue: 1 << 3) + static let suspend = PredictionInputEffect(rawValue: 1 << 4) + + static let all: PredictionInputEffect = [.carbs, .insulin, .momentum, .retrospection] + + var localizedTitle: String? { switch self { - case .carbs: + case [.carbs]: return NSLocalizedString("Carbohydrates", comment: "Title of the prediction input effect for carbohydrates") - case .insulin: + case [.insulin]: return NSLocalizedString("Insulin", comment: "Title of the prediction input effect for insulin") - case .momentum: + case [.momentum]: return NSLocalizedString("Glucose Momentum", comment: "Title of the prediction input effect for glucose momentum") - case .retrospection: + case [.retrospection]: return NSLocalizedString("Retrospective Correction", comment: "Title of the prediction input effect for retrospective correction") + case [.suspend]: + return NSLocalizedString("Suspension of Insulin Delivery", comment: "Title of the prediction input effect for suspension of insulin delivery") + default: + return nil } } - func localizedDescription(forGlucoseUnit unit: HKUnit) -> String { + func localizedDescription(forGlucoseUnit unit: HKUnit) -> String? { switch self { - case .carbs: - return String(format: NSLocalizedString("Carbs Absorbed (g) ÷ Carb Ratio (g/U) × Insulin Sensitivity (%1$@/U)", comment: "Description of the prediction input effect for carbohydrates. (1: The glucose unit string)"), unit.glucoseUnitDisplayString) - case .insulin: - return String(format: NSLocalizedString("Insulin Absorbed (U) × Insulin Sensitivity (%1$@/U)", comment: "Description of the prediction input effect for insulin"), unit.glucoseUnitDisplayString) - case .momentum: + case [.carbs]: + return String(format: NSLocalizedString("Carbs Absorbed (g) ÷ Carb Ratio (g/U) × Insulin Sensitivity (%1$@/U)", comment: "Description of the prediction input effect for carbohydrates. (1: The glucose unit string)"), unit.localizedShortUnitString) + case [.insulin]: + return String(format: NSLocalizedString("Insulin Absorbed (U) × Insulin Sensitivity (%1$@/U)", comment: "Description of the prediction input effect for insulin"), unit.localizedShortUnitString) + case [.momentum]: return NSLocalizedString("15 min glucose regression coefficient (b₁), continued with decay over 30 min", comment: "Description of the prediction input effect for glucose momentum") - case .retrospection: + case [.retrospection]: return NSLocalizedString("30 min comparison of glucose prediction vs actual, continued with decay over 60 min", comment: "Description of the prediction input effect for retrospective correction") + case [.suspend]: + return NSLocalizedString("Glucose effect of suspending insulin delivery", comment: "Description of the prediction input effect for suspension of insulin delivery") + default: + return nil } } } diff --git a/Loop/Models/ServiceAuthentication/AmplitudeService.swift b/Loop/Models/ServiceAuthentication/AmplitudeService.swift deleted file mode 100644 index f2712df54b..0000000000 --- a/Loop/Models/ServiceAuthentication/AmplitudeService.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// AmplitudeService.swift -// Loop -// -// Created by Nate Racklyeft on 7/3/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import Foundation -import Amplitude - - -struct AmplitudeService: ServiceAuthentication { - var credentials: [ServiceCredential] - - let title: String = NSLocalizedString("Amplitude", comment: "The title of the Amplitude service") - - init(APIKey: String?) { - credentials = [ - ServiceCredential( - title: NSLocalizedString("API Key", comment: "The title of the amplitude API key credential"), - placeholder: nil, - isSecret: false, - keyboardType: .asciiCapable, - value: APIKey - ) - ] - - verify { _, _ in } - } - - var client: Amplitude? - - var APIKey: String? { - return credentials[0].value - } - - var isAuthorized: Bool = true - - mutating func verify(_ completion: @escaping (_ success: Bool, _ error: Error?) -> Void) { - guard let APIKey = APIKey else { - isAuthorized = false - completion(false, nil) - return - } - - isAuthorized = true - let client = Amplitude() - client.initializeApiKey(APIKey) - self.client = client - completion(true, nil) - } - - mutating func reset() { - credentials[0].value = nil - isAuthorized = false - client = nil - } -} diff --git a/Loop/Models/ServiceAuthentication/MLabService.swift b/Loop/Models/ServiceAuthentication/MLabService.swift deleted file mode 100644 index 174f205b1e..0000000000 --- a/Loop/Models/ServiceAuthentication/MLabService.swift +++ /dev/null @@ -1,126 +0,0 @@ -// -// mLabService.swift -// Loop -// -// Created by Nate Racklyeft on 7/3/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import Foundation - - -private let mLabAPIHost = URL(string: "https://api.mongolab.com/api/1/databases")! - - -struct MLabService: ServiceAuthentication { - var credentials: [ServiceCredential] - - let title: String = NSLocalizedString("mLab", comment: "The title of the mLab service") - - init(databaseName: String?, APIKey: String?) { - credentials = [ - ServiceCredential( - title: NSLocalizedString("Database", comment: "The title of the mLab database name credential"), - placeholder: "nightscoutdb", - isSecret: false, - keyboardType: .asciiCapable, - value: databaseName - ), - ServiceCredential( - title: NSLocalizedString("API Key", comment: "The title of the mLab API Key credential"), - placeholder: nil, - isSecret: false, - keyboardType: .asciiCapable, - value: APIKey - ) - ] - - if databaseName != nil && APIKey != nil { - isAuthorized = true - } - } - - var databaseName: String? { - return credentials[0].value - } - - var APIKey: String? { - return credentials[1].value - } - - var isAuthorized: Bool = false - - mutating func verify(_ completion: @escaping (_ success: Bool, _ error: Error?) -> Void) { - guard let APIURL = APIURLForCollection("") else { - completion(false, nil) - return - } - - URLSession.shared.dataTask(with: APIURL, completionHandler: { (_, response, error) in - var error: Error? = error - if error == nil, let response = response as? HTTPURLResponse, response.statusCode >= 300 { - error = LoopError.connectionError - } - - completion(true, error) - }).resume() - } - - mutating func reset() { - credentials[0].value = nil - credentials[1].value = nil - isAuthorized = false - } - - private func APIURLForCollection(_ collection: String) -> URL? { - guard let databaseName = databaseName, let APIKey = APIKey else { - return nil - } - - let APIURL = mLabAPIHost.appendingPathComponent("\(databaseName)/collections").appendingPathComponent(collection) - var components = URLComponents(url: APIURL, resolvingAgainstBaseURL: true)! - - var items = components.queryItems ?? [] - items.append(URLQueryItem(name: "apiKey", value: APIKey)) - components.queryItems = items - - return components.url - } - - func uploadTaskWithData(_ data: Data, inCollection collection: String) -> URLSessionTask? { - guard let URL = APIURLForCollection(collection) else { - return nil - } - - var request = URLRequest(url: URL) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - return URLSession.shared.uploadTask(with: request, from: data) - } -} - - -extension KeychainManager { - func setMLabDatabaseName(_ databaseName: String?, APIKey: String?) throws { - let credentials: InternetCredentials? - - if let username = databaseName, let password = APIKey { - credentials = InternetCredentials(username: username, password: password, url: mLabAPIHost) - } else { - credentials = nil - } - - try replaceInternetCredentials(credentials, forURL: mLabAPIHost) - } - - func getMLabCredentials() -> (databaseName: String, APIKey: String)? { - do { - let credentials = try getInternetCredentials(url: mLabAPIHost) - - return (databaseName: credentials.username, APIKey: credentials.password) - } catch { - return nil - } - } -} diff --git a/Loop/Models/ServiceAuthentication/NightscoutService.swift b/Loop/Models/ServiceAuthentication/NightscoutService.swift deleted file mode 100644 index 3c796e480b..0000000000 --- a/Loop/Models/ServiceAuthentication/NightscoutService.swift +++ /dev/null @@ -1,83 +0,0 @@ -// -// NightscoutService.swift -// Loop -// -// Created by Nate Racklyeft on 7/3/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import Foundation -import NightscoutUploadKit - - -// Encapsulates a Nightscout site and its authentication -struct NightscoutService: ServiceAuthentication { - var credentials: [ServiceCredential] - - let title: String = NSLocalizedString("Nightscout", comment: "The title of the Nightscout service") - - init(siteURL: URL?, APISecret: String?) { - credentials = [ - ServiceCredential( - title: NSLocalizedString("Site URL", comment: "The title of the nightscout site URL credential"), - placeholder: NSLocalizedString("https://mysite.azurewebsites.net", comment: "The placeholder text for the nightscout site URL credential"), - isSecret: false, - keyboardType: .URL, - value: siteURL?.absoluteString - ), - ServiceCredential( - title: NSLocalizedString("API Secret", comment: "The title of the nightscout API secret credential"), - placeholder: nil, - isSecret: false, - keyboardType: .asciiCapable, - value: APISecret - ) - ] - - verify { _, _ in } - } - - // The uploader instance, if credentials are present - private(set) var uploader: NightscoutUploader? { - didSet { - uploader?.errorHandler = { (error: Error, context: String) -> Void in - print("Error \(error), while \(context)") - } - } - } - - var siteURL: URL? { - if let URLString = credentials[0].value, !URLString.isEmpty { - return URL(string: URLString) - } - - return nil - } - - var APISecret: String? { - return credentials[1].value - } - - var isAuthorized: Bool = true - - mutating func verify(_ completion: @escaping (_ success: Bool, _ error: Error?) -> Void) { - guard let siteURL = siteURL, let APISecret = APISecret else { - isAuthorized = false - completion(false, nil) - return - } - - let uploader = NightscoutUploader(siteURL: siteURL, APISecret: APISecret) - uploader.checkAuth { (error) in - completion(true, error) - } - self.uploader = uploader - } - - mutating func reset() { - credentials[0].value = nil - credentials[1].value = nil - isAuthorized = false - uploader = nil - } -} diff --git a/Loop/Models/ServiceAuthentication/ServiceAuthentication.swift b/Loop/Models/ServiceAuthentication/ServiceAuthentication.swift deleted file mode 100644 index f5fb520a42..0000000000 --- a/Loop/Models/ServiceAuthentication/ServiceAuthentication.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// ServiceAuthentication.swift -// Loop -// -// Created by Nate Racklyeft on 7/2/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - - -// Defines the authentication for a service -protocol ServiceAuthentication { - // The title of the service - var title: String { get } - - // The credentials (e.g. username, password) used to authenticate - var credentials: [ServiceCredential] { get set } - - // Whether the current credential values are valid authorization - var isAuthorized: Bool { get set } - - // Tests the credentials for valid authorization - mutating func verify(_ completion: @escaping (_ success: Bool, _ error: Error?) -> Void) - - // Clears the credential values and authorization status - mutating func reset() -} diff --git a/Loop/Models/ServiceAuthentication/ServiceCredential.swift b/Loop/Models/ServiceAuthentication/ServiceCredential.swift deleted file mode 100644 index 2556617541..0000000000 --- a/Loop/Models/ServiceAuthentication/ServiceCredential.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// ServiceCredential.swift -// Loop -// -// Created by Nate Racklyeft on 7/2/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import UIKit - - -// Represents a credential for a service, including its text input traits -struct ServiceCredential { - // The localized title of the credential (e.g. "Username") - let title: String - - // The localized placeholder text to assist text input - let placeholder: String? - - // Whether the credential is considered secret. Correponds to the `secureTextEntry` trait. - let isSecret: Bool - - // The type of keyboard to use to enter the credential - let keyboardType: UIKeyboardType - - // The credential value - var value: String? -} diff --git a/Loop/Models/ServiceAuthentication/ShareService.swift b/Loop/Models/ServiceAuthentication/ShareService.swift deleted file mode 100644 index 93b40f9370..0000000000 --- a/Loop/Models/ServiceAuthentication/ShareService.swift +++ /dev/null @@ -1,75 +0,0 @@ -// -// ShareService.swift -// Loop -// -// Created by Nate Racklyeft on 7/2/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import Foundation -import ShareClient - - -// Encapsulates the Dexcom Share client service and its authentication -struct ShareService: ServiceAuthentication { - var credentials: [ServiceCredential] - - let title: String = NSLocalizedString("Dexcom Share", comment: "The title of the Dexcom Share service") - - init(username: String?, password: String?) { - credentials = [ - ServiceCredential( - title: NSLocalizedString("Username", comment: "The title of the Dexcom share username credential"), - placeholder: nil, - isSecret: false, - keyboardType: .asciiCapable, - value: username - ), - ServiceCredential( - title: NSLocalizedString("Password", comment: "The title of the Dexcom share password credential"), - placeholder: nil, - isSecret: true, - keyboardType: .asciiCapable, - value: password - ) - ] - - if let username = username, let password = password { - isAuthorized = true - client = ShareClient(username: username, password: password) - } - } - - // The share client, if credentials are present - private(set) var client: ShareClient? - - var username: String? { - return credentials[0].value - } - - var password: String? { - return credentials[1].value - } - - var isAuthorized: Bool = false - - mutating func verify(_ completion: @escaping (_ success: Bool, _ error: Error?) -> Void) { - guard let username = username, let password = password else { - completion(false, nil) - return - } - - let client = ShareClient(username: username, password: password) - client.fetchLast(1) { (error, _) in - completion(true, error) - } - self.client = client - } - - mutating func reset() { - credentials[0].value = nil - credentials[1].value = nil - isAuthorized = false - client = nil - } -} diff --git a/Loop/Models/ShareGlucose+GlucoseKit.swift b/Loop/Models/ShareGlucose+GlucoseKit.swift deleted file mode 100644 index b40b2349fa..0000000000 --- a/Loop/Models/ShareGlucose+GlucoseKit.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// ShareGlucose+GlucoseKit.swift -// Naterade -// -// Created by Nathan Racklyeft on 5/8/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import Foundation -import HealthKit -import LoopKit -import LoopUI -import ShareClient - - -extension ShareGlucose: GlucoseValue { - public var startDate: Date { - return timestamp - } - - public var quantity: HKQuantity { - return HKQuantity(unit: HKUnit.milligramsPerDeciliterUnit(), doubleValue: Double(glucose)) - } -} - - -extension ShareGlucose: SensorDisplayable { - public var isStateValid: Bool { - return glucose >= 20 - } - - public var trendType: GlucoseTrend? { - return GlucoseTrend(rawValue: Int(trend)) - } - - public var isLocal: Bool { - return false - } -} - -extension SensorDisplayable { - public var stateDescription: String { - if isStateValid { - return NSLocalizedString("OK", comment: "Sensor state description for the valid state") - } else { - return NSLocalizedString("Needs Attention", comment: "Sensor state description for the non-valid state") - } - } -} diff --git a/Loop/Models/SimpleBolusCalculator.swift b/Loop/Models/SimpleBolusCalculator.swift new file mode 100644 index 0000000000..a26af98466 --- /dev/null +++ b/Loop/Models/SimpleBolusCalculator.swift @@ -0,0 +1,51 @@ +// +// SimpleBolusCalculator.swift +// Loop +// +// Created by Pete Schwamb on 9/28/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopCore +import HealthKit +import LoopKit + +struct SimpleBolusCalculator { + + public static func recommendedInsulin(mealCarbs: HKQuantity?, manualGlucose: HKQuantity?, activeInsulin: HKQuantity, carbRatioSchedule: CarbRatioSchedule, correctionRangeSchedule: GlucoseRangeSchedule, sensitivitySchedule: InsulinSensitivitySchedule, at date: Date = Date()) -> HKQuantity { + var recommendedBolus: Double = 0 + + if let mealCarbs = mealCarbs { + let carbRatio = carbRatioSchedule.quantity(at: date) + recommendedBolus += mealCarbs.doubleValue(for: .gram()) / carbRatio.doubleValue(for: .gram()) + } + + if let manualGlucose = manualGlucose { + let sensitivity = sensitivitySchedule.quantity(at: date).doubleValue(for: .milligramsPerDeciliter) + let correctionRange = correctionRangeSchedule.quantityRange(at: date) + if (!correctionRange.contains(manualGlucose)) { + let correctionTarget = correctionRange.averageValue(for: .milligramsPerDeciliter) + let correctionBolus = (manualGlucose.doubleValue(for: .milligramsPerDeciliter) - correctionTarget) / sensitivity + if correctionBolus >= 0 { + let activeInsulin = max(0, activeInsulin.doubleValue(for: .internationalUnit())) + let correctionBolusMinusActiveInsulin = correctionBolus - activeInsulin + recommendedBolus += max(0, correctionBolusMinusActiveInsulin) + } else { + recommendedBolus += correctionBolus + } + } + + let recommendationLimit = mealCarbs != nil ? LoopConstants.simpleBolusCalculatorMinGlucoseMealBolusRecommendation : LoopConstants.simpleBolusCalculatorMinGlucoseBolusRecommendation + + if manualGlucose < recommendationLimit { + recommendedBolus = 0 + } + } + + // No negative recommendation + recommendedBolus = max(0, recommendedBolus) + + return HKQuantity(unit: .internationalUnit(), doubleValue: recommendedBolus) + } +} diff --git a/Loop/Models/StoredLoopNotRunningNotification.swift b/Loop/Models/StoredLoopNotRunningNotification.swift new file mode 100644 index 0000000000..0b66f8eeb2 --- /dev/null +++ b/Loop/Models/StoredLoopNotRunningNotification.swift @@ -0,0 +1,17 @@ +// +// StoredLoopNotRunningNotification.swift +// LoopCore +// +// Created by Pete Schwamb on 5/5/22. +// Copyright © 2022 LoopKit Authors. All rights reserved. +// + +import Foundation + +struct StoredLoopNotRunningNotification: Codable { + var alertAt: Date + var title: String + var body: String + var isCritical: Bool +} + diff --git a/Loop/Models/TransmitterGlucose.swift b/Loop/Models/TransmitterGlucose.swift deleted file mode 100644 index 7985c3b9b8..0000000000 --- a/Loop/Models/TransmitterGlucose.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// TransmitterGlucose.swift -// Loop -// -// Created by Nathan Racklyeft on 5/30/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import LoopKit -import HealthKit -import xDripG5 - - -struct TransmitterGlucose: GlucoseValue { - let glucoseMessage: GlucoseRxMessage - let startTime: NSTimeInterval - - init?(glucoseMessage: GlucoseRxMessage, startTime: NSTimeInterval?) { - - guard glucoseMessage.state > 5 && glucoseMessage.glucose >= 20, let startTime = startTime else { - return nil - } - - self.glucoseMessage = glucoseMessage - self.startTime = startTime - } - - var quantity: HKQuantity { - return HKQuantity(unit: HKUnit.milligramsPerDeciliterUnit(), doubleValue: Double(glucoseMessage.glucose)) - } - - var startDate: NSDate { - return NSDate(timeIntervalSince1970: startTime).dateByAddingTimeInterval(NSTimeInterval(glucoseMessage.timestamp)) - } -} diff --git a/Loop/Models/WatchContext+LoopKit.swift b/Loop/Models/WatchContext+LoopKit.swift index ae24ada8ae..a9adf41da4 100644 --- a/Loop/Models/WatchContext+LoopKit.swift +++ b/Loop/Models/WatchContext+LoopKit.swift @@ -8,18 +8,16 @@ import Foundation import HealthKit -import InsulinKit import LoopKit -import xDripG5 - extension WatchContext { - convenience init(glucose: GlucoseValue?, eventualGlucose: GlucoseValue?, glucoseUnit: HKUnit?) { + convenience init(glucose: GlucoseSampleValue?, glucoseUnit: HKUnit?) { self.init() self.glucose = glucose?.quantity self.glucoseDate = glucose?.startDate - self.eventualGlucose = eventualGlucose?.quantity - self.preferredGlucoseUnit = glucoseUnit + self.glucoseIsDisplayOnly = glucose?.isDisplayOnly + self.glucoseWasUserEntered = glucose?.wasUserEntered + self.displayGlucoseUnit = glucoseUnit } } diff --git a/Loop/Models/ZipArchive.swift b/Loop/Models/ZipArchive.swift new file mode 100644 index 0000000000..999854500f --- /dev/null +++ b/Loop/Models/ZipArchive.swift @@ -0,0 +1,179 @@ +// +// ZipArchive.swift +// Loop +// +// Created by Darin Krauss on 6/25/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit +import ZIPFoundation + +public enum ZipArchiveError: Error, Equatable { + case streamFinished +} + +public class ZipArchive { + + public enum CompressionMethod { + case none + case deflate + + var zfMethod: ZIPFoundation.CompressionMethod { + switch self { + case .deflate: + return .deflate + case .none: + return .none + } + } + } + + public class Stream: NSObject, DataOutputStream { + + enum StreamState: Int { + case open + case closing + case closed + } + + private let archive: Archive + private let compressionMethod: CompressionMethod + + private let semaphore = DispatchSemaphore(value: 0) + private let processingQueue: DispatchQueue + + private let chunks = Locked<[Data]>([]) + private let error = Locked(nil) + private let state = Locked(.open) + + fileprivate init(archive: Archive, path: String, compressionMethod: CompressionMethod) { + self.archive = archive + self.compressionMethod = compressionMethod + processingQueue = DispatchQueue(label: "org.loopkit.Loop.zipArchive." + path) + super.init() + startProcessing(path) + } + + public var streamError: Error? { + return error.value + } + + private func startProcessing(_ path: String) { + processingQueue.async { + do { + try self.archive.addEntry(with: path, type: .file, compressionMethod: self.compressionMethod.zfMethod) { position, size in + + if self.state.value == .closed { + return Data() + } + self.semaphore.wait() + var chunk: Data! + self.chunks.mutate { (value) in + if value.count > 0 { + chunk = value.removeFirst() + } else if self.state.value == .closing { + chunk = Data() + self.state.value = .closed + } + } + return chunk + } + } catch { + self.error.mutate { value in + value = error + } + } + } + } + + // MARK: - DataOutputStream + public func write(_ data: Data) throws { + if let error = error.value { + throw error + } + if self.state.value != .open { + throw ZipArchiveError.streamFinished + } + chunks.mutate { value in + value.append(data) + } + semaphore.signal() + } + + public func finish(sync: Bool) throws { + // An empty Data() is the sigil for the ZipFoundation read callback + // to detect end of stream. + state.value = .closing + semaphore.signal() + if sync { + // Block until processingQueue is finished, and then check error state + processingQueue.sync(flags: .barrier) { } + if let error = error.value { + throw error + } + } + } + } + + private var closed: Bool = false + private let archive: Archive + private var stream: Stream? + private var error: Error? + + private let lock = UnfairLock() + + public init?(url: URL) { + guard let archive = Archive(url: url, accessMode: .create) else { + return nil + } + self.archive = archive + } + + public func createArchiveFile(withPath path: String, compressionMethod: CompressionMethod = .deflate) -> DataOutputStream { + return lock.withLock { + try? stream?.finish(sync: true) + stream = Stream(archive: archive, path: path, compressionMethod: compressionMethod) + return stream! + } + } + + public func createArchiveFile(withPath path: String, contentsOf url: URL, compressionMethod: CompressionMethod = .deflate) -> Error? { + let data: Data + + do { + data = try Data(contentsOf: url) + } catch let error { + return error + } + + let stream = createArchiveFile(withPath: path, compressionMethod: compressionMethod) + try? stream.write(data) + + return lock.withLock { error } + } + + @discardableResult + public func close() -> Error? { + return lock.withLock { + if closed { + return nil + } + defer { closed = true } + do { + try stream?.finish(sync: true) + } catch { + setError(error) + } + return error + } + } + + private func setError(_ err: Error) { + guard error == nil else { + return + } + error = err + } +} diff --git a/Loop/Plugins/PluginManager.swift b/Loop/Plugins/PluginManager.swift new file mode 100644 index 0000000000..a254d26872 --- /dev/null +++ b/Loop/Plugins/PluginManager.swift @@ -0,0 +1,251 @@ +// +// PluginManager.swift +// Loop +// +// Created by Pete Schwamb on 7/24/19. +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import os.log +import Foundation +import LoopKit +import LoopKitUI + +class PluginManager { + let pluginBundles: [Bundle] + + private let log = OSLog(category: "PluginManager") + + public init(pluginsURL: URL? = Bundle.main.privateFrameworksURL) { + var bundles = [Bundle]() + + if let pluginsURL = pluginsURL { + do { + for pluginURL in try FileManager.default.contentsOfDirectory(at: pluginsURL, includingPropertiesForKeys: nil).filter({$0.path.hasSuffix(".framework")}) { + if let bundle = Bundle(url: pluginURL) { + if bundle.isLoopPlugin && (!bundle.isSimulator || FeatureFlags.allowSimulators) { + log.debug("Found loop plugin: %{public}@", pluginURL.absoluteString) + bundles.append(bundle) + } + } + } + } catch let error { + log.error("Error loading plugins: %{public}@", String(describing: error)) + } + } + self.pluginBundles = bundles + } + + + + func getPumpManagerTypeByIdentifier(_ identifier: String) -> PumpManagerUI.Type? { + for bundle in pluginBundles { + if let name = bundle.object(forInfoDictionaryKey: LoopPluginBundleKey.pumpManagerIdentifier.rawValue) as? String, name == identifier { + do { + try bundle.loadAndReturnError() + + if let principalClass = bundle.principalClass as? NSObject.Type { + + if let plugin = principalClass.init() as? PumpManagerUIPlugin { + return plugin.pumpManagerType + } else { + fatalError("PrincipalClass does not conform to PumpManagerUIPlugin") + } + + } else { + fatalError("PrincipalClass not found") + } + } catch let error { + log.error("Error loading plugin: %{public}@", String(describing: error)) + } + } + } + return nil + } + + var availablePumpManagers: [PumpManagerDescriptor] { + pluginBundles.compactMap({ (bundle) -> PumpManagerDescriptor? in + guard let title = bundle.object(forInfoDictionaryKey: LoopPluginBundleKey.pumpManagerDisplayName.rawValue) as? String, + let identifier = bundle.object(forInfoDictionaryKey: LoopPluginBundleKey.pumpManagerIdentifier.rawValue) as? String else { + return nil + } + + return PumpManagerDescriptor(identifier: identifier, localizedTitle: title) + }) + } + + func getCGMManagerTypeByIdentifier(_ identifier: String) -> CGMManagerUI.Type? { + for bundle in pluginBundles { + if let name = bundle.object(forInfoDictionaryKey: LoopPluginBundleKey.cgmManagerIdentifier.rawValue) as? String, name == identifier { + do { + try bundle.loadAndReturnError() + + if let principalClass = bundle.principalClass as? NSObject.Type { + + if let plugin = principalClass.init() as? CGMManagerUIPlugin { + return plugin.cgmManagerType + } else { + fatalError("PrincipalClass does not conform to CGMManagerUIPlugin") + } + + } else { + fatalError("PrincipalClass not found") + } + } catch let error { + log.error("Error loading plugin: %{public}@", String(describing: error)) + } + } + } + return nil + } + + var availableCGMManagers: [CGMManagerDescriptor] { + pluginBundles.compactMap({ (bundle) -> CGMManagerDescriptor? in + guard let title = bundle.object(forInfoDictionaryKey: LoopPluginBundleKey.cgmManagerDisplayName.rawValue) as? String, + let identifier = bundle.object(forInfoDictionaryKey: LoopPluginBundleKey.cgmManagerIdentifier.rawValue) as? String else { + return nil + } + + return CGMManagerDescriptor(identifier: identifier, localizedTitle: title) + }) + } + + func getServiceTypeByIdentifier(_ identifier: String) -> ServiceUI.Type? { + for bundle in pluginBundles { + if let name = bundle.object(forInfoDictionaryKey: LoopPluginBundleKey.serviceIdentifier.rawValue) as? String, name == identifier { + do { + try bundle.loadAndReturnError() + + if let principalClass = bundle.principalClass as? NSObject.Type { + + if let plugin = principalClass.init() as? ServiceUIPlugin { + return plugin.serviceType + } else { + fatalError("PrincipalClass does not conform to ServiceUIPlugin") + } + + } else { + fatalError("PrincipalClass not found") + } + } catch let error { + log.error("Error loading plugin: %{public}@", String(describing: error)) + } + } + } + return nil + } + + var availableServices: [ServiceDescriptor] { + return pluginBundles.compactMap({ (bundle) -> ServiceDescriptor? in + guard let title = bundle.object(forInfoDictionaryKey: LoopPluginBundleKey.serviceDisplayName.rawValue) as? String, + let identifier = bundle.object(forInfoDictionaryKey: LoopPluginBundleKey.serviceIdentifier.rawValue) as? String else { + return nil + } + + return ServiceDescriptor(identifier: identifier, localizedTitle: title) + }) + } + + func getStatefulPluginTypeByIdentifier(_ identifier: String) -> StatefulPluggable.Type? { + for bundle in pluginBundles { + if let name = bundle.object(forInfoDictionaryKey: LoopPluginBundleKey.statefulPluginIdentifier.rawValue) as? String, name == identifier { + do { + try bundle.loadAndReturnError() + + if let principalClass = bundle.principalClass as? NSObject.Type { + + if let plugin = principalClass.init() as? StatefulPlugin { + return plugin.pluginType + } else { + fatalError("PrincipalClass does not conform to StatefulPlugin") + } + + } else { + fatalError("PrincipalClass not found") + } + } catch let error { + log.error("Error loading plugin: %{public}@", String(describing: error)) + } + } + } + return nil + } + + var availableStatefulPluginIdentifiers: [String] { + return pluginBundles.compactMap({ (bundle) -> String? in + return bundle.object(forInfoDictionaryKey: LoopPluginBundleKey.statefulPluginIdentifier.rawValue) as? String + }) + } + + func getOnboardingTypeByIdentifier(_ identifier: String) -> OnboardingUI.Type? { + for bundle in pluginBundles { + if let name = bundle.object(forInfoDictionaryKey: LoopPluginBundleKey.onboardingIdentifier.rawValue) as? String, name == identifier { + do { + try bundle.loadAndReturnError() + + if let principalClass = bundle.principalClass as? NSObject.Type { + + if let plugin = principalClass.init() as? OnboardingUIPlugin { + return plugin.onboardingType + } else { + fatalError("PrincipalClass does not conform to OnboardingUIPlugin") + } + + } else { + fatalError("PrincipalClass not found") + } + } catch let error { + log.error("Error loading plugin: %{public}@", String(describing: error)) + } + } + } + return nil + } + + var availableOnboardingIdentifiers: [String] { + return pluginBundles.compactMap({ (bundle) -> String? in + return bundle.object(forInfoDictionaryKey: LoopPluginBundleKey.onboardingIdentifier.rawValue) as? String + }) + } + + func getSupportUITypeByIdentifier(_ identifier: String) -> SupportUI.Type? { + for bundle in pluginBundles { + if let name = bundle.object(forInfoDictionaryKey: LoopPluginBundleKey.supportIdentifier.rawValue) as? String, name == identifier { + do { + try bundle.loadAndReturnError() + + if let principalClass = bundle.principalClass as? NSObject.Type { + + if let plugin = principalClass.init() as? SupportUIPlugin { + return type(of: plugin.support) + } else { + fatalError("PrincipalClass does not conform to SupportUIPlugin") + } + + } else { + fatalError("PrincipalClass not found") + } + } catch let error { + log.error("Error loading plugin: %{public}@", String(describing: error)) + } + } + } + return nil + } +} + + +extension Bundle { + var isPumpManagerPlugin: Bool { object(forInfoDictionaryKey: LoopPluginBundleKey.pumpManagerIdentifier.rawValue) as? String != nil } + var isCGMManagerPlugin: Bool { object(forInfoDictionaryKey: LoopPluginBundleKey.cgmManagerIdentifier.rawValue) as? String != nil } + var isStatefulPlugin: Bool { object(forInfoDictionaryKey: LoopPluginBundleKey.statefulPluginIdentifier.rawValue) as? String != nil } + var isServicePlugin: Bool { object(forInfoDictionaryKey: LoopPluginBundleKey.serviceIdentifier.rawValue) as? String != nil } + var isOnboardingPlugin: Bool { object(forInfoDictionaryKey: LoopPluginBundleKey.onboardingIdentifier.rawValue) as? String != nil } + var isSupportPlugin: Bool { object(forInfoDictionaryKey: LoopPluginBundleKey.supportIdentifier.rawValue) as? String != nil } + + var isLoopPlugin: Bool { isPumpManagerPlugin || isCGMManagerPlugin || isStatefulPlugin || isServicePlugin || isOnboardingPlugin || isSupportPlugin } + + var isLoopExtension: Bool { object(forInfoDictionaryKey: LoopPluginBundleKey.extensionIdentifier.rawValue) as? String != nil } + + var isSimulator: Bool { object(forInfoDictionaryKey: LoopPluginBundleKey.pluginIsSimulator.rawValue) as? Bool == true } +} diff --git a/Loop/View Controllers/AuthenticationViewController.swift b/Loop/View Controllers/AuthenticationViewController.swift deleted file mode 100644 index adc690fd72..0000000000 --- a/Loop/View Controllers/AuthenticationViewController.swift +++ /dev/null @@ -1,210 +0,0 @@ -// -// AuthenticationViewController.swift -// Loop -// -// Created by Nate Racklyeft on 7/2/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import UIKit - - -final class AuthenticationViewController: UITableViewController, IdentifiableClass, UITextFieldDelegate { - - typealias AuthenticationObserver = (_ authentication: T) -> Void - - var authenticationObserver: AuthenticationObserver? - - var authentication: T - - private var state: AuthenticationState = .empty { - didSet { - switch (oldValue, state) { - case let (x, y) where x == y: - break - case (_, .verifying): - let titleView = ValidatingIndicatorView(frame: CGRect.zero) - UIView.animate(withDuration: 0.25, animations: { - self.navigationItem.hidesBackButton = true - self.navigationItem.titleView = titleView - }) - - tableView.reloadSections(IndexSet(integersIn: 0...1), with: .automatic) - authentication.verify { [unowned self] (success, error) in - DispatchQueue.main.async { - UIView.animate(withDuration: 0.25, animations: { - self.navigationItem.titleView = nil - self.navigationItem.hidesBackButton = false - }) - - if let error = error { - self.presentAlertController(with: error) - } - - if success { - self.state = .authorized - } else { - self.state = .unauthorized - } - } - } - case (_, .authorized), (_, .unauthorized): - authentication.isAuthorized = (state == .authorized) - - authenticationObserver?(authentication) - tableView.reloadSections(IndexSet(integersIn: 0...1), with: .automatic) - default: - break - } - } - } - - init(authentication: T) { - self.authentication = authentication - - state = authentication.isAuthorized ? .authorized : .unauthorized - - super.init(style: .grouped) - - title = authentication.title - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - - tableView.register(AuthenticationTableViewCell.nib(), forCellReuseIdentifier: AuthenticationTableViewCell.className) - tableView.register(ButtonTableViewCell.nib(), forCellReuseIdentifier: ButtonTableViewCell.className) - } - - // MARK: - Table view data source - - override func numberOfSections(in tableView: UITableView) -> Int { - return Section.count - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - switch Section(rawValue: section)! { - case .credentials: - switch state { - case .authorized: - return authentication.credentials.filter({ !$0.isSecret }).count - default: - return authentication.credentials.count - } - case .button: - return 1 - } - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - switch Section(rawValue: indexPath.section)! { - case .button: - let cell = tableView.dequeueReusableCell(withIdentifier: ButtonTableViewCell.className, for: indexPath) as! ButtonTableViewCell - - switch state { - case .authorized: - cell.button.setTitle(NSLocalizedString("Delete Account", comment: "The title of the button to remove the credentials for a service"), for: UIControlState()) - cell.button.setTitleColor(UIColor.deleteColor, for: UIControlState()) - case .empty, .unauthorized, .verifying: - cell.button.setTitle(NSLocalizedString("Add Account", comment: "The title of the button to add the credentials for a service"), for: UIControlState()) - cell.button.setTitleColor(nil, for: UIControlState()) - } - - if case .verifying = state { - cell.button.isEnabled = false - } else { - cell.button.isEnabled = true - } - - cell.button.addTarget(self, action: #selector(buttonPressed(_:)), for: .touchUpInside) - - return cell - case .credentials: - let cell = tableView.dequeueReusableCell(withIdentifier: AuthenticationTableViewCell.className, for: indexPath) as! AuthenticationTableViewCell - - let credential = authentication.credentials[indexPath.row] - - cell.titleLabel.text = credential.title - cell.textField.tag = indexPath.row - cell.textField.keyboardType = credential.keyboardType - cell.textField.isSecureTextEntry = credential.isSecret - cell.textField.returnKeyType = (indexPath.row < authentication.credentials.count - 1) ? .next : .done - cell.textField.text = credential.value - cell.textField.placeholder = credential.placeholder ?? NSLocalizedString("Required", comment: "The default placeholder string for a credential") - - cell.textField.delegate = self - - switch state { - case .authorized, .verifying, .empty: - cell.textField.isEnabled = false - case .unauthorized: - cell.textField.isEnabled = true - } - - return cell - } - } - - private func validate() { - state = .verifying - } - - // MARK: - Actions - - @objc private func buttonPressed(_: Any) { - tableView.endEditing(false) - - switch state { - case .authorized: - authentication.reset() - state = .unauthorized - case .unauthorized: - validate() - default: - break - } - - } - - // MARK: - UITextFieldDelegate - - func textFieldDidEndEditing(_ textField: UITextField) { - authentication.credentials[textField.tag].value = textField.text - } - - func textFieldShouldReturn(_ textField: UITextField) -> Bool { - if textField.returnKeyType == .done { - textField.resignFirstResponder() - validate() - } else { - let point = tableView.convert(textField.frame.origin, from: textField.superview) - if let indexPath = tableView.indexPathForRow(at: point), - let cell = tableView.cellForRow(at: IndexPath(row: indexPath.row + 1, section: indexPath.section)) as? AuthenticationTableViewCell - { - cell.textField.becomeFirstResponder() - } - } - - return true - } -} - - -private enum Section: Int { - case credentials - case button - - static let count = 2 -} - - -enum AuthenticationState { - case empty - case authorized - case verifying - case unauthorized -} diff --git a/Loop/View Controllers/BolusViewController.swift b/Loop/View Controllers/BolusViewController.swift deleted file mode 100644 index 2e80ff9da3..0000000000 --- a/Loop/View Controllers/BolusViewController.swift +++ /dev/null @@ -1,125 +0,0 @@ -// -// BolusViewController.swift -// Naterade -// -// Created by Nathan Racklyeft on 3/11/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import UIKit -import LocalAuthentication -import LoopKit - - -final class BolusViewController: UITableViewController, IdentifiableClass, UITextFieldDelegate { - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - let spellOutFormatter = NumberFormatter() - spellOutFormatter.numberStyle = .spellOut - - bolusAmountTextField.accessibilityHint = String(format: NSLocalizedString("Recommended Bolus: %@ Units", comment: "Accessibility hint describing recommended bolus units"), spellOutFormatter.string(from: NSNumber(value: recommendedBolus)) ?? "0") - - bolusAmountTextField.becomeFirstResponder() - - AnalyticsManager.sharedManager.didDisplayBolusScreen() - } - - var recommendedBolus: Double = 0 { - didSet { - recommendedBolusAmountLabel?.text = decimalFormatter.string(from: NSNumber(value: recommendedBolus)) - } - } - - var maxBolus: Double = 25 - - private(set) var bolus: Double? - - @IBOutlet weak var recommendedBolusAmountLabel: UILabel? { - didSet { - recommendedBolusAmountLabel?.text = decimalFormatter.string(from: NSNumber(value: recommendedBolus)) - } - } - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - if (indexPath.row == 0) { - acceptRecommendedBolus(); - } - } - - override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { - if (indexPath.row == 0) { - cell.accessibilityCustomActions = [ - UIAccessibilityCustomAction(name: NSLocalizedString("AcceptRecommendedBolus", comment: "Action to copy the recommended Bolus value to the actual Bolus Field"), target: self, selector: #selector(BolusViewController.acceptRecommendedBolus)) - ] - } - } - - @objc - func acceptRecommendedBolus() { - bolusAmountTextField?.text = recommendedBolusAmountLabel?.text - } - - - @IBOutlet weak var bolusAmountTextField: UITextField! - - // MARK: - Actions - - @IBAction func authenticateBolus(_ sender: Any) { - bolusAmountTextField.resignFirstResponder() - - guard let text = bolusAmountTextField?.text, let bolus = decimalFormatter.number(from: text)?.doubleValue, - let amountString = decimalFormatter.string(from: NSNumber(value: bolus)) else { - return - } - - guard bolus <= maxBolus else { - presentAlertController(withTitle: NSLocalizedString("Exceeds Maximum Bolus", comment: "The title of the alert describing a maximum bolus validation error"), message: String(format: NSLocalizedString("The maximum bolus amount is %@ Units", comment: "Body of the alert describing a maximum bolus validation error. (1: The localized max bolus value)"), decimalFormatter.string(from: NSNumber(value: maxBolus)) ?? "")) - return - } - - let context = LAContext() - - if context.canEvaluatePolicy(.deviceOwnerAuthentication, error: nil) { - context.evaluatePolicy(.deviceOwnerAuthentication, - localizedReason: String(format: NSLocalizedString("Authenticate to Bolus %@ Units", comment: "The message displayed during a device authentication prompt for bolus specification"), amountString), - reply: { (success, error) in - if success { - self.setBolusAndClose(bolus) - } - }) - } else { - setBolusAndClose(bolus) - } - } - - private func setBolusAndClose(_ bolus: Double) { - self.bolus = bolus - - self.performSegue(withIdentifier: "close", sender: nil) - } - - private lazy var decimalFormatter: NumberFormatter = { - let numberFormatter = NumberFormatter() - - numberFormatter.maximumSignificantDigits = 3 - numberFormatter.minimumFractionDigits = 1 - - return numberFormatter - }() - - override func prepare(for segue: UIStoryboardSegue, sender: Any?) { - super.prepare(for: segue, sender: sender) - - bolusAmountTextField.resignFirstResponder() - } - - // MARK: - UITextFieldDelegate - - func textFieldShouldReturn(_ textField: UITextField) -> Bool { - textField.resignFirstResponder() - - return true - } -} diff --git a/Loop/View Controllers/CarbAbsorptionViewController.swift b/Loop/View Controllers/CarbAbsorptionViewController.swift new file mode 100644 index 0000000000..419c707cbd --- /dev/null +++ b/Loop/View Controllers/CarbAbsorptionViewController.swift @@ -0,0 +1,531 @@ +// +// CarbAbsorptionViewController.swift +// Loop +// +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import HealthKit +import Intents +import LoopCore +import LoopKit +import LoopKitUI +import LoopUI +import os.log + + +private extension RefreshContext { + static let all: Set = [.glucose, .carbs, .status] +} + + +final class CarbAbsorptionViewController: LoopChartsTableViewController, IdentifiableClass { + + private let log = OSLog(category: "StatusTableViewController") + + private var allowEditing: Bool = true + + var isOnboardingComplete: Bool = true + + var automaticDosingStatus: AutomaticDosingStatus! + + override func viewDidLoad() { + super.viewDidLoad() + + self.tableView.allowsSelectionDuringEditing = true + + carbEffectChart.glucoseDisplayRange = LoopConstants.glucoseChartDefaultDisplayBound + + let notificationCenter = NotificationCenter.default + + notificationObservers += [ + notificationCenter.addObserver(forName: .LoopDataUpdated, object: deviceManager.loopManager, queue: nil) { [weak self] note in + let context = note.userInfo?[LoopDataManager.LoopUpdateContextKey] as! LoopDataManager.LoopUpdateContext.RawValue + DispatchQueue.main.async { + switch LoopDataManager.LoopUpdateContext(rawValue: context) { + case .carbs?: + self?.refreshContext.formUnion([.carbs, .glucose]) + case .glucose?: + self?.refreshContext.update(with: .glucose) + default: + break + } + + self?.refreshContext.update(with: .status) + self?.reloadData(animated: true) + } + }, + ] + + if let gestureRecognizer = charts.gestureRecognizer { + tableView.addGestureRecognizer(gestureRecognizer) + } + + navigationItem.rightBarButtonItem?.isEnabled = isOnboardingComplete + + allowEditing = automaticDosingStatus.automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled + + if allowEditing { + navigationItem.rightBarButtonItems?.append(editButtonItem) + } + + tableView.rowHeight = UITableView.automaticDimension + + reloadData(animated: false) + } + + override func didReceiveMemoryWarning() { + super.didReceiveMemoryWarning() + + if !visible { + refreshContext = RefreshContext.all + } + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + refreshContext.update(with: .size(size)) + + super.viewWillTransition(to: size, with: coordinator) + } + + // MARK: - State + + private var refreshContext = RefreshContext.all + + private var reloading = false + + private var carbStatuses: [CarbStatus] = [] + + private var carbsOnBoard: CarbValue? + + private var carbTotal: CarbValue? + + // MARK: - Data loading + + private let carbEffectChart = CarbEffectChart() + + override func createChartsManager() -> ChartsManager { + return ChartsManager(colors: .primary, settings: .default, charts: [carbEffectChart], traitCollection: traitCollection) + } + + override func glucoseUnitDidChange() { + self.log.debug("[reloadData] for HealthKit unit preference change") + refreshContext = RefreshContext.all + } + + override func reloadData(animated: Bool = false) { + guard active && !reloading && !self.refreshContext.isEmpty else { return } + var currentContext = self.refreshContext + var retryContext: Set = [] + self.refreshContext = [] + reloading = true + + // How far back should we show data? Use the screen size as a guide. + let minimumSegmentWidth: CGFloat = 75 + + let size = currentContext.newSize ?? self.tableView.bounds.size + let availableWidth = size.width - self.charts.fixedHorizontalMargin + let totalHours = floor(Double(availableWidth / minimumSegmentWidth)) + + var components = DateComponents() + components.minute = 0 + let date = Date(timeIntervalSinceNow: -TimeInterval(hours: max(1, totalHours))) + let chartStartDate = Calendar.current.nextDate(after: date, matching: components, matchingPolicy: .strict, direction: .backward) ?? date + if charts.startDate != chartStartDate { + currentContext.formUnion(RefreshContext.all) + } + charts.startDate = chartStartDate + charts.updateEndDate(chartStartDate.addingTimeInterval(.hours(totalHours+1))) // When there is no data, this allows presenting current hour + 1 + + let midnight = Calendar.current.startOfDay(for: Date()) + let listStart = min(midnight, chartStartDate, Date(timeIntervalSinceNow: -deviceManager.carbStore.maximumAbsorptionTimeInterval)) + + let reloadGroup = DispatchGroup() + let shouldUpdateGlucose = currentContext.contains(.glucose) + let shouldUpdateCarbs = currentContext.contains(.carbs) + + var carbEffects: [GlucoseEffect]? + var carbStatuses: [CarbStatus]? + var carbsOnBoard: CarbValue? + var carbTotal: CarbValue? + var insulinCounteractionEffects: [GlucoseEffectVelocity]? + + // TODO: Don't always assume currentContext.contains(.status) + reloadGroup.enter() + deviceManager.loopManager.getLoopState { (manager, state) in + if shouldUpdateGlucose || shouldUpdateCarbs { + let allInsulinCounteractionEffects = state.insulinCounteractionEffects + insulinCounteractionEffects = allInsulinCounteractionEffects.filterDateRange(chartStartDate, nil) + + reloadGroup.enter() + self.deviceManager.carbStore.getCarbStatus(start: listStart, end: nil, effectVelocities: allInsulinCounteractionEffects) { (result) in + switch result { + case .success(let status): + carbStatuses = status + carbsOnBoard = status.getClampedCarbsOnBoard() + case .failure(let error): + self.log.error("CarbStore failed to get carbStatus: %{public}@", String(describing: error)) + retryContext.update(with: .carbs) + } + + reloadGroup.leave() + } + + reloadGroup.enter() + self.deviceManager.carbStore.getGlucoseEffects(start: chartStartDate, end: nil, effectVelocities: allInsulinCounteractionEffects) { (result) in + switch result { + case .success((_, let effects)): + carbEffects = effects + case .failure(let error): + carbEffects = [] + self.log.error("CarbStore failed to get glucoseEffects: %{public}@", String(describing: error)) + retryContext.update(with: .carbs) + } + reloadGroup.leave() + } + } + + reloadGroup.leave() + } + + if shouldUpdateCarbs { + reloadGroup.enter() + deviceManager.carbStore.getTotalCarbs(since: midnight) { (result) in + switch result { + case .success(let total): + carbTotal = total + case .failure(let error): + self.log.error("CarbStore failed to get total carbs: %{public}@", String(describing: error)) + retryContext.update(with: .carbs) + } + + reloadGroup.leave() + } + } + + reloadGroup.notify(queue: .main) { + if let carbEffects = carbEffects { + self.carbEffectChart.setCarbEffects(carbEffects) + self.charts.invalidateChart(atIndex: 0) + } + + if let insulinCounteractionEffects = insulinCounteractionEffects { + self.carbEffectChart.setInsulinCounteractionEffects(insulinCounteractionEffects) + self.charts.invalidateChart(atIndex: 0) + } + + self.charts.prerender() + + for case let cell as ChartTableViewCell in self.tableView.visibleCells { + cell.reloadChart() + } + + if shouldUpdateCarbs || shouldUpdateGlucose { + // Change to descending order for display + self.carbStatuses = carbStatuses?.reversed() ?? [] + + if shouldUpdateCarbs { + self.carbTotal = carbTotal + } + + self.carbsOnBoard = carbsOnBoard + + self.tableView.reloadSections(IndexSet(integer: Section.entries.rawValue), with: .fade) + } + + if let cell = self.tableView.cellForRow(at: IndexPath(row: 0, section: Section.totals.rawValue)) as? HeaderValuesTableViewCell { + self.updateCell(cell) + } + + self.reloading = false + let reloadNow = !self.refreshContext.isEmpty + self.refreshContext.formUnion(retryContext) + + // Trigger a reload if new context exists. + if reloadNow { + self.reloadData() + } + } + } + + // MARK: - UITableViewDataSource + + private enum Section: Int { + case charts + case totals + case entries + + static let count = 3 + } + + private enum ChartRow: Int { + case carbEffect + + static let count = 1 + } + + private lazy var carbFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .none + return formatter + }() + + private lazy var absorptionFormatter: DateComponentsFormatter = { + let formatter = DateComponentsFormatter() + formatter.collapsesLargestUnit = true + formatter.unitsStyle = .abbreviated + formatter.allowsFractionalUnits = true + formatter.allowedUnits = [.hour, .minute] + return formatter + }() + + private lazy var timeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .none + formatter.timeStyle = .short + return formatter + }() + + override func numberOfSections(in tableView: UITableView) -> Int { + return Section.count + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + switch Section(rawValue: section)! { + case .charts: + return ChartRow.count + case .totals: + return 1 + case .entries: + return carbStatuses.count + } + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + switch Section(rawValue: indexPath.section)! { + case .charts: + let cell = tableView.dequeueReusableCell(withIdentifier: ChartTableViewCell.className, for: indexPath) as! ChartTableViewCell + + switch ChartRow(rawValue: indexPath.row)! { + case .carbEffect: + cell.setChartGenerator(generator: { [weak self] (frame) in + return self?.charts.chart(atIndex: 0, frame: frame)?.view + }) + } + + let alpha: CGFloat = charts.gestureRecognizer?.state == .possible ? 1 : 0 + cell.setAlpha(alpha: alpha) + + cell.setSubtitleTextColor(color: UIColor.secondaryLabel) + + return cell + case .totals: + let cell = tableView.dequeueReusableCell(withIdentifier: HeaderValuesTableViewCell.className, for: indexPath) as! HeaderValuesTableViewCell + updateCell(cell) + + return cell + case .entries: + let unit = HKUnit.gram() + let cell = tableView.dequeueReusableCell(withIdentifier: CarbEntryTableViewCell.className, for: indexPath) as! CarbEntryTableViewCell + + // Entry value + let status = carbStatuses[indexPath.row] + let carbText = carbFormatter.string(from: status.entry.quantity.doubleValue(for: unit), unit: unit.unitString) + + if let carbText = carbText, let foodType = status.entry.foodType { + cell.valueLabel?.text = String( + format: NSLocalizedString("%1$@: %2$@", comment: "Formats (1: carb value) and (2: food type)"), + carbText, foodType + ) + } else { + cell.valueLabel?.text = carbText + } + + // Entry time + let startTime = timeFormatter.string(from: status.entry.startDate) + if let absorptionTime = status.entry.absorptionTime, + let duration = absorptionFormatter.string(from: absorptionTime) + { + cell.dateLabel?.text = String( + format: NSLocalizedString("%1$@ + %2$@", comment: "Formats (1: carb start time) and (2: carb absorption duration)"), + startTime, duration + ) + } else { + cell.dateLabel?.text = startTime + } + + if let absorption = status.absorption { + // Absorbed value + let observedProgress = Float(absorption.observedProgress.doubleValue(for: .percent())) + let observedCarbs = max(0, absorption.observed.doubleValue(for: unit)) + + if let observedCarbsText = carbFormatter.string(from: observedCarbs, unit: unit.unitString) { + cell.observedValueText = String( + format: NSLocalizedString("%@ absorbed", comment: "Formats absorbed carb value"), + observedCarbsText + ) + + if absorption.isActive { + cell.observedValueTextColor = UIColor.carbTintColor + } else if 0.9 <= observedProgress && observedProgress <= 1.1 { + cell.observedValueTextColor = UIColor.systemGray + } else { + cell.observedValueTextColor = UIColor.agingColor + } + } + + cell.observedProgress = observedProgress + cell.clampedProgress = Float(absorption.clampedProgress.doubleValue(for: .percent())) + cell.observedDateText = absorptionFormatter.string(from: absorption.estimatedDate.duration) + + // Absorbed time + if absorption.isActive { + cell.observedDateTextColor = UIColor.carbTintColor + } else { + cell.observedDateTextColor = UIColor.systemGray + + if let absorptionTime = status.entry.absorptionTime { + let durationProgress = absorption.estimatedDate.duration / absorptionTime + if 0.9 > durationProgress || durationProgress > 1.1 { + cell.observedDateTextColor = UIColor.agingColor + } + } + } + } + + cell.isEditable = allowEditing + return cell + } + } + + private func updateCell(_ cell: HeaderValuesTableViewCell) { + let unit = HKUnit.gram() + + if let carbsOnBoard = carbsOnBoard, carbsOnBoard.quantity.doubleValue(for: unit) > 0 { + cell.COBDateLabel.text = String( + format: NSLocalizedString("at %@", comment: "Format fragment for a specific time"), + timeFormatter.string(from: carbsOnBoard.startDate) + ) + cell.COBValueLabel.text = carbFormatter.string(from: carbsOnBoard.quantity.doubleValue(for: unit)) + + // Warn the user if the carbsOnBoard value isn't recent + let textColor: UIColor + switch carbsOnBoard.startDate.timeIntervalSinceNow { + case let t where t < .minutes(-30): + textColor = .staleColor + case let t where t < .minutes(-15): + textColor = .agingColor + default: + textColor = .secondaryLabel + } + + cell.COBDateLabel.textColor = textColor + } else { + cell.COBDateLabel.text = nil + cell.COBValueLabel.text = carbFormatter.string(from: 0.0) + } + + if let carbTotal = carbTotal { + cell.totalDateLabel.text = String( + format: NSLocalizedString("since %@", comment: "Format fragment for a start time"), + timeFormatter.string(from: carbTotal.startDate) + ) + cell.totalValueLabel.text = carbFormatter.string(from: carbTotal.quantity.doubleValue(for: unit)) + } else { + cell.totalDateLabel.text = nil + cell.totalValueLabel.text = carbFormatter.string(from: 0.0) + } + } + + override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { + switch Section(rawValue: indexPath.section)! { + case .charts, .totals: + return false + case .entries: + return allowEditing && carbStatuses[indexPath.row].entry.createdByCurrentApp + } + } + + public override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) { + if editingStyle == .delete { + let status = carbStatuses[indexPath.row] + deviceManager.loopManager.deleteCarbEntry(status.entry) { (result) -> Void in + DispatchQueue.main.async { + switch result { + case .success: + self.isEditing = false + break // Notification will trigger update + case .failure(let error): + self.refreshContext.update(with: .carbs) + self.present(UIAlertController(with: error), animated: true) + } + } + } + } + } + + // MARK: - UITableViewDelegate + + override func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + switch Section(rawValue: indexPath.section)! { + case .charts: + return 170 + case .totals: + return 66 + case .entries: + return 66 + } + } + + override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { + switch Section(rawValue: indexPath.section)! { + case .charts: + return indexPath + case .totals: + return nil + case .entries: + return (allowEditing && carbStatuses[indexPath.row].entry.createdByCurrentApp) ? indexPath : nil + } + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard indexPath.row < carbStatuses.count else { return } + tableView.deselectRow(at: indexPath, animated: true) + + let originalCarbEntry = carbStatuses[indexPath.row].entry + + let viewModel = CarbEntryViewModel(delegate: deviceManager, originalCarbEntry: originalCarbEntry) + let carbEntryView = CarbEntryView(viewModel: viewModel) + .environmentObject(deviceManager.displayGlucosePreference) + .environment(\.dismissAction, carbEditWasCanceled) + let hostingController = UIHostingController(rootView: carbEntryView) + hostingController.title = "Edit Carb Entry" + hostingController.navigationItem.largeTitleDisplayMode = .never + let leftBarButton = UIBarButtonItem(title: "Back", style: .plain, target: self, action: #selector(carbEditWasCanceled)) + hostingController.navigationItem.backBarButtonItem = leftBarButton + navigationController?.pushViewController(hostingController, animated: true) + } + + @objc func carbEditWasCanceled() { + navigationController?.popToViewController(self, animated: true) + } + + // MARK: - Navigation + @IBAction func presentCarbEntryScreen() { + if FeatureFlags.simpleBolusCalculatorEnabled && !automaticDosingStatus.automaticDosingEnabled { + let viewModel = SimpleBolusViewModel(delegate: deviceManager, displayMealEntry: true) + let bolusEntryView = SimpleBolusView(viewModel: viewModel).environmentObject(DisplayGlucosePreference(displayGlucoseUnit: .milligramsPerDeciliter)) + let hostingController = DismissibleHostingController(rootView: bolusEntryView, isModalInPresentation: false) + let navigationWrapper = UINavigationController(rootViewController: hostingController) + hostingController.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: navigationWrapper, action: #selector(dismissWithAnimation)) + present(navigationWrapper, animated: true) + } else { + let viewModel = CarbEntryViewModel(delegate: deviceManager) + let carbEntryView = CarbEntryView(viewModel: viewModel) + .environmentObject(deviceManager.displayGlucosePreference) + let hostingController = DismissibleHostingController(rootView: carbEntryView, isModalInPresentation: false) + present(hostingController, animated: true) + } + } +} diff --git a/Loop/View Controllers/CarbEntryEditTableViewController.swift b/Loop/View Controllers/CarbEntryEditTableViewController.swift deleted file mode 100644 index 195d940ccb..0000000000 --- a/Loop/View Controllers/CarbEntryEditTableViewController.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// CarbEntryEditTableViewController.swift -// Naterade -// -// Created by Nathan Racklyeft on 3/25/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import CarbKit - - -extension CarbEntryEditViewController: IdentifiableClass { -} \ No newline at end of file diff --git a/Loop/View Controllers/CarbEntryTableViewController.swift b/Loop/View Controllers/CarbEntryTableViewController.swift deleted file mode 100644 index 91a275fc37..0000000000 --- a/Loop/View Controllers/CarbEntryTableViewController.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// CarbEntryTableViewController.swift -// Naterade -// -// Created by Nathan Racklyeft on 3/11/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import CarbKit - - -extension CarbEntryTableViewController: IdentifiableClass { -} \ No newline at end of file diff --git a/Loop/View Controllers/CommandResponseViewController.swift b/Loop/View Controllers/CommandResponseViewController.swift index 7ad0fdedca..e14c41c8a4 100644 --- a/Loop/View Controllers/CommandResponseViewController.swift +++ b/Loop/View Controllers/CommandResponseViewController.swift @@ -7,61 +7,30 @@ // import Foundation -import LoopKit +import LoopKitUI extension CommandResponseViewController { - static func generateDiagnosticReport(dataManager: DeviceDataManager) -> CommandResponseViewController { - let vc = CommandResponseViewController(command: { (completionHandler) in - let group = DispatchGroup() - - var doseStoreResponse = "" - group.enter() - dataManager.doseStore.generateDiagnosticReport { (report) in - doseStoreResponse = report - group.leave() - } - - var carbStoreResponse = "" - if let carbStore = dataManager.carbStore { - group.enter() - carbStore.generateDiagnosticReport { (report) in - carbStoreResponse = report - group.leave() - } - } - - var glucoseStoreResponse = "" - if let glucoseStore = dataManager.glucoseStore { - group.enter() - glucoseStore.generateDiagnosticReport { (report) in - glucoseStoreResponse = report - group.leave() + typealias T = CommandResponseViewController + + static func generateDiagnosticReport(deviceManager: DeviceDataManager) -> T { + let date = Date() + let vc = T(command: { (completionHandler) in + deviceManager.generateDiagnosticReport { (report) in + DispatchQueue.main.async { + completionHandler([ + "Use the Share button above to save this diagnostic report to aid investigating your problem. Issues can be filed at https://github.com/LoopKit/Loop/issues.", + "Generated: \(date)", + "", + report, + "", + ].joined(separator: "\n\n")) } } - // LoopStatus - var loopManagerResponse = "" - group.enter() - dataManager.loopManager.generateDiagnosticReport { (report) in - loopManagerResponse = report - group.leave() - } - - group.notify(queue: DispatchQueue.main) { - completionHandler([ - "Use the Share button above save this diagnostic report to aid investigating your problem. Issues can be filed at https://github.com/LoopKit/Loop/issues.", - "Generated: \(Date())", - String(reflecting: dataManager), - loopManagerResponse, - doseStoreResponse, - carbStoreResponse, - glucoseStoreResponse - ].joined(separator: "\n\n")) - } - return NSLocalizedString("Loading...", comment: "The loading message for the diagnostic report screen") }) + vc.fileName = "Loop Report \(ISO8601DateFormatter.string(from: date, timeZone: .current, formatOptions: [.withSpaceBetweenDateAndTime, .withInternetDateTime])).md" return vc } diff --git a/Loop/View Controllers/GlucoseThresholdTableViewController.swift b/Loop/View Controllers/GlucoseThresholdTableViewController.swift new file mode 100644 index 0000000000..1657be2779 --- /dev/null +++ b/Loop/View Controllers/GlucoseThresholdTableViewController.swift @@ -0,0 +1,41 @@ +// +// GlucoseThresholdTableViewController.swift +// Loop +// +// Created by Pete Schwamb on 1/1/17. +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +import Foundation +import HealthKit +import LoopKit +import LoopKitUI +import UIKit + +final class GlucoseThresholdTableViewController: TextFieldTableViewController { + + public let glucoseUnit: HKUnit + + init(threshold: Double?, glucoseUnit: HKUnit) { + self.glucoseUnit = glucoseUnit + + super.init(style: .grouped) + + placeholder = NSLocalizedString("Enter glucose safety limit", comment: "The placeholder text instructing users to enter a glucose safety limit") + keyboardType = .decimalPad + contextHelp = NSLocalizedString("When current or forecasted glucose is below the glucose safety limit, Loop will not recommend a bolus, and will always recommend a temporary basal rate of 0 units per hour.", comment: "Explanation of glucose safety limit") + + let formatter = QuantityFormatter(for: glucoseUnit) + + unit = formatter.localizedUnitStringWithPlurality() + + if let threshold = threshold { + value = formatter.numberFormatter.string(from: threshold) + } + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} diff --git a/Loop/View Controllers/InsulinDeliveryTableViewController.swift b/Loop/View Controllers/InsulinDeliveryTableViewController.swift index 34b196fd07..c340f8f536 100644 --- a/Loop/View Controllers/InsulinDeliveryTableViewController.swift +++ b/Loop/View Controllers/InsulinDeliveryTableViewController.swift @@ -2,12 +2,665 @@ // InsulinDeliveryTableViewController.swift // Naterade // -// Created by Nathan Racklyeft on 3/11/16. +// Created by Nathan Racklyeft on 1/30/16. // Copyright © 2016 Nathan Racklyeft. All rights reserved. // -import InsulinKit +import UIKit +import LoopCore +import LoopKit +import LoopKitUI +private let ReuseIdentifier = "Right Detail" -extension InsulinDeliveryTableViewController: IdentifiableClass { -} \ No newline at end of file + +public final class InsulinDeliveryTableViewController: UITableViewController { + + private static let historicDataDisplayTimeInterval = TimeInterval.hours(24) + + @IBOutlet var needsConfigurationMessageView: ErrorBackgroundView! + + @IBOutlet weak var iobValueLabel: UILabel! { + didSet { + iobValueLabel.textColor = headerValueLabelColor + } + } + + @IBOutlet weak var iobDateLabel: UILabel! + + @IBOutlet weak var totalValueLabel: UILabel! { + didSet { + totalValueLabel.textColor = headerValueLabelColor + } + } + + @IBOutlet weak var totalDateLabel: UILabel! + + @IBOutlet weak var dataSourceSegmentedControl: UISegmentedControl! { + didSet { + let titleFont = UIFont.systemFont(ofSize: 15, weight: .semibold) + dataSourceSegmentedControl.setTitleTextAttributes([NSAttributedString.Key.font: titleFont], for: .normal) + dataSourceSegmentedControl.setTitle(NSLocalizedString("Event History", comment: "Segmented button title for insulin delivery log event history"), forSegmentAt: 0) + dataSourceSegmentedControl.setTitle(NSLocalizedString("Reservoir", comment: "Segmented button title for insulin delivery log reservoir history"), forSegmentAt: 1) + } + } + + public var enableEntryDeletion: Bool = true + + var deviceManager: DeviceDataManager? { + didSet { + doseStore = deviceManager?.doseStore + } + } + + public var doseStore: DoseStore? { + didSet { + if let doseStore = doseStore { + doseStoreObserver = NotificationCenter.default.addObserver(forName: nil, object: doseStore, queue: OperationQueue.main, using: { [weak self] (note) -> Void in + + switch note.name { + case DoseStore.valuesDidChange: + if self?.isViewLoaded == true { + self?.reloadData() + } + default: + break + } + }) + } else { + doseStoreObserver = nil + } + } + } + + public var headerValueLabelColor: UIColor = .label + + private var updateTimer: Timer? { + willSet { + if let timer = updateTimer { + timer.invalidate() + } + } + } + + public override func viewDidLoad() { + super.viewDidLoad() + + state = .display + + if FeatureFlags.manualDoseEntryEnabled { + let enterDoseButton = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(didTapEnterDoseButton)) + navigationItem.rightBarButtonItems = [enterDoseButton, editButtonItem] + } else { + dataSourceSegmentedControl.removeSegment(at: 2, animated: false) + } + if !FeatureFlags.insulinDeliveryReservoirViewEnabled { + dataSourceSegmentedControl.removeSegment(at: 1, animated: false) + } + } + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + updateTimelyStats(nil) + } + + public override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + let updateInterval = TimeInterval(minutes: 5) + let timer = Timer( + fireAt: Date().dateCeiledToTimeInterval(updateInterval).addingTimeInterval(2), + interval: updateInterval, + target: self, + selector: #selector(updateTimelyStats(_:)), + userInfo: nil, + repeats: true + ) + updateTimer = timer + + RunLoop.current.add(timer, forMode: RunLoop.Mode.default) + } + + public override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + updateTimer = nil + } + + public override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + if tableView.isEditing { + tableView.endEditing(true) + } + } + + deinit { + if let observer = doseStoreObserver { + NotificationCenter.default.removeObserver(observer) + } + } + + public override func setEditing(_ editing: Bool, animated: Bool) { + super.setEditing(editing, animated: animated) + + if editing && enableEntryDeletion { + let item = UIBarButtonItem( + title: NSLocalizedString("Delete All", comment: "Button title to delete all objects"), + style: .plain, + target: self, + action: #selector(confirmDeletion(_:)) + ) + navigationItem.setLeftBarButton(item, animated: true) + } else { + navigationItem.setLeftBarButton(nil, animated: true) + } + } + + @objc func didTapEnterDoseButton(sender: AnyObject){ + guard let deviceManager = deviceManager else { + return + } + + tableView.endEditing(true) + + let viewModel = ManualEntryDoseViewModel(delegate: deviceManager) + let bolusEntryView = ManualEntryDoseView(viewModel: viewModel) + let hostingController = DismissibleHostingController(rootView: bolusEntryView, isModalInPresentation: false) + let navigationWrapper = UINavigationController(rootViewController: hostingController) + hostingController.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: navigationWrapper, action: #selector(dismissWithAnimation)) + self.present(navigationWrapper, animated: true) + } + + + // MARK: - Data + + private enum State { + case unknown + case unavailable(Error?) + case display + } + + private var state = State.unknown { + didSet { + if isViewLoaded { + reloadData() + } + } + } + + private enum DataSourceSegment: Int { + case history = 0 + case reservoir + case manualEntryDose + } + + private enum Values { + case reservoir([ReservoirValue]) + case history([PersistedPumpEvent]) + case manualEntryDoses([DoseEntry]) + } + + // Not thread-safe + private var values = Values.reservoir([]) { + didSet { + let count: Int + + switch values { + case .reservoir(let values): + count = values.count + case .history(let values): + count = values.count + case .manualEntryDoses(let values): + count = values.count + } + + if count > 0 && enableEntryDeletion { + navigationItem.rightBarButtonItem = self.editButtonItem + } + } + } + + private func reloadData() { + let sinceDate = Date().addingTimeInterval(-InsulinDeliveryTableViewController.historicDataDisplayTimeInterval) + switch state { + case .unknown: + break + case .unavailable(let error): + self.tableView.tableHeaderView?.isHidden = true + self.tableView.tableFooterView = UIView() + tableView.backgroundView = needsConfigurationMessageView + + if let error = error { + needsConfigurationMessageView.setErrorDescriptionLabel(with: String(describing: error)) + } + case .display: + self.tableView.backgroundView = nil + self.tableView.tableHeaderView?.isHidden = false + self.tableView.tableFooterView = nil + + switch DataSourceSegment(rawValue: dataSourceSegmentedControl.selectedSegmentIndex)! { + case .reservoir: + doseStore?.getReservoirValues(since: sinceDate) { (result) in + DispatchQueue.main.async { () -> Void in + switch result { + case .failure(let error): + self.state = .unavailable(error) + case .success(let reservoirValues): + self.values = .reservoir(reservoirValues) + self.tableView.reloadData() + } + } + + self.updateTimelyStats(nil) + self.updateTotal() + } + case .history: + doseStore?.getPumpEventValues(since: sinceDate) { (result) in + DispatchQueue.main.async { () -> Void in + switch result { + case .failure(let error): + self.state = .unavailable(error) + case .success(let pumpEventValues): + self.values = .history(pumpEventValues) + self.tableView.reloadData() + } + } + + self.updateTimelyStats(nil) + self.updateTotal() + } + case .manualEntryDose: + doseStore?.getManuallyEnteredDoses(since: sinceDate) { (result) in + DispatchQueue.main.async { () -> Void in + switch result { + case .failure(let error): + self.state = .unavailable(error) + case .success(let values): + self.values = .manualEntryDoses(values) + self.tableView.reloadData() + } + } + } + + self.updateTimelyStats(nil) + self.updateTotal() + } + } + } + + @objc func updateTimelyStats(_: Timer?) { + updateIOB() + } + + private lazy var iobNumberFormatter: NumberFormatter = { + let formatter = NumberFormatter() + + formatter.numberStyle = .decimal + formatter.maximumFractionDigits = 2 + + return formatter + }() + + private lazy var timeFormatter: DateFormatter = { + let formatter = DateFormatter() + + formatter.dateStyle = .none + formatter.timeStyle = .short + + return formatter + }() + + private func updateIOB() { + if case .display = state { + doseStore?.insulinOnBoard(at: Date()) { (result) -> Void in + DispatchQueue.main.async { + switch result { + case .failure: + self.iobValueLabel.text = "…" + self.iobDateLabel.text = nil + case .success(let iob): + self.iobValueLabel.text = self.iobNumberFormatter.string(from: iob.value) + self.iobDateLabel.text = String(format: NSLocalizedString("com.loudnate.InsulinKit.IOBDateLabel", value: "at %1$@", comment: "The format string describing the date of an IOB value. The first format argument is the localized date."), self.timeFormatter.string(from: iob.startDate)) + } + } + } + } + } + + private func updateTotal() { + if case .display = state { + let midnight = Calendar.current.startOfDay(for: Date()) + + doseStore?.getTotalUnitsDelivered(since: midnight) { (result) in + DispatchQueue.main.async { + switch result { + case .failure: + self.totalValueLabel.text = "…" + self.totalDateLabel.text = nil + case .success(let result): + self.totalValueLabel.text = NumberFormatter.localizedString(from: NSNumber(value: result.value), number: .none) + self.totalDateLabel.text = String(format: NSLocalizedString("com.loudnate.InsulinKit.totalDateLabel", value: "since %1$@", comment: "The format string describing the starting date of a total value. The first format argument is the localized date."), DateFormatter.localizedString(from: result.startDate, dateStyle: .none, timeStyle: .short)) + } + } + } + } + } + + private var doseStoreObserver: Any? { + willSet { + if let observer = doseStoreObserver { + NotificationCenter.default.removeObserver(observer) + } + } + } + + @IBAction func selectedSegmentChanged(_ sender: Any) { + reloadData() + } + + @IBAction func confirmDeletion(_ sender: Any) { + guard !deletionPending else { + return + } + + let confirmMessage: String + + switch DataSourceSegment(rawValue: dataSourceSegmentedControl.selectedSegmentIndex)! { + case .reservoir: + confirmMessage = NSLocalizedString("Are you sure you want to delete all reservoir values?", comment: "Action sheet confirmation message for reservoir deletion") + case .history: + confirmMessage = NSLocalizedString("Are you sure you want to delete all history entries?", comment: "Action sheet confirmation message for pump history deletion") + case .manualEntryDose: + confirmMessage = NSLocalizedString("Are you sure you want to delete all logged dose entries?", comment: "Action sheet confirmation message for logged dose deletion") + } + + let sheet = UIAlertController(deleteAllConfirmationMessage: confirmMessage) { + self.deleteAllObjects() + } + present(sheet, animated: true) + } + + private var deletionPending = false + + private func deleteAllObjects() { + guard !deletionPending else { + return + } + + deletionPending = true + + let completion = { (_: DoseStore.DoseStoreError?) -> Void in + DispatchQueue.main.async { + self.deletionPending = false + self.setEditing(false, animated: true) + } + } + + let sinceDate = Date().addingTimeInterval(-InsulinDeliveryTableViewController.historicDataDisplayTimeInterval) + + switch DataSourceSegment(rawValue: dataSourceSegmentedControl.selectedSegmentIndex)! { + case .reservoir: + doseStore?.deleteAllReservoirValues(completion) + case .history: + doseStore?.deleteAllPumpEvents(completion) + case .manualEntryDose: + doseStore?.deleteAllManuallyEnteredDoses(since: sinceDate, completion) + } + } + + // MARK: - Table view data source + + public override func numberOfSections(in tableView: UITableView) -> Int { + switch state { + case .unknown, .unavailable: + return 0 + case .display: + return 1 + } + } + + public override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + switch values { + case .reservoir(let values): + return values.count + case .history(let values): + return values.count + case .manualEntryDoses(let values): + return values.count + } + } + + public override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: ReuseIdentifier, for: indexPath) + + if case .display = state { + switch self.values { + case .reservoir(let values): + let entry = values[indexPath.row] + let volume = NumberFormatter.localizedString(from: NSNumber(value: entry.unitVolume), number: .decimal) + let time = timeFormatter.string(from: entry.startDate) + + cell.textLabel?.text = String(format: NSLocalizedString("%1$@ U", comment: "Reservoir entry (1: volume value)"), volume) + cell.textLabel?.textColor = .label + cell.detailTextLabel?.text = time + cell.accessoryType = .none + cell.selectionStyle = .none + case .history(let values): + let entry = values[indexPath.row] + let time = timeFormatter.string(from: entry.date) + + if let attributedText = entry.localizedAttributedDescription { + cell.textLabel?.attributedText = attributedText + } else { + cell.textLabel?.text = NSLocalizedString("Unknown", comment: "The default description to use when an entry has no dose description") + } + + cell.detailTextLabel?.text = time + cell.accessoryType = entry.isUploaded ? .checkmark : .none + cell.selectionStyle = .default + case .manualEntryDoses(let values): + let entry = values[indexPath.row] + let time = timeFormatter.string(from: entry.startDate) + let font = UIFont.preferredFont(forTextStyle: .body) + + let description = String(format: NSLocalizedString("Manual Dose: %1$@ %2$@", comment: "Description of a bolus dose entry (1: value (? if no value) in bold, 2: unit)"), numberFormatter.string(from: entry.programmedUnits) ?? "?", DoseEntry.units.shortLocalizedUnitString(avoidLineBreaking: false)) + + let attributedDescription = createAttributedDescription(from: description, with: font) + cell.textLabel?.attributedText = attributedDescription + cell.detailTextLabel?.text = time + cell.selectionStyle = .default + } + } + + return cell + } + + public override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { + return enableEntryDeletion + } + + public override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) { + if editingStyle == .delete, case .display = state { + switch values { + case .reservoir(let reservoirValues): + var reservoirValues = reservoirValues + let value = reservoirValues.remove(at: indexPath.row) + self.values = .reservoir(reservoirValues) + + tableView.deleteRows(at: [indexPath], with: .automatic) + + doseStore?.deleteReservoirValue(value) { (_, error) -> Void in + if let error = error { + DispatchQueue.main.async { + self.present(UIAlertController(with: error), animated: true) + self.reloadData() + } + } + } + case .history(let historyValues): + var historyValues = historyValues + let value = historyValues.remove(at: indexPath.row) + self.values = .history(historyValues) + + tableView.deleteRows(at: [indexPath], with: .automatic) + + doseStore?.deletePumpEvent(value) { (error) -> Void in + if let error = error { + DispatchQueue.main.async { + self.present(UIAlertController(with: error), animated: true) + self.reloadData() + } + } + } + case .manualEntryDoses(let doses): + var doses = doses + let value = doses.remove(at: indexPath.row) + self.values = .manualEntryDoses(doses) + + tableView.deleteRows(at: [indexPath], with: .automatic) + doseStore?.deleteDose(value) { error in + if let error = error { + DispatchQueue.main.async { + self.present(UIAlertController(with: error), animated: true) + self.reloadData() + } + } + } + } + } + } + + public override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + if case .display = state, case .history(let history) = values { + let entry = history[indexPath.row] + + let vc = CommandResponseViewController(command: { (completionHandler) -> String in + var description = [String]() + + description.append(self.timeFormatter.string(from: entry.date)) + + if let title = entry.title { + description.append(title) + } + + if let dose = entry.dose { + description.append(String(describing: dose)) + } + + if let raw = entry.raw { + description.append(raw.hexadecimalString) + } + + return description.joined(separator: "\n\n") + }) + + vc.title = NSLocalizedString("Pump Event", comment: "The title of the screen displaying a pump event") + + show(vc, sender: indexPath) + } + else if case .display = state, case .manualEntryDoses(let doses) = values { + let entry = doses[indexPath.row] + + let vc = CommandResponseViewController(command: { (completionHandler) -> String in + var description = [String]() + description.append(self.timeFormatter.string(from: entry.startDate)) + description.append(String(describing: entry)) + + return description.joined(separator: "\n\n") + }) + + vc.title = NSLocalizedString("Logged Insulin Dose", comment: "The title of the screen displaying a manually entered insulin dose") + + show(vc, sender: indexPath) + } + } + +} + +fileprivate extension UIAlertController { + convenience init(deleteAllConfirmationMessage: String, confirmationHandler handler: @escaping () -> Void) { + self.init( + title: nil, + message: deleteAllConfirmationMessage, + preferredStyle: .actionSheet + ) + + addAction(UIAlertAction( + title: NSLocalizedString("Delete All", comment: "Button title to delete all objects"), + style: .destructive, + handler: { (_) in handler() } + )) + + addAction(UIAlertAction( + title: NSLocalizedString("Cancel", comment: "The title of the cancel action in an action sheet"), + style: .cancel + )) + } +} + +fileprivate var numberFormatter: NumberFormatter { + let numberFormatter = NumberFormatter() + numberFormatter.maximumFractionDigits = DoseEntry.unitsPerHour.maxFractionDigits + return numberFormatter +} + +fileprivate func createAttributedDescription(from description: String, with font: UIFont) -> NSAttributedString? { + let descriptionWithFont = String(format:"%@", description) + + guard let attributedDescription = try? NSMutableAttributedString(data: descriptionWithFont.data(using: .utf16)!, options: [.documentType: NSAttributedString.DocumentType.html], documentAttributes: nil) else { + return nil + } + + attributedDescription.enumerateAttribute(.font, in: NSRange(location: 0, length: attributedDescription.length)) { value, range, stop in + // bold font items have a dominate colour + if let font = value as? UIFont, + font.fontDescriptor.symbolicTraits.contains(.traitBold) + { + attributedDescription.addAttributes([.foregroundColor: UIColor.label], range: range) + } else { + attributedDescription.addAttributes([.foregroundColor: UIColor.secondaryLabel], range: range) + } + } + + return attributedDescription +} + +extension PersistedPumpEvent { + + fileprivate var localizedAttributedDescription: NSAttributedString? { + let font = UIFont.preferredFont(forTextStyle: .body) + + let eventTitle = title ?? NSLocalizedString("Unknown", comment: "Event title displayed when StoredPumpEvent.title is not set") + + if let dose = dose { + switch dose.type { + case .bolus: + let description: String + if let deliveredUnits = dose.deliveredUnits, + deliveredUnits != dose.programmedUnits + { + description = String(format: NSLocalizedString("Interrupted %1$@: %2$@ of %3$@ %4$@", comment: "Description of an interrupted bolus dose entry (1: title for dose type, 2: value (? if no value) in bold, 3: programmed value (? if no value), 4: unit)"), eventTitle, numberFormatter.string(from: deliveredUnits) ?? "?", numberFormatter.string(from: dose.programmedUnits) ?? "?", DoseEntry.units.shortLocalizedUnitString()) + } else { + description = String(format: NSLocalizedString("%1$@: %2$@ %3$@", comment: "Description of a bolus dose entry (1: title for dose type, 2: value (? if no value) in bold, 3: unit)"), eventTitle, numberFormatter.string(from: dose.programmedUnits) ?? "?", DoseEntry.units.shortLocalizedUnitString(avoidLineBreaking: false)) + } + + return createAttributedDescription(from: description, with: font) + case .basal, .tempBasal: + let description = String(format: NSLocalizedString("%1$@: %2$@ %3$@", comment: "Description of a basal temp basal dose entry (1: title for dose type, 2: value (? if no value) in bold, 3: unit)"), eventTitle, numberFormatter.string(from: dose.unitsPerHour) ?? "?", DoseEntry.unitsPerHour.shortLocalizedUnitString(avoidLineBreaking: false)) + return createAttributedDescription(from: description, with: font) + case .suspend, .resume: + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: UIColor.secondaryLabel + ] + return NSAttributedString(string: eventTitle, attributes: attributes) + } + } else { + return createAttributedDescription(from: eventTitle, with: font) + } + } +} + +extension InsulinDeliveryTableViewController: IdentifiableClass { } diff --git a/Loop/View Controllers/LoopChartsTableViewController.swift b/Loop/View Controllers/LoopChartsTableViewController.swift new file mode 100644 index 0000000000..8b1e56447b --- /dev/null +++ b/Loop/View Controllers/LoopChartsTableViewController.swift @@ -0,0 +1,88 @@ +// +// LoopChartsTableViewController.swift +// Loop +// +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +import UIKit +import LoopUI +import LoopKitUI +import HealthKit +import os.log + + +enum RefreshContext: Equatable { + /// Catch-all for lastLoopCompleted, recommendedTempBasal, lastTempBasal, preferences + case status + + case glucose + case insulin + case carbs + case targets + + case size(CGSize) +} + +extension RefreshContext: Hashable { + func hash(into hasher: inout Hasher) { + hasher.combine(rawValue) + } + + private var rawValue: Int { + switch self { + case .status: + return 1 + case .glucose: + return 2 + case .insulin: + return 3 + case .carbs: + return 4 + case .targets: + return 5 + case .size: + // We don't use CGSize in our determination of hash nor equality + return 6 + } + } + + static func ==(lhs: RefreshContext, rhs: RefreshContext) -> Bool { + return lhs.rawValue == rhs.rawValue + } +} + +extension Set where Element == RefreshContext { + /// Returns the size value in the set if one exists + var newSize: CGSize? { + guard let index = firstIndex(of: .size(.zero)), + case .size(let size) = self[index] else + { + return nil + } + + return size + } + + /// Removes and returns the size value in the set if one exists + /// + /// - Returns: The size, if contained + mutating func removeNewSize() -> CGSize? { + guard case .size(let newSize)? = remove(.size(.zero)) else { + return nil + } + + return newSize + } +} + +/// Abstract class providing boilerplate setup for chart-based table view controllers +/// The logic is split between Loop and LoopKit because the DeviceDataManager is a Loop-specific concept +open class LoopChartsTableViewController: ChartsTableViewController { + weak var deviceManager: DeviceDataManager! { + didSet { + self.displayGlucosePreference = deviceManager.displayGlucosePreference + } + } +} + diff --git a/Loop/View Controllers/PredictionTableViewController.swift b/Loop/View Controllers/PredictionTableViewController.swift index a446fe1951..a460e52aaf 100644 --- a/Loop/View Controllers/PredictionTableViewController.swift +++ b/Loop/View Controllers/PredictionTableViewController.swift @@ -6,217 +6,203 @@ // Copyright © 2016 Nathan Racklyeft. All rights reserved. // -import UIKit import HealthKit +import LoopCore import LoopKit +import LoopKitUI +import LoopUI +import UIKit +import os.log -class PredictionTableViewController: UITableViewController, IdentifiableClass, UIGestureRecognizerDelegate { +private extension RefreshContext { + static let all: Set = [.glucose, .targets] +} + + +class PredictionTableViewController: LoopChartsTableViewController, IdentifiableClass { + private let log = OSLog(category: "PredictionTableViewController") override func viewDidLoad() { super.viewDidLoad() + tableView.rowHeight = UITableView.automaticDimension tableView.cellLayoutMarginsFollowReadableWidth = true + glucoseChart.glucoseDisplayRange = LoopConstants.glucoseChartDefaultDisplayRangeWide + let notificationCenter = NotificationCenter.default - let mainQueue = OperationQueue.main - let application = UIApplication.shared notificationObservers += [ - notificationCenter.addObserver(forName: .LoopDataUpdated, object: dataManager.loopManager, queue: nil) { note in - guard let rawContext = note.userInfo?[LoopDataManager.LoopUpdateContextKey] as? Int, LoopDataManager.LoopUpdateContext(rawValue: rawContext) != .preferences else { - return - } - + notificationCenter.addObserver(forName: .LoopDataUpdated, object: deviceManager.loopManager, queue: nil) { [weak self] note in + let context = note.userInfo?[LoopDataManager.LoopUpdateContextKey] as! LoopDataManager.LoopUpdateContext.RawValue DispatchQueue.main.async { - self.needsRefresh = true - self.reloadData(animated: true) + switch LoopDataManager.LoopUpdateContext(rawValue: context) { + case .preferences?: + self?.refreshContext.formUnion([.status, .targets]) + case .glucose?: + self?.refreshContext.update(with: .glucose) + default: + break + } + + self?.reloadData(animated: true) } }, - notificationCenter.addObserver(forName: .UIApplicationWillResignActive, object: application, queue: mainQueue) { _ in - self.active = false - }, - notificationCenter.addObserver(forName: .UIApplicationDidBecomeActive, object: application, queue: mainQueue) { _ in - self.active = true - } ] - - let chartPanGestureRecognizer = UIPanGestureRecognizer() - chartPanGestureRecognizer.delegate = self - chartPanGestureRecognizer.addTarget(self, action: #selector(handlePan(_:))) - charts.panGestureRecognizer = chartPanGestureRecognizer } - deinit { - for observer in notificationObservers { - NotificationCenter.default.removeObserver(observer) - } - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - visible = true - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - AnalyticsManager.sharedManager.didDisplayStatusScreen() - } + override func didReceiveMemoryWarning() { + super.didReceiveMemoryWarning() - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - - visible = false + if !visible { + refreshContext = RefreshContext.all + } } override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { - super.viewWillTransition(to: size, with: coordinator) + refreshContext.update(with: .size(size)) - needsRefresh = true - if visible { - reloadData(animated: false) - } + super.viewWillTransition(to: size, with: coordinator) } // MARK: - State - // References to registered notification center observers - private var notificationObservers: [Any] = [] - - var dataManager: DeviceDataManager! + private var retrospectiveGlucoseDiscrepancies: [GlucoseChange]? - private lazy var charts: StatusChartsManager = { - let charts = StatusChartsManager() + private var totalRetrospectiveCorrection: HKQuantity? - charts.glucoseDisplayRange = ( - min: HKQuantity(unit: HKUnit.milligramsPerDeciliterUnit(), doubleValue: 60), - max: HKQuantity(unit: HKUnit.milligramsPerDeciliterUnit(), doubleValue: 200) - ) + private var refreshContext = RefreshContext.all - return charts - }() - - private var retrospectivePredictedGlucose: [GlucoseValue]? - - private var active = true { - didSet { - reloadData() + private var chartStartDate: Date { + get { + return charts.startDate } - } - - private var needsRefresh = true + set { + if newValue != chartStartDate { + refreshContext = RefreshContext.all + } - private var visible = false { - didSet { - reloadData() + charts.startDate = newValue } } - private var reloading = false - - private func reloadData(animated: Bool = false) { - if active && visible && needsRefresh { - needsRefresh = false - reloading = true - - let calendar = Calendar.current - var components = DateComponents() - components.minute = 0 - let date = Date(timeIntervalSinceNow: -TimeInterval(hours: 1)) - charts.startDate = calendar.nextDate(after: date, matching: components, matchingPolicy: .strict, direction: .backward) ?? date + let glucoseChart = PredictedGlucoseChart(yAxisStepSizeMGDLOverride: FeatureFlags.predictedGlucoseChartClampEnabled ? 40 : nil) - let reloadGroup = DispatchGroup() - - if let glucoseStore = dataManager.glucoseStore { - reloadGroup.enter() - - glucoseStore.preferredUnit { (unit, error) in - if let unit = unit { - self.charts.glucoseUnit = unit - } - - reloadGroup.enter() - glucoseStore.getRecentGlucoseValues(startDate: self.charts.startDate) { (values, error) -> Void in - if let error = error { - self.dataManager.logger.addError(error, fromSource: "GlucoseStore") - self.needsRefresh = true - // TODO: Display error in the cell - } else { - self.charts.glucoseValues = values - } - - reloadGroup.leave() - } + override func createChartsManager() -> ChartsManager { + return ChartsManager(colors: .primary, settings: .default, charts: [glucoseChart], traitCollection: traitCollection) + } - reloadGroup.enter() - self.dataManager.loopManager.getLoopStatus { (predictedGlucose, retrospectivePredictedGlucose, _, _, _, _, _, error) in - if error != nil { - self.needsRefresh = true - } + override func glucoseUnitDidChange() { + self.log.debug("[reloadData] for HealthKit unit preference change") + refreshContext = RefreshContext.all + } - self.retrospectivePredictedGlucose = retrospectivePredictedGlucose - self.charts.predictedGlucoseValues = predictedGlucose ?? [] - - reloadGroup.leave() - } + override func reloadData(animated: Bool = false) { + guard active && visible && !refreshContext.isEmpty else { return } + + refreshContext.remove(.size(.zero)) + let calendar = Calendar.current + var components = DateComponents() + components.minute = 0 + let date = Date(timeIntervalSinceNow: -TimeInterval(hours: 1)) + chartStartDate = calendar.nextDate(after: date, matching: components, matchingPolicy: .strict, direction: .backward) ?? date + + let reloadGroup = DispatchGroup() + var glucoseSamples: [StoredGlucoseSample]? + var totalRetrospectiveCorrection: HKQuantity? + + if self.refreshContext.remove(.glucose) != nil { + reloadGroup.enter() + deviceManager.glucoseStore.getGlucoseSamples(start: self.chartStartDate, end: nil) { (result) -> Void in + switch result { + case .failure(let error): + self.log.error("Failure getting glucose samples: %{public}@", String(describing: error)) + glucoseSamples = nil + case .success(let samples): + glucoseSamples = samples + } + reloadGroup.leave() + } + } - reloadGroup.enter() - self.dataManager.loopManager.modelPredictedGlucose(using: self.selectedInputs.flatMap { $0.selected ? $0.input : nil }) { (predictedGlucose, error) in - if error != nil { - self.needsRefresh = true - } + // For now, do this every time + _ = self.refreshContext.remove(.status) + reloadGroup.enter() + deviceManager.loopManager.getLoopState { (manager, state) in + self.retrospectiveGlucoseDiscrepancies = state.retrospectiveGlucoseDiscrepancies + totalRetrospectiveCorrection = state.totalRetrospectiveCorrection + self.glucoseChart.setPredictedGlucoseValues(state.predictedGlucoseIncludingPendingInsulin ?? []) + + do { + let glucose = try state.predictGlucose(using: self.selectedInputs, includingPendingInsulin: true) + self.glucoseChart.setAlternatePredictedGlucoseValues(glucose) + } catch { + self.refreshContext.update(with: .status) + self.glucoseChart.setAlternatePredictedGlucoseValues([]) + } - self.charts.alternatePredictedGlucoseValues = predictedGlucose ?? [] + if let lastPoint = self.glucoseChart.alternatePredictedGlucosePoints?.last?.y { + self.eventualGlucoseDescription = String(describing: lastPoint) + } else { + self.eventualGlucoseDescription = nil + } - if let lastPoint = self.charts.alternatePredictedGlucosePoints?.last?.y { - self.eventualGlucoseDescription = String(describing: lastPoint) - } + if self.refreshContext.remove(.targets) != nil { + self.glucoseChart.targetGlucoseSchedule = manager.settings.glucoseTargetRangeSchedule + } - reloadGroup.leave() - } + reloadGroup.leave() + } - reloadGroup.leave() - } + reloadGroup.notify(queue: .main) { + if let glucoseSamples = glucoseSamples { + self.glucoseChart.setGlucoseValues(glucoseSamples) } + self.charts.invalidateChart(atIndex: 0) - charts.glucoseTargetRangeSchedule = dataManager.glucoseTargetRangeSchedule + if let totalRetrospectiveCorrection = totalRetrospectiveCorrection { + self.totalRetrospectiveCorrection = totalRetrospectiveCorrection + } - reloadGroup.notify(queue: DispatchQueue.main) { - self.charts.prerender() + self.charts.prerender() - for case let cell as ChartTableViewCell in self.tableView.visibleCells { + self.tableView.beginUpdates() + for cell in self.tableView.visibleCells { + switch cell { + case let cell as ChartTableViewCell: cell.reloadChart() if let indexPath = self.tableView.indexPath(for: cell) { self.tableView(self.tableView, updateTitleFor: cell, at: indexPath) } + case let cell as PredictionInputEffectTableViewCell: + if let indexPath = self.tableView.indexPath(for: cell) { + self.tableView(self.tableView, updateTextFor: cell, at: indexPath) + } + default: + break } - - self.reloading = false } + self.tableView.endUpdates() } } // MARK: - UITableViewDataSource - private enum Section: Int { + private enum Section: Int, CaseIterable { case charts case inputs - case settings - - static let count = 3 } private var eventualGlucoseDescription: String? - private lazy var selectedInputs: [(input: PredictionInputEffect, selected: Bool)] = [ - (.carbs, true), (.insulin, true), (.momentum, true), (.retrospection, true) - ] + private var availableInputs: [PredictionInputEffect] = [.carbs, .insulin, .momentum, .retrospection, .suspend] + + private var selectedInputs = PredictionInputEffect.all override func numberOfSections(in tableView: UITableView) -> Int { - return Section.count + return Section.allCases.count } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { @@ -224,9 +210,7 @@ class PredictionTableViewController: UITableViewController, IdentifiableClass, U case .charts: return 1 case .inputs: - return selectedInputs.count - case .settings: - return 1 + return availableInputs.count } } @@ -234,86 +218,86 @@ class PredictionTableViewController: UITableViewController, IdentifiableClass, U switch Section(rawValue: indexPath.section)! { case .charts: let cell = tableView.dequeueReusableCell(withIdentifier: ChartTableViewCell.className, for: indexPath) as! ChartTableViewCell - cell.titleLabel?.textColor = UIColor.secondaryLabelColor - cell.subtitleLabel?.textColor = UIColor.secondaryLabelColor cell.contentView.layoutMargins.left = tableView.separatorInset.left - cell.chartContentView.chartGenerator = { [weak self] (frame) in - return self?.charts.glucoseChartWithFrame(frame)?.view - } + cell.setChartGenerator(generator: { [weak self] (frame) in + return self?.charts.chart(atIndex: 0, frame: frame)?.view + }) self.tableView(tableView, updateTitleFor: cell, at: indexPath) - cell.titleLabel?.textColor = UIColor.secondaryLabelColor + cell.setTitleTextColor(color: UIColor.secondaryLabel) cell.selectionStyle = .none - cell.addGestureRecognizer(charts.panGestureRecognizer!) + cell.addGestureRecognizer(charts.gestureRecognizer!) return cell case .inputs: let cell = tableView.dequeueReusableCell(withIdentifier: PredictionInputEffectTableViewCell.className, for: indexPath) as! PredictionInputEffectTableViewCell + self.tableView(tableView, updateTextFor: cell, at: indexPath) + return cell + } + } - let (input, selected) = selectedInputs[indexPath.row] - - cell.titleLabel?.text = input.localizedTitle - cell.accessoryType = selected ? .checkmark : .none - cell.enabled = input != .retrospection || dataManager.loopManager.retrospectiveCorrectionEnabled - - var subtitleText = input.localizedDescription(forGlucoseUnit: charts.glucoseUnit) - - if input == .retrospection, - let startGlucose = retrospectivePredictedGlucose?.first, - let endGlucose = retrospectivePredictedGlucose?.last, - let currentGlucose = self.dataManager.glucoseStore?.latestGlucose - { - let formatter = NumberFormatter.glucoseFormatter(for: charts.glucoseUnit) - let values = [startGlucose, endGlucose, currentGlucose].map { formatter.string(from: NSNumber(value: $0.quantity.doubleValue(for: charts.glucoseUnit))) ?? "?" } - - let retro = String( - format: NSLocalizedString("Last comparison: %1$@ → %2$@ vs %3$@", comment: "Format string describing retrospective glucose prediction comparison. (1: Previous glucose)(2: Predicted glucose)(3: Actual glucose)"), - values[0], values[1], values[2] - ) - - subtitleText = String(format: "%@\n%@", subtitleText, retro) - } + private func tableView(_ tableView: UITableView, updateTitleFor cell: ChartTableViewCell, at indexPath: IndexPath) { + guard case .charts? = Section(rawValue: indexPath.section) else { + return + } - cell.subtitleLabel?.text = subtitleText + if let eventualGlucose = eventualGlucoseDescription { + cell.setTitleLabelText(label: String(format: NSLocalizedString("Eventually %@", comment: "The subtitle format describing eventual glucose. (1: localized glucose value description)"), eventualGlucose)) + } else { + cell.setTitleLabelText(label: SettingsTableViewCell.NoValueString) + } + } - cell.contentView.layoutMargins.left = tableView.separatorInset.left + private func tableView(_ tableView: UITableView, updateTextFor cell: PredictionInputEffectTableViewCell, at indexPath: IndexPath) { + guard case .inputs? = Section(rawValue: indexPath.section) else { + return + } - return cell - case .settings: - let cell = tableView.dequeueReusableCell(withIdentifier: SwitchTableViewCell.className, for: indexPath) as! SwitchTableViewCell + let input = availableInputs[indexPath.row] - cell.titleLabel?.text = NSLocalizedString("Enable Retrospective Correction", comment: "Title of the switch which toggles retrospective correction effects") - cell.subtitleLabel?.text = NSLocalizedString("This will more aggresively increase or decrease basal delivery when glucose movement doesn't match the carbohydrate and insulin-based model.", comment: "The description of the switch which toggles retrospective correction effects") - cell.`switch`?.isOn = dataManager.loopManager.retrospectiveCorrectionEnabled - cell.`switch`?.addTarget(self, action: #selector(retrospectiveCorrectionSwitchChanged(_:)), for: .valueChanged) + cell.titleLabel?.text = input.localizedTitle + cell.accessoryType = selectedInputs.contains(input) ? .checkmark : .none - cell.contentView.layoutMargins.left = tableView.separatorInset.left + var subtitleText = input.localizedDescription(forGlucoseUnit: glucoseChart.glucoseUnit) ?? "" - return cell - } - } - - private func tableView(_ tableView: UITableView, updateTitleFor cell: ChartTableViewCell, at indexPath: IndexPath) { - switch Section(rawValue: indexPath.section)! { - case .charts: - if let eventualGlucose = eventualGlucoseDescription { - cell.titleLabel?.text = String(format: NSLocalizedString("Eventually %@", comment: "The subtitle format describing eventual glucose. (1: localized glucose value description)"), eventualGlucose) + if input == .retrospection, + let lastDiscrepancy = retrospectiveGlucoseDiscrepancies?.last, + let currentGlucose = deviceManager.glucoseStore.latestGlucose + { + let formatter = QuantityFormatter(for: glucoseChart.glucoseUnit) + let predicted = HKQuantity(unit: glucoseChart.glucoseUnit, doubleValue: currentGlucose.quantity.doubleValue(for: glucoseChart.glucoseUnit) - lastDiscrepancy.quantity.doubleValue(for: glucoseChart.glucoseUnit)) + var values = [predicted, currentGlucose.quantity].map { formatter.string(from: $0) ?? "?" } + formatter.numberFormatter.positivePrefix = formatter.numberFormatter.plusSign + values.append(formatter.string(from: lastDiscrepancy.quantity) ?? "?") + + let retro = String( + format: NSLocalizedString("Predicted: %1$@\nActual: %2$@ (%3$@)", comment: "Format string describing retrospective glucose prediction comparison. (1: Predicted glucose)(2: Actual glucose)(3: difference)"), + values[0], values[1], values[2] + ) + let isIntegralRetrospectiveCorrectionEnabled = UserDefaults.standard.integralRetrospectiveCorrectionEnabled + + if isIntegralRetrospectiveCorrectionEnabled { + var integralEffectDisplay = "?" + var totalEffectDisplay = "?" + if let totalEffect = self.totalRetrospectiveCorrection { + let integralEffectValue = totalEffect.doubleValue(for: glucoseChart.glucoseUnit) - lastDiscrepancy.quantity.doubleValue(for: glucoseChart.glucoseUnit) + let integralEffect = HKQuantity(unit: glucoseChart.glucoseUnit, doubleValue: integralEffectValue) + integralEffectDisplay = formatter.string(from: integralEffect) ?? "?" + totalEffectDisplay = formatter.string(from: totalEffect) ?? "?" + } + let integralRetro = String( + format: NSLocalizedString("prediction-description-integral-retrospective-correction", comment: "Format string describing integral retrospective correction. (1: Integral glucose effect)(2: Total glucose effect)"), + integralEffectDisplay, totalEffectDisplay + ) + subtitleText = String(format: "%@\n%@", retro, integralRetro) } else { - cell.titleLabel?.text = "–" + subtitleText = String(format: "%@\n%@", subtitleText, retro) } - default: - break + } - } - override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - switch Section(rawValue: section)! { - case .settings: - return NSLocalizedString("Algorithm Settings", comment: "The title of the section containing algorithm settings") - default: - return nil - } + cell.subtitleLabel?.text = subtitleText } // MARK: - UITableViewDelegate @@ -322,7 +306,7 @@ class PredictionTableViewController: UITableViewController, IdentifiableClass, U switch Section(rawValue: indexPath.section)! { case .charts: return 275 - case .inputs, .settings: + case .inputs: return 60 } } @@ -330,51 +314,18 @@ class PredictionTableViewController: UITableViewController, IdentifiableClass, U override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { guard Section(rawValue: indexPath.section) == .inputs else { return } - let (input, selected) = selectedInputs[indexPath.row] + let input = availableInputs[indexPath.row] + let isSelected = selectedInputs.contains(input) if let cell = tableView.cellForRow(at: indexPath) { - cell.accessoryType = !selected ? .checkmark : .none + cell.accessoryType = !isSelected ? .checkmark : .none } - selectedInputs[indexPath.row] = (input, !selected) + selectedInputs.formSymmetricDifference(input) tableView.deselectRow(at: indexPath, animated: true) - needsRefresh = true + refreshContext.update(with: .status) reloadData() } - - // MARK: - UIGestureRecognizer - - func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { - return true - } - - @objc func handlePan(_ gestureRecognizer: UIGestureRecognizer) { - switch gestureRecognizer.state { - case .possible, .changed: - // Follow your dreams! - break - case .began, .cancelled, .ended, .failed: - for case let row as ChartTableViewCell in self.tableView.visibleCells { - let forwards = gestureRecognizer.state == .began - UIView.animate(withDuration: forwards ? 0.2 : 0.5, delay: forwards ? 0 : 1, animations: { - let alpha: CGFloat = forwards ? 0 : 1 - row.titleLabel?.alpha = alpha - }) - } - } - } - - // MARK: - Actions - - @objc private func retrospectiveCorrectionSwitchChanged(_ sender: UISwitch) { - dataManager.loopManager.retrospectiveCorrectionEnabled = sender.isOn - - if let row = selectedInputs.index(where: { $0.input == PredictionInputEffect.retrospection }), - let cell = tableView.cellForRow(at: IndexPath(row: row, section: Section.inputs.rawValue)) as? PredictionInputEffectTableViewCell - { - cell.enabled = self.dataManager.loopManager.retrospectiveCorrectionEnabled - } - } } diff --git a/Loop/View Controllers/PumpIDTableViewController.swift b/Loop/View Controllers/PumpIDTableViewController.swift deleted file mode 100644 index 8b5fd13851..0000000000 --- a/Loop/View Controllers/PumpIDTableViewController.swift +++ /dev/null @@ -1,113 +0,0 @@ -// -// PumpIDTableViewController.swift -// Loop -// -// Created by Nate Racklyeft on 9/30/16. -// Copyright © 2016 LoopKit Authors. All rights reserved. -// - -import UIKit -import MinimedKit -import LoopKit - -protocol PumpIDTableViewControllerDelegate: TextFieldTableViewControllerDelegate { - func pumpIDTableViewControllerDidChangePumpRegion(_ controller: PumpIDTableViewController) -} - - -extension PumpRegion { - static let count = 2 -} - - -final class PumpIDTableViewController: TextFieldTableViewController { - - /// The selected pump region - var region: PumpRegion? { - didSet { - if let oldValue = oldValue, oldValue != region { - tableView.cellForRow(at: IndexPath(row: oldValue.rawValue, section: Section.region.rawValue))?.accessoryType = .none - } - - if let region = region, oldValue != region { - tableView.cellForRow(at: IndexPath(row: region.rawValue, section: Section.region.rawValue))?.accessoryType = .checkmark - } - - if let delegate = delegate as? PumpIDTableViewControllerDelegate { - delegate.pumpIDTableViewControllerDidChangePumpRegion(self) - } - } - } - - convenience init(pumpID: String?, region: PumpRegion?) { - self.init(style: .grouped) - - self.region = region - - placeholder = NSLocalizedString("Enter the 6-digit pump ID", comment: "The placeholder text instructing users how to enter a pump ID") - keyboardType = .numberPad - value = pumpID - contextHelp = NSLocalizedString("The pump ID can be found printed on the back, or near the bottom of the STATUS/Esc screen. It is the strictly numerical portion of the serial number (shown as SN or S/N).", comment: "Instructions on where to find the pump ID on a Minimed pump") - } - - override func viewDidLoad() { - super.viewDidLoad() - } - - // MARK: - Table view data source - - private enum Section: Int { - case id - case region - - static let count = 2 - } - - override func numberOfSections(in tableView: UITableView) -> Int { - return Section.count - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - switch Section(rawValue: section)! { - case .id: - return super.tableView(tableView, numberOfRowsInSection: section) - case .region: - return PumpRegion.count - } - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - switch Section(rawValue: indexPath.section)! { - case .id: - return super.tableView(tableView, cellForRowAt: indexPath) - case .region: - let cell = tableView.dequeueReusableCell(withIdentifier: "Cell") ?? UITableViewCell(style: .default, reuseIdentifier: "Cell") - - let region = PumpRegion(rawValue: indexPath.row)! - - cell.textLabel?.text = String(describing: region) - cell.accessoryType = self.region == region ? .checkmark : .none - - return cell - } - } - - override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { - switch Section(rawValue: section)! { - case .id: - return super.tableView(tableView, titleForFooterInSection: section) - case .region: - return NSLocalizedString("The pump regioncan be found printed on the back as part of the model number (REF), for example: MMT-551NAB, or MMT-515LWWS. If the model number contains \"NA\" or \"CA\", then the region is North America. If if contains \"WW\", then the region is World-Wide.", comment: "Instructions on selecting the pump region") - } - } - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - switch Section(rawValue: indexPath.section)! { - case .id: - break - case .region: - region = PumpRegion(rawValue: indexPath.row) - tableView.deselectRow(at: indexPath, animated: true) - } - } -} diff --git a/Loop/View Controllers/RadioSelectionTableViewController.swift b/Loop/View Controllers/RadioSelectionTableViewController.swift deleted file mode 100644 index 8dd424df0d..0000000000 --- a/Loop/View Controllers/RadioSelectionTableViewController.swift +++ /dev/null @@ -1,97 +0,0 @@ -// -// RadioSelectionTableViewController.swift -// Loop -// -// Created by Nate Racklyeft on 8/26/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import UIKit -import MinimedKit - -protocol RadioSelectionTableViewControllerDelegate: class { - func radioSelectionTableViewControllerDidChangeSelectedIndex(_ controller: RadioSelectionTableViewController) -} - - -class RadioSelectionTableViewController: UITableViewController, IdentifiableClass { - - var options = [String]() - - var selectedIndex: Int? { - didSet { - if let oldValue = oldValue, oldValue != selectedIndex { - tableView.cellForRow(at: IndexPath(row: oldValue, section: 0))?.accessoryType = .none - } - - if let selectedIndex = selectedIndex, oldValue != selectedIndex { - tableView.cellForRow(at: IndexPath(row: selectedIndex, section: 0))?.accessoryType = .checkmark - - delegate?.radioSelectionTableViewControllerDidChangeSelectedIndex(self) - } - } - } - - var contextHelp: String? - - weak var delegate: RadioSelectionTableViewControllerDelegate? - - convenience init() { - self.init(style: .grouped) - } - - // MARK: - Table view data source - - override func numberOfSections(in tableView: UITableView) -> Int { - return 1 - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return options.count - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "Cell") ?? UITableViewCell(style: .default, reuseIdentifier: "Cell") - - cell.textLabel?.text = options[indexPath.row] - cell.accessoryType = selectedIndex == indexPath.row ? .checkmark : .none - - return cell - } - - override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { - return contextHelp - } - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - selectedIndex = indexPath.row - - tableView.deselectRow(at: indexPath, animated: true) - } -} - - -extension RadioSelectionTableViewController { - typealias T = RadioSelectionTableViewController - - static func insulinDataSource(_ value: InsulinDataSource) -> T { - let vc = T() - - vc.selectedIndex = value.rawValue - vc.options = (0..<2).flatMap({ InsulinDataSource(rawValue: $0) }).map { String(describing: $0) } - vc.contextHelp = NSLocalizedString("Insulin delivery can be determined from the pump by either interpreting the event history or comparing the reservoir volume over time. Reading event history allows for a more accurate status graph and uploading up-to-date treatment data to Nightscout, at the cost of faster pump battery drain and the possibility of a higher radio error rate compared to reading only reservoir volume. If the selected source cannot be used for any reason, the system will attempt to fall back to the other option.", comment: "Instructions on selecting an insulin data source") - - return vc - } - - static func batteryChemistryType(_ value: BatteryChemistryType) -> T { - let vc = T() - - vc.selectedIndex = value.rawValue - vc.options = (0..<2).flatMap({ BatteryChemistryType(rawValue: $0) }).map { String(describing: $0) } - vc.contextHelp = NSLocalizedString("Alkaline and Lithium batteries decay at differing rates. Alkaline tend to have a linear voltage drop over time whereas lithium cell batteries tend to maintain voltage until halfway through their lifespan. Under normal usage in a Non-MySentry compatible Minimed (x22/x15) insulin pump running Loop, Alkaline batteries last approximately 4 to 5 days. Lithium batteries last between 1-2 weeks. This selection will use different battery voltage decay rates for each of the battery chemistry types and alert the user when a battery is approximately 8 to 10 hours from failure.", comment: "Instructions on selecting battery chemistry type") - - return vc - } - -} diff --git a/Loop/View Controllers/RootNavigationController.swift b/Loop/View Controllers/RootNavigationController.swift new file mode 100644 index 0000000000..87b04d5e93 --- /dev/null +++ b/Loop/View Controllers/RootNavigationController.swift @@ -0,0 +1,48 @@ +// +// RootNavigationController.swift +// Loop +// +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +import UIKit +import LoopKit +import LoopKitUI + +/// The root view controller in Loop +class RootNavigationController: UINavigationController { + + /// Its root view controller is always StatusTableViewController after loading + var statusTableViewController: StatusTableViewController! { + return viewControllers.first as? StatusTableViewController + } + + func navigate(to deeplink: Deeplink) { + switch deeplink { + case .carbEntry: + statusTableViewController.presentCarbEntryScreen(nil) + case .preMeal: + statusTableViewController.togglePreMealMode() + case .bolus: + statusTableViewController.presentBolusScreen() + case .customPresets: + statusTableViewController.presentCustomPresets() + } + } + + override func restoreUserActivityState(_ activity: NSUserActivity) { + switch activity.activityType { + case NSUserActivity.viewLoopStatusActivityType: + if presentedViewController != nil { + dismiss(animated: false, completion: nil) + } + + if viewControllers.count > 1 { + popToRootViewController(animated: false) + } + default: + statusTableViewController.restoreUserActivityState(activity) + } + } + +} diff --git a/Loop/View Controllers/SettingsTableViewController.swift b/Loop/View Controllers/SettingsTableViewController.swift deleted file mode 100644 index 5250817f37..0000000000 --- a/Loop/View Controllers/SettingsTableViewController.swift +++ /dev/null @@ -1,700 +0,0 @@ -// -// SettingsTableViewController.swift -// Naterade -// -// Created by Nathan Racklyeft on 8/29/15. -// Copyright © 2015 Nathan Racklyeft. All rights reserved. -// - -import UIKit -import HealthKit -import LoopKit -import RileyLinkKit -import MinimedKit - -private let ConfigCellIdentifier = "ConfigTableViewCell" - -private let TapToSetString = NSLocalizedString("Tap to set", comment: "The empty-state text for a configuration value") - - -final class SettingsTableViewController: UITableViewController, DailyValueScheduleTableViewControllerDelegate { - - @IBOutlet var devicesSectionTitleView: UIView! - - override func viewDidLoad() { - super.viewDidLoad() - - tableView.register(RileyLinkDeviceTableViewCell.nib(), forCellReuseIdentifier: RileyLinkDeviceTableViewCell.className) - - dataManagerObserver = NotificationCenter.default.addObserver(forName: nil, object: dataManager, queue: nil) { [weak self = self] (note) -> Void in - if let deviceManager = self?.dataManager.rileyLinkManager { - switch note.name { - case Notification.Name.DeviceManagerDidDiscoverDevice: - self?.tableView.insertRows(at: [IndexPath(row: deviceManager.devices.count - 1, section: Section.devices.rawValue)], with: .automatic) - case Notification.Name.DeviceConnectionStateDidChange: - if let device = note.userInfo?[RileyLinkDeviceManager.RileyLinkDeviceKey] as? RileyLinkDevice, let index = deviceManager.devices.index(where: { $0 === device }) { - self?.tableView.reloadRows(at: [IndexPath(row: index, section: Section.devices.rawValue)], with: .none) - } - default: - break - } - } - } - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - dataManager.rileyLinkManager.deviceScanningEnabled = true - - if dataManager.transmitterID != nil || dataManager.receiverEnabled, let glucoseStore = dataManager.glucoseStore, glucoseStore.authorizationRequired { - glucoseStore.authorize({ (success, error) -> Void in - // Do nothing for now - }) - } - - AnalyticsManager.sharedManager.didDisplaySettingsScreen() - } - - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - - dataManager.rileyLinkManager.deviceScanningEnabled = false - } - - deinit { - dataManagerObserver = nil - } - - var dataManager: DeviceDataManager! - - private var dataManagerObserver: Any? { - willSet { - if let observer = dataManagerObserver { - NotificationCenter.default.removeObserver(observer) - } - } - } - - fileprivate enum Section: Int { - case loop = 0 - case devices - case configuration - case services - - static let count = 4 - } - - fileprivate enum LoopRow: Int { - case dosing = 0 - case preferredInsulinDataSource - case diagnostic - - static let count = 3 - } - - fileprivate enum ConfigurationRow: Int { - case pumpID = 0 - case transmitterID - case receiverEnabled - case glucoseTargetRange - case insulinActionDuration - case basalRate - case carbRatio - case insulinSensitivity - case maxBasal - case maxBolus - case batteryChemistry - - static let count = 11 - } - - fileprivate enum ServiceRow: Int { - case share = 0 - case nightscout - case mLab - case amplitude - - static let count = 4 - } - - fileprivate lazy var valueNumberFormatter: NumberFormatter = { - let formatter = NumberFormatter() - - formatter.numberStyle = .decimal - formatter.minimumFractionDigits = 0 - formatter.maximumFractionDigits = 2 - - return formatter - }() - - // MARK: - UITableViewDataSource - - override func numberOfSections(in tableView: UITableView) -> Int { - return Section.count - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - switch Section(rawValue: section)! { - case .loop: - return LoopRow.count - case .configuration: - return ConfigurationRow.count - case .devices: - return dataManager.rileyLinkManager.devices.count - case .services: - return ServiceRow.count - } - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell: UITableViewCell - - switch Section(rawValue: indexPath.section)! { - case .loop: - switch LoopRow(rawValue: indexPath.row)! { - case .dosing: - let switchCell = tableView.dequeueReusableCell(withIdentifier: SwitchTableViewCell.className, for: indexPath) as! SwitchTableViewCell - - switchCell.`switch`?.isOn = dataManager.loopManager.dosingEnabled - switchCell.titleLabel.text = NSLocalizedString("Closed Loop", comment: "The title text for the looping enabled switch cell") - - switchCell.`switch`?.addTarget(self, action: #selector(dosingEnabledChanged(_:)), for: .valueChanged) - - return switchCell - case .preferredInsulinDataSource: - let cell = tableView.dequeueReusableCell(withIdentifier: ConfigCellIdentifier, for: indexPath) - - cell.textLabel?.text = NSLocalizedString("Preferred Data Source", comment: "The title text for the preferred insulin data source config") - cell.detailTextLabel?.text = String(describing: dataManager.preferredInsulinDataSource) - - return cell - case .diagnostic: - let cell = tableView.dequeueReusableCell(withIdentifier: ConfigCellIdentifier, for: indexPath) - - cell.textLabel?.text = NSLocalizedString("Issue Report", comment: "The title text for the issue report cell") - cell.detailTextLabel?.text = nil - - return cell - } - case .configuration: - if case .receiverEnabled = ConfigurationRow(rawValue: indexPath.row)! { - let switchCell = tableView.dequeueReusableCell(withIdentifier: SwitchTableViewCell.className, for: indexPath) as! SwitchTableViewCell - - switchCell.`switch`?.isOn = dataManager.receiverEnabled - switchCell.titleLabel.text = NSLocalizedString("G4 Share Receiver", comment: "The title text for the G4 Share Receiver enabled switch cell") - - switchCell.`switch`?.addTarget(self, action: #selector(receiverEnabledChanged(_:)), for: .valueChanged) - - return switchCell - } - - let configCell = tableView.dequeueReusableCell(withIdentifier: ConfigCellIdentifier, for: indexPath) - - switch ConfigurationRow(rawValue: indexPath.row)! { - case .pumpID: - configCell.textLabel?.text = NSLocalizedString("Pump ID", comment: "The title text for the pump ID config value") - configCell.detailTextLabel?.text = dataManager.pumpID ?? TapToSetString - case .transmitterID: - configCell.textLabel?.text = NSLocalizedString("G5 Transmitter ID", comment: "The title text for the Dexcom G5 transmitter ID config value") - configCell.detailTextLabel?.text = dataManager.transmitterID ?? TapToSetString - case .receiverEnabled: - break - case .basalRate: - configCell.textLabel?.text = NSLocalizedString("Basal Rates", comment: "The title text for the basal rate schedule") - - if let basalRateSchedule = dataManager.basalRateSchedule { - configCell.detailTextLabel?.text = "\(basalRateSchedule.total()) U" - } else { - configCell.detailTextLabel?.text = TapToSetString - } - case .carbRatio: - configCell.textLabel?.text = NSLocalizedString("Carb Ratios", comment: "The title text for the carb ratio schedule") - - if let carbRatioSchedule = dataManager.carbRatioSchedule { - let unit = carbRatioSchedule.unit - let value = valueNumberFormatter.string(from: NSNumber(value: carbRatioSchedule.averageQuantity().doubleValue(for: unit))) ?? "—" - - configCell.detailTextLabel?.text = String(format: NSLocalizedString("%1$@ %2$@/U", comment: "Format string for carb ratio average. (1: value)(2: carb unit)"), value, unit) - } else { - configCell.detailTextLabel?.text = TapToSetString - } - case .insulinSensitivity: - configCell.textLabel?.text = NSLocalizedString("Insulin Sensitivities", comment: "The title text for the insulin sensitivity schedule") - - if let insulinSensitivitySchedule = dataManager.insulinSensitivitySchedule { - let unit = insulinSensitivitySchedule.unit - let value = valueNumberFormatter.string(from: NSNumber(value: insulinSensitivitySchedule.averageQuantity().doubleValue(for: unit))) ?? "—" - - configCell.detailTextLabel?.text = String(format: NSLocalizedString("%1$@ %2$@/U", comment: "Format string for insulin sensitivity average (1: value)(2: glucose unit)"), value, unit.glucoseUnitDisplayString) - } else { - configCell.detailTextLabel?.text = TapToSetString - } - case .glucoseTargetRange: - configCell.textLabel?.text = NSLocalizedString("Target Range", comment: "The title text for the glucose target range schedule") - - if let glucoseTargetRangeSchedule = dataManager.glucoseTargetRangeSchedule { - let unit = glucoseTargetRangeSchedule.unit - let value = glucoseTargetRangeSchedule.value(at: Date()) - let minTarget = valueNumberFormatter.string(from: NSNumber(value: value.minValue)) ?? "—" - let maxTarget = valueNumberFormatter.string(from: NSNumber(value: value.maxValue)) ?? "—" - - configCell.detailTextLabel?.text = String(format: NSLocalizedString("%1$@ – %2$@ %3$@", comment: "Format string for glucose target range. (1: Min target)(2: Max target)(3: glucose unit)"), minTarget, maxTarget, unit.glucoseUnitDisplayString) - } else { - configCell.detailTextLabel?.text = TapToSetString - } - case .insulinActionDuration: - configCell.textLabel?.text = NSLocalizedString("Insulin Action Duration", comment: "The title text for the insulin action duration value") - - if let insulinActionDuration = dataManager.insulinActionDuration { - let formatter = DateComponentsFormatter() - formatter.unitsStyle = .abbreviated - // Seems to have no effect. - // http://stackoverflow.com/questions/32522965/what-am-i-doing-wrong-with-allowsfractionalunits-on-nsdatecomponentsformatter - formatter.allowsFractionalUnits = true - // formatter.allowedUnits = [.hour] - - configCell.detailTextLabel?.text = formatter.string(from: insulinActionDuration) - } else { - configCell.detailTextLabel?.text = TapToSetString - } - case .maxBasal: - configCell.textLabel?.text = NSLocalizedString("Maximum Basal Rate", comment: "The title text for the maximum basal rate value") - - if let maxBasal = dataManager.maximumBasalRatePerHour { - configCell.detailTextLabel?.text = "\(valueNumberFormatter.string(from: NSNumber(value: maxBasal))!) U/hour" - } else { - configCell.detailTextLabel?.text = TapToSetString - } - case .maxBolus: - configCell.textLabel?.text = NSLocalizedString("Maximum Bolus", comment: "The title text for the maximum bolus value") - - if let maxBolus = dataManager.maximumBolus { - configCell.detailTextLabel?.text = "\(valueNumberFormatter.string(from: NSNumber(value: maxBolus))!) U" - } else { - configCell.detailTextLabel?.text = TapToSetString - } - case .batteryChemistry: - configCell.textLabel?.text = NSLocalizedString("Pump Battery Type", comment: "The title text for the battery type value") - configCell.detailTextLabel?.text = String(describing: dataManager.batteryChemistry) -// if let sentrySupported = dataManager.pumpState?.pumpModel?.hasMySentry, sentrySupported { -// configCell.textLabel?.isEnabled = false -// configCell.detailTextLabel?.isEnabled = false -// configCell.isUserInteractionEnabled = false -// } - } - - cell = configCell - case .devices: - let deviceCell = tableView.dequeueReusableCell(withIdentifier: RileyLinkDeviceTableViewCell.className) as! RileyLinkDeviceTableViewCell - let device = dataManager.rileyLinkManager.devices[indexPath.row] - - deviceCell.configureCellWithName(device.name, - signal: device.RSSI, - peripheralState: device.peripheral.state - ) - - deviceCell.connectSwitch.addTarget(self, action: #selector(deviceConnectionChanged(_:)), for: .valueChanged) - - cell = deviceCell - case .services: - let configCell = tableView.dequeueReusableCell(withIdentifier: ConfigCellIdentifier, for: indexPath) - - switch ServiceRow(rawValue: indexPath.row)! { - case .share: - let shareService = dataManager.remoteDataManager.shareService - - configCell.textLabel?.text = shareService.title - configCell.detailTextLabel?.text = shareService.username ?? TapToSetString - case .nightscout: - let nightscoutService = dataManager.remoteDataManager.nightscoutService - - configCell.textLabel?.text = nightscoutService.title - configCell.detailTextLabel?.text = nightscoutService.siteURL?.absoluteString ?? TapToSetString - case .mLab: - let mLabService = dataManager.logger.mLabService - - configCell.textLabel?.text = mLabService.title - configCell.detailTextLabel?.text = mLabService.databaseName ?? TapToSetString - case .amplitude: - let amplitudeService = AnalyticsManager.sharedManager.amplitudeService - - configCell.textLabel?.text = amplitudeService.title - configCell.detailTextLabel?.text = amplitudeService.isAuthorized ? NSLocalizedString("Enabled", comment: "The detail text describing an enabled setting") : TapToSetString - } - - return configCell - } - return cell - } - - override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - switch Section(rawValue: section)! { - case .loop: - let bundle = Bundle.main - return bundle.localizedNameAndVersion - case .configuration: - return NSLocalizedString("Configuration", comment: "The title of the configuration section in settings") - case .devices: - return nil - case .services: - return NSLocalizedString("Services", comment: "The title of the services section in settings") - } - } - - // MARK: - UITableViewDelegate - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - let sender = tableView.cellForRow(at: indexPath) - - switch Section(rawValue: indexPath.section)! { - case .configuration: - let row = ConfigurationRow(rawValue: indexPath.row)! - switch row { - case .pumpID, .transmitterID, .insulinActionDuration, .maxBasal, .maxBolus: - let vc: TextFieldTableViewController - - switch row { - case .pumpID: - vc = PumpIDTableViewController(pumpID: dataManager.pumpID, region: dataManager.pumpState?.pumpRegion) - case .transmitterID: - vc = .transmitterID(dataManager.transmitterID) - case .insulinActionDuration: - vc = .insulinActionDuration(dataManager.insulinActionDuration) - case .maxBasal: - vc = .maxBasal(dataManager.maximumBasalRatePerHour) - case .maxBolus: - vc = .maxBolus(dataManager.maximumBolus) - default: - fatalError() - } - - vc.title = sender?.textLabel?.text - vc.indexPath = indexPath - vc.delegate = self - - show(vc, sender: indexPath) - case .basalRate: - let scheduleVC = SingleValueScheduleTableViewController() - - if let profile = dataManager.basalRateSchedule { - scheduleVC.timeZone = profile.timeZone - scheduleVC.scheduleItems = profile.items - } - scheduleVC.delegate = self - scheduleVC.title = NSLocalizedString("Basal Rates", comment: "The title of the basal rate profile screen") - - show(scheduleVC, sender: sender) - case .carbRatio: - let scheduleVC = DailyQuantityScheduleTableViewController() - - scheduleVC.delegate = self - scheduleVC.title = NSLocalizedString("Carb Ratios", comment: "The title of the carb ratios schedule screen") - - if let schedule = dataManager.carbRatioSchedule { - scheduleVC.timeZone = schedule.timeZone - scheduleVC.scheduleItems = schedule.items - scheduleVC.unit = schedule.unit - - show(scheduleVC, sender: sender) - } else if let carbStore = dataManager.carbStore { - carbStore.preferredUnit({ (unit, error) -> Void in - DispatchQueue.main.async { - if let error = error { - self.presentAlertController(with: error) - } else if let unit = unit { - scheduleVC.unit = unit - self.show(scheduleVC, sender: sender) - } - } - }) - } else { - show(scheduleVC, sender: sender) - } - case .insulinSensitivity: - let scheduleVC = DailyQuantityScheduleTableViewController() - - scheduleVC.delegate = self - scheduleVC.title = NSLocalizedString("Insulin Sensitivities", comment: "The title of the insulin sensitivities schedule screen") - - if let schedule = dataManager.insulinSensitivitySchedule { - scheduleVC.timeZone = schedule.timeZone - scheduleVC.scheduleItems = schedule.items - scheduleVC.unit = schedule.unit - - show(scheduleVC, sender: sender) - } else if let glucoseStore = dataManager.glucoseStore { - glucoseStore.preferredUnit({ (unit, error) -> Void in - DispatchQueue.main.async { - if let error = error { - self.presentAlertController(with: error) - } else if let unit = unit { - scheduleVC.unit = unit - self.show(scheduleVC, sender: sender) - } - } - }) - } else { - show(scheduleVC, sender: sender) - } - case .glucoseTargetRange: - let scheduleVC = GlucoseRangeScheduleTableViewController() - - scheduleVC.delegate = self - scheduleVC.title = NSLocalizedString("Target Range", comment: "The title of the glucose target range schedule screen") - - if let schedule = dataManager.glucoseTargetRangeSchedule { - scheduleVC.timeZone = schedule.timeZone - scheduleVC.scheduleItems = schedule.items - scheduleVC.unit = schedule.unit - scheduleVC.workoutRange = schedule.workoutRange - - show(scheduleVC, sender: sender) - } else if let glucoseStore = dataManager.glucoseStore { - glucoseStore.preferredUnit({ (unit, error) -> Void in - DispatchQueue.main.async { - if let error = error { - self.presentAlertController(with: error) - } else if let unit = unit { - scheduleVC.unit = unit - self.show(scheduleVC, sender: sender) - } - } - }) - } else { - show(scheduleVC, sender: sender) - } - case .receiverEnabled: - break - case .batteryChemistry: - let vc = RadioSelectionTableViewController.batteryChemistryType(dataManager.batteryChemistry) - vc.title = sender?.textLabel?.text - vc.delegate = self - - show(vc, sender: sender) - } - case .devices: - let vc = RileyLinkDeviceTableViewController() - vc.device = dataManager.rileyLinkManager.devices[indexPath.row] - - show(vc, sender: sender) - case .loop: - switch LoopRow(rawValue: indexPath.row)! { - case .preferredInsulinDataSource: - let vc = RadioSelectionTableViewController.insulinDataSource(dataManager.preferredInsulinDataSource) - vc.title = sender?.textLabel?.text - vc.delegate = self - - show(vc, sender: sender) - case .diagnostic: - let vc = CommandResponseViewController.generateDiagnosticReport(dataManager: dataManager) - vc.title = sender?.textLabel?.text - - show(vc, sender: sender) - case .dosing: - break - } - case .services: - switch ServiceRow(rawValue: indexPath.row)! { - case .share: - let service = dataManager.remoteDataManager.shareService - let vc = AuthenticationViewController(authentication: service) - vc.authenticationObserver = { [unowned self] (service) in - self.dataManager.remoteDataManager.shareService = service - - self.tableView.reloadRows(at: [indexPath], with: .none) - } - - show(vc, sender: sender) - case .nightscout: - let service = dataManager.remoteDataManager.nightscoutService - let vc = AuthenticationViewController(authentication: service) - vc.authenticationObserver = { [unowned self] (service) in - self.dataManager.remoteDataManager.nightscoutService = service - - self.tableView.reloadRows(at: [indexPath], with: .none) - } - - show(vc, sender: sender) - case .mLab: - let service = dataManager.logger.mLabService - let vc = AuthenticationViewController(authentication: service) - vc.authenticationObserver = { [unowned self] (service) in - self.dataManager.logger.mLabService = service - - self.tableView.reloadRows(at: [indexPath], with: .none) - } - - show(vc, sender: sender) - case .amplitude: - let service = AnalyticsManager.sharedManager.amplitudeService - let vc = AuthenticationViewController(authentication: service) - vc.authenticationObserver = { [unowned self] (service) in - AnalyticsManager.sharedManager.amplitudeService = service - - self.tableView.reloadRows(at: [indexPath], with: .none) - } - - show(vc, sender: sender) - } - } - } - - override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - switch Section(rawValue: section)! { - case .devices: - return devicesSectionTitleView - case .loop, .configuration, .services: - return nil - } - } - - // MARK: - Device mangement - - func dosingEnabledChanged(_ sender: UISwitch) { - dataManager.loopManager.dosingEnabled = sender.isOn - } - - func deviceConnectionChanged(_ connectSwitch: UISwitch) { - let switchOrigin = connectSwitch.convert(CGPoint.zero, to: tableView) - - if let indexPath = tableView.indexPathForRow(at: switchOrigin), indexPath.section == Section.devices.rawValue - { - let device = dataManager.rileyLinkManager.devices[indexPath.row] - - if connectSwitch.isOn { - dataManager.connectToRileyLink(device) - } else { - dataManager.disconnectFromRileyLink(device) - } - } - } - - func receiverEnabledChanged(_ sender: UISwitch) { - dataManager.receiverEnabled = sender.isOn - } - - // MARK: - DailyValueScheduleTableViewControllerDelegate - - func dailyValueScheduleTableViewControllerWillFinishUpdating(_ controller: DailyValueScheduleTableViewController) { - if let indexPath = tableView.indexPathForSelectedRow { - switch Section(rawValue: indexPath.section)! { - case .configuration: - switch ConfigurationRow(rawValue: indexPath.row)! { - case .basalRate: - if let controller = controller as? SingleValueScheduleTableViewController { - dataManager.basalRateSchedule = BasalRateSchedule(dailyItems: controller.scheduleItems, timeZone: controller.timeZone) - } - case .glucoseTargetRange: - if let controller = controller as? GlucoseRangeScheduleTableViewController { - dataManager.glucoseTargetRangeSchedule = GlucoseRangeSchedule(unit: controller.unit, dailyItems: controller.scheduleItems, workoutRange: controller.workoutRange, timeZone: controller.timeZone) - } - case let row: - if let controller = controller as? DailyQuantityScheduleTableViewController { - switch row { - case .carbRatio: - dataManager.carbRatioSchedule = CarbRatioSchedule(unit: controller.unit, dailyItems: controller.scheduleItems, timeZone: controller.timeZone) - case .insulinSensitivity: - dataManager.insulinSensitivitySchedule = InsulinSensitivitySchedule(unit: controller.unit, dailyItems: controller.scheduleItems, timeZone: controller.timeZone) - default: - break - } - } - } - - tableView.reloadRows(at: [indexPath], with: .none) - default: - break - } - } - } -} - - -extension SettingsTableViewController: RadioSelectionTableViewControllerDelegate { - func radioSelectionTableViewControllerDidChangeSelectedIndex(_ controller: RadioSelectionTableViewController) { - if let indexPath = self.tableView.indexPathForSelectedRow { - switch Section(rawValue: indexPath.section)! { - case .loop: - switch LoopRow(rawValue: indexPath.row)! { - case .preferredInsulinDataSource: - if let selectedIndex = controller.selectedIndex, let dataSource = InsulinDataSource(rawValue: selectedIndex) { - dataManager.preferredInsulinDataSource = dataSource - - tableView.reloadRows(at: [IndexPath(row: LoopRow.preferredInsulinDataSource.rawValue, section: Section.loop.rawValue)], with: .none) - } - default: - assertionFailure() - } - - case .configuration: - switch ConfigurationRow(rawValue: indexPath.row)! { - case .batteryChemistry: - if let selectedIndex = controller.selectedIndex, let dataSource = BatteryChemistryType(rawValue: selectedIndex) { - dataManager.batteryChemistry = dataSource - - tableView.reloadRows(at: [IndexPath(row: ConfigurationRow.batteryChemistry.rawValue, section: Section.configuration.rawValue)], with: .none) - } - default: - assertionFailure() - } - default: - assertionFailure() - } - } - } -} - -extension SettingsTableViewController: TextFieldTableViewControllerDelegate { - func textFieldTableViewControllerDidEndEditing(_ controller: TextFieldTableViewController) { - if let indexPath = controller.indexPath { - switch ConfigurationRow(rawValue: indexPath.row)! { - case .pumpID: - dataManager.pumpID = controller.value - - if let controller = controller as? PumpIDTableViewController, - let region = controller.region - { - dataManager.pumpState?.pumpRegion = region - } - case .transmitterID: - dataManager.transmitterID = controller.value - case .insulinActionDuration: - if let value = controller.value, let duration = valueNumberFormatter.number(from: value)?.doubleValue { - dataManager.insulinActionDuration = TimeInterval(hours: duration) - } else { - dataManager.insulinActionDuration = nil - } - case .maxBasal: - if let value = controller.value, let rate = valueNumberFormatter.number(from: value)?.doubleValue { - dataManager.maximumBasalRatePerHour = rate - } else { - dataManager.maximumBasalRatePerHour = nil - } - case .maxBolus: - if let value = controller.value, let units = valueNumberFormatter.number(from: value)?.doubleValue { - dataManager.maximumBolus = units - } else { - dataManager.maximumBolus = nil - } - default: - assertionFailure() - } - } - - tableView.reloadData() - } - - func textFieldTableViewControllerDidReturn(_ controller: TextFieldTableViewController) { - _ = navigationController?.popViewController(animated: true) - } -} - - -extension SettingsTableViewController: PumpIDTableViewControllerDelegate { - func pumpIDTableViewControllerDidChangePumpRegion(_ controller: PumpIDTableViewController) { - if let region = controller.region { - dataManager.pumpState?.pumpRegion = region - } - } -} diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 94c3cb4ff8..94df013244 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -7,331 +7,672 @@ // import UIKit -import CarbKit -import GlucoseKit import HealthKit -import InsulinKit +import SwiftUI +import Intents +import LoopCore import LoopKit +import LoopKitUI +import LoopTestingKit import LoopUI import SwiftCharts +import os.log +import Combine +import WidgetKit -final class StatusTableViewController: UITableViewController, UIGestureRecognizerDelegate { +private extension RefreshContext { + static let all: Set = [.status, .glucose, .insulin, .carbs, .targets] +} + +final class StatusTableViewController: LoopChartsTableViewController { + + private let log = OSLog(category: "StatusTableViewController") + + lazy var carbFormatter: QuantityFormatter = QuantityFormatter(for: .gram()) + + var onboardingManager: OnboardingManager! + + var testingScenariosManager: TestingScenariosManager! + + var automaticDosingStatus: AutomaticDosingStatus! + + var alertPermissionsChecker: AlertPermissionsChecker! + + var alertMuter: AlertMuter! + + var supportManager: SupportManager! + + lazy private var cancellables = Set() override func viewDidLoad() { + super.viewDidLoad() + + setupToolbarItems() + + tableView.register(BolusProgressTableViewCell.nib(), forCellReuseIdentifier: BolusProgressTableViewCell.className) + tableView.register(AlertPermissionsDisabledWarningCell.self, forCellReuseIdentifier: AlertPermissionsDisabledWarningCell.className) + tableView.register(MuteAlertsWarningCell.self, forCellReuseIdentifier: MuteAlertsWarningCell.className) + + if FeatureFlags.predictedGlucoseChartClampEnabled { + statusCharts.glucose.glucoseDisplayRange = LoopConstants.glucoseChartDefaultDisplayBoundClamped + } else { + statusCharts.glucose.glucoseDisplayRange = LoopConstants.glucoseChartDefaultDisplayBound + } + + registerPumpManager() + registerCGMManager() let notificationCenter = NotificationCenter.default - let mainQueue = OperationQueue.main - let application = UIApplication.shared notificationObservers += [ - notificationCenter.addObserver(forName: .LoopDataUpdated, object: dataManager.loopManager, queue: nil) { _ in + notificationCenter.addObserver(forName: .LoopDataUpdated, object: deviceManager.loopManager, queue: nil) { [weak self] note in + let rawContext = note.userInfo?[LoopDataManager.LoopUpdateContextKey] as! LoopDataManager.LoopUpdateContext.RawValue + let context = LoopDataManager.LoopUpdateContext(rawValue: rawContext) + DispatchQueue.main.async { + switch context { + case .none, .insulin?: + self?.refreshContext.formUnion([.status, .insulin]) + case .preferences?: + self?.refreshContext.formUnion([.status, .targets]) + case .carbs?: + self?.refreshContext.update(with: .carbs) + case .glucose?: + self?.refreshContext.formUnion([.glucose, .carbs]) + case .loopFinished?: + self?.refreshContext.update(with: .insulin) + } + + self?.hudView?.loopCompletionHUD.loopInProgress = false + self?.log.debug("[reloadData] from notification with context %{public}@", String(describing: context)) + self?.reloadData(animated: true) + } + + WidgetCenter.shared.reloadAllTimelines() + }, + notificationCenter.addObserver(forName: .LoopRunning, object: deviceManager.loopManager, queue: nil) { [weak self] _ in DispatchQueue.main.async { - self.needsRefresh = true - self.loopCompletionHUD.loopInProgress = false - self.reloadData(animated: true) + self?.hudView?.loopCompletionHUD.loopInProgress = true } }, - notificationCenter.addObserver(forName: .LoopRunning, object: dataManager.loopManager, queue: nil) { _ in + notificationCenter.addObserver(forName: .PumpManagerChanged, object: deviceManager, queue: nil) { [weak self] (notification: Notification) in DispatchQueue.main.async { - self.loopCompletionHUD.loopInProgress = true + self?.registerPumpManager() + self?.configurePumpManagerHUDViews() + self?.updateToolbarItems() } }, - notificationCenter.addObserver(forName: .LoopSettingsUpdated, object: dataManager, queue: nil) { _ in + notificationCenter.addObserver(forName: .CGMManagerChanged, object: deviceManager, queue: nil) { [weak self] (notification: Notification) in DispatchQueue.main.async { - self.needsRefresh = true - self.reloadData(animated: true) + self?.registerCGMManager() + self?.configureCGMManagerHUDViews() + self?.updateToolbarItems() } }, - notificationCenter.addObserver(forName: .UIApplicationWillResignActive, object: application, queue: mainQueue) { _ in - self.active = false + notificationCenter.addObserver(forName: .PumpEventsAdded, object: deviceManager, queue: nil) { [weak self] (notification: Notification) in + DispatchQueue.main.async { + self?.refreshContext.update(with: .insulin) + self?.reloadData(animated: true) + } }, - notificationCenter.addObserver(forName: .UIApplicationDidBecomeActive, object: application, queue: mainQueue) { _ in - self.active = true - } ] - let chartPanGestureRecognizer = UIPanGestureRecognizer() - chartPanGestureRecognizer.delegate = self - chartPanGestureRecognizer.addTarget(self, action: #selector(handlePan(_:))) - tableView.addGestureRecognizer(chartPanGestureRecognizer) - charts.panGestureRecognizer = chartPanGestureRecognizer + automaticDosingStatus.$automaticDosingEnabled + .receive(on: DispatchQueue.main) + .sink { self.automaticDosingStatusChanged($0) } + .store(in: &cancellables) + + alertMuter.$configuration + .removeDuplicates() + .receive(on: RunLoop.main) + .dropFirst() + .sink { _ in + self.refreshContext.update(with: .status) + self.reloadData(animated: true) + } + .store(in: &cancellables) - // Toolbar - toolbarItems![0].accessibilityLabel = NSLocalizedString("Add Meal", comment: "The label of the carb entry button") - toolbarItems![0].tintColor = UIColor.COBTintColor - toolbarItems![2].accessibilityLabel = NSLocalizedString("Bolus", comment: "The label of the bolus entry button") - toolbarItems![2].tintColor = UIColor.doseTintColor - toolbarItems![6].accessibilityLabel = NSLocalizedString("Settings", comment: "The label of the settings button") - toolbarItems![6].tintColor = UIColor.secondaryLabelColor + if let gestureRecognizer = charts.gestureRecognizer { + tableView.addGestureRecognizer(gestureRecognizer) + } + + tableView.estimatedRowHeight = 74 + + // Estimate an initial value + landscapeMode = UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height + + addScenarioStepGestureRecognizers() + + tableView.backgroundColor = .secondarySystemBackground + } - deinit { - for observer in notificationObservers { - NotificationCenter.default.removeObserver(observer) + override func didReceiveMemoryWarning() { + super.didReceiveMemoryWarning() + + if !visible { + refreshContext.formUnion(RefreshContext.all) } } + private var appearedOnce = false + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) navigationController?.setNavigationBarHidden(true, animated: animated) - visible = true + navigationController?.setToolbarHidden(false, animated: animated) + + updateToolbarItems() + + alertPermissionsChecker.checkNow() + + updateBolusProgress() + + onboardingManager.$isComplete + .merge(with: onboardingManager.$isSuspended) + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.refreshContext.update(with: .status) + self?.reloadData(animated: true) + self?.updateToolbarItems() + } + .store(in: &cancellables) } override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) - AnalyticsManager.sharedManager.didDisplayStatusScreen() + if !appearedOnce { + appearedOnce = true + DispatchQueue.main.async { + self.log.debug("[reloadData] after HealthKit authorization") + self.reloadData() + } + } + + onscreen = true + + deviceManager.analyticsServicesManager.didDisplayStatusScreen() + + deviceManager.checkDeliveryUncertaintyState() } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) + onscreen = false + if presentedViewController == nil { navigationController?.setNavigationBarHidden(false, animated: animated) } - visible = false } override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { - super.viewWillTransition(to: size, with: coordinator) + refreshContext.update(with: .size(size)) - needsRefresh = true - if visible { - reloadData(animated: false, to: size) - } + maybeOpenDebugMenu() + + super.viewWillTransition(to: size, with: coordinator) } // MARK: - State - // References to registered notification center observers - private var notificationObservers: [Any] = [] + // This reflects whether the application is active + override var active: Bool { + didSet { + hudView?.loopCompletionHUD.assertTimer(active) + updateHUDActive() + } + } - weak var dataManager: DeviceDataManager! + // This is similar to the visible property, but is set later, on viewDidAppear, to be + // suitable for animations that should be seen in their entirety. + var onscreen: Bool = false { + didSet { + updateHUDActive() + } + } - private var active = true { + private var bolusState: PumpManagerStatus.BolusState = .noBolus { didSet { - reloadData() - loopCompletionHUD.assertTimer(active) + if oldValue != bolusState { + switch bolusState { + case .inProgress(let dose): + guard case .inProgress = oldValue else { + // Bolus starting + bolusProgressReporter = deviceManager.pumpManager?.createBolusProgressReporter(reportingOn: DispatchQueue.main) + // If there is an existing bolus progressCell, update its dose values now in case the app is currently in the + // background as otherwise these values won't get initialized and can contain stale data from some earlier bolus. + if let progressCell = tableView.cellForRow(at: IndexPath(row: StatusRow.status.rawValue, section: Section.status.rawValue)) as? BolusProgressTableViewCell { + progressCell.totalUnits = dose.programmedUnits + progressCell.deliveredUnits = 0 + } + break + } + default: + break + } + refreshContext.update(with: .status) + reloadData(animated: true) + } + } + } + + private var bolusProgressReporter: DoseProgressReporter? + + private func updateBolusProgress() { + if let cell = tableView.cellForRow(at: IndexPath(row: StatusRow.status.rawValue, section: Section.status.rawValue)) as? BolusProgressTableViewCell { + cell.deliveredUnits = bolusProgressReporter?.progress.deliveredUnits } } - private var needsRefresh = true + private func updateHUDActive() { + deviceManager.pumpManagerHUDProvider?.visible = active && onscreen + } + + private func setupToolbarItems() { + let space = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: self, action: nil) + let carbs = UIBarButtonItem(image: UIImage(named: "carbs"), style: .plain, target: self, action: #selector(userTappedAddCarbs)) + let bolus = UIBarButtonItem(image: UIImage(named: "bolus"), style: .plain, target: self, action: #selector(presentBolusScreen)) + let settings = UIBarButtonItem(image: UIImage(named: "settings"), style: .plain, target: self, action: #selector(onSettingsTapped)) + + let preMeal = createPreMealButtonItem(selected: false, isEnabled: true) + let workout = createWorkoutButtonItem(selected: false, isEnabled: true) + toolbarItems = [ + carbs, + space, + preMeal, + space, + bolus, + space, + workout, + space, + settings + ] + } + + private func updateToolbarItems() { + let isPumpOnboarded = onboardingManager.isComplete || deviceManager.pumpManager?.isOnboarded == true + + toolbarItems![0].accessibilityLabel = NSLocalizedString("Add Meal", comment: "The label of the carb entry button") + toolbarItems![0].isEnabled = isPumpOnboarded + toolbarItems![0].tintColor = UIColor.carbTintColor + toolbarItems![4].accessibilityLabel = NSLocalizedString("Bolus", comment: "The label of the bolus entry button") + toolbarItems![4].isEnabled = isPumpOnboarded + toolbarItems![4].tintColor = UIColor.insulinTintColor + toolbarItems![8].accessibilityLabel = NSLocalizedString("Settings", comment: "The label of the settings button") + toolbarItems![8].tintColor = UIColor.secondaryLabel + + toolbarItems![2] = createPreMealButtonItem(selected: preMealMode == true && preMealModeAllowed, isEnabled: preMealModeAllowed) + toolbarItems![6] = createWorkoutButtonItem(selected: workoutMode == true && workoutModeAllowed, isEnabled: workoutModeAllowed) + } - private var visible = false { + public var basalDeliveryState: PumpManagerStatus.BasalDeliveryState? = nil { didSet { - reloadData() + if oldValue != basalDeliveryState { + log.debug("New basalDeliveryState: %@", String(describing: basalDeliveryState)) + refreshContext.update(with: .status) + reloadData(animated: true) + } } } + // Toggles the display mode based on the screen aspect ratio. Should not be updated outside of reloadData(). + private var landscapeMode = false + + private var lastLoopError: Error? + private var reloading = false - /// Refetches all data and updates the views. Must be called on the main queue. - /// - /// - parameter animated: Whether the updating should be animated if possible - private func reloadData(animated: Bool = false, to size: CGSize? = nil) { - if active && visible && needsRefresh { - needsRefresh = false - reloading = true - - // How far back should we show data? Use the screen size as a guid. - let minimumSegmentWidth: CGFloat = 50 - let availableWidth = (size ?? self.tableView.bounds.size).width - self.charts.fixedHorizontalMargin - let totalHours = floor(Double(availableWidth / minimumSegmentWidth)) - let historyHours = totalHours - (dataManager.insulinActionDuration ?? TimeInterval(hours: 4)).hours - - var components = DateComponents() - components.minute = 0 - let date = Date(timeIntervalSinceNow: -TimeInterval(hours: max(1, historyHours))) - charts.startDate = Calendar.current.nextDate(after: date, matching: components, matchingPolicy: .strict, direction: .backward) ?? date - - let reloadGroup = DispatchGroup() - var newRecommendedTempBasal: LoopDataManager.TempBasalRecommendation? - - if let glucoseStore = dataManager.glucoseStore { - reloadGroup.enter() - glucoseStore.preferredUnit { (unit, error) in - if let unit = unit { - self.charts.glucoseUnit = unit - } + private var refreshContext = RefreshContext.all - reloadGroup.enter() - glucoseStore.getRecentGlucoseValues(startDate: self.charts.startDate) { (values, error) -> Void in - if let error = error { - self.dataManager.logger.addError(error, fromSource: "GlucoseStore") - self.needsRefresh = true - } else { - self.charts.glucoseValues = values - } + private var shouldShowHUD: Bool { + return !landscapeMode + } - reloadGroup.leave() - } + private var shouldShowStatus: Bool { + return !landscapeMode && statusRowMode.hasRow + } - reloadGroup.enter() - self.dataManager.loopManager.getLoopStatus { (predictedGlucose, _, recommendedTempBasal, lastTempBasal, lastLoopCompleted, _, _, error) -> Void in - if error != nil { - self.needsRefresh = true - } + override func glucoseUnitDidChange() { + log.debug("[reloadData] for HealthKit unit preference change") + refreshContext = RefreshContext.all + } + + private func registerCGMManager() { + deviceManager.cgmManager?.removeStatusObserver(self) + deviceManager.cgmManager?.addStatusObserver(self, queue: .main) + } - self.charts.predictedGlucoseValues = predictedGlucose ?? [] - newRecommendedTempBasal = recommendedTempBasal - self.lastTempBasal = lastTempBasal - self.lastLoopCompleted = lastLoopCompleted + private func registerPumpManager() { + basalDeliveryState = deviceManager.pumpManager?.status.basalDeliveryState + bolusState = deviceManager.pumpManager?.status.bolusState ?? .noBolus + deviceManager.pumpManager?.removeStatusObserver(self) + deviceManager.pumpManager?.addStatusObserver(self, queue: .main) + } + + private lazy var statusCharts = StatusChartsManager(colors: .primary, settings: .default, traitCollection: traitCollection) - if let lastPoint = self.charts.predictedGlucosePoints.last?.y { - self.eventualGlucoseDescription = String(describing: lastPoint) - } + override func createChartsManager() -> ChartsManager { + return statusCharts + } - reloadGroup.leave() - } + private func updateChartDateRange() { + // How far back should we show data? Use the screen size as a guide. + let availableWidth = (refreshContext.newSize ?? tableView.bounds.size).width - charts.fixedHorizontalMargin - reloadGroup.leave() + let totalHours = floor(Double(availableWidth / LoopConstants.minimumChartWidthPerHour)) + let futureHours = ceil(deviceManager.doseStore.longestEffectDuration.hours) + let historyHours = max(LoopConstants.statusChartMinimumHistoryDisplay.hours, totalHours - futureHours) + + let date = Date(timeIntervalSinceNow: -TimeInterval(hours: historyHours)) + let chartStartDate = Calendar.current.nextDate(after: date, matching: DateComponents(minute: 0), matchingPolicy: .strict, direction: .backward) ?? date + if charts.startDate != chartStartDate { + refreshContext.formUnion(RefreshContext.all) + } + charts.startDate = chartStartDate + charts.maxEndDate = chartStartDate.addingTimeInterval(.hours(totalHours)) + charts.updateEndDate(charts.maxEndDate) + } + + override func reloadData(animated: Bool = false) { + dispatchPrecondition(condition: .onQueue(.main)) + + guard view.window != nil else { + return + } + + // This should be kept up to date immediately + hudView?.loopCompletionHUD.lastLoopCompleted = deviceManager.loopManager.lastLoopCompleted + + guard !reloading && !deviceManager.authorizationRequired else { + return + } + + updateChartDateRange() + + if case .bolusing = statusRowMode, bolusProgressReporter?.progress.isComplete == true { + refreshContext.update(with: .status) + } + + if visible && active { + bolusProgressReporter?.addObserver(self) + } else { + bolusProgressReporter?.removeObserver(self) + } + + guard active && visible && !refreshContext.isEmpty else { + updateBannerRow(animated: animated) + redrawCharts() + return + } + + log.debug("Reloading data with context: %@", String(describing: refreshContext)) + + let currentContext = refreshContext + var retryContext: Set = [] + refreshContext = [] + reloading = true + + let reloadGroup = DispatchGroup() + var glucoseSamples: [StoredGlucoseSample]? + var predictedGlucoseValues: [GlucoseValue]? + var iobValues: [InsulinValue]? + var doseEntries: [DoseEntry]? + var totalDelivery: Double? + var cobValues: [CarbValue]? + var carbsOnBoard: HKQuantity? + let startDate = charts.startDate + let basalDeliveryState = self.basalDeliveryState + let automaticDosingEnabled = automaticDosingStatus.automaticDosingEnabled + + // TODO: Don't always assume currentContext.contains(.status) + reloadGroup.enter() + deviceManager.loopManager.getLoopState { (manager, state) -> Void in + predictedGlucoseValues = state.predictedGlucoseIncludingPendingInsulin ?? [] + + // Retry this refresh again if predicted glucose isn't available + if state.predictedGlucose == nil { + retryContext.update(with: .status) + } + + /// Update the status HUDs immediately + let lastLoopError = state.error + + // Net basal rate HUD + let netBasal: NetBasal? + if let basalSchedule = manager.basalRateScheduleApplyingOverrideHistory { + netBasal = basalDeliveryState?.getNetBasal(basalSchedule: basalSchedule, settings: manager.settings) + } else { + netBasal = nil + } + self.log.debug("Update net basal to %{public}@", String(describing: netBasal)) + + DispatchQueue.main.async { + self.lastLoopError = lastLoopError + + if let netBasal = netBasal { + self.hudView?.pumpStatusHUD.basalRateHUD.setNetBasalRate(netBasal.rate, percent: netBasal.percent, at: netBasal.start) } } - reloadGroup.enter() - dataManager.doseStore.getInsulinOnBoardValues(startDate: charts.startDate) { (values, error) -> Void in - if let error = error { - self.dataManager.logger.addError(error, fromSource: "DoseStore") - self.needsRefresh = true + if currentContext.contains(.carbs) { + reloadGroup.enter() + self.deviceManager.carbStore.getCarbsOnBoardValues(start: startDate, end: nil, effectVelocities: state.insulinCounteractionEffects) { (result) in + switch result { + case .failure(let error): + self.log.error("CarbStore failed to get carbs on board values: %{public}@", String(describing: error)) + retryContext.update(with: .carbs) + cobValues = [] + case .success(let values): + cobValues = values + } + reloadGroup.leave() } + } + // always check for cob + carbsOnBoard = state.carbsOnBoard?.quantity - self.charts.iobValues = values + reloadGroup.leave() + } - if let index = values.closestIndexPriorToDate(Date()) { - self.currentIOBDescription = String(describing: self.charts.iobPoints[index].y) + if currentContext.contains(.glucose) { + reloadGroup.enter() + deviceManager.glucoseStore.getGlucoseSamples(start: startDate, end: nil) { (result) -> Void in + switch result { + case .failure(let error): + self.log.error("Failure getting glucose samples: %{public}@", String(describing: error)) + glucoseSamples = nil + case .success(let samples): + glucoseSamples = samples } - reloadGroup.leave() } + } + if currentContext.contains(.insulin) { reloadGroup.enter() - dataManager.doseStore.getRecentNormalizedDoseEntries(startDate: charts.startDate) { (doses, error) -> Void in - if let error = error { - self.dataManager.logger.addError(error, fromSource: "DoseStore") - self.needsRefresh = true + deviceManager.doseStore.getInsulinOnBoardValues(start: startDate, end: nil, basalDosingEnd: nil) { (result) -> Void in + switch result { + case .failure(let error): + self.log.error("DoseStore failed to get insulin on board values: %{public}@", String(describing: error)) + retryContext.update(with: .insulin) + iobValues = [] + case .success(let values): + iobValues = values } + reloadGroup.leave() + } - self.charts.doseEntries = doses - + reloadGroup.enter() + deviceManager.doseStore.getNormalizedDoseEntries(start: startDate, end: nil) { (result) -> Void in + switch result { + case .failure(let error): + self.log.error("DoseStore failed to get normalized dose entries: %{public}@", String(describing: error)) + retryContext.update(with: .insulin) + doseEntries = [] + case .success(let doses): + doseEntries = doses + } reloadGroup.leave() } reloadGroup.enter() - dataManager.doseStore.getTotalRecentUnitsDelivered { (units, _, error) in - if error != nil { - self.needsRefresh = true - } else { - self.totalDelivery = units + deviceManager.doseStore.getTotalUnitsDelivered(since: Calendar.current.startOfDay(for: Date())) { (result) in + switch result { + case .failure: + retryContext.update(with: .insulin) + totalDelivery = nil + case .success(let total): + totalDelivery = total.value } reloadGroup.leave() } + } - if let carbStore = dataManager.carbStore { - reloadGroup.enter() - carbStore.getCarbsOnBoardValues(startDate: charts.startDate) { (values, error) -> Void in - if let error = error { - self.dataManager.logger.addError(error, fromSource: "CarbStore") - self.needsRefresh = true - } + updatePresetModeAvailability(automaticDosingEnabled: automaticDosingEnabled) - self.charts.cobValues = values + if deviceManager.loopManager.settings.preMealTargetRange == nil { + preMealMode = nil + } else { + preMealMode = deviceManager.loopManager.settings.preMealTargetEnabled() + } - if let index = values.closestIndexPriorToDate(Date()) { - self.currentCOBDescription = String(describing: self.charts.cobPoints[index].y) - } + if !FeatureFlags.sensitivityOverridesEnabled, deviceManager.loopManager.settings.legacyWorkoutTargetRange == nil { + workoutMode = nil + } else { + workoutMode = deviceManager.loopManager.settings.nonPreMealOverrideEnabled() + } - reloadGroup.leave() - } - } + reloadGroup.notify(queue: .main) { + /// Update the chart data - if let reservoir = dataManager.doseStore.lastReservoirValue { - if let capacity = dataManager.pumpState?.pumpModel?.reservoirCapacity { - reservoirVolumeHUD.reservoirLevel = min(1, max(0, Double(reservoir.unitVolume / Double(capacity)))) - } - - reservoirVolumeHUD.setReservoirVolume(volume: reservoir.unitVolume, at: reservoir.startDate) + // Glucose + if let glucoseSamples = glucoseSamples { + self.statusCharts.setGlucoseValues(glucoseSamples) + } + if (automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled), let predictedGlucoseValues = predictedGlucoseValues { + self.statusCharts.setPredictedGlucoseValues(predictedGlucoseValues) + } else { + self.statusCharts.setPredictedGlucoseValues([]) + } + if !FeatureFlags.predictedGlucoseChartClampEnabled, + let lastPoint = self.statusCharts.glucose.predictedGlucosePoints.last?.y + { + self.eventualGlucoseDescription = String(describing: lastPoint) + } else { + // if the predicted glucose values are clamped, the eventually glucose description should not be displayed, since it may not align with what is being charted. + self.eventualGlucoseDescription = nil + } + if currentContext.contains(.targets) { + self.statusCharts.targetGlucoseSchedule = self.deviceManager.loopManager.settings.glucoseTargetRangeSchedule + self.statusCharts.preMealOverride = self.deviceManager.loopManager.settings.preMealOverride + self.statusCharts.scheduleOverride = self.deviceManager.loopManager.settings.scheduleOverride } + if self.statusCharts.scheduleOverride?.hasFinished() == true { + self.statusCharts.scheduleOverride = nil + } + + let charts = self.statusCharts - if let level = dataManager.pumpBatteryChargeRemaining { - batteryLevelHUD.batteryLevel = level + // Active Insulin + if let iobValues = iobValues { + charts.setIOBValues(iobValues) } - loopCompletionHUD.dosingEnabled = dataManager.loopManager.dosingEnabled + // Show the larger of the value either before or after the current date + if let maxValue = charts.iob.iobPoints.allElementsAdjacent(to: Date()).max(by: { + return $0.y.scalar < $1.y.scalar + }) { + self.currentIOBDescription = String(describing: maxValue.y) + } else { + self.currentIOBDescription = nil + } - charts.glucoseTargetRangeSchedule = dataManager.glucoseTargetRangeSchedule + // Insulin Delivery + if let doseEntries = doseEntries { + charts.setDoseEntries(doseEntries) + } + if let totalDelivery = totalDelivery { + self.totalDelivery = totalDelivery + } - workoutMode = dataManager.workoutModeEnabled + // Active Carbohydrates + if let cobValues = cobValues { + charts.setCOBValues(cobValues) + } + if let index = charts.cob.cobPoints.closestIndex(priorTo: Date()) { + self.currentCOBDescription = String(describing: charts.cob.cobPoints[index].y) + } else if let carbsOnBoard = carbsOnBoard { + self.currentCOBDescription = self.carbFormatter.string(from: carbsOnBoard) + } else { + self.currentCOBDescription = nil + } - reloadGroup.notify(queue: DispatchQueue.main) { - if let glucose = self.dataManager.glucoseStore?.latestGlucose { - self.glucoseHUD.set(glucoseQuantity: glucose.quantity.doubleValue(for: self.charts.glucoseUnit), - at: glucose.startDate, - unitString: self.charts.glucoseUnit.unitString, - from: self.dataManager.sensorInfo) + self.tableView.beginUpdates() + if let hudView = self.hudView { + // CGM Status + if let glucose = self.deviceManager.glucoseStore.latestGlucose { + let unit = self.statusCharts.glucose.glucoseUnit + hudView.cgmStatusHUD.setGlucoseQuantity(glucose.quantity.doubleValue(for: unit), + at: glucose.startDate, + unit: unit, + staleGlucoseAge: LoopCoreConstants.inputDataRecencyInterval, + glucoseDisplay: self.deviceManager.glucoseDisplay(for: glucose), + wasUserEntered: glucose.wasUserEntered, + isDisplayOnly: glucose.isDisplayOnly) } + hudView.cgmStatusHUD.presentStatusHighlight(self.deviceManager.cgmStatusHighlight) + hudView.cgmStatusHUD.presentStatusBadge(self.deviceManager.cgmStatusBadge) + hudView.cgmStatusHUD.lifecycleProgress = self.deviceManager.cgmLifecycleProgress + + // Pump Status + hudView.pumpStatusHUD.presentStatusHighlight(self.deviceManager.pumpStatusHighlight) + hudView.pumpStatusHUD.presentStatusBadge(self.deviceManager.pumpStatusBadge) + hudView.pumpStatusHUD.lifecycleProgress = self.deviceManager.pumpLifecycleProgress + } - self.charts.prerender() + // Show/hide the table view rows + let statusRowMode = self.determineStatusRowMode() - // Show/hide the recommended temp basal row - let oldRecommendedTempBasal = self.recommendedTempBasal - self.recommendedTempBasal = newRecommendedTempBasal - switch (oldRecommendedTempBasal, newRecommendedTempBasal) { - case (let old?, let new?) where old != new: - self.tableView.reloadRows(at: [IndexPath(row: 0, section: Section.status.rawValue)], with: animated ? .top : .none) - case (.none, .some): - self.tableView.insertRows(at: [IndexPath(row: 0, section: Section.status.rawValue)], with: animated ? .top : .none) - case (.some, .none): - self.tableView.deleteRows(at: [IndexPath(row: 0, section: Section.status.rawValue)], with: animated ? .top : .none) - default: - break - } + self.updateBannerAndHUDandStatusRows(statusRowMode: statusRowMode, newSize: currentContext.newSize, animated: animated) - for case let cell as ChartTableViewCell in self.tableView.visibleCells { - cell.reloadChart() + self.redrawCharts() - if let indexPath = self.tableView.indexPath(for: cell) { - self.tableView(self.tableView, updateSubtitleFor: cell, at: indexPath) - } - } + self.tableView.endUpdates() + + self.reloading = false + let reloadNow = !self.refreshContext.isEmpty + self.refreshContext.formUnion(retryContext) - self.reloading = false + // Trigger a reload if new context exists. + if reloadNow { + self.log.debug("[reloadData] due to context change during previous reload") + self.reloadData() } } } - private enum Section: Int { - case status = 0 + private enum Section: Int, CaseIterable { + case alertWarning + case hud + case status case charts - - static let count = 2 } // MARK: - Chart Section Data - private enum ChartRow: Int { - case glucose = 0 + private enum ChartRow: Int, CaseIterable { + case glucose case iob case dose case cob - - static let count = 4 } - private lazy var charts: StatusChartsManager = { - let charts = StatusChartsManager() - - charts.glucoseDisplayRange = ( - min: HKQuantity(unit: HKUnit.milligramsPerDeciliterUnit(), doubleValue: 100), - max: HKQuantity(unit: HKUnit.milligramsPerDeciliterUnit(), doubleValue: 175) - ) - - return charts - }() - // MARK: Glucose private var eventualGlucoseDescription: String? @@ -344,160 +685,461 @@ final class StatusTableViewController: UITableViewController, UIGestureRecognize private var totalDelivery: Double? - private lazy var integerFormatter: NumberFormatter = { - let formatter = NumberFormatter() - formatter.maximumFractionDigits = 0 - - return formatter - }() - // MARK: COB private var currentCOBDescription: String? // MARK: - Loop Status Section Data - private enum StatusRow: Int { - case recommendedBasal = 0 + private enum StatusRow: Int, CaseIterable { + case status = 0 + } - static let count = 1 + private enum StatusRowMode { + case hidden + case scheduleOverrideEnabled(TemporaryScheduleOverride) + case enactingBolus + case bolusing(dose: DoseEntry) + case cancelingBolus + case pumpSuspended(resuming: Bool) + case onboardingSuspended + case recommendManualGlucoseEntry + + var hasRow: Bool { + switch self { + case .hidden: + return false + default: + return true + } + } } - private var recommendedTempBasal: LoopDataManager.TempBasalRecommendation? + private var statusRowMode = StatusRowMode.hidden + + private func determineStatusRowMode() -> StatusRowMode { + let statusRowMode: StatusRowMode + + if case .initiating = bolusState { + statusRowMode = .enactingBolus + } else if case .canceling = bolusState { + statusRowMode = .cancelingBolus + } else if case .suspended = basalDeliveryState { + statusRowMode = .pumpSuspended(resuming: false) + } else if case .resuming = basalDeliveryState { + statusRowMode = .pumpSuspended(resuming: true) + } else if case .inProgress(let dose) = bolusState, dose.endDate.timeIntervalSinceNow > 0 { + statusRowMode = .bolusing(dose: dose) + } else if !onboardingManager.isComplete, deviceManager.pumpManager?.isOnboarded == true { + statusRowMode = .onboardingSuspended + } else if onboardingManager.isComplete, deviceManager.isGlucoseValueStale { + statusRowMode = .recommendManualGlucoseEntry + } else if let scheduleOverride = deviceManager.loopManager.settings.scheduleOverride, + !scheduleOverride.hasFinished() + { + statusRowMode = .scheduleOverrideEnabled(scheduleOverride) + } else if let premealOverride = deviceManager.loopManager.settings.preMealOverride, + !premealOverride.hasFinished() + { + statusRowMode = .scheduleOverrideEnabled(premealOverride) + } else { + statusRowMode = .hidden + } - private var settingTempBasal: Bool = false { - didSet { - if let cell = tableView.cellForRow(at: IndexPath(row: StatusRow.recommendedBasal.rawValue, section: Section.status.rawValue)) { - if settingTempBasal { - let indicatorView = UIActivityIndicatorView(activityIndicatorStyle: .gray) - indicatorView.startAnimating() - cell.accessoryView = indicatorView - } else { - cell.accessoryView = nil - } - } + return statusRowMode + } + + private var shouldShowBannerWarning: Bool { + alertPermissionsChecker.showWarning || alertMuter.configuration.shouldMute + } + + private func updateBannerRow(animated: Bool) { + let warningWasVisible = tableView.numberOfRows(inSection: Section.alertWarning.rawValue) != 0 + if !shouldShowBannerWarning && warningWasVisible { + tableView.deleteRows(at: [IndexPath(row: 0, section: Section.alertWarning.rawValue)], with: animated ? .top : .none) + } else if shouldShowBannerWarning && !warningWasVisible { + tableView.insertRows(at: [IndexPath(row: 0, section: Section.alertWarning.rawValue)], with: animated ? .top : .none) + } else { + tableView.reloadRows(at: [IndexPath(row: 0, section: Section.alertWarning.rawValue)], with: .none) } } - private lazy var timeFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateStyle = .none - formatter.timeStyle = .short + private func updateBannerAndHUDandStatusRows(statusRowMode: StatusRowMode, newSize: CGSize?, animated: Bool) { + let hudWasVisible = self.shouldShowHUD + let statusWasVisible = self.shouldShowStatus - return formatter - }() + let oldStatusRowMode = self.statusRowMode - // MARK: - HUD Data - - private var lastTempBasal: DoseEntry? { - didSet { - if let lastNetBasal = self.dataManager.loopManager.lastNetBasal { - DispatchQueue.main.async { - self.basalRateHUD.setNetBasalRate(lastNetBasal.rate, percent: lastNetBasal.percent, at: lastNetBasal.startDate) + self.statusRowMode = statusRowMode + + if let newSize = newSize { + landscapeMode = newSize.width > newSize.height + } + + let hudIsVisible = self.shouldShowHUD + let statusIsVisible = self.shouldShowStatus + + hudView?.cgmStatusHUD?.isVisible = hudIsVisible + + tableView.beginUpdates() + + updateBannerRow(animated: animated) + + switch (hudWasVisible, hudIsVisible) { + case (false, true): + tableView.insertRows(at: [IndexPath(row: 0, section: Section.hud.rawValue)], with: animated ? .top : .none) + case (true, false): + tableView.deleteRows(at: [IndexPath(row: 0, section: Section.hud.rawValue)], with: animated ? .top : .none) + default: + break + } + + let statusIndexPath = IndexPath(row: StatusRow.status.rawValue, section: Section.status.rawValue) + + switch (statusWasVisible, statusIsVisible) { + case (true, true): + switch (oldStatusRowMode, self.statusRowMode) { + case (.enactingBolus, .enactingBolus): + break + case (.bolusing(let oldDose), .bolusing(let newDose)): + if oldDose.syncIdentifier != newDose.syncIdentifier { + tableView.reloadRows(at: [statusIndexPath], with: animated ? .fade : .none) + } + case (.pumpSuspended(resuming: let wasResuming), .pumpSuspended(resuming: let isResuming)): + if isResuming != wasResuming { + tableView.reloadRows(at: [statusIndexPath], with: animated ? .fade : .none) } + default: + tableView.reloadRows(at: [statusIndexPath], with: animated ? .fade : .none) } + case (false, true): + tableView.insertRows(at: [statusIndexPath], with: animated ? .bottom : .none) + case (true, false): + tableView.deleteRows(at: [statusIndexPath], with: animated ? .top : .none) + default: + break } + + tableView.endUpdates() } - private var lastLoopCompleted: Date? { - didSet { - DispatchQueue.main.async { - self.loopCompletionHUD.lastLoopCompleted = self.lastLoopCompleted + private func redrawCharts() { + tableView.beginUpdates() + charts.prerender() + for case let cell as ChartTableViewCell in tableView.visibleCells { + cell.reloadChart() + + if let indexPath = tableView.indexPath(for: cell) { + self.tableView(tableView, updateSubtitleFor: cell, at: indexPath) } } + tableView.endUpdates() } // MARK: - Toolbar data - private var workoutMode: Bool? = nil { + private var preMealMode: Bool? = nil { didSet { - guard oldValue != workoutMode else { + guard oldValue != preMealMode else { return } + updatePresetModeAvailability(automaticDosingEnabled: automaticDosingStatus.automaticDosingEnabled) + } + } + private lazy var preMealModeAllowed: Bool = { + onboardingManager.isComplete && + (automaticDosingStatus.automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled) + && deviceManager.loopManager.settings.preMealTargetRange != nil + }() - if let workoutMode = workoutMode { - toolbarItems![4] = createWorkoutButtonItem(selected: workoutMode) - } else { - toolbarItems![4].isEnabled = false + private func updatePresetModeAvailability(automaticDosingEnabled: Bool) { + preMealModeAllowed = onboardingManager.isComplete && + (automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled) + && deviceManager.loopManager.settings.preMealTargetRange != nil + workoutModeAllowed = onboardingManager.isComplete && workoutMode != nil + updateToolbarItems() + } + + private var workoutMode: Bool? = nil { + didSet { + guard oldValue != workoutMode else { + return } + workoutModeAllowed = workoutMode != nil && onboardingManager.isComplete + updateToolbarItems() } } + private lazy var workoutModeAllowed: Bool = { + workoutMode != nil && onboardingManager.isComplete + }() // MARK: - Table view data source override func numberOfSections(in tableView: UITableView) -> Int { - return Section.count + return Section.allCases.count } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { switch Section(rawValue: section)! { + case .alertWarning: + return shouldShowBannerWarning ? 1 : 0 + case .hud: + return shouldShowHUD ? 1 : 0 case .charts: - return ChartRow.count + return ChartRow.allCases.count case .status: - return self.recommendedTempBasal == nil ? 0 : StatusRow.count + return shouldShowStatus ? StatusRow.allCases.count : 0 + } + } + + private class AlertPermissionsDisabledWarningCell: UITableViewCell { + override func updateConfiguration(using state: UICellConfigurationState) { + super.updateConfiguration(using: state) + + let adjustViewForNarrowDisplay = bounds.width < 350 + + var contentConfig = defaultContentConfiguration().updated(for: state) + let titleImageAttachment = NSTextAttachment() + titleImageAttachment.image = UIImage(systemName: "exclamationmark.triangle.fill")?.withTintColor(.white) + let title = NSMutableAttributedString(string: NSLocalizedString(" Safety Notifications are OFF", comment: "Warning text for when Notifications or Critical Alerts Permissions is disabled")) + let titleWithImage = NSMutableAttributedString(attachment: titleImageAttachment) + titleWithImage.append(title) + contentConfig.attributedText = titleWithImage + contentConfig.textProperties.color = .white + contentConfig.textProperties.font = .systemFont(ofSize: adjustViewForNarrowDisplay ? 16 : 18, weight: .bold) + contentConfig.textProperties.adjustsFontSizeToFitWidth = true + contentConfig.secondaryText = NSLocalizedString("Fix now by turning Notifications, Critical Alerts and Time Sensitive Notifications ON.", comment: "Secondary text for alerts disabled warning, which appears on the main status screen.") + contentConfig.secondaryTextProperties.color = .white + contentConfig.secondaryTextProperties.font = .systemFont(ofSize: adjustViewForNarrowDisplay ? 13 : 15) + contentConfiguration = contentConfig + + var backgroundConfig = backgroundConfiguration?.updated(for: state) + backgroundConfig?.backgroundColor = .critical + backgroundConfiguration = backgroundConfig + backgroundConfiguration?.backgroundInsets = NSDirectionalEdgeInsets(top: 0, leading: 10, bottom: 5, trailing: 10) + backgroundConfiguration?.cornerRadius = 10 + + let disclosureIndicator = UIImage(systemName: "chevron.right")?.withTintColor(.white) + let imageView = UIImageView(image: disclosureIndicator) + imageView.tintColor = .white + accessoryView = imageView + + contentView.directionalLayoutMargins = NSDirectionalEdgeInsets(top: 6, leading: 0, bottom: 13, trailing: 0) } } + private class MuteAlertsWarningCell: UITableViewCell { + var formattedAlertMuteEndTime: String = NSLocalizedString("Unknown", comment: "label for when the alert mute end time is unknown") + + fileprivate class GradientView: UIView { + override static var layerClass: AnyClass { CAGradientLayer.self } + } + + override func updateConfiguration(using state: UICellConfigurationState) { + super.updateConfiguration(using: state) + + let adjustViewForNarrowDisplay = bounds.width < 350 + + var contentConfig = defaultContentConfiguration().updated(for: state) + let title = NSMutableAttributedString(string: NSLocalizedString("All Alerts Muted", comment: "Warning text for when alerts are muted")) + let image = UIImage(systemName: "speaker.slash.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 25, weight: .thin, scale: .large)) + contentConfig.image = image + contentConfig.imageProperties.tintColor = .white + contentConfig.attributedText = title + contentConfig.textProperties.color = .white + contentConfig.textProperties.font = .systemFont(ofSize: adjustViewForNarrowDisplay ? 16 : 18, weight: .semibold) + contentConfig.textProperties.adjustsFontSizeToFitWidth = true + contentConfig.secondaryText = String(format: NSLocalizedString("Until %1$@", comment: "indication of when alerts will be unmuted (1: time when alerts unmute)"), formattedAlertMuteEndTime) + contentConfig.secondaryTextProperties.color = .white + contentConfig.secondaryTextProperties.font = .systemFont(ofSize: adjustViewForNarrowDisplay ? 13 : 15) + contentConfiguration = contentConfig + + let backgroundGradient = GradientView() + (backgroundGradient.layer as? CAGradientLayer)?.colors = [UIColor.warning.cgColor, UIColor.warning.withAlphaComponent(0.9).cgColor] + + var backgroundConfig = backgroundConfiguration?.updated(for: state) + backgroundConfig?.customView = backgroundGradient + backgroundConfiguration = backgroundConfig + backgroundConfiguration?.backgroundInsets = NSDirectionalEdgeInsets(top: 0, leading: 5, bottom: 5, trailing: 5) + backgroundConfiguration?.cornerRadius = 10 + + let unmuteIndicator = UIImage(systemName: "stop.circle")?.withTintColor(.white) + let imageView = UIImageView(image: unmuteIndicator) + imageView.tintColor = .white + imageView.frame.size = CGSize(width: 30, height: 30) + accessoryView = imageView + + contentView.directionalLayoutMargins = NSDirectionalEdgeInsets(top: 6, leading: 0, bottom: 13, trailing: 0) + } + } + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { switch Section(rawValue: indexPath.section)! { + case .alertWarning: + if alertPermissionsChecker.showWarning { + let cell = tableView.dequeueReusableCell(withIdentifier: AlertPermissionsDisabledWarningCell.className, for: indexPath) as! AlertPermissionsDisabledWarningCell + return cell + } else { + let cell = tableView.dequeueReusableCell(withIdentifier: MuteAlertsWarningCell.className, for: indexPath) as! MuteAlertsWarningCell + cell.formattedAlertMuteEndTime = alertMuter.formattedEndTime + cell.selectionStyle = .none + return cell + } + case .hud: + let cell = tableView.dequeueReusableCell(withIdentifier: HUDViewTableViewCell.className, for: indexPath) as! HUDViewTableViewCell + hudView = cell.hudView + + return cell case .charts: let cell = tableView.dequeueReusableCell(withIdentifier: ChartTableViewCell.className, for: indexPath) as! ChartTableViewCell switch ChartRow(rawValue: indexPath.row)! { case .glucose: - cell.chartContentView.chartGenerator = { [unowned self] (frame) in - return self.charts.glucoseChartWithFrame(frame)?.view - } - cell.titleLabel?.text = NSLocalizedString("Glucose", comment: "The title of the glucose and prediction graph") + cell.setChartGenerator(generator: { [weak self] (frame) in + return self?.statusCharts.glucoseChart(withFrame: frame)?.view + }) + cell.setTitleLabelText(label: NSLocalizedString("Glucose", comment: "The title of the glucose and prediction graph")) + cell.doesNavigate = automaticDosingStatus.automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled case .iob: - cell.chartContentView.chartGenerator = { [unowned self] (frame) in - return self.charts.iobChartWithFrame(frame)?.view - } - cell.titleLabel?.text = NSLocalizedString("Active Insulin", comment: "The title of the Insulin On-Board graph") + cell.setChartGenerator(generator: { [weak self] (frame) in + return self?.statusCharts.iobChart(withFrame: frame)?.view + }) + cell.setTitleLabelText(label: NSLocalizedString("Active Insulin", comment: "The title of the Insulin On-Board graph")) case .dose: - cell.chartContentView?.chartGenerator = { [unowned self] (frame) in - return self.charts.doseChartWithFrame(frame)?.view - } - cell.titleLabel?.text = NSLocalizedString("Insulin Delivery", comment: "The title of the insulin delivery graph") + cell.setChartGenerator(generator: { [weak self] (frame) in + return self?.statusCharts.doseChart(withFrame: frame)?.view + }) + cell.setTitleLabelText(label: NSLocalizedString("Insulin Delivery", comment: "The title of the insulin delivery graph")) case .cob: - cell.chartContentView?.chartGenerator = { [unowned self] (frame) in - return self.charts.cobChartWithFrame(frame)?.view - } - cell.titleLabel?.text = NSLocalizedString("Active Carbohydrates", comment: "The title of the Carbs On-Board graph") + cell.setChartGenerator(generator: { [weak self] (frame) in + return self?.statusCharts.cobChart(withFrame: frame)?.view + }) + cell.setTitleLabelText(label: NSLocalizedString("Active Carbohydrates", comment: "The title of the Carbs On-Board graph")) } self.tableView(tableView, updateSubtitleFor: cell, at: indexPath) - let alpha: CGFloat = charts.panGestureRecognizer?.state == .possible ? 1 : 0 - cell.titleLabel?.alpha = alpha - cell.subtitleLabel?.alpha = alpha + let alpha: CGFloat = charts.gestureRecognizer?.state == .possible ? 1 : 0 + cell.setAlpha(alpha: alpha) - cell.subtitleLabel?.textColor = UIColor.secondaryLabelColor + cell.setSubtitleTextColor(color: UIColor.secondaryLabel) return cell case .status: - let cell = tableView.dequeueReusableCell(withIdentifier: TitleSubtitleTableViewCell.className, for: indexPath) as! TitleSubtitleTableViewCell - cell.selectionStyle = .none + + func getTitleSubtitleCell() -> TitleSubtitleTableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: TitleSubtitleTableViewCell.className, for: indexPath) as! TitleSubtitleTableViewCell + cell.selectionStyle = .none + cell.backgroundColor = .secondarySystemBackground + cell.titleLabel.text = nil + cell.subtitleLabel.text = nil + cell.accessoryView = nil + return cell + } switch StatusRow(rawValue: indexPath.row)! { - case .recommendedBasal: - if let recommendedTempBasal = recommendedTempBasal { - cell.subtitleLabel?.text = String(format: NSLocalizedString("%1$@ U/hour @ %2$@", comment: "The format for recommended temp basal rate and time. (1: localized rate number)(2: localized time)"), NumberFormatter.localizedString(from: NSNumber(value: recommendedTempBasal.rate), number: .decimal), timeFormatter.string(from: recommendedTempBasal.recommendedDate)) - cell.selectionStyle = .default - } else { - cell.subtitleLabel?.text = "––" - } + case .status: + switch statusRowMode { + case .hidden: + let cell = getTitleSubtitleCell() + return cell + case .scheduleOverrideEnabled(let override): + let cell = getTitleSubtitleCell() + switch override.context { + case .preMeal: + let symbolAttachment = NSTextAttachment() + symbolAttachment.image = UIImage(named: "Pre-Meal-symbol")?.withTintColor(.carbTintColor) + + let attributedString = NSMutableAttributedString(attachment: symbolAttachment) + attributedString.append(NSAttributedString(string: NSLocalizedString(" Pre-meal Preset", comment: "Status row title for premeal override enabled (leading space is to separate from symbol)"))) + cell.titleLabel.attributedText = attributedString + case .legacyWorkout: + let symbolAttachment = NSTextAttachment() + symbolAttachment.image = UIImage(named: "workout-symbol")?.withTintColor(.glucoseTintColor) + + let attributedString = NSMutableAttributedString(attachment: symbolAttachment) + attributedString.append(NSAttributedString(string: NSLocalizedString(" Workout Preset", comment: "Status row title for workout override enabled (leading space is to separate from symbol)"))) + cell.titleLabel.attributedText = attributedString + case .preset(let preset): + cell.titleLabel.text = String(format: NSLocalizedString("%@ %@", comment: "The format for an active custom preset. (1: preset symbol)(2: preset name)"), preset.symbol, preset.name) + case .custom: + cell.titleLabel.text = NSLocalizedString("Custom Preset", comment: "The title of the cell indicating a generic custom preset is enabled") + } - if settingTempBasal { - let indicatorView = UIActivityIndicatorView(activityIndicatorStyle: .gray) + if override.isActive() { + switch override.duration { + case .finite: + let endTimeText = DateFormatter.localizedString(from: override.activeInterval.end, dateStyle: .none, timeStyle: .short) + cell.subtitleLabel.text = String(format: NSLocalizedString("until %@", comment: "The format for the description of a custom preset end date"), endTimeText) + case .indefinite: + cell.subtitleLabel.text = nil + } + } else { + let startTimeText = DateFormatter.localizedString(from: override.startDate, dateStyle: .none, timeStyle: .short) + cell.subtitleLabel.text = String(format: NSLocalizedString("starting at %@", comment: "The format for the description of a custom preset start date"), startTimeText) + } + + return cell + case .enactingBolus: + let cell = getTitleSubtitleCell() + cell.titleLabel.text = NSLocalizedString("Starting Bolus", comment: "The title of the cell indicating a bolus is being sent") + + let indicatorView = UIActivityIndicatorView(style: .default) indicatorView.startAnimating() cell.accessoryView = indicatorView - } else { + return cell + case .bolusing(let dose): + let progressCell = tableView.dequeueReusableCell(withIdentifier: BolusProgressTableViewCell.className, for: indexPath) as! BolusProgressTableViewCell + progressCell.selectionStyle = .none + progressCell.totalUnits = dose.programmedUnits + progressCell.tintColor = .insulinTintColor + progressCell.deliveredUnits = bolusProgressReporter?.progress.deliveredUnits + progressCell.backgroundColor = .secondarySystemBackground + return progressCell + case .cancelingBolus: + let cell = getTitleSubtitleCell() + cell.titleLabel.text = NSLocalizedString("Canceling Bolus", comment: "The title of the cell indicating a bolus is being canceled") + + let indicatorView = UIActivityIndicatorView(style: .default) + indicatorView.startAnimating() + cell.accessoryView = indicatorView + return cell + case .pumpSuspended(let resuming): + let cell = getTitleSubtitleCell() + cell.titleLabel.text = NSLocalizedString("Insulin Suspended", comment: "The title of the cell indicating the pump is suspended") + + if resuming { + let indicatorView = UIActivityIndicatorView(style: .default) + indicatorView.startAnimating() + cell.accessoryView = indicatorView + } else { + cell.subtitleLabel.text = NSLocalizedString("Tap to Resume", comment: "The subtitle of the cell displaying an action to resume insulin delivery") + } + cell.selectionStyle = .default + return cell + case .onboardingSuspended: + let cell = tableView.dequeueReusableCell(withIdentifier: IconTitleSubtitleTableViewCell.className, for: indexPath) as! IconTitleSubtitleTableViewCell + cell.selectionStyle = .default + cell.backgroundColor = .secondarySystemBackground + cell.iconImageView.image = UIImage(systemName: "exclamationmark.circle.fill") + cell.iconImageView.tintColor = .warning + cell.iconImageView.contentMode = .scaleAspectFit + cell.iconImageView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 28) + cell.titleLabel.text = NSLocalizedString("Setup Incomplete", comment: "The title of the cell indicating that onboarding is suspended") + cell.subtitleLabel.text = NSLocalizedString("Tap to Resume", comment: "The subtitle of the cell displaying an action to resume onboarding") cell.accessoryView = nil + return cell + case .recommendManualGlucoseEntry: + let cell = getTitleSubtitleCell() + cell.titleLabel.text = NSLocalizedString("No Recent Glucose", comment: "The title of the cell indicating that there is no recent glucose") + cell.subtitleLabel.text = NSLocalizedString("Tap to Add", comment: "The subtitle of the cell displaying an action to add a manually measurement glucose value") + cell.selectionStyle = .default + let imageView = UIImageView(image: UIImage(named: "drop.circle")) + imageView.tintColor = .glucoseTintColor + cell.accessoryView = imageView + return cell } } - - return cell } } @@ -507,31 +1149,35 @@ final class StatusTableViewController: UITableViewController, UIGestureRecognize switch ChartRow(rawValue: indexPath.row)! { case .glucose: if let eventualGlucose = eventualGlucoseDescription { - cell.subtitleLabel?.text = String(format: NSLocalizedString("Eventually %@", comment: "The subtitle format describing eventual glucose. (1: localized glucose value description)"), eventualGlucose) + cell.setSubtitleLabel(label: String(format: NSLocalizedString("Eventually %@", comment: "The subtitle format describing eventual glucose. (1: localized glucose value description)"), eventualGlucose)) } else { - cell.subtitleLabel?.text = nil + cell.setSubtitleLabel(label: nil) } + cell.doesNavigate = automaticDosingStatus.automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled case .iob: if let currentIOB = currentIOBDescription { - cell.subtitleLabel?.text = currentIOB + cell.setSubtitleLabel(label: currentIOB) } else { - cell.subtitleLabel?.text = nil + cell.setSubtitleLabel(label: nil) } case .dose: + let integerFormatter = NumberFormatter() + integerFormatter.maximumFractionDigits = 0 + if let total = totalDelivery, - let totalString = integerFormatter.string(from: NSNumber(value: total)) { - cell.subtitleLabel?.text = String(format: NSLocalizedString("%@ U Total", comment: "The subtitle format describing total insulin. (1: localized insulin total)"), totalString) + let totalString = integerFormatter.string(from: total) { + cell.setSubtitleLabel(label: String(format: NSLocalizedString("%@ U Total", comment: "The subtitle format describing total insulin. (1: localized insulin total)"), totalString)) } else { - cell.subtitleLabel?.text = nil + cell.setSubtitleLabel(label: nil) } case .cob: if let currentCOB = currentCOBDescription { - cell.subtitleLabel?.text = currentCOB + cell.setSubtitleLabel(label: currentCOB) } else { - cell.subtitleLabel?.text = nil + cell.setSubtitleLabel(label: nil) } } - case .status: + case .hud, .status, .alertWarning: break } } @@ -541,99 +1187,142 @@ final class StatusTableViewController: UITableViewController, UIGestureRecognize override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { switch Section(rawValue: indexPath.section)! { case .charts: - // 20: Status bar - // 44: Toolbar - let availableSize = max(tableView.bounds.width, tableView.bounds.height) - 20 - (tableView.tableHeaderView?.frame.height ?? 0) - 44 + // Compute the height of the HUD, defaulting to 70 + let hudHeight = ceil(hudView?.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).height ?? 74) + var availableSize = max(tableView.bounds.width, tableView.bounds.height) + availableSize -= (tableView.safeAreaInsets.top + tableView.safeAreaInsets.bottom + hudHeight) switch ChartRow(rawValue: indexPath.row)! { case .glucose: - return max(100, 0.37 * availableSize) + return max(106, 0.37 * availableSize) case .iob, .dose, .cob: - return max(100, 0.21 * availableSize) + return max(106, 0.21 * availableSize) } - case .status: - return UITableViewAutomaticDimension + case .hud, .status, .alertWarning: + return UITableView.automaticDimension } } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { switch Section(rawValue: indexPath.section)! { - case .charts: - switch ChartRow(rawValue: indexPath.row)! { - case .glucose: - performSegue(withIdentifier: PredictionTableViewController.className, sender: indexPath) - case .iob, .dose: - performSegue(withIdentifier: InsulinDeliveryTableViewController.className, sender: indexPath) - case .cob: - performSegue(withIdentifier: CarbEntryTableViewController.className, sender: indexPath) + case .alertWarning: + if alertPermissionsChecker.showWarning { + tableView.deselectRow(at: indexPath, animated: true) + AlertPermissionsChecker.gotoSettings() + } else { + tableView.deselectRow(at: indexPath, animated: true) + presentUnmuteAlertConfirmation() } + case .hud: + break case .status: switch StatusRow(rawValue: indexPath.row)! { - case .recommendedBasal: + case .status: tableView.deselectRow(at: indexPath, animated: true) - if recommendedTempBasal != nil && !settingTempBasal { - settingTempBasal = true - self.dataManager.loopManager.enactRecommendedTempBasal { (success, error) -> Void in + switch statusRowMode { + case .pumpSuspended(let resuming) where !resuming: + updateBannerAndHUDandStatusRows(statusRowMode: .pumpSuspended(resuming: true) , newSize: nil, animated: true) + deviceManager.pumpManager?.resumeDelivery() { (error) in DispatchQueue.main.async { - self.settingTempBasal = false - if let error = error { - self.dataManager.logger.addError(error, fromSource: "TempBasal") - self.presentAlertController(with: error) - } else if success { - self.needsRefresh = true + let alert = UIAlertController(with: error, title: NSLocalizedString("Failed to Resume Insulin Delivery", comment: "The alert title for a resume error")) + self.present(alert, animated: true, completion: nil) + if case .suspended = self.basalDeliveryState { + self.updateBannerAndHUDandStatusRows(statusRowMode: .pumpSuspended(resuming: false), newSize: nil, animated: true) + } + } else { + self.updateBannerAndHUDandStatusRows(statusRowMode: self.determineStatusRowMode(), newSize: nil, animated: true) + self.refreshContext.update(with: .insulin) + self.log.debug("[reloadData] after manually resuming suspend") self.reloadData() } } } + case .scheduleOverrideEnabled(let override): + switch override.context { + case .preMeal, .legacyWorkout: + break + default: + let vc = AddEditOverrideTableViewController(glucoseUnit: statusCharts.glucose.glucoseUnit) + vc.inputMode = .editOverride(override) + vc.delegate = self + show(vc, sender: tableView.cellForRow(at: indexPath)) + } + case .bolusing: + updateBannerAndHUDandStatusRows(statusRowMode: .cancelingBolus, newSize: nil, animated: true) + deviceManager.pumpManager?.cancelBolus() { (result) in + DispatchQueue.main.async { + switch result { + case .success: + // show user confirmation and actual delivery amount? + break + case .failure(let error): + self.presentErrorCancelingBolus(error) + if case .inProgress(let dose) = self.bolusState { + self.updateBannerAndHUDandStatusRows(statusRowMode: .bolusing(dose: dose), newSize: nil, animated: true) + } else { + self.updateBannerAndHUDandStatusRows(statusRowMode: .hidden, newSize: nil, animated: true) + } + } + } + } + case .onboardingSuspended: + onboardingManager.resume() + case .recommendManualGlucoseEntry: + presentBolusEntryView(enableManualGlucoseEntry: true) + default: + break + } + } + case .charts: + switch ChartRow(rawValue: indexPath.row)! { + case .glucose: + if automaticDosingStatus.automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled { + performSegue(withIdentifier: PredictionTableViewController.className, sender: indexPath) } + case .iob, .dose: + performSegue(withIdentifier: InsulinDeliveryTableViewController.className, sender: indexPath) + case .cob: + performSegue(withIdentifier: CarbAbsorptionViewController.className, sender: indexPath) } } } - // MARK: - UIGestureRecognizer - - func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { - return true + private func presentUnmuteAlertConfirmation() { + let title = NSLocalizedString("Unmute Alerts?", comment: "The alert title for unmute alert confirmation") + let body = NSLocalizedString("Tap Unmute to resume sound for your alerts and alarms.", comment: "The alert body for unmute alert confirmation") + let action = UIAlertAction( + title: NSLocalizedString("Unmute", comment: "The title of the action used to unmute alerts"), + style: .default) { _ in + self.alertMuter.unmuteAlerts() + } + let alert = UIAlertController(title: title, message: body, preferredStyle: .alert) + alert.addAction(action) + alert.addCancelAction { _ in } + present(alert, animated: true, completion: nil) } - @objc func handlePan(_ gestureRecognizer: UIGestureRecognizer) { - switch gestureRecognizer.state { - case .possible, .changed: - // Follow your dreams! - break - case .began, .cancelled, .ended, .failed: - for case let row as ChartTableViewCell in self.tableView.visibleCells { - let forwards = gestureRecognizer.state == .began - UIView.animate(withDuration: forwards ? 0.2 : 0.5, delay: forwards ? 0 : 1, animations: { - let alpha: CGFloat = forwards ? 0 : 1 - row.titleLabel?.alpha = alpha - row.subtitleLabel?.alpha = alpha - }) - } - } + private func presentErrorCancelingBolus(_ error: (Error)) { + log.error("Error Canceling Bolus: %@", error.localizedDescription) + let title = NSLocalizedString("Error Canceling Bolus", comment: "The alert title for an error while canceling a bolus") + let body = NSLocalizedString("Unable to stop the bolus in progress. Move your iPhone closer to the pump and try again. Check your insulin delivery history for details, and monitor your glucose closely.", comment: "The alert body for an error while canceling a bolus") + let action = UIAlertAction( + title: NSLocalizedString("com.loudnate.LoopKit.errorAlertActionTitle", value: "OK", comment: "The title of the action used to dismiss an error alert"), style: .default) + let alert = UIAlertController(title: title, message: body, preferredStyle: .alert) + alert.addAction(action) + present(alert, animated: true, completion: nil) } // MARK: - Actions - override func shouldPerformSegue(withIdentifier identifier: String, sender: Any?) -> Bool { - if identifier == CarbEntryEditViewController.className { - if let carbStore = dataManager.carbStore { - if carbStore.authorizationRequired { - carbStore.authorize { (success, error) in - if success { - self.performSegue(withIdentifier: CarbEntryEditViewController.className, sender: sender) - } - } - return false - } - } else { - return false - } + override func restoreUserActivityState(_ activity: NSUserActivity) { + switch activity.activityType { + case NSUserActivity.newCarbEntryActivityType: + presentCarbEntryScreen(activity) + default: + break } - - return true } override func prepare(for segue: UIStoryboardSegue, sender: Any?) { @@ -646,174 +1335,938 @@ final class StatusTableViewController: UITableViewController, UIGestureRecognize } switch targetViewController { - case let vc as CarbEntryTableViewController: - vc.carbStore = dataManager.carbStore + case let vc as CarbAbsorptionViewController: + vc.isOnboardingComplete = onboardingManager.isComplete + vc.automaticDosingStatus = automaticDosingStatus + vc.deviceManager = deviceManager vc.hidesBottomBarWhenPushed = true - self.needsRefresh = true - case let vc as CarbEntryEditViewController: - if let carbStore = dataManager.carbStore { - vc.defaultAbsorptionTimes = carbStore.defaultAbsorptionTimes - vc.preferredUnit = carbStore.preferredUnit - } case let vc as InsulinDeliveryTableViewController: - vc.doseStore = dataManager.doseStore + vc.deviceManager = deviceManager vc.hidesBottomBarWhenPushed = true - case let vc as BolusViewController: - if let maxBolus = self.dataManager.maximumBolus { - vc.maxBolus = maxBolus - } - - if let bolus = sender as? Double { - vc.recommendedBolus = bolus - } else { - self.dataManager.loopManager.getRecommendedBolus { (units, error) -> Void in - if let error = error { - self.dataManager.logger.addError(error, fromSource: "Bolus") - } else if let bolus = units { - DispatchQueue.main.async { - vc.recommendedBolus = bolus - } - } - } + vc.enableEntryDeletion = FeatureFlags.entryDeletionEnabled + vc.headerValueLabelColor = .insulinTintColor + case let vc as OverrideSelectionViewController: + if deviceManager.loopManager.settings.futureOverrideEnabled() { + vc.scheduledOverride = deviceManager.loopManager.settings.scheduleOverride } + vc.presets = deviceManager.loopManager.settings.overridePresets + vc.glucoseUnit = statusCharts.glucose.glucoseUnit + vc.overrideHistory = deviceManager.loopManager.overrideHistory.getEvents() + vc.delegate = self case let vc as PredictionTableViewController: - vc.dataManager = dataManager - case let vc as SettingsTableViewController: - vc.dataManager = dataManager + vc.deviceManager = deviceManager default: break } } - /// Unwind segue action from the CarbEntryEditViewController - /// - /// - parameter segue: The unwind segue - @IBAction func unwindFromEditing(_ segue: UIStoryboardSegue) { - if let carbVC = segue.source as? CarbEntryEditViewController, let updatedEntry = carbVC.updatedCarbEntry { + @IBAction func unwindFromEditing(_ segue: UIStoryboardSegue) {} - dataManager.loopManager.addCarbEntryAndRecommendBolus(updatedEntry) { (units, error) -> Void in - DispatchQueue.main.async { - if let error = error { - // Ignore bolus wizard errors - if error is CarbStore.CarbStoreError { - self.presentAlertController(with: error) - } else { - self.dataManager.logger.addError(error, fromSource: "Bolus") - self.needsRefresh = true - self.reloadData() - } - } else if self.active && self.visible, let bolus = units, bolus > 0 { - self.performSegue(withIdentifier: BolusViewController.className, sender: bolus) - self.needsRefresh = true - } else { - self.needsRefresh = true - self.reloadData() - } - } + @IBAction func unwindFromSettings(_ segue: UIStoryboardSegue) {} + + @IBAction func userTappedAddCarbs() { + presentCarbEntryScreen(nil) + } + + func presentCarbEntryScreen(_ activity: NSUserActivity?) { + let navigationWrapper: UINavigationController + if FeatureFlags.simpleBolusCalculatorEnabled && !automaticDosingStatus.automaticDosingEnabled { + let viewModel = SimpleBolusViewModel(delegate: deviceManager, displayMealEntry: true) + if let activity = activity { + viewModel.restoreUserActivityState(activity) + } + let bolusEntryView = SimpleBolusView(viewModel: viewModel).environmentObject(deviceManager.displayGlucosePreference) + let hostingController = DismissibleHostingController(rootView: bolusEntryView, isModalInPresentation: false) + navigationWrapper = UINavigationController(rootViewController: hostingController) + hostingController.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: navigationWrapper, action: #selector(dismissWithAnimation)) + present(navigationWrapper, animated: true) + } else { + let viewModel = CarbEntryViewModel(delegate: deviceManager) + if let activity { + viewModel.restoreUserActivityState(activity) } + let carbEntryView = CarbEntryView(viewModel: viewModel) + .environmentObject(deviceManager.displayGlucosePreference) + let hostingController = DismissibleHostingController(rootView: carbEntryView, isModalInPresentation: false) + present(hostingController, animated: true) } + deviceManager.analyticsServicesManager.didDisplayCarbEntryScreen() } - @IBAction func unwindFromBolusViewController(_ segue: UIStoryboardSegue) { - if let bolusViewController = segue.source as? BolusViewController { - if let bolus = bolusViewController.bolus, bolus > 0 { - let startDate = Date() - dataManager.enactBolus(units: bolus) { (error) in - if error != nil { - NotificationManager.sendBolusFailureNotificationForAmount(bolus, atStartDate: startDate) - } + @IBAction func presentBolusScreen() { + presentBolusEntryView() + } + + @ViewBuilder + func bolusEntryView(enableManualGlucoseEntry: Bool = false) -> some View { + if FeatureFlags.simpleBolusCalculatorEnabled && !automaticDosingStatus.automaticDosingEnabled { + SimpleBolusView( + viewModel: SimpleBolusViewModel( + delegate: deviceManager, + displayMealEntry: false + ) + ) + .environmentObject(deviceManager.displayGlucosePreference) + } else { + let viewModel: BolusEntryViewModel = { + let viewModel = BolusEntryViewModel( + delegate: deviceManager, + screenWidth: UIScreen.main.bounds.width, + isManualGlucoseEntryEnabled: enableManualGlucoseEntry + ) + + Task { @MainActor in + await viewModel.generateRecommendationAndStartObserving() } - } + + viewModel.analyticsServicesManager = deviceManager.analyticsServicesManager + + return viewModel + }() + + BolusEntryView(viewModel: viewModel) + .environmentObject(deviceManager.displayGlucosePreference) } } - @IBAction func unwindFromSettings(_ segue: UIStoryboardSegue) { + func presentBolusEntryView(enableManualGlucoseEntry: Bool = false) { + let hostingController = DismissibleHostingController( + content: bolusEntryView( + enableManualGlucoseEntry: enableManualGlucoseEntry + ) + ) + + let navigationWrapper = UINavigationController(rootViewController: hostingController) + hostingController.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: navigationWrapper, action: #selector(dismissWithAnimation)) + present(navigationWrapper, animated: true) + deviceManager.analyticsServicesManager.didDisplayBolusScreen() } - private func createWorkoutButtonItem(selected: Bool) -> UIBarButtonItem { + private func createPreMealButtonItem(selected: Bool, isEnabled: Bool) -> UIBarButtonItem { + let item = UIBarButtonItem(image: UIImage.preMealImage(selected: selected), style: .plain, target: self, action: #selector(premealButtonTapped(_:))) + item.accessibilityLabel = NSLocalizedString("Pre-Meal Targets", comment: "The label of the pre-meal mode toggle button") + + if selected { + item.accessibilityTraits.insert(.selected) + item.accessibilityHint = NSLocalizedString("Disables", comment: "The action hint of the workout mode toggle button when enabled") + } else { + item.accessibilityHint = NSLocalizedString("Enables", comment: "The action hint of the workout mode toggle button when disabled") + } + + item.tintColor = UIColor.carbTintColor + item.isEnabled = isEnabled + + return item + } + + private func createWorkoutButtonItem(selected: Bool, isEnabled: Bool) -> UIBarButtonItem { let item = UIBarButtonItem(image: UIImage.workoutImage(selected: selected), style: .plain, target: self, action: #selector(toggleWorkoutMode(_:))) - item.accessibilityLabel = NSLocalizedString("Workout Mode", comment: "The label of the workout mode toggle button") + item.accessibilityLabel = NSLocalizedString("Workout Targets", comment: "The label of the workout mode toggle button") if selected { - item.accessibilityTraits = item.accessibilityTraits | UIAccessibilityTraitSelected + item.accessibilityTraits.insert(.selected) item.accessibilityHint = NSLocalizedString("Disables", comment: "The action hint of the workout mode toggle button when enabled") } else { item.accessibilityHint = NSLocalizedString("Enables", comment: "The action hint of the workout mode toggle button when disabled") } item.tintColor = UIColor.glucoseTintColor + item.isEnabled = isEnabled return item } - @IBAction func toggleWorkoutMode(_ sender: UIBarButtonItem) { - if let workoutModeEnabled = workoutMode, workoutModeEnabled { - dataManager.disableWorkoutMode() + @IBAction func premealButtonTapped(_ sender: UIBarButtonItem) { + togglePreMealMode(confirm: false) + } + + func togglePreMealMode(confirm: Bool = true) { + if preMealMode == true { + if confirm { + let alert = UIAlertController(title: "Disable Pre-Meal Preset?", message: "This will remove any currently applied pre-meal preset.", preferredStyle: .alert) + alert.addCancelAction() + alert.addAction(UIAlertAction(title: "Disable", style: .destructive, handler: { [weak self] _ in + self?.deviceManager.loopManager.mutateSettings { settings in + settings.clearOverride(matching: .preMeal) + } + })) + present(alert, animated: true) + } else { + deviceManager.loopManager.mutateSettings { settings in + settings.clearOverride(matching: .preMeal) + } + } } else { - let vc = UIAlertController(workoutDurationSelectionHandler: { (endDate) in - self.dataManager.enableWorkoutMode(until: endDate) - }) + presentPreMealModeAlertController() + } + } + + func presentPreMealModeAlertController() { + let vc = UIAlertController(premealDurationSelectionHandler: { duration in + let startDate = Date() + + guard self.workoutMode != true else { + // allow cell animation when switching between presets + self.deviceManager.loopManager.mutateSettings { settings in + settings.clearOverride() + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + self.deviceManager.loopManager.mutateSettings { settings in + settings.enablePreMealOverride(at: startDate, for: duration) + } + } + return + } + + self.deviceManager.loopManager.mutateSettings { settings in + settings.enablePreMealOverride(at: startDate, for: duration) + } + }) + + present(vc, animated: true, completion: nil) + } + + func presentCustomPresets(confirm: Bool = true) { + if workoutMode == true { + if confirm { + let alert = UIAlertController(title: "Disable Preset?", message: "This will remove any currently applied preset.", preferredStyle: .alert) + alert.addCancelAction() + alert.addAction(UIAlertAction(title: "Disable", style: .destructive, handler: { [weak self] _ in + self?.deviceManager.loopManager.mutateSettings { settings in + settings.clearOverride() + } + })) + present(alert, animated: true) + } else { + deviceManager.loopManager.mutateSettings { settings in + settings.clearOverride() + } + } + } else { + if FeatureFlags.sensitivityOverridesEnabled { + performSegue(withIdentifier: OverrideSelectionViewController.className, sender: toolbarItems![6]) + } else { + presentWorkoutModeAlertController() + } + } + } + + func presentWorkoutModeAlertController() { + let vc = UIAlertController(workoutDurationSelectionHandler: { duration in + let startDate = Date() + + guard self.preMealMode != true else { + // allow cell animation when switching between presets + self.deviceManager.loopManager.mutateSettings { settings in + settings.clearOverride(matching: .preMeal) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + self.deviceManager.loopManager.mutateSettings { settings in + settings.enableLegacyWorkoutOverride(at: startDate, for: duration) + } + } + return + } + + self.deviceManager.loopManager.mutateSettings { settings in + settings.enableLegacyWorkoutOverride(at: startDate, for: duration) + } + }) - present(vc, animated: true, completion: nil) + present(vc, animated: true, completion: nil) + } + + @IBAction func toggleWorkoutMode(_ sender: UIBarButtonItem) { + presentCustomPresets(confirm: false) + } + + @IBAction func onSettingsTapped(_ sender: UIBarButtonItem) { + presentSettings() + } + + private func presentSettings() { + let deletePumpDataFunc: () -> PumpManagerViewModel.DeleteTestingDataFunc? = { [weak self] in + (self?.deviceManager.pumpManager is TestingPumpManager) ? { + [weak self] in self?.deviceManager.deleteTestingPumpData() + } : nil + } + let deleteCGMDataFunc: () -> CGMManagerViewModel.DeleteTestingDataFunc? = { [weak self] in + (self?.deviceManager.cgmManager is TestingCGMManager) ? { + [weak self] in self?.deviceManager.deleteTestingCGMData() + } : nil } + let pumpViewModel = PumpManagerViewModel( + image: { [weak self] in self?.deviceManager.pumpManager?.smallImage }, + name: { [weak self] in self?.deviceManager.pumpManager?.localizedTitle ?? "" }, + isSetUp: { [weak self] in self?.deviceManager.pumpManager?.isOnboarded == true }, + availableDevices: deviceManager.availablePumpManagers, + deleteTestingDataFunc: deletePumpDataFunc, + onTapped: { [weak self] in + self?.onPumpTapped() + }, + didTapAddDevice: { [weak self] in + self?.addPumpManager(withIdentifier: $0.identifier) + }) + + let cgmViewModel = CGMManagerViewModel( + image: {[weak self] in (self?.deviceManager.cgmManager as? DeviceManagerUI)?.smallImage }, + name: {[weak self] in self?.deviceManager.cgmManager?.localizedTitle ?? "" }, + isSetUp: {[weak self] in self?.deviceManager.cgmManager?.isOnboarded == true }, + availableDevices: deviceManager.availableCGMManagers, + deleteTestingDataFunc: deleteCGMDataFunc, + onTapped: { [weak self] in + self?.onCGMTapped() + }, + didTapAddDevice: { [weak self] in + self?.addCGMManager(withIdentifier: $0.identifier) + }) + let servicesViewModel = ServicesViewModel(showServices: FeatureFlags.includeServicesInSettingsEnabled, + availableServices: { [weak self] in self?.deviceManager.servicesManager.availableServices ?? [] }, + activeServices: { [weak self] in self?.deviceManager.servicesManager.activeServices ?? [] }, + delegate: self) + let versionUpdateViewModel = VersionUpdateViewModel(supportManager: supportManager, guidanceColors: .default) + let viewModel = SettingsViewModel(alertPermissionsChecker: alertPermissionsChecker, + alertMuter: alertMuter, + versionUpdateViewModel: versionUpdateViewModel, + pumpManagerSettingsViewModel: pumpViewModel, + cgmManagerSettingsViewModel: cgmViewModel, + servicesViewModel: servicesViewModel, + criticalEventLogExportViewModel: CriticalEventLogExportViewModel(exporterFactory: deviceManager.criticalEventLogExportManager), + therapySettings: { [weak self] in self?.deviceManager.loopManager.therapySettings ?? TherapySettings() }, + sensitivityOverridesEnabled: FeatureFlags.sensitivityOverridesEnabled, + initialDosingEnabled: deviceManager.loopManager.settings.dosingEnabled, + isClosedLoopAllowed: automaticDosingStatus.$isAutomaticDosingAllowed, + automaticDosingStrategy: deviceManager.loopManager.settings.automaticDosingStrategy, + availableSupports: supportManager.availableSupports, + isOnboardingComplete: onboardingManager.isComplete, + therapySettingsViewModelDelegate: deviceManager, + delegate: self) + let hostingController = DismissibleHostingController( + rootView: SettingsView(viewModel: viewModel, localizedAppNameAndVersion: supportManager.localizedAppNameAndVersion) + .environmentObject(deviceManager.displayGlucosePreference) + .environment(\.appName, Bundle.main.bundleDisplayName), + isModalInPresentation: false) + present(hostingController, animated: true) + } + + private func onPumpTapped() { + guard var settingsViewController = deviceManager.pumpManager?.settingsViewController(bluetoothProvider: deviceManager.bluetoothProvider, colorPalette: .default, allowDebugFeatures: FeatureFlags.allowDebugFeatures, allowedInsulinTypes: deviceManager.allowedInsulinTypes) else { + // assert? + return + } + settingsViewController.pumpManagerOnboardingDelegate = deviceManager + settingsViewController.completionDelegate = self + show(settingsViewController, sender: self) + } + + private func onCGMTapped() { + guard let cgmManager = deviceManager.cgmManager as? CGMManagerUI else { + // assert? + return + } + + var settings = cgmManager.settingsViewController(bluetoothProvider: deviceManager.bluetoothProvider, displayGlucosePreference: deviceManager.displayGlucosePreference, colorPalette: .default, allowDebugFeatures: FeatureFlags.allowDebugFeatures) + settings.cgmManagerOnboardingDelegate = deviceManager + settings.completionDelegate = self + show(settings, sender: self) + } + + private func automaticDosingStatusChanged(_ automaticDosingEnabled: Bool) { + updatePresetModeAvailability(automaticDosingEnabled: automaticDosingEnabled) + hudView?.loopCompletionHUD.loopIconClosed = automaticDosingEnabled + hudView?.loopCompletionHUD.closedLoopDisallowedLocalizedDescription = deviceManager.closedLoopDisallowedLocalizedDescription } // MARK: - HUDs - @IBOutlet weak var hudView: HUDView! { + @IBOutlet var hudView: StatusBarHUDView? { didSet { - let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(openCGMApp(_:))) - glucoseHUD.addGestureRecognizer(tapGestureRecognizer) - - if cgmAppURL != nil { - glucoseHUD.accessibilityHint = NSLocalizedString("Launches CGM app", comment: "Glucose HUD accessibility hint") + guard let hudView = hudView, hudView != oldValue else { + return } + + let statusTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(showLoopCompletionMessage(_:))) + hudView.loopCompletionHUD.addGestureRecognizer(statusTapGestureRecognizer) + hudView.loopCompletionHUD.accessibilityHint = NSLocalizedString("Shows last loop error", comment: "Loop Completion HUD accessibility hint") + + let pumpStatusTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(pumpStatusTapped(_:))) + hudView.pumpStatusHUD.addGestureRecognizer(pumpStatusTapGestureRecognizer) + + let cgmStatusTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(cgmStatusTapped(_:))) + hudView.cgmStatusHUD.addGestureRecognizer(cgmStatusTapGestureRecognizer) + + configurePumpManagerHUDViews() + configureCGMManagerHUDViews() + + // when HUD view is initialized, update loop completion HUD (e.g., icon and last loop completed) + hudView.loopCompletionHUD.stateColors = .loopStatus + hudView.loopCompletionHUD.loopIconClosed = automaticDosingStatus.automaticDosingEnabled + hudView.loopCompletionHUD.lastLoopCompleted = deviceManager.loopManager.lastLoopCompleted + + hudView.cgmStatusHUD.stateColors = .cgmStatus + hudView.cgmStatusHUD.tintColor = .label + hudView.pumpStatusHUD.stateColors = .pumpStatus + hudView.pumpStatusHUD.tintColor = .insulinTintColor + + refreshContext.update(with: .status) + log.debug("[reloadData] after hudView loaded") + reloadData() } } - - var loopCompletionHUD: LoopCompletionHUDView! { - get { - return hudView.loopCompletionHUD + + private func configurePumpManagerHUDViews() { + if let hudView = hudView { + hudView.removePumpManagerProvidedView() + if let pumpManagerHUDProvider = deviceManager.pumpManagerHUDProvider { + if let view = pumpManagerHUDProvider.createHUDView() { + addPumpManagerViewToHUD(view) + } + pumpManagerHUDProvider.visible = active && onscreen + } + hudView.pumpStatusHUD.presentStatusHighlight(deviceManager.pumpStatusHighlight) + hudView.pumpStatusHUD.lifecycleProgress = deviceManager.pumpLifecycleProgress } } - - var glucoseHUD: GlucoseHUDView! { - get { - return hudView.glucoseHUD + + private func configureCGMManagerHUDViews() { + if let hudView = hudView { + hudView.cgmStatusHUD.presentStatusHighlight(deviceManager.cgmStatusHighlight) + hudView.cgmStatusHUD.lifecycleProgress = deviceManager.cgmLifecycleProgress + } + } + + private func addPumpManagerViewToHUD(_ view: BaseHUDView) { + if let hudView = hudView { + view.stateColors = .pumpStatus + hudView.addPumpManagerProvidedHUDView(view) } } - private var cgmAppURL: URL? { - if let url = URL(string: "dexcomcgm://"), UIApplication.shared.canOpenURL(url) { - return url - } else if let url = URL(string: "dexcomshare://"), UIApplication.shared.canOpenURL(url) { - return url + @objc private func showLoopCompletionMessage(_: Any) { + guard let loopCompletionMessage = hudView?.loopCompletionHUD.loopCompletionMessage else { return } + presentLoopCompletionMessage(title: loopCompletionMessage.title, message: loopCompletionMessage.message) + } + + private func presentLoopCompletionMessage(title: String, message: String) { + let action = UIAlertAction(title: NSLocalizedString("Dismiss", comment: "The button label of the action used to dismiss an error alert"), + style: .default) + let alertController = UIAlertController(title: title, + message: message, + preferredStyle: .alert) + alertController.addAction(action) + present(alertController, animated: true) + } + + @objc private func showLastError(_: Any) { + let error: Error? + // First, check whether we have a device error after the most recent completion date + if let deviceError = deviceManager.lastError, + deviceError.date > (hudView?.loopCompletionHUD.lastLoopCompleted ?? .distantPast) + { + error = deviceError.error + } else if let lastLoopError = lastLoopError { + error = lastLoopError } else { - return nil + error = nil + } + if let error = error { + let alertController = UIAlertController(with: error) + let manualLoopAction = UIAlertAction(title: NSLocalizedString("Retry", comment: "The button text for attempting a manual loop"), style: .default, handler: { _ in + self.deviceManager.refreshDeviceData() + }) + alertController.addAction(manualLoopAction) + present(alertController, animated: true) + } + } + + @objc private func pumpStatusTapped(_ sender: UIGestureRecognizer) { + if let pumpStatusView = sender.view as? PumpStatusHUDView { + executeHUDTapAction(deviceManager.didTapOnPumpStatus(pumpStatusView.pumpManagerProvidedHUD)) } } - @objc private func openCGMApp(_: Any) { - if let url = cgmAppURL { + @objc private func cgmStatusTapped( _ sender: UIGestureRecognizer) { + executeHUDTapAction(deviceManager.didTapOnCGMStatus()) + } + + private func executeHUDTapAction(_ action: HUDTapAction?) { + guard let action = action else { + return + } + + switch action { + case .presentViewController(let vc): + var completionNotifyingVC = vc + completionNotifyingVC.completionDelegate = self + present(completionNotifyingVC, animated: true, completion: nil) + case .openAppURL(let url): UIApplication.shared.open(url) + case .setupNewCGM: + addNewCGMManager() + case .setupNewPump: + addNewPumpManager() + default: + return } } - var basalRateHUD: BasalRateHUDView! { - get { - return hudView.basalRateHUD + private func addNewPumpManager() { + let availablePumpManagers = deviceManager.availablePumpManagers + + switch availablePumpManagers.count { + case 1: + if let availablePumpManager = availablePumpManagers.first { + addPumpManager(withIdentifier: availablePumpManager.identifier) + } + default: + let alert = UIAlertController(availablePumpManagers: availablePumpManagers) { [weak self] (identifier) in + self?.addPumpManager(withIdentifier: identifier) + } + alert.addCancelAction { _ in } + present(alert, animated: true, completion: nil) } } - - var reservoirVolumeHUD: ReservoirVolumeHUDView! { - get { - return hudView.reservoirVolumeHUD + + private func addNewCGMManager() { + let availableCGMManagers = deviceManager.availableCGMManagers + + switch availableCGMManagers.count { + case 1: + if let availableCGMManager = availableCGMManagers.first { + addCGMManager(withIdentifier: availableCGMManager.identifier) + } + default: + let alert = UIAlertController(availableCGMManagers: availableCGMManagers) { [weak self] identifier in + self?.addCGMManager(withIdentifier: identifier) + } + alert.addCancelAction { _ in } + present(alert, animated: true, completion: nil) + } + } + + + // MARK: - Debug Scenarios and Simulated Core Data + + var lastOrientation: UIDeviceOrientation? + var rotateCount = 0 + let maxRotationsToTrigger = 6 + var rotateTimer: Timer? + let rotateTimerTimeout = TimeInterval.seconds(2) + private func maybeOpenDebugMenu() { + guard FeatureFlags.allowDebugFeatures else { + return + } + // Opens the debug menu if you rotate the phone 6 times (or back & forth 3 times), each rotation within 2 secs. + if lastOrientation != UIDevice.current.orientation { + if UIDevice.current.orientation == .portrait && rotateCount >= maxRotationsToTrigger-1 { + presentDebugMenu() + rotateCount = 0 + rotateTimer?.invalidate() + rotateTimer = nil + } else { + rotateTimer?.invalidate() + rotateTimer = Timer.scheduledTimer(withTimeInterval: rotateTimerTimeout, repeats: false) { [weak self] _ in + self?.rotateCount = 0 + self?.rotateTimer?.invalidate() + self?.rotateTimer = nil + } + rotateCount += 1 + } + } + lastOrientation = UIDevice.current.orientation + } + + private func presentDebugMenu() { + guard FeatureFlags.allowDebugFeatures else { + return + } + + let actionSheet = UIAlertController(title: "Debug", message: nil, preferredStyle: .actionSheet) + if FeatureFlags.scenariosEnabled { + actionSheet.addAction(UIAlertAction(title: "Scenarios", style: .default) { _ in + DispatchQueue.main.async { + self.presentScenarioSelector() + } + }) + } + if FeatureFlags.simulatedCoreDataEnabled { + actionSheet.addAction(UIAlertAction(title: "Simulated Core Data", style: .default) { _ in + self.presentSimulatedCoreDataMenu() + }) + } + actionSheet.addAction(UIAlertAction(title: "Remove Exports Directory", style: .default) { _ in + if let error = self.deviceManager.removeExportsDirectory() { + self.presentError(error) + } + }) + if FeatureFlags.mockTherapySettingsEnabled { + actionSheet.addAction(UIAlertAction(title: "Mock Therapy Settings", style: .default) { _ in + let therapySettings = TherapySettings.mockTherapySettings + self.deviceManager.loopManager.mutateSettings { settings in + settings.glucoseTargetRangeSchedule = therapySettings.glucoseTargetRangeSchedule + settings.preMealTargetRange = therapySettings.correctionRangeOverrides?.preMeal + settings.legacyWorkoutTargetRange = therapySettings.correctionRangeOverrides?.workout + settings.suspendThreshold = therapySettings.suspendThreshold + settings.maximumBolus = therapySettings.maximumBolus + settings.maximumBasalRatePerHour = therapySettings.maximumBasalRatePerHour + settings.insulinSensitivitySchedule = therapySettings.insulinSensitivitySchedule + settings.carbRatioSchedule = therapySettings.carbRatioSchedule + settings.basalRateSchedule = therapySettings.basalRateSchedule + settings.defaultRapidActingModel = therapySettings.defaultRapidActingModel + } + }) + } + actionSheet.addAction(UIAlertAction(title: "Crash the App", style: .destructive) { _ in + fatalError("Test Crash") + }) + actionSheet.addAction(UIAlertAction(title: "Delete CGM Manager", style: .destructive) { _ in + self.deviceManager.cgmManager?.delete() { } + }) + + actionSheet.addCancelAction() + present(actionSheet, animated: true) + } + + private func presentScenarioSelector() { + guard FeatureFlags.scenariosEnabled else { + fatalError("\(#function) should be invoked only when scenarios are enabled") + } + + let vc = TestingScenariosTableViewController(scenariosManager: testingScenariosManager) + present(UINavigationController(rootViewController: vc), animated: true) + } + + private func addScenarioStepGestureRecognizers() { + if FeatureFlags.scenariosEnabled { + let leftSwipe = UISwipeGestureRecognizer(target: self, action: #selector(stepActiveScenarioForward)) + leftSwipe.direction = .left + let rightSwipe = UISwipeGestureRecognizer(target: self, action: #selector(stepActiveScenarioBackward)) + rightSwipe.direction = .right + + if let toolBar = navigationController?.toolbar { + toolBar.addGestureRecognizer(leftSwipe) + toolBar.addGestureRecognizer(rightSwipe) + } + } + } + + private func presentSimulatedCoreDataMenu() { + guard FeatureFlags.simulatedCoreDataEnabled else { + fatalError("\(#function) should be invoked only when simulated core data is enabled") + } + + let actionSheet = UIAlertController(title: "Simulated Core Data", message: nil, preferredStyle: .actionSheet) + actionSheet.addAction(UIAlertAction(title: "Generate Simulated Historical", style: .default) { _ in + self.presentConfirmation(actionSheetMessage: "All existing Core Data older than 24 hours will be purged before generating new simulated historical Core Data. Are you sure?", actionTitle: "Generate Simulated Historical") { + self.generateSimulatedHistoricalCoreData() + } + }) + actionSheet.addAction(UIAlertAction(title: "Purge Historical", style: .default) { _ in + self.presentConfirmation(actionSheetMessage: "All existing Core Data older than 24 hours will be purged. Are you sure?", actionTitle: "Purge Historical") { + self.purgeHistoricalCoreData() + } + }) + actionSheet.addCancelAction() + present(actionSheet, animated: true) + } + + private func generateSimulatedHistoricalCoreData() { + guard FeatureFlags.simulatedCoreDataEnabled else { + fatalError("\(#function) should be invoked only when simulated core data is enabled") + } + + presentActivityIndicator(title: "Simulated Core Data", message: "Generating simulated historical...") { dismissActivityIndicator in + self.deviceManager.purgeHistoricalCoreData() { error in + DispatchQueue.main.async { + if let error = error { + dismissActivityIndicator() + self.presentError(error) + return + } + + self.deviceManager.generateSimulatedHistoricalCoreData() { error in + DispatchQueue.main.async { + dismissActivityIndicator() + if let error = error { + self.presentError(error) + } + } + } + } + } + } + } + + private func purgeHistoricalCoreData() { + guard FeatureFlags.simulatedCoreDataEnabled else { + fatalError("\(#function) should be invoked only when simulated core data is enabled") + } + + presentActivityIndicator(title: "Simulated Core Data", message: "Purging historical...") { dismissActivityIndicator in + self.deviceManager.purgeHistoricalCoreData() { error in + DispatchQueue.main.async { + dismissActivityIndicator() + if let error = error { + self.presentError(error) + } + } + } + } + } + + private func presentConfirmation(actionSheetMessage: String, actionTitle: String, handler: @escaping () -> Void) { + let actionSheet = UIAlertController(title: nil, message: actionSheetMessage, preferredStyle: .actionSheet) + actionSheet.addAction(UIAlertAction(title: actionTitle, style: .destructive) { _ in handler() }) + actionSheet.addCancelAction() + present(actionSheet, animated: true) + } + + private func presentError(_ error: Error, handler: (() -> Void)? = nil) { + let alert = UIAlertController(title: "Error", message: "An error occurred: \(String(describing: error))", preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in handler?() }) + present(alert, animated: true) + } + + private func presentActivityIndicator(title: String, message: String, completion: @escaping (@escaping () -> Void) -> Void) { + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + alert.addActivityIndicator() + present(alert, animated: true) { completion { alert.dismiss(animated: true) } } + } + + @objc private func stepActiveScenarioForward() { + testingScenariosManager.stepActiveScenarioForward { _ in } + } + + @objc private func stepActiveScenarioBackward() { + testingScenariosManager.stepActiveScenarioBackward { _ in } + } +} + +extension UIAlertController { + func addActivityIndicator() { + let frame = CGRect(x: 0, y: 0, width: 40, height: 40) + let activityIndicator = UIActivityIndicatorView(frame: frame) + activityIndicator.style = .default + activityIndicator.startAnimating() + let viewController = UIViewController() + viewController.preferredContentSize = frame.size + viewController.view.addSubview(activityIndicator) + setValue(viewController, forKey: "contentViewController") + } +} + +extension StatusTableViewController: CompletionDelegate { + func completionNotifyingDidComplete(_ object: CompletionNotifying) { + if let vc = object as? UIViewController { + if presentedViewController === vc { + dismiss(animated: true, completion: nil) + } else { + vc.dismiss(animated: true, completion: nil) + } + } + } +} + +extension StatusTableViewController: PumpManagerStatusObserver { + func pumpManager(_ pumpManager: PumpManager, didUpdate status: PumpManagerStatus, oldStatus: PumpManagerStatus) { + dispatchPrecondition(condition: .onQueue(.main)) + log.default("PumpManager:%{public}@ did update status", String(describing: type(of: pumpManager))) + + basalDeliveryState = status.basalDeliveryState + bolusState = status.bolusState + + refreshContext.update(with: .status) + reloadData(animated: true) + } +} + +extension StatusTableViewController: CGMManagerStatusObserver { + func cgmManager(_ manager: CGMManager, didUpdate status: CGMManagerStatus) { + refreshContext.update(with: .status) + reloadData(animated: true) + } +} + +extension StatusTableViewController: DoseProgressObserver { + func doseProgressReporterDidUpdate(_ doseProgressReporter: DoseProgressReporter) { + + updateBolusProgress() + + if doseProgressReporter.progress.isComplete { + // Bolus ended + self.bolusProgressReporter = nil + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: { + self.bolusState = .noBolus + self.reloadData(animated: true) + }) + } + } +} + +extension StatusTableViewController: OverrideSelectionViewControllerDelegate { + func overrideSelectionViewController(_ vc: OverrideSelectionViewController, didUpdatePresets presets: [TemporaryScheduleOverridePreset]) { + deviceManager.loopManager.mutateSettings { settings in + settings.overridePresets = presets + } + } + + func overrideSelectionViewController(_ vc: OverrideSelectionViewController, didConfirmOverride override: TemporaryScheduleOverride) { + deviceManager.loopManager.mutateSettings { settings in + settings.scheduleOverride = override + } + } + + func overrideSelectionViewController(_ vc: OverrideSelectionViewController, didConfirmPreset preset: TemporaryScheduleOverridePreset) { + let intent = EnableOverridePresetIntent() + intent.overrideName = preset.name + + let interaction = INInteraction(intent: intent, response: nil) + interaction.identifier = preset.id.uuidString + interaction.groupIdentifier = preset.name + interaction.donate { (error) in + if let error = error { + os_log(.error, "Failed to donate intent: %{public}@", String(describing: error)) + } + } + deviceManager.loopManager.mutateSettings { settings in + settings.scheduleOverride = preset.createOverride(enactTrigger: .local) + } + } + + func overrideSelectionViewController(_ vc: OverrideSelectionViewController, didCancelOverride override: TemporaryScheduleOverride) { + deviceManager.loopManager.mutateSettings { settings in + settings.scheduleOverride = nil + } + } +} + +extension StatusTableViewController: AddEditOverrideTableViewControllerDelegate { + func addEditOverrideTableViewController(_ vc: AddEditOverrideTableViewController, didSaveOverride override: TemporaryScheduleOverride) { + deviceManager.loopManager.mutateSettings { settings in + settings.scheduleOverride = override + } + } + + func addEditOverrideTableViewController(_ vc: AddEditOverrideTableViewController, didCancelOverride override: TemporaryScheduleOverride) { + deviceManager.loopManager.mutateSettings { settings in + settings.scheduleOverride = nil + } + } +} + +extension StatusTableViewController { + fileprivate func addCGMManager(withIdentifier identifier: String) { + switch deviceManager.setupCGMManager(withIdentifier: identifier) { + case .failure(let error): + log.error("Failure to setup CGM manager with identifier '%{public}@': %{public}@", identifier, String(describing: error)) + case .success(let success): + switch success { + case .userInteractionRequired(var setupViewController): + setupViewController.cgmManagerOnboardingDelegate = deviceManager + setupViewController.completionDelegate = self + show(setupViewController, sender: self) + case .createdAndOnboarded: + log.default("CGM manager with identifier '%{public}@' created and onboarded", identifier) + } + } + } +} + +extension StatusTableViewController { + fileprivate func addPumpManager(withIdentifier identifier: String) { + guard let maximumBasalRate = deviceManager.loopManager.settings.maximumBasalRatePerHour, + let maxBolus = deviceManager.loopManager.settings.maximumBolus, + let basalSchedule = deviceManager.loopManager.settings.basalRateSchedule else + { + log.error("Failure to setup pump manager: incomplete settings") + return + } + + let settings = PumpManagerSetupSettings(maxBasalRateUnitsPerHour: maximumBasalRate, + maxBolusUnits: maxBolus, + basalSchedule: basalSchedule) + switch deviceManager.setupPumpManagerUI(withIdentifier: identifier, initialSettings: settings) { + case .failure(let error): + log.error("Failure to setup pump manager with identifier '%{public}@': %{public}@", identifier, String(describing: error)) + case .success(let success): + switch success { + case .userInteractionRequired(var setupViewController): + setupViewController.pumpManagerOnboardingDelegate = deviceManager + setupViewController.completionDelegate = self + show(setupViewController, sender: self) + case .createdAndOnboarded: + log.default("Pump manager with identifier '%{public}@' created and onboarded", identifier) + } + } + } +} + +extension StatusTableViewController: BluetoothObserver { + func bluetoothDidUpdateState(_ state: BluetoothState) { + refreshContext.update(with: .status) + reloadData(animated: true) + } +} + +// MARK: - SettingsViewModel delegation +extension StatusTableViewController: SettingsViewModelDelegate { + var closedLoopDescriptiveText: String? { + return deviceManager.closedLoopDisallowedLocalizedDescription + } + + func dosingEnabledChanged(_ value: Bool) { + deviceManager.loopManager.mutateSettings { settings in + settings.dosingEnabled = value } } - var batteryLevelHUD: BatteryLevelHUDView! { - get { - return hudView.batteryHUD + func dosingStrategyChanged(_ strategy: AutomaticDosingStrategy) { + self.deviceManager.loopManager.mutateSettings { settings in + settings.automaticDosingStrategy = strategy + } + } + + func didTapIssueReport() { + // TODO: this dismiss here is temporary, until we know exactly where + // we want this screen to belong in the navigation flow + dismiss(animated: true) { + let vc = CommandResponseViewController.generateDiagnosticReport(deviceManager: self.deviceManager) + vc.title = NSLocalizedString("Issue Report", comment: "The view controller title for the issue report screen") + self.show(vc, sender: nil) + } + } +} + +// MARK: - Services delegation + +extension StatusTableViewController: ServicesViewModelDelegate { + func addService(withIdentifier identifier: String) { + switch deviceManager.servicesManager.setupService(withIdentifier: identifier) { + case .failure(let error): + log.default("Failure to setup service with identifier '%{public}@': %{public}@", identifier, String(describing: error)) + case .success(let success): + switch success { + case .userInteractionRequired(var setupViewController): + setupViewController.serviceOnboardingDelegate = deviceManager.servicesManager + setupViewController.completionDelegate = self + show(setupViewController, sender: self) + case .createdAndOnboarded: + log.default("Service with identifier '%{public}@' created and onboarded", identifier) + } } } + + func gotoService(withIdentifier identifier: String) { + guard let serviceUI = deviceManager.servicesManager.activeServices.first(where: { $0.pluginIdentifier == identifier }) as? ServiceUI else { + return + } + showServiceSettings(serviceUI) + } + + fileprivate func showServiceSettings(_ serviceUI: ServiceUI) { + var settingsViewController = serviceUI.settingsViewController(colorPalette: .default) + settingsViewController.serviceOnboardingDelegate = deviceManager.servicesManager + settingsViewController.completionDelegate = self + show(settingsViewController, sender: self) + } } diff --git a/Loop/View Controllers/TestingScenariosTableViewController.swift b/Loop/View Controllers/TestingScenariosTableViewController.swift new file mode 100644 index 0000000000..0d1ea136f7 --- /dev/null +++ b/Loop/View Controllers/TestingScenariosTableViewController.swift @@ -0,0 +1,176 @@ +// +// TestingScenariosTableViewController.swift +// Loop +// +// Created by Michael Pangburn on 4/20/19. +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import UIKit +import LoopKitUI + + +final class TestingScenariosTableViewController: RadioSelectionTableViewController { + + private let scenariosManager: TestingScenariosManager + + private var scenarios: [LoopScenario] = [] { + didSet { + options = scenarios.map(\.name) + + if isViewLoaded { + DispatchQueue.main.async { + self.updateLoadButtonEnabled() + self.tableView.reloadData() + } + } + } + } + + override var selectedIndex: Int? { + didSet { + updateLoadButtonEnabled() + } + } + + private lazy var loadButtonItem = UIBarButtonItem(title: "Load", style: .done, target: self, action: #selector(loadSelectedScenario)) + + init(scenariosManager: TestingScenariosManager) { + guard FeatureFlags.scenariosEnabled else { + fatalError("\(#function) should be invoked only when scenarios are enabled") + } + + self.scenariosManager = scenariosManager + super.init(style: .grouped) + scenariosManager.delegate = self + } + + required init?(coder aDecoder: NSCoder) { + fatalError() + } + + override func viewDidLoad() { + super.viewDidLoad() + + navigationController?.navigationBar.prefersLargeTitles = true + title = "🧪 Scenarios" + navigationItem.rightBarButtonItem = loadButtonItem + navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancel)) + contextHelp = "The scenarios directory location is available in the debug output of the Xcode console." + + if let activeScenarioURL = scenariosManager.activeScenarioURL { + selectedIndex = scenarios.firstIndex(where: { $0.url == activeScenarioURL }) + } + + updateLoadButtonEnabled() + } + + override func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + let url = scenarios[indexPath.row].url + + let rewindScenario = contextualAction( + rowTitle: "⏮ Rewind", + alertTitle: "Rewind Scenario", + message: "Step backward a number of loop iterations.", + loadScenario: { self.scenariosManager.loadScenario(from: url, rewoundByLoopIterations: $0, completion: $1) } + ) + rewindScenario.backgroundColor = .lightGray + + return UISwipeActionsConfiguration(actions: [rewindScenario]) + } + + override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + let url = scenarios[indexPath.row].url + + let advanceScenario = contextualAction( + rowTitle: "Advance ⏭", + alertTitle: "Advance Scenario", + message: "Step forward a number of loop iterations.", + loadScenario: { self.scenariosManager.loadScenario(from: url, advancedByLoopIterations: $0, completion: $1) } + ) + advanceScenario.backgroundColor = .HIGGreenColor() + + return UISwipeActionsConfiguration(actions: [advanceScenario]) + } + + private func contextualAction( + rowTitle: String, alertTitle: String, message: String, + loadScenario: @escaping (_ iterations: Int, _ completion: @escaping (Error?) -> Void) -> Void + ) -> UIContextualAction { + return UIContextualAction(style: .normal, title: rowTitle) { action, sourceView, completion in + let alert = UIAlertController( + title: alertTitle, + message: message, + cancelButtonTitle: "Cancel", + okButtonTitle: "OK", + validate: { text in + guard let iterations = Int(text) else { + return false + } + return iterations > 0 + }, + textFieldConfiguration: { textField in + textField.placeholder = "Iteration count" + textField.keyboardType = .numberPad + } + ) { result in + switch result { + case .cancel: + completion(false) + case .ok(let iterationsText): + let iterations = Int(iterationsText)! + loadScenario(iterations) { [weak self] _ in + self?.dismiss(animated: true) + } + completion(true) + } + } + + self.present(alert, animated: true) + } + } + + private func updateLoadButtonEnabled() { + loadButtonItem.isEnabled = !scenarios.isEmpty && selectedIndex != nil + } + + @objc private func loadSelectedScenario() { + guard let selectedIndex = selectedIndex else { + assertionFailure("Loading should be possible only when a scenario is selected") + return + } + + let url = scenarios[selectedIndex].url + + loadButtonItem.isEnabled = false + loadButtonItem.title = "Loading..." + scenariosManager.loadScenario(from: url) { error in + DispatchQueue.main.async { + self.loadButtonItem.isEnabled = true + self.loadButtonItem.title = "Load" + if let error = error { + self.present(UIAlertController(with: error), animated: true) + } else { + self.dismiss(animated: true) + } + } + } + } + + @objc private func cancel() { + self.dismiss(animated: true) + } +} + +extension TestingScenariosTableViewController: TestingScenariosManagerDelegate { + func testingScenariosManager(_ manager: TestingScenariosManager, didUpdateScenarioURLs scenarioURLs: [URL]) { + var filteredScenarios = Set() + manager.supportManager.availableSupports.forEach { supportUI in + supportUI.getScenarios(from: scenarioURLs).forEach { scenario in + filteredScenarios.insert(scenario) + } + } + + self.scenarios = Array(filteredScenarios).sorted(by: { $0.name < $1.name }) + } +} diff --git a/Loop/View Controllers/TextFieldTableViewController.swift b/Loop/View Controllers/TextFieldTableViewController.swift index 501add03b0..07c686e0f8 100644 --- a/Loop/View Controllers/TextFieldTableViewController.swift +++ b/Loop/View Controllers/TextFieldTableViewController.swift @@ -6,13 +6,14 @@ // Copyright © 2016 Nathan Racklyeft. All rights reserved. // -import LoopKit +import LoopKitUI +import HealthKit /// Convenience static constructors used to contain common configuration extension TextFieldTableViewController { typealias T = TextFieldTableViewController - + private static let valueNumberFormatter: NumberFormatter = { let formatter = NumberFormatter() @@ -22,67 +23,4 @@ extension TextFieldTableViewController { return formatter }() - - static func pumpID(_ value: String?) -> T { - let vc = T() - - vc.placeholder = NSLocalizedString("Enter the 6-digit pump ID", comment: "The placeholder text instructing users how to enter a pump ID") - vc.keyboardType = .numberPad - vc.value = value - vc.contextHelp = NSLocalizedString("The pump ID can be found printed on the back, or near the bottom of the STATUS/Esc screen. It is the strictly numerical portion of the serial number (shown as SN or S/N).", comment: "Instructions on where to find the pump ID on a Minimed pump") - - return vc - } - - static func transmitterID(_ value: String?) -> T { - let vc = T() - - vc.placeholder = NSLocalizedString("Enter the 6-digit transmitter ID", comment: "The placeholder text instructing users how to enter a pump ID") - vc.value = value - vc.contextHelp = NSLocalizedString("The transmitter ID can be found printed on the back of the device, on the side of the box it came in, and from within the settings menus of the G5 receiver and mobile app.", comment: "Instructions on where to find the transmitter ID") - - return vc - } - - static func insulinActionDuration(_ value: TimeInterval?) -> T { - let vc = T() - - vc.placeholder = NSLocalizedString("Enter a number of hours", comment: "The placeholder text instructing users how to enter an insulin action duration") - vc.keyboardType = .decimalPad - vc.unit = NSLocalizedString("hours", comment: "The unit string for hours") - - if let insulinActionDuration = value { - vc.value = valueNumberFormatter.string(from: NSNumber(value: insulinActionDuration.hours)) - } - - return vc - } - - static func maxBasal(_ value: Double?) -> T { - let vc = T() - - vc.placeholder = NSLocalizedString("Enter a rate in units per hour", comment: "The placeholder text instructing users how to enter a maximum basal rate") - vc.keyboardType = .decimalPad - vc.unit = NSLocalizedString("U/hour", comment: "The unit string for units per hour") - - if let maxBasal = value { - vc.value = valueNumberFormatter.string(from: NSNumber(value: maxBasal)) - } - - return vc - } - - static func maxBolus(_ value: Double?) -> T { - let vc = T() - - vc.placeholder = NSLocalizedString("Enter a number of units", comment: "The placeholder text instructing users how to enter a maximum bolus") - vc.keyboardType = .decimalPad - vc.unit = NSLocalizedString("Units", comment: "The unit string for units") - - if let maxBolus = value { - vc.value = valueNumberFormatter.string(from: NSNumber(value: maxBolus)) - } - - return vc - } } diff --git a/Loop/View Models/AddEditFavoriteFoodViewModel.swift b/Loop/View Models/AddEditFavoriteFoodViewModel.swift new file mode 100644 index 0000000000..5bd6eb8775 --- /dev/null +++ b/Loop/View Models/AddEditFavoriteFoodViewModel.swift @@ -0,0 +1,109 @@ +// +// AddEditFavoriteFoodViewModel.swift +// Loop +// +// Created by Noah Brauner on 7/31/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import HealthKit + +final class AddEditFavoriteFoodViewModel: ObservableObject { + enum Alert: Identifiable { + var id: Self { + return self + } + + case maxQuantityExceded + case warningQuantityValidation + } + + @Published var name = "" + + @Published var carbsQuantity: Double? = nil + var preferredCarbUnit = HKUnit.gram() + var maxCarbEntryQuantity = LoopConstants.maxCarbEntryQuantity + var warningCarbEntryQuantity = LoopConstants.warningCarbEntryQuantity + + @Published var foodType = "" + + @Published var absorptionTime: TimeInterval + let minAbsorptionTime = LoopConstants.minCarbAbsorptionTime + let maxAbsorptionTime = LoopConstants.maxCarbAbsorptionTime + var absorptionRimesRange: ClosedRange { + return minAbsorptionTime...maxAbsorptionTime + } + + @Published var alert: AddEditFavoriteFoodViewModel.Alert? + + private let onSave: (NewFavoriteFood) -> () + + init(originalFavoriteFood: StoredFavoriteFood?, onSave: @escaping (NewFavoriteFood) -> ()) { + self.onSave = onSave + if let food = originalFavoriteFood { + self.originalFavoriteFood = food + self.name = food.name + self.carbsQuantity = food.carbsQuantity.doubleValue(for: preferredCarbUnit) + self.foodType = food.foodType + self.absorptionTime = food.absorptionTime + } + else { + self.absorptionTime = .hours(3) + } + } + + init(carbsQuantity: Double?, foodType: String, absorptionTime: TimeInterval, onSave: @escaping (NewFavoriteFood) -> ()) { + self.onSave = onSave + self.carbsQuantity = carbsQuantity + self.foodType = foodType + self.absorptionTime = absorptionTime + } + + var originalFavoriteFood: StoredFavoriteFood? + var updatedFavoriteFood: NewFavoriteFood? { + if let quantity = carbsQuantity, quantity != 0, name != "", foodType != "" { + if let o = originalFavoriteFood, o.name == name, o.carbsQuantity.doubleValue(for: preferredCarbUnit) == carbsQuantity && o.foodType == foodType && o.absorptionTime == absorptionTime { + return nil // No changes were made + } + + return NewFavoriteFood( + name: name, + carbsQuantity: HKQuantity(unit: preferredCarbUnit, doubleValue: quantity), + foodType: foodType, + absorptionTime: absorptionTime + ) + } + else { + return nil + } + } + + func save() { + guard let updatedFavoriteFood, absorptionTime <= maxAbsorptionTime else { return } + + guard let carbsQuantity, carbsQuantity > 0 else { return } + let quantity = HKQuantity(unit: preferredCarbUnit, doubleValue: carbsQuantity) + if quantity.compare(maxCarbEntryQuantity) == .orderedDescending { + self.alert = .maxQuantityExceded + return + } + else if quantity.compare(warningCarbEntryQuantity) == .orderedDescending { + self.alert = .warningQuantityValidation + return + } + + onSave(updatedFavoriteFood) + } + + func clearAlertAndSave() { + guard let updatedFavoriteFood else { return } + self.alert = nil + onSave(updatedFavoriteFood) + } + + func clearAlert() { + self.alert = nil + } +} diff --git a/Loop/View Models/BolusEntryViewModel.swift b/Loop/View Models/BolusEntryViewModel.swift new file mode 100644 index 0000000000..a86f20e0cc --- /dev/null +++ b/Loop/View Models/BolusEntryViewModel.swift @@ -0,0 +1,873 @@ +// +// BolusEntryViewModel.swift +// Loop +// +// Created by Michael Pangburn on 7/17/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import Combine +import HealthKit +import LocalAuthentication +import Intents +import os.log +import LoopCore +import LoopKit +import LoopKitUI +import LoopUI +import SwiftUI +import SwiftCharts + +protocol BolusEntryViewModelDelegate: AnyObject { + + func withLoopState(do block: @escaping (LoopState) -> Void) + + func saveGlucose(sample: NewGlucoseSample) async -> StoredGlucoseSample? + + func addCarbEntry(_ carbEntry: NewCarbEntry, replacing replacingEntry: StoredCarbEntry? , + completion: @escaping (_ result: Result) -> Void) + + func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) + + func enactBolus(units: Double, activationType: BolusActivationType, completion: @escaping (_ error: Error?) -> Void) + + func getGlucoseSamples(start: Date?, end: Date?, completion: @escaping (_ samples: Swift.Result<[StoredGlucoseSample], Error>) -> Void) + + func insulinOnBoard(at date: Date, completion: @escaping (_ result: DoseStoreResult) -> Void) + + func carbsOnBoard(at date: Date, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (_ result: CarbStoreResult) -> Void) + + func insulinActivityDuration(for type: InsulinType?) -> TimeInterval + + var mostRecentGlucoseDataDate: Date? { get } + + var mostRecentPumpDataDate: Date? { get } + + var isPumpConfigured: Bool { get } + + var pumpInsulinType: InsulinType? { get } + + var settings: LoopSettings { get } + + var displayGlucosePreference: DisplayGlucosePreference { get } + + func roundBolusVolume(units: Double) -> Double + + func updateRemoteRecommendation() +} + +@MainActor +final class BolusEntryViewModel: ObservableObject { + enum Alert: Int { + case recommendationChanged + case maxBolusExceeded + case bolusTooSmall + case noPumpManagerConfigured + case noMaxBolusConfigured + case carbEntryPersistenceFailure + case manualGlucoseEntryOutOfAcceptableRange + case manualGlucoseEntryPersistenceFailure + case glucoseNoLongerStale + case forecastInfo + } + + enum Notice: Equatable { + case predictedGlucoseInRange + case predictedGlucoseBelowSuspendThreshold(suspendThreshold: HKQuantity) + case glucoseBelowTarget + case staleGlucoseData + case futureGlucoseData + case stalePumpData + } + + var authenticationHandler: (String) async -> Bool = { message in + return await withCheckedContinuation { continuation in + LocalAuthentication.deviceOwnerCheck(message) { result in + switch result { + case .success: + continuation.resume(returning: true) + case .failure: + continuation.resume(returning: false) + } + } + } + } + + // MARK: - State + + @Published var glucoseValues: [GlucoseValue] = [] // stored glucose values + manual glucose entry + private var storedGlucoseValues: [GlucoseValue] = [] + @Published var predictedGlucoseValues: [GlucoseValue] = [] + @Published var chartDateInterval: DateInterval + + @Published var activeCarbs: HKQuantity? + @Published var activeInsulin: HKQuantity? + + @Published var targetGlucoseSchedule: GlucoseRangeSchedule? + @Published var preMealOverride: TemporaryScheduleOverride? + private var savedPreMealOverride: TemporaryScheduleOverride? + @Published var scheduleOverride: TemporaryScheduleOverride? + var maximumBolus: HKQuantity? + + let originalCarbEntry: StoredCarbEntry? + let potentialCarbEntry: NewCarbEntry? + let selectedCarbAbsorptionTimeEmoji: String? + + @Published var recommendedBolus: HKQuantity? + var recommendedBolusAmount: Double? { + recommendedBolus?.doubleValue(for: .internationalUnit()) + } + @Published var enteredBolus = HKQuantity(unit: .internationalUnit(), doubleValue: 0) + var enteredBolusAmount: Double { + enteredBolus.doubleValue(for: .internationalUnit()) + } + private var userChangedBolusAmount = false + @Published var isInitiatingSaveOrBolus = false + @Published var enacting = false + + private var dosingDecision = BolusDosingDecision(for: .normalBolus) + + @Published var activeAlert: Alert? + @Published var activeNotice: Notice? + + private let log = OSLog(category: "BolusEntryViewModel") + private var cancellables: Set = [] + + let chartManager: ChartsManager = { + let predictedGlucoseChart = PredictedGlucoseChart(predictedGlucoseBounds: FeatureFlags.predictedGlucoseChartClampEnabled ? .default : nil, + yAxisStepSizeMGDLOverride: FeatureFlags.predictedGlucoseChartClampEnabled ? 40 : nil) + predictedGlucoseChart.glucoseDisplayRange = LoopConstants.glucoseChartDefaultDisplayRangeWide + return ChartsManager( + colors: ChartColorPalette.primary, + settings: ChartSettings.default, + charts: [predictedGlucoseChart], + traitCollection: UITraitCollection.current) + }() + + @Published var isManualGlucoseEntryEnabled = false + @Published var manualGlucoseQuantity: HKQuantity? + + var manualGlucoseSample: NewGlucoseSample? + + // MARK: - Seams + private weak var delegate: BolusEntryViewModelDelegate? + private let now: () -> Date + private let screenWidth: CGFloat + private let debounceIntervalMilliseconds: Int + private let uuidProvider: () -> String + private let carbEntryDateFormatter: DateFormatter + + var analyticsServicesManager: AnalyticsServicesManager? + + // MARK: - Initialization + + init( + delegate: BolusEntryViewModelDelegate?, + now: @escaping () -> Date = { Date() }, + screenWidth: CGFloat, + debounceIntervalMilliseconds: Int = 400, + uuidProvider: @escaping () -> String = { UUID().uuidString }, + timeZone: TimeZone? = nil, + originalCarbEntry: StoredCarbEntry? = nil, + potentialCarbEntry: NewCarbEntry? = nil, + selectedCarbAbsorptionTimeEmoji: String? = nil, + isManualGlucoseEntryEnabled: Bool = false + ) { + self.delegate = delegate + self.now = now + self.screenWidth = screenWidth + self.debounceIntervalMilliseconds = debounceIntervalMilliseconds + self.uuidProvider = uuidProvider + self.carbEntryDateFormatter = DateFormatter() + self.carbEntryDateFormatter.dateStyle = .none + self.carbEntryDateFormatter.timeStyle = .short + if let timeZone = timeZone { + self.carbEntryDateFormatter.timeZone = timeZone + } + + self.originalCarbEntry = originalCarbEntry + self.potentialCarbEntry = potentialCarbEntry + self.selectedCarbAbsorptionTimeEmoji = selectedCarbAbsorptionTimeEmoji + + self.isManualGlucoseEntryEnabled = isManualGlucoseEntryEnabled + + self.chartDateInterval = DateInterval(start: Date(timeInterval: .hours(-1), since: now()), duration: .hours(7)) + + self.dosingDecision.originalCarbEntry = originalCarbEntry + + self.updateSettings() + } + + public func generateRecommendationAndStartObserving() async { + await update() + + // Only start observing after first update is complete + self.observeLoopUpdates() + self.observeElapsedTime() + self.observeEnteredManualGlucoseChanges() + self.observeEnteredBolusChanges() + + } + + private func observeLoopUpdates() { + NotificationCenter.default + .publisher(for: .LoopDataUpdated) + .receive(on: DispatchQueue.main) + .sink { [weak self] note in + Task { + if let rawContext = note.userInfo?[LoopDataManager.LoopUpdateContextKey] as? LoopDataManager.LoopUpdateContext.RawValue, + let context = LoopDataManager.LoopUpdateContext(rawValue: rawContext), + context == .preferences + { + self?.updateSettings() + } + await self?.update() + } + } + .store(in: &cancellables) + } + + private func observeEnteredBolusChanges() { + $enteredBolus + .dropFirst() + .removeDuplicates() + .debounce(for: .milliseconds(debounceIntervalMilliseconds), scheduler: RunLoop.main) + .sink { [weak self] _ in + self?.delegate?.withLoopState { [weak self] state in + self?.updatePredictedGlucoseValues(from: state) + } + } + .store(in: &cancellables) + } + + private func observeEnteredManualGlucoseChanges() { + $manualGlucoseQuantity + .sink { [weak self] manualGlucoseQuantity in + guard let self = self else { return } + + // Clear out any entered bolus whenever the glucose entry changes + self.enteredBolus = HKQuantity(unit: .internationalUnit(), doubleValue: 0) + + self.delegate?.withLoopState { [weak self] state in + self?.updatePredictedGlucoseValues(from: state, completion: { + // Ensure the manual glucose entry appears on the chart at the same time as the updated prediction + self?.updateGlucoseChartValues() + }) + + self?.updateRecommendedBolusAndNotice(from: state, isUpdatingFromUserInput: true) + } + + if let manualGlucoseQuantity = manualGlucoseQuantity { + self.manualGlucoseSample = NewGlucoseSample( + date: self.now(), + quantity: manualGlucoseQuantity, + condition: nil, // All manual glucose entries are assumed to have no condition. + trend: nil, // All manual glucose entries are assumed to have no trend. + trendRate: nil, // All manual glucose entries are assumed to have no trend rate. + isDisplayOnly: false, + wasUserEntered: true, + syncIdentifier: self.uuidProvider() + ) + } + } + .store(in: &cancellables) + } + + + private func observeElapsedTime() { + // If glucose data is stale, loop status updates cannot be expected to keep presented data fresh. + // Periodically update the UI to ensure recommendations do not go stale. + Timer.publish(every: .minutes(5), tolerance: .seconds(15), on: .main, in: .default) + .autoconnect() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + self.log.default("5 minutes elapsed on bolus screen; refreshing UI") + Task { + await self.update() + } + } + .store(in: &cancellables) + } + + // MARK: - View API + + var isBolusRecommended: Bool { + guard let recommendedBolusAmount = recommendedBolusAmount else { + return false + } + + return recommendedBolusAmount > 0 + } + + func saveCarbEntry(_ entry: NewCarbEntry, replacingEntry: StoredCarbEntry?) async -> StoredCarbEntry? { + guard let delegate = delegate else { + return nil + } + + return await withCheckedContinuation { continuation in + delegate.addCarbEntry(entry, replacing: replacingEntry) { result in + switch result { + case .success(let storedCarbEntry): + continuation.resume(returning: storedCarbEntry) + case .failure(let error): + self.log.error("Failed to add carb entry: %{public}@", String(describing: error)) + continuation.resume(returning: nil) + } + } + } + } + + // returns true if action succeeded + func didPressActionButton() async -> Bool { + enacting = true + if await saveAndDeliver() { + return true + } else { + enacting = false + return false + } + } + + // returns true if no errors + func saveAndDeliver() async -> Bool { + guard delegate?.isPumpConfigured ?? false else { + presentAlert(.noPumpManagerConfigured) + return false + } + + guard let delegate = delegate else { + assertionFailure("Missing BolusEntryViewModelDelegate") + return false + } + + let amountToDeliver = delegate.roundBolusVolume(units: enteredBolusAmount) + guard enteredBolusAmount == 0 || amountToDeliver > 0 else { + presentAlert(.bolusTooSmall) + return false + } + + let amountToDeliverString = formatBolusAmount(amountToDeliver) + + let manualGlucoseSample = manualGlucoseSample + let potentialCarbEntry = potentialCarbEntry + + guard let maximumBolus = maximumBolus else { + presentAlert(.noMaxBolusConfigured) + return false + } + + guard amountToDeliver <= maximumBolus.doubleValue(for: .internationalUnit()) else { + presentAlert(.maxBolusExceeded) + return false + } + + if let manualGlucoseSample = manualGlucoseSample { + guard LoopConstants.validManualGlucoseEntryRange.contains(manualGlucoseSample.quantity) else { + presentAlert(.manualGlucoseEntryOutOfAcceptableRange) + return false + } + } + + // Authenticate the bolus before saving anything + if amountToDeliver > 0 { + let message = String(format: NSLocalizedString("Authenticate to Bolus %@ Units", comment: "The message displayed during a device authentication prompt for bolus specification"), amountToDeliverString) + + if !(await authenticationHandler(message)) { + return false + } + } + + defer { + delegate.updateRemoteRecommendation() + } + + if let manualGlucoseSample = manualGlucoseSample { + if let glucoseValue = await delegate.saveGlucose(sample: manualGlucoseSample) { + dosingDecision.manualGlucoseSample = glucoseValue + } else { + presentAlert(.manualGlucoseEntryPersistenceFailure) + return false + } + } else { + self.dosingDecision.manualGlucoseSample = nil + } + + let activationType = BolusActivationType.activationTypeFor(recommendedAmount: recommendedBolus?.doubleValue(for: .internationalUnit()), bolusAmount: amountToDeliver) + + if let carbEntry = potentialCarbEntry { + if originalCarbEntry == nil { + let interaction = INInteraction(intent: NewCarbEntryIntent(), response: nil) + + do { + try await interaction.donate() + } catch { + log.error("Failed to donate intent: %{public}@", String(describing: error)) + } + } + if let storedCarbEntry = await saveCarbEntry(carbEntry, replacingEntry: originalCarbEntry) { + self.dosingDecision.carbEntry = storedCarbEntry + self.analyticsServicesManager?.didAddCarbs(source: "Phone", amount: storedCarbEntry.quantity.doubleValue(for: .gram())) + } else { + self.presentAlert(.carbEntryPersistenceFailure) + return false + } + } + + dosingDecision.manualBolusRequested = amountToDeliver + + let now = self.now() + delegate.storeManualBolusDosingDecision(dosingDecision, withDate: now) + + if amountToDeliver > 0 { + savedPreMealOverride = nil + delegate.enactBolus(units: amountToDeliver, activationType: activationType, completion: { _ in + self.analyticsServicesManager?.didBolus(source: "Phone", units: amountToDeliver) + }) + } + return true + } + + private func presentAlert(_ alert: Alert) { + dispatchPrecondition(condition: .onQueue(.main)) + + // As of iOS 13.6 / Xcode 11.6, swapping out an alert while one is active crashes SwiftUI. + guard activeAlert == nil else { + return + } + + activeAlert = alert + } + + private lazy var bolusAmountFormatter: NumberFormatter = { + let formatter = QuantityFormatter(for: .internationalUnit()) + formatter.numberFormatter.roundingMode = .down + return formatter.numberFormatter + }() + + private lazy var absorptionTimeFormatter: DateComponentsFormatter = { + let formatter = DateComponentsFormatter() + formatter.collapsesLargestUnit = true + formatter.unitsStyle = .abbreviated + formatter.allowsFractionalUnits = true + formatter.allowedUnits = [.hour, .minute] + return formatter + }() + + var enteredBolusAmountString: String { + let bolusAmount = enteredBolusAmount + return formatBolusAmount(bolusAmount) + } + + var maximumBolusAmountString: String? { + guard let maxBolusAmount = maximumBolus?.doubleValue(for: .internationalUnit()) else { + return nil + } + return formatBolusAmount(maxBolusAmount) + } + + var carbEntryAmountAndEmojiString: String? { + guard + let potentialCarbEntry = potentialCarbEntry, + let carbAmountString = QuantityFormatter(for: .gram()).string(from: potentialCarbEntry.quantity) + else { + return nil + } + + if let emoji = potentialCarbEntry.foodType ?? selectedCarbAbsorptionTimeEmoji { + return String(format: NSLocalizedString("%1$@ %2$@", comment: "Format string combining carb entry quantity and absorption time emoji"), carbAmountString, emoji) + } else { + return carbAmountString + } + } + + var carbEntryDateAndAbsorptionTimeString: String? { + guard let potentialCarbEntry = potentialCarbEntry else { + return nil + } + + let entryTimeString = carbEntryDateFormatter.string(from: potentialCarbEntry.startDate) + + if let absorptionTime = potentialCarbEntry.absorptionTime, let absorptionTimeString = absorptionTimeFormatter.string(from: absorptionTime) { + return String(format: NSLocalizedString("%1$@ + %2$@", comment: "Format string combining carb entry time and absorption time"), entryTimeString, absorptionTimeString) + } else { + return entryTimeString + } + } + + // MARK: - Data upkeep + func update() async { + dispatchPrecondition(condition: .onQueue(.main)) + + // Prevent any UI updates after a bolus has been initiated. + guard !enacting else { + return + } + + disableManualGlucoseEntryIfNecessary() + updateChartDateInterval() + updateStoredGlucoseValues() + await updatePredictionAndRecommendation() + + if let iob = await getInsulinOnBoard() { + self.activeInsulin = HKQuantity(unit: .internationalUnit(), doubleValue: iob.value) + self.dosingDecision.insulinOnBoard = iob + } else { + self.activeInsulin = nil + self.dosingDecision.insulinOnBoard = nil + } + } + + private func disableManualGlucoseEntryIfNecessary() { + dispatchPrecondition(condition: .onQueue(.main)) + + if isManualGlucoseEntryEnabled, !isGlucoseDataStale { + isManualGlucoseEntryEnabled = false + manualGlucoseQuantity = nil + manualGlucoseSample = nil + presentAlert(.glucoseNoLongerStale) + } + } + + private func updateStoredGlucoseValues() { + let historicalGlucoseStartDate = Date(timeInterval: -LoopCoreConstants.dosingDecisionHistoricalGlucoseInterval, since: now()) + let chartStartDate = chartDateInterval.start + delegate?.getGlucoseSamples(start: min(historicalGlucoseStartDate, chartStartDate), end: nil) { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { return } + switch result { + case .failure(let error): + self.log.error("Failure getting glucose samples: %{public}@", String(describing: error)) + self.storedGlucoseValues = [] + self.dosingDecision.historicalGlucose = [] + case .success(let samples): + self.storedGlucoseValues = samples.filter { $0.startDate >= chartStartDate } + self.dosingDecision.historicalGlucose = samples.filter { $0.startDate >= historicalGlucoseStartDate }.map { HistoricalGlucoseValue(startDate: $0.startDate, quantity: $0.quantity) } + } + self.updateGlucoseChartValues() + } + } + } + + private func updateGlucoseChartValues() { + dispatchPrecondition(condition: .onQueue(.main)) + + var chartGlucoseValues = storedGlucoseValues + if let manualGlucoseSample = manualGlucoseSample { + chartGlucoseValues.append(manualGlucoseSample.quantitySample) + } + + self.glucoseValues = chartGlucoseValues + } + + /// - NOTE: `completion` is invoked on the main queue after predicted glucose values are updated + private func updatePredictedGlucoseValues(from state: LoopState, completion: @escaping () -> Void = {}) { + dispatchPrecondition(condition: .notOnQueue(.main)) + + let (manualGlucoseSample, enteredBolus, insulinType) = DispatchQueue.main.sync { (self.manualGlucoseSample, self.enteredBolus, delegate?.pumpInsulinType) } + + let enteredBolusDose = DoseEntry(type: .bolus, startDate: Date(), value: enteredBolus.doubleValue(for: .internationalUnit()), unit: .units, insulinType: insulinType) + + let predictedGlucoseValues: [PredictedGlucoseValue] + do { + if let manualGlucoseEntry = manualGlucoseSample { + predictedGlucoseValues = try state.predictGlucoseFromManualGlucose( + manualGlucoseEntry, + potentialBolus: enteredBolusDose, + potentialCarbEntry: potentialCarbEntry, + replacingCarbEntry: originalCarbEntry, + includingPendingInsulin: true, + considerPositiveVelocityAndRC: true + ) + } else { + predictedGlucoseValues = try state.predictGlucose( + using: .all, + potentialBolus: enteredBolusDose, + potentialCarbEntry: potentialCarbEntry, + replacingCarbEntry: originalCarbEntry, + includingPendingInsulin: true, + considerPositiveVelocityAndRC: true + ) + } + } catch { + predictedGlucoseValues = [] + } + + DispatchQueue.main.async { + self.predictedGlucoseValues = predictedGlucoseValues + self.dosingDecision.predictedGlucose = predictedGlucoseValues + completion() + } + } + + private func getInsulinOnBoard() async -> InsulinValue? { + guard let delegate = delegate else { + return nil + } + + return await withCheckedContinuation { continuation in + delegate.insulinOnBoard(at: Date()) { result in + switch result { + case .success(let iob): + continuation.resume(returning: iob) + case .failure: + continuation.resume(returning: nil) + } + } + } + } + + private func updatePredictionAndRecommendation() async { + guard let delegate = delegate else { + return + } + return await withCheckedContinuation { continuation in + delegate.withLoopState { [weak self] state in + self?.updateCarbsOnBoard(from: state) + self?.updateRecommendedBolusAndNotice(from: state, isUpdatingFromUserInput: false) + self?.updatePredictedGlucoseValues(from: state) + continuation.resume() + } + } + } + + private func updateCarbsOnBoard(from state: LoopState) { + delegate?.carbsOnBoard(at: Date(), effectVelocities: state.insulinCounteractionEffects) { result in + DispatchQueue.main.async { + switch result { + case .success(let carbValue): + self.activeCarbs = carbValue.quantity + self.dosingDecision.carbsOnBoard = carbValue + case .failure: + self.activeCarbs = nil + self.dosingDecision.carbsOnBoard = nil + } + } + } + } + + private func updateRecommendedBolusAndNotice(from state: LoopState, isUpdatingFromUserInput: Bool) { + dispatchPrecondition(condition: .notOnQueue(.main)) + + guard let delegate = delegate else { + assertionFailure("Missing BolusEntryViewModelDelegate") + return + } + + let now = Date() + var recommendation: ManualBolusRecommendation? + let recommendedBolus: HKQuantity? + let notice: Notice? + do { + recommendation = try computeBolusRecommendation(from: state) + + if let recommendation = recommendation { + recommendedBolus = HKQuantity(unit: .internationalUnit(), doubleValue: delegate.roundBolusVolume(units: recommendation.amount)) + //recommendedBolus = HKQuantity(unit: .internationalUnit(), doubleValue: recommendation.amount) + + switch recommendation.notice { + case .glucoseBelowSuspendThreshold: + if let suspendThreshold = delegate.settings.suspendThreshold { + notice = .predictedGlucoseBelowSuspendThreshold(suspendThreshold: suspendThreshold.quantity) + } else { + notice = nil + } + case .predictedGlucoseInRange: + notice = .predictedGlucoseInRange + case .allGlucoseBelowTarget(minGlucose: _): + notice = .glucoseBelowTarget + default: + notice = nil + } + } else { + recommendedBolus = HKQuantity(unit: .internationalUnit(), doubleValue: 0) + notice = nil + } + } catch { + recommendedBolus = nil + + switch error { + case LoopError.missingDataError(.glucose), LoopError.glucoseTooOld: + notice = .staleGlucoseData + case LoopError.invalidFutureGlucose: + notice = .futureGlucoseData + case LoopError.pumpDataTooOld: + notice = .stalePumpData + default: + notice = nil + } + } + + DispatchQueue.main.async { + let priorRecommendedBolus = self.recommendedBolus + self.recommendedBolus = recommendedBolus + self.dosingDecision.manualBolusRecommendation = recommendation.map { ManualBolusRecommendationWithDate(recommendation: $0, date: now) } + self.activeNotice = notice + + if priorRecommendedBolus != nil, + priorRecommendedBolus != recommendedBolus, + !self.enacting, + !isUpdatingFromUserInput + { + self.presentAlert(.recommendationChanged) + } + } + } + + private func computeBolusRecommendation(from state: LoopState) throws -> ManualBolusRecommendation? { + dispatchPrecondition(condition: .notOnQueue(.main)) + + let manualGlucoseSample = DispatchQueue.main.sync { self.manualGlucoseSample } + if manualGlucoseSample != nil { + return try state.recommendBolusForManualGlucose( + manualGlucoseSample!, + consideringPotentialCarbEntry: potentialCarbEntry, + replacingCarbEntry: originalCarbEntry, + considerPositiveVelocityAndRC: FeatureFlags.usePositiveMomentumAndRCForManualBoluses + ) + } else { + return try state.recommendBolus( + consideringPotentialCarbEntry: potentialCarbEntry, + replacingCarbEntry: originalCarbEntry, + considerPositiveVelocityAndRC: FeatureFlags.usePositiveMomentumAndRCForManualBoluses + ) + } + } + + func updateSettings() { + dispatchPrecondition(condition: .onQueue(.main)) + + guard let delegate = delegate else { + return + } + + targetGlucoseSchedule = delegate.settings.glucoseTargetRangeSchedule + // Pre-meal override should be ignored if we have carbs (LOOP-1964) + preMealOverride = potentialCarbEntry == nil ? delegate.settings.preMealOverride : nil + scheduleOverride = delegate.settings.scheduleOverride + + if preMealOverride?.hasFinished() == true { + preMealOverride = nil + } + + if scheduleOverride?.hasFinished() == true { + scheduleOverride = nil + } + + maximumBolus = delegate.settings.maximumBolus.map { maxBolusAmount in + HKQuantity(unit: .internationalUnit(), doubleValue: maxBolusAmount) + } + + dosingDecision.scheduleOverride = scheduleOverride + + if scheduleOverride != nil || preMealOverride != nil { + dosingDecision.glucoseTargetRangeSchedule = delegate.settings.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: potentialCarbEntry != nil) + } else { + dosingDecision.glucoseTargetRangeSchedule = targetGlucoseSchedule + } + } + + private func updateChartDateInterval() { + dispatchPrecondition(condition: .onQueue(.main)) + + // How far back should we show data? Use the screen size as a guide. + let viewMarginInset: CGFloat = 14 + let availableWidth = screenWidth - chartManager.fixedHorizontalMargin - 2 * viewMarginInset + + let totalHours = floor(Double(availableWidth / LoopConstants.minimumChartWidthPerHour)) + let futureHours = ceil((delegate?.insulinActivityDuration(for: delegate?.pumpInsulinType) ?? .hours(4)).hours) + let historyHours = max(LoopConstants.statusChartMinimumHistoryDisplay.hours, totalHours - futureHours) + + let date = Date(timeInterval: -TimeInterval(hours: historyHours), since: now()) + let chartStartDate = Calendar.current.nextDate( + after: date, + matching: DateComponents(minute: 0), + matchingPolicy: .strict, + direction: .backward + ) ?? date + + chartDateInterval = DateInterval(start: chartStartDate, duration: .hours(totalHours)) + } + + func formatBolusAmount(_ bolusAmount: Double) -> String { + bolusAmountFormatter.string(from: bolusAmount) ?? String(bolusAmount) + } + + var recommendedBolusString: String { + guard let amount = recommendedBolusAmount else { + return "–" + } + return formatBolusAmount(amount) + } + + func updateEnteredBolus(_ enteredBolusString: String) { + updateEnteredBolus(bolusAmountFormatter.number(from: enteredBolusString)?.doubleValue) + } + + func updateEnteredBolus(_ enteredBolusAmount: Double?) { + enteredBolus = HKQuantity(unit: .internationalUnit(), doubleValue: enteredBolusAmount ?? 0) + } +} + +extension BolusEntryViewModel.Alert: Identifiable { + var id: Self { self } +} + +// MARK: Helpers +extension BolusEntryViewModel { + + var isGlucoseDataStale: Bool { + guard let latestGlucoseDataDate = delegate?.mostRecentGlucoseDataDate else { return true } + return now().timeIntervalSince(latestGlucoseDataDate) > LoopCoreConstants.inputDataRecencyInterval + } + + var isPumpDataStale: Bool { + guard let latestPumpDataDate = delegate?.mostRecentPumpDataDate else { return true } + return now().timeIntervalSince(latestPumpDataDate) > LoopCoreConstants.inputDataRecencyInterval + } + + var isManualGlucosePromptVisible: Bool { + activeNotice == .staleGlucoseData && !isManualGlucoseEntryEnabled + } + + var isNoticeVisible: Bool { + if activeNotice == nil { + return false + } else if activeNotice != .staleGlucoseData { + return true + } else { + return !isManualGlucoseEntryEnabled + } + } + + private var hasBolusEntryReadyToDeliver: Bool { + enteredBolus.doubleValue(for: .internationalUnit()) != 0 + } + + private var hasDataToSave: Bool { + manualGlucoseQuantity != nil || potentialCarbEntry != nil + } + + enum ButtonChoice { case manualGlucoseEntry, actionButton } + var primaryButton: ButtonChoice { + if !isManualGlucosePromptVisible { return .actionButton } + if hasBolusEntryReadyToDeliver { return .actionButton } + return .manualGlucoseEntry + } + + enum ActionButtonAction { + case saveWithoutBolusing + case saveAndDeliver + case enterBolus + case deliver + } + + var actionButtonAction: ActionButtonAction { + switch (hasDataToSave, hasBolusEntryReadyToDeliver) { + case (true, true): return .saveAndDeliver + case (true, false): return .saveWithoutBolusing + case (false, true): return .deliver + case (false, false): return .enterBolus + } + } +} diff --git a/Loop/View Models/CarbEntryViewModel.swift b/Loop/View Models/CarbEntryViewModel.swift new file mode 100644 index 0000000000..37dedee326 --- /dev/null +++ b/Loop/View Models/CarbEntryViewModel.swift @@ -0,0 +1,318 @@ +// +// CarbEntryViewModel.swift +// Loop +// +// Created by Noah Brauner on 7/21/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import HealthKit +import Combine + +protocol CarbEntryViewModelDelegate: AnyObject, BolusEntryViewModelDelegate { + var analyticsServicesManager: AnalyticsServicesManager { get } + var defaultAbsorptionTimes: CarbStore.DefaultAbsorptionTimes { get } +} + +final class CarbEntryViewModel: ObservableObject { + enum Alert: Identifiable { + var id: Self { + return self + } + + case maxQuantityExceded + case warningQuantityValidation + } + + enum Warning: Identifiable { + var id: Self { + return self + } + + var priority: Int { + switch self { + case .entryIsMissedMeal: + return 1 + case .overrideInProgress: + return 2 + } + } + + case entryIsMissedMeal + case overrideInProgress + } + + @Published var alert: CarbEntryViewModel.Alert? + @Published var warnings: Set = [] + + @Published var bolusViewModel: BolusEntryViewModel? + + let shouldBeginEditingQuantity: Bool + + @Published var carbsQuantity: Double? = nil + var preferredCarbUnit = HKUnit.gram() + var maxCarbEntryQuantity = LoopConstants.maxCarbEntryQuantity + var warningCarbEntryQuantity = LoopConstants.warningCarbEntryQuantity + + @Published var time = Date() + private var date = Date() + var minimumDate: Date { + get { date.addingTimeInterval(LoopConstants.maxCarbEntryPastTime) } + } + var maximumDate: Date { + get { date.addingTimeInterval(LoopConstants.maxCarbEntryFutureTime) } + } + + @Published var foodType = "" + @Published var selectedDefaultAbsorptionTimeEmoji: String = "" + @Published var usesCustomFoodType = false + @Published var absorptionTimeWasEdited = false // if true, selecting an emoji will not alter the absorption time + private var absorptionEditIsProgrammatic = false // needed for when absorption time is changed due to favorite food selection, so that absorptionTimeWasEdited does not get set to true + + @Published var absorptionTime: TimeInterval + let defaultAbsorptionTimes: CarbStore.DefaultAbsorptionTimes + let minAbsorptionTime = LoopConstants.minCarbAbsorptionTime + let maxAbsorptionTime = LoopConstants.maxCarbAbsorptionTime + var absorptionRimesRange: ClosedRange { + return minAbsorptionTime...maxAbsorptionTime + } + + @Published var favoriteFoods = UserDefaults.standard.favoriteFoods + @Published var selectedFavoriteFoodIndex = -1 + + weak var delegate: CarbEntryViewModelDelegate? + + private lazy var cancellables = Set() + + /// Initalizer for when`CarbEntryView` is presented from the home screen + init(delegate: CarbEntryViewModelDelegate) { + self.delegate = delegate + self.absorptionTime = delegate.defaultAbsorptionTimes.medium + self.defaultAbsorptionTimes = delegate.defaultAbsorptionTimes + self.shouldBeginEditingQuantity = true + + observeAbsorptionTimeChange() + observeFavoriteFoodChange() + observeFavoriteFoodIndexChange() + observeLoopUpdates() + } + + /// Initalizer for when`CarbEntryView` has an entry to edit + init(delegate: CarbEntryViewModelDelegate, originalCarbEntry: StoredCarbEntry) { + self.delegate = delegate + self.originalCarbEntry = originalCarbEntry + self.defaultAbsorptionTimes = delegate.defaultAbsorptionTimes + + self.carbsQuantity = originalCarbEntry.quantity.doubleValue(for: preferredCarbUnit) + self.time = originalCarbEntry.startDate + self.foodType = originalCarbEntry.foodType ?? "" + self.absorptionTime = originalCarbEntry.absorptionTime ?? .hours(3) + self.absorptionTimeWasEdited = true + self.usesCustomFoodType = true + self.shouldBeginEditingQuantity = false + + observeLoopUpdates() + } + + var originalCarbEntry: StoredCarbEntry? = nil + private var favoriteFood: FavoriteFood? = nil + + private var updatedCarbEntry: NewCarbEntry? { + if let quantity = carbsQuantity, quantity != 0 { + if let o = originalCarbEntry, o.quantity.doubleValue(for: preferredCarbUnit) == quantity && o.startDate == time && o.foodType == foodType && o.absorptionTime == absorptionTime { + return nil // No changes were made + } + + return NewCarbEntry( + date: date, + quantity: HKQuantity(unit: preferredCarbUnit, doubleValue: quantity), + startDate: time, + foodType: usesCustomFoodType ? foodType : selectedDefaultAbsorptionTimeEmoji, + absorptionTime: absorptionTime + ) + } + else { + return nil + } + } + + var saveFavoriteFoodButtonDisabled: Bool { + get { + if let carbsQuantity, 0...maxCarbEntryQuantity.doubleValue(for: preferredCarbUnit) ~= carbsQuantity, selectedFavoriteFoodIndex == -1 { + return false + } + return true + } + } + + var continueButtonDisabled: Bool { + get { updatedCarbEntry == nil } + } + + // MARK: - Continue to Bolus and Carb Quantity Warnings + func continueToBolus() { + guard updatedCarbEntry != nil else { + return + } + + validateInputAndContinue() + } + + private func validateInputAndContinue() { + guard absorptionTime <= maxAbsorptionTime else { + return + } + + guard let carbsQuantity, carbsQuantity > 0 else { return } + let quantity = HKQuantity(unit: preferredCarbUnit, doubleValue: carbsQuantity) + if quantity.compare(maxCarbEntryQuantity) == .orderedDescending { + self.alert = .maxQuantityExceded + return + } + else if quantity.compare(warningCarbEntryQuantity) == .orderedDescending, selectedFavoriteFoodIndex == -1 { + self.alert = .warningQuantityValidation + return + } + + Task { @MainActor in + setBolusViewModel() + } + } + + @MainActor private func setBolusViewModel() { + let viewModel = BolusEntryViewModel( + delegate: delegate, + screenWidth: UIScreen.main.bounds.width, + originalCarbEntry: originalCarbEntry, + potentialCarbEntry: updatedCarbEntry, + selectedCarbAbsorptionTimeEmoji: selectedDefaultAbsorptionTimeEmoji + ) + Task { + await viewModel.generateRecommendationAndStartObserving() + } + + viewModel.analyticsServicesManager = delegate?.analyticsServicesManager + bolusViewModel = viewModel + + delegate?.analyticsServicesManager.didDisplayBolusScreen() + } + + func clearAlert() { + self.alert = nil + } + + func clearAlertAndContinueToBolus() { + self.alert = nil + Task { @MainActor in + setBolusViewModel() + } + } + + // MARK: - Favorite Foods + func onFavoriteFoodSave(_ food: NewFavoriteFood) { + let newStoredFood = StoredFavoriteFood(name: food.name, carbsQuantity: food.carbsQuantity, foodType: food.foodType, absorptionTime: food.absorptionTime) + favoriteFoods.append(newStoredFood) + selectedFavoriteFoodIndex = favoriteFoods.count - 1 + } + + private func observeFavoriteFoodIndexChange() { + $selectedFavoriteFoodIndex + .receive(on: RunLoop.main) + .dropFirst() + .sink { [weak self] index in + self?.favoriteFoodSelected(at: index) + } + .store(in: &cancellables) + } + + private func observeFavoriteFoodChange() { + $favoriteFoods + .dropFirst() + .removeDuplicates() + .sink { newValue in + UserDefaults.standard.favoriteFoods = newValue + } + .store(in: &cancellables) + } + + private func favoriteFoodSelected(at index: Int) { + self.absorptionEditIsProgrammatic = true + if index == -1 { + self.carbsQuantity = 0 + self.foodType = "" + self.absorptionTime = defaultAbsorptionTimes.medium + self.absorptionTimeWasEdited = false + self.usesCustomFoodType = false + } + else { + let food = favoriteFoods[index] + self.carbsQuantity = food.carbsQuantity.doubleValue(for: preferredCarbUnit) + self.foodType = food.foodType + self.absorptionTime = food.absorptionTime + self.absorptionTimeWasEdited = true + self.usesCustomFoodType = true + } + } + + // MARK: - Utility + func restoreUserActivityState(_ activity: NSUserActivity) { + if let entry = activity.newCarbEntry { + time = entry.date + carbsQuantity = entry.quantity.doubleValue(for: preferredCarbUnit) + + if let foodType = entry.foodType { + self.foodType = foodType + usesCustomFoodType = true + } + + if let absorptionTime = entry.absorptionTime { + self.absorptionTime = absorptionTime + absorptionTimeWasEdited = true + } + + if activity.entryisMissedMeal { + warnings.insert(.entryIsMissedMeal) + } + } + } + + private func observeLoopUpdates() { + self.checkIfOverrideEnabled() + NotificationCenter.default + .publisher(for: .LoopDataUpdated) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.checkIfOverrideEnabled() + } + .store(in: &cancellables) + } + + private func checkIfOverrideEnabled() { + if let managerSettings = delegate?.settings, + managerSettings.scheduleOverrideEnabled(at: Date()), + let overrideSettings = managerSettings.scheduleOverride?.settings, + overrideSettings.effectiveInsulinNeedsScaleFactor != 1.0 { + self.warnings.insert(.overrideInProgress) + } + else { + self.warnings.remove(.overrideInProgress) + } + } + + private func observeAbsorptionTimeChange() { + $absorptionTime + .receive(on: RunLoop.main) + .dropFirst() + .sink { [weak self] _ in + if self?.absorptionEditIsProgrammatic == true { + self?.absorptionEditIsProgrammatic = false + } + else { + self?.absorptionTimeWasEdited = true + } + } + .store(in: &cancellables) + } +} diff --git a/Loop/View Models/CriticalEventLogExportViewModel.swift b/Loop/View Models/CriticalEventLogExportViewModel.swift new file mode 100644 index 0000000000..0211c541ea --- /dev/null +++ b/Loop/View Models/CriticalEventLogExportViewModel.swift @@ -0,0 +1,222 @@ +// +// CriticalEventLogExportViewModel.swift +// Loop +// +// Created by Darin Krauss on 7/10/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import os.log +import Foundation +import Combine +import SwiftUI +import LoopKit + +protocol CriticalEventLogExporterFactory { + func createExporter(to url: URL) -> CriticalEventLogExporter +} + +extension CriticalEventLogExportManager: CriticalEventLogExporterFactory {} + +public class CriticalEventLogExportViewModel: ObservableObject, Identifiable, CriticalEventLogExporterDelegate { + @Published var isExporting: Bool = false + @Published var showingSuccess: Bool = false + @Published var showingShare: Bool = false + @Published var showingError: Bool = false + @Published var progress: Double = 0 { + didSet { + let now = Date() + if self.progressStartDate == nil { + self.progressStartDate = now + } + + // If no progress in the last few seconds, then means we were backgrounded, so discount that gap (move start forward by same) + if let progressLatestDate = self.progressLatestDate { + let progressLatestDuration = now.timeIntervalSince(progressLatestDate) + if progressLatestDuration > self.progressDurationBackgroundMaximum { + self.progressStartDate! += progressLatestDuration + } + } + + self.progressLatestDate = now + + // If no progress, then we have no idea when we will finish and bail (prevents divide by zero) + guard progress > 0 else { + self.remainingDuration = nil + return + } + + // If we haven't been exporting for long, then remaining duration may be wildly inaccurate so just bail + let progressDuration = now.timeIntervalSince(self.progressStartDate!) + guard progressDuration > self.progressDurationMinimum else { + self.remainingDuration = nil + return + } + + self.remainingDuration = self.remainingDurationAsString(progressDuration / progress - progressDuration) + } + } + @Published var remainingDuration: String? + + var activityItems: [UIActivityItemSource] = [] + + private var progressPublisher = PassthroughSubject() + private var progressStartDate: Date? + private var progressLatestDate: Date? + + private let exporterFactory: CriticalEventLogExporterFactory + private var exporter: CriticalEventLogExporter? + + private var cancellables: Set = [] + + private let log = OSLog(category: "CriticalEventLogExportManager") + + init(exporterFactory: CriticalEventLogExporterFactory) { + self.exporterFactory = exporterFactory + + progressPublisher + .removeDuplicates() + .throttle(for: .seconds(0.25), scheduler: RunLoop.main, latest: true) + .sink { [weak self] in self?.progress = $0 } + .store(in: &cancellables) + } + + func export() { + dispatchPrecondition(condition: .onQueue(.main)) + + guard !isExporting else { + return + } + + self.isExporting = true + + self.showingSuccess = false + self.showingShare = false + self.showingError = false + self.progress = 0 + self.remainingDuration = nil + + self.progressStartDate = nil + self.progressLatestDate = nil + self.activityItems = [] + + let filename = String(format: NSLocalizedString("Export-%1$@", comment: "The export file name formatted string (1: timestamp)"), self.timestampFormatter.string(from: Date())) + let url = FileManager.default.temporaryDirectory.appendingPathComponent(filename).appendingPathExtension("zip") + + var exporter = exporterFactory.createExporter(to: url) + exporter.delegate = self + + self.exporter = exporter + + exporter.export() { error in + DispatchQueue.main.async { + guard !exporter.isCancelled else { + return + } + + if let error = error { + self.log.error("Failure during critical event log export: %{public}@", String(describing: error)) + self.showingError = true + } else { + self.progress = 1.0 + self.activityItems = [CriticalEventLogExportActivityItemSource(url: url)] + self.showingSuccess = true + self.showingShare = true + } + } + } + } + + func cancel() { + dispatchPrecondition(condition: .onQueue(.main)) + + self.exporter?.cancel() + self.exporter = nil + self.activityItems = [] + self.isExporting = false + } + + // MARK: - CriticalEventLogExporterDelegate + + private let progressDurationBackgroundMaximum: TimeInterval = .seconds(5) + private let progressDurationMinimum: TimeInterval = .seconds(5) + + public func exportDidProgress(_ progress: Double) { + progressPublisher.send(progress) + } + + // The default duration formatter formats a duration in the range of X minutes through X minutes and 59 seconds as + // "About X minutes remaining". Offset calculation to effectively change the range to X+1 minutes and 30 seconds through + // X minutes and 29 seconds to address misleading messages when duration is two minutes down to complete. + private let remainingDurationApproximationOffset: TimeInterval = 30 + + private func remainingDurationAsString(_ remainingDuration: TimeInterval) -> String? { + switch remainingDuration { + case 0..<15: + return NSLocalizedString("A few seconds remaining", comment: "Estimated remaining duration with a few seconds") + case 15..<60: + return NSLocalizedString("Less than a minute remaining", comment: "Estimated remaining duration with less than a minute") + default: + guard let durationString = durationFormatter.string(from: remainingDuration + remainingDurationApproximationOffset) else { + return nil + } + return String(format: NSLocalizedString("%@ remaining", comment: "Estimated remaining duration with more than a minute"), durationString) + } + } + + private var durationFormatter: DateComponentsFormatter { Self.durationFormatter } + + private var timestampFormatter: ISO8601DateFormatter { Self.timestampFormatter } + + private static var durationFormatter: DateComponentsFormatter = { + let durationFormatter = DateComponentsFormatter() + durationFormatter.allowedUnits = [.hour, .minute] + durationFormatter.includesApproximationPhrase = true + durationFormatter.unitsStyle = .full + return durationFormatter + }() + + private static let timestampFormatter: ISO8601DateFormatter = { + let timestampFormatter = ISO8601DateFormatter() + timestampFormatter.timeZone = calendar.timeZone + timestampFormatter.formatOptions = [.withYear, .withMonth, .withDay, .withTime, .withTimeZone] + return timestampFormatter + }() + + private static let calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + return calendar + }() +} + +fileprivate class CriticalEventLogExportActivityItemSource: NSObject, UIActivityItemSource { + private let url: URL + + init(url: URL) { + self.url = url + super.init() + } + + deinit { + try? FileManager.default.removeItem(at: url) + } + + // MARK: - UIActivityItemSource + + func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any { + return url + } + + func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? { + return url + } + + func activityViewController(_ activityViewController: UIActivityViewController, subjectForActivityType activityType: UIActivity.ActivityType?) -> String { + return url.lastPathComponent + } + + func activityViewController(_ activityViewController: UIActivityViewController, dataTypeIdentifierForActivityType activityType: UIActivity.ActivityType?) -> String { + return "com.pkware.zip-archive" + } +} diff --git a/Loop/View Models/FavoriteFoodsViewModel.swift b/Loop/View Models/FavoriteFoodsViewModel.swift new file mode 100644 index 0000000000..48934d1c10 --- /dev/null +++ b/Loop/View Models/FavoriteFoodsViewModel.swift @@ -0,0 +1,83 @@ +// +// FavoriteFoodsViewModel.swift +// Loop +// +// Created by Noah Brauner on 7/27/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import HealthKit +import LoopKit +import Combine + +final class FavoriteFoodsViewModel: ObservableObject { + @Published var favoriteFoods = UserDefaults.standard.favoriteFoods + @Published var selectedFood: StoredFavoriteFood? + + @Published var isDetailViewActive = false + @Published var isEditViewActive = false + @Published var isAddViewActive = false + + var preferredCarbUnit = HKUnit.gram() + lazy var carbFormatter = QuantityFormatter(for: preferredCarbUnit) + lazy var absorptionTimeFormatter: DateComponentsFormatter = { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.hour, .minute] + formatter.unitsStyle = .abbreviated + return formatter + }() + + private lazy var cancellables = Set() + + init() { + observeFavoriteFoodChange() + } + + func onFoodSave(_ newFood: NewFavoriteFood) { + if isAddViewActive { + let newStoredFood = StoredFavoriteFood(name: newFood.name, carbsQuantity: newFood.carbsQuantity, foodType: newFood.foodType, absorptionTime: newFood.absorptionTime) + withAnimation { + favoriteFoods.append(newStoredFood) + } + isAddViewActive = false + } + else if var selectedFood, let selectedFooxIndex = favoriteFoods.firstIndex(of: selectedFood) { + selectedFood.name = newFood.name + selectedFood.carbsQuantity = newFood.carbsQuantity + selectedFood.foodType = newFood.foodType + selectedFood.absorptionTime = newFood.absorptionTime + favoriteFoods[selectedFooxIndex] = selectedFood + isEditViewActive = false + } + } + + func onFoodDelete(_ food: StoredFavoriteFood) { + if isDetailViewActive { + isDetailViewActive = false + } + withAnimation { + _ = favoriteFoods.remove(food) + } + } + + func onFoodReorder(from: IndexSet, to: Int) { + withAnimation { + favoriteFoods.move(fromOffsets: from, toOffset: to) + } + } + + func addFoodTapped() { + isAddViewActive = true + } + + private func observeFavoriteFoodChange() { + $favoriteFoods + .dropFirst() + .removeDuplicates() + .sink { newValue in + UserDefaults.standard.favoriteFoods = newValue + } + .store(in: &cancellables) + } +} diff --git a/Loop/View Models/LiveActivityManagementViewModel.swift b/Loop/View Models/LiveActivityManagementViewModel.swift new file mode 100644 index 0000000000..46fc560d6c --- /dev/null +++ b/Loop/View Models/LiveActivityManagementViewModel.swift @@ -0,0 +1,35 @@ +// +// LiveActivityManagementViewModel.swift +// Loop +// +// Created by Bastiaan Verhaar on 12/09/2024. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopCore + +class LiveActivityManagementViewModel : ObservableObject { + @Published var enabled: Bool + @Published var mode: LiveActivityMode + @Published var isEditingMode: Bool = false + @Published var addPredictiveLine: Bool + @Published var useLimits: Bool + @Published var upperLimitChartMmol: Double + @Published var lowerLimitChartMmol: Double + @Published var upperLimitChartMg: Double + @Published var lowerLimitChartMg: Double + + init() { + let liveActivitySettings = UserDefaults.standard.liveActivity ?? LiveActivitySettings() + + self.enabled = liveActivitySettings.enabled + self.mode = liveActivitySettings.mode + self.addPredictiveLine = liveActivitySettings.addPredictiveLine + self.useLimits = liveActivitySettings.useLimits + self.upperLimitChartMmol = liveActivitySettings.upperLimitChartMmol + self.lowerLimitChartMmol = liveActivitySettings.lowerLimitChartMmol + self.upperLimitChartMg = liveActivitySettings.upperLimitChartMg + self.lowerLimitChartMg = liveActivitySettings.lowerLimitChartMg + } +} diff --git a/Loop/View Models/ManualEntryDoseViewModel.swift b/Loop/View Models/ManualEntryDoseViewModel.swift new file mode 100644 index 0000000000..5fcd966c62 --- /dev/null +++ b/Loop/View Models/ManualEntryDoseViewModel.swift @@ -0,0 +1,363 @@ +// +// ManualEntryDoseViewModel.swift +// Loop +// +// Created by Pete Schwamb on 12/29/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import Combine +import HealthKit +import LocalAuthentication +import Intents +import os.log +import LoopCore +import LoopKit +import LoopKitUI +import LoopUI +import SwiftUI + +protocol ManualDoseViewModelDelegate: AnyObject { + + func withLoopState(do block: @escaping (LoopState) -> Void) + + func addManuallyEnteredDose(startDate: Date, units: Double, insulinType: InsulinType?) + + func getGlucoseSamples(start: Date?, end: Date?, completion: @escaping (_ samples: Swift.Result<[StoredGlucoseSample], Error>) -> Void) + + func insulinOnBoard(at date: Date, completion: @escaping (_ result: DoseStoreResult) -> Void) + + func carbsOnBoard(at date: Date, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (_ result: CarbStoreResult) -> Void) + + func insulinActivityDuration(for type: InsulinType?) -> TimeInterval + + var mostRecentGlucoseDataDate: Date? { get } + + var mostRecentPumpDataDate: Date? { get } + + var isPumpConfigured: Bool { get } + + var preferredGlucoseUnit: HKUnit { get } + + var pumpInsulinType: InsulinType? { get } + + var settings: LoopSettings { get } +} + +final class ManualEntryDoseViewModel: ObservableObject { + + var authenticate: AuthenticationChallenge = LocalAuthentication.deviceOwnerCheck + + // MARK: - State + + @Published var glucoseValues: [GlucoseValue] = [] // stored glucose values + private var storedGlucoseValues: [GlucoseValue] = [] + @Published var predictedGlucoseValues: [GlucoseValue] = [] + @Published var glucoseUnit: HKUnit = .milligramsPerDeciliter + @Published var chartDateInterval: DateInterval + + @Published var activeCarbs: HKQuantity? + @Published var activeInsulin: HKQuantity? + + @Published var targetGlucoseSchedule: GlucoseRangeSchedule? + @Published var preMealOverride: TemporaryScheduleOverride? + private var savedPreMealOverride: TemporaryScheduleOverride? + @Published var scheduleOverride: TemporaryScheduleOverride? + + @Published var enteredBolus = HKQuantity(unit: .internationalUnit(), doubleValue: 0) + private var isInitiatingSaveOrBolus = false + + private let log = OSLog(category: "ManualEntryDoseViewModel") + private var cancellables: Set = [] + + let chartManager: ChartsManager = { + let predictedGlucoseChart = PredictedGlucoseChart(predictedGlucoseBounds: FeatureFlags.predictedGlucoseChartClampEnabled ? .default : nil, + yAxisStepSizeMGDLOverride: FeatureFlags.predictedGlucoseChartClampEnabled ? 40 : nil) + predictedGlucoseChart.glucoseDisplayRange = LoopConstants.glucoseChartDefaultDisplayRangeWide + return ChartsManager(colors: .primary, settings: .default, charts: [predictedGlucoseChart], traitCollection: .current) + }() + + // MARK: - External Insulin + @Published var selectedInsulinType: InsulinType + + @Published var selectedDoseDate: Date = Date() + + var insulinTypePickerOptions: [InsulinType] + + // MARK: - Seams + private weak var delegate: ManualDoseViewModelDelegate? + private let now: () -> Date + private let screenWidth: CGFloat + private let debounceIntervalMilliseconds: Int + private let uuidProvider: () -> String + + // MARK: - Initialization + + init( + delegate: ManualDoseViewModelDelegate, + now: @escaping () -> Date = { Date() }, + screenWidth: CGFloat = UIScreen.main.bounds.width, + debounceIntervalMilliseconds: Int = 400, + uuidProvider: @escaping () -> String = { UUID().uuidString }, + timeZone: TimeZone? = nil + ) { + self.delegate = delegate + self.now = now + self.screenWidth = screenWidth + self.debounceIntervalMilliseconds = debounceIntervalMilliseconds + self.uuidProvider = uuidProvider + + self.insulinTypePickerOptions = [.novolog, .humalog, .apidra, .fiasp, .lyumjev, .afrezza] + + self.chartDateInterval = DateInterval(start: Date(timeInterval: .hours(-1), since: now()), duration: .hours(7)) + + if let pumpInsulinType = delegate.pumpInsulinType { + selectedInsulinType = pumpInsulinType + } else { + selectedInsulinType = .novolog + } + + observeLoopUpdates() + observeEnteredBolusChanges() + observeInsulinModelChanges() + observeDoseDateChanges() + + update() + } + + private func observeLoopUpdates() { + NotificationCenter.default + .publisher(for: .LoopDataUpdated) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in self?.update() } + .store(in: &cancellables) + } + + private func observeEnteredBolusChanges() { + $enteredBolus + .removeDuplicates() + .debounce(for: .milliseconds(debounceIntervalMilliseconds), scheduler: RunLoop.main) + .sink { [weak self] _ in + self?.delegate?.withLoopState { [weak self] state in + self?.updatePredictedGlucoseValues(from: state) + } + } + .store(in: &cancellables) + } + + private func observeInsulinModelChanges() { + $selectedInsulinType + .removeDuplicates() + .debounce(for: .milliseconds(400), scheduler: RunLoop.main) + .sink { [weak self] _ in + self?.delegate?.withLoopState { [weak self] state in + self?.updatePredictedGlucoseValues(from: state) + } + } + .store(in: &cancellables) + } + + private func observeDoseDateChanges() { + $selectedDoseDate + .removeDuplicates() + .debounce(for: .milliseconds(400), scheduler: RunLoop.main) + .sink { [weak self] _ in + self?.delegate?.withLoopState { [weak self] state in + self?.updatePredictedGlucoseValues(from: state) + } + } + .store(in: &cancellables) + } + + // MARK: - View API + + func saveManualDose(onSuccess completion: @escaping () -> Void) { + // Authenticate before saving anything + if enteredBolus.doubleValue(for: .internationalUnit()) > 0 { + let message = String(format: NSLocalizedString("Authenticate to log %@ Units", comment: "The message displayed during a device authentication prompt to log an insulin dose"), enteredBolusAmountString) + authenticate(message) { + switch $0 { + case .success: + self.continueSaving(onSuccess: completion) + case .failure: + break + } + } + } else { + completion() + } + } + + private func continueSaving(onSuccess completion: @escaping () -> Void) { + let doseVolume = enteredBolus.doubleValue(for: .internationalUnit()) + guard doseVolume > 0 else { + completion() + return + } + + delegate?.addManuallyEnteredDose(startDate: selectedDoseDate, units: doseVolume, insulinType: selectedInsulinType) + completion() + } + + private lazy var bolusVolumeFormatter = QuantityFormatter(for: .internationalUnit()) + + private lazy var absorptionTimeFormatter: DateComponentsFormatter = { + let formatter = DateComponentsFormatter() + formatter.collapsesLargestUnit = true + formatter.unitsStyle = .abbreviated + formatter.allowsFractionalUnits = true + formatter.allowedUnits = [.hour, .minute] + return formatter + }() + + var enteredBolusAmountString: String { + let bolusVolume = enteredBolus.doubleValue(for: .internationalUnit()) + return bolusVolumeFormatter.numberFormatter.string(from: bolusVolume) ?? String(bolusVolume) + } + + // MARK: - Data upkeep + + private func update() { + dispatchPrecondition(condition: .onQueue(.main)) + + // Prevent any UI updates after a bolus has been initiated. + guard !isInitiatingSaveOrBolus else { return } + + updateChartDateInterval() + updateStoredGlucoseValues() + updateFromLoopState() + updateActiveInsulin() + } + + private func updateStoredGlucoseValues() { + delegate?.getGlucoseSamples(start: chartDateInterval.start, end: nil) { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { return } + switch result { + case .failure(let error): + self.log.error("Failure getting glucose samples: %{public}@", String(describing: error)) + self.storedGlucoseValues = [] + case .success(let samples): + self.storedGlucoseValues = samples + } + self.updateGlucoseChartValues() + } + } + } + + private func updateGlucoseChartValues() { + dispatchPrecondition(condition: .onQueue(.main)) + + self.glucoseValues = storedGlucoseValues + } + + /// - NOTE: `completion` is invoked on the main queue after predicted glucose values are updated + private func updatePredictedGlucoseValues(from state: LoopState, completion: @escaping () -> Void = {}) { + dispatchPrecondition(condition: .notOnQueue(.main)) + + let (enteredBolus, doseDate, insulinType) = DispatchQueue.main.sync { (self.enteredBolus, self.selectedDoseDate, self.selectedInsulinType) } + + let enteredBolusDose = DoseEntry(type: .bolus, startDate: doseDate, value: enteredBolus.doubleValue(for: .internationalUnit()), unit: .units, insulinType: insulinType) + + let predictedGlucoseValues: [PredictedGlucoseValue] + do { + predictedGlucoseValues = try state.predictGlucose( + using: .all, + potentialBolus: enteredBolusDose, + potentialCarbEntry: nil, + replacingCarbEntry: nil, + includingPendingInsulin: true, + considerPositiveVelocityAndRC: true + ) + } catch { + predictedGlucoseValues = [] + } + + DispatchQueue.main.async { + self.predictedGlucoseValues = predictedGlucoseValues + completion() + } + } + + private func updateActiveInsulin() { + delegate?.insulinOnBoard(at: Date()) { [weak self] result in + guard let self = self else { return } + + DispatchQueue.main.async { + switch result { + case .success(let iob): + self.activeInsulin = HKQuantity(unit: .internationalUnit(), doubleValue: iob.value) + case .failure: + self.activeInsulin = nil + } + } + } + } + + private func updateFromLoopState() { + delegate?.withLoopState { [weak self] state in + self?.updatePredictedGlucoseValues(from: state) + self?.updateCarbsOnBoard(from: state) + DispatchQueue.main.async { + self?.updateSettings() + } + } + } + + private func updateCarbsOnBoard(from state: LoopState) { + delegate?.carbsOnBoard(at: Date(), effectVelocities: state.insulinCounteractionEffects) { result in + DispatchQueue.main.async { + switch result { + case .success(let carbValue): + self.activeCarbs = carbValue.quantity + case .failure: + self.activeCarbs = nil + } + } + } + } + + + private func updateSettings() { + dispatchPrecondition(condition: .onQueue(.main)) + + guard let delegate = delegate else { + return + } + + glucoseUnit = delegate.preferredGlucoseUnit + + targetGlucoseSchedule = delegate.settings.glucoseTargetRangeSchedule + scheduleOverride = delegate.settings.scheduleOverride + + if preMealOverride?.hasFinished() == true { + preMealOverride = nil + } + + if scheduleOverride?.hasFinished() == true { + scheduleOverride = nil + } + } + + private func updateChartDateInterval() { + dispatchPrecondition(condition: .onQueue(.main)) + + // How far back should we show data? Use the screen size as a guide. + let viewMarginInset: CGFloat = 14 + let availableWidth = screenWidth - chartManager.fixedHorizontalMargin - 2 * viewMarginInset + + let totalHours = floor(Double(availableWidth / LoopConstants.minimumChartWidthPerHour)) + let futureHours = ceil((delegate?.insulinActivityDuration(for: selectedInsulinType) ?? .hours(4)).hours) + let historyHours = max(LoopConstants.statusChartMinimumHistoryDisplay.hours, totalHours - futureHours) + + let date = Date(timeInterval: -TimeInterval(hours: historyHours), since: now()) + let chartStartDate = Calendar.current.nextDate( + after: date, + matching: DateComponents(minute: 0), + matchingPolicy: .strict, + direction: .backward + ) ?? date + + chartDateInterval = DateInterval(start: chartStartDate, duration: .hours(totalHours)) + } +} diff --git a/Loop/View Models/ServicesViewModel.swift b/Loop/View Models/ServicesViewModel.swift new file mode 100644 index 0000000000..19fb2a7d57 --- /dev/null +++ b/Loop/View Models/ServicesViewModel.swift @@ -0,0 +1,83 @@ +// +// ServicesViewModel.swift +// Loop +// +// Created by Rick Pasetto on 8/14/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopKitUI + +public protocol ServicesViewModelDelegate: AnyObject { + func addService(withIdentifier identifier: String) + func gotoService(withIdentifier identifier: String) +} + +public class ServicesViewModel: ObservableObject { + + @Published var showServices: Bool + @Published var availableServices: () -> [ServiceDescriptor] + @Published var activeServices: () -> [Service] + + var inactiveServices: () -> [ServiceDescriptor] { + return { + return self.availableServices().filter { availableService in + !self.activeServices().contains { $0.pluginIdentifier == availableService.identifier } + } + } + } + + weak var delegate: ServicesViewModelDelegate? + + init(showServices: Bool, + availableServices: @escaping () -> [ServiceDescriptor], + activeServices: @escaping () -> [Service], + delegate: ServicesViewModelDelegate? = nil) { + self.showServices = showServices + self.activeServices = activeServices + self.availableServices = availableServices + self.delegate = delegate + } + + func didTapService(_ index: Int) { + delegate?.gotoService(withIdentifier: activeServices()[index].pluginIdentifier) + } + + func didTapAddService(_ availableService: ServiceDescriptor) { + delegate?.addService(withIdentifier: availableService.identifier) + } +} + +// For previews only +extension ServicesViewModel { + fileprivate class FakeService1: Service { + static var localizedTitle: String = "Service 1" + static var pluginIdentifier: String = "FakeService1" + var stateDelegate: StatefulPluggableDelegate? + var serviceDelegate: ServiceDelegate? + var rawState: RawStateValue = [:] + required init() {} + required init?(rawState: RawStateValue) {} + let isOnboarded = true + var available: ServiceDescriptor { ServiceDescriptor(identifier: pluginIdentifier, localizedTitle: localizedTitle) } + } + fileprivate class FakeService2: Service { + static var localizedTitle: String = "Service 2" + static var pluginIdentifier: String = "FakeService2" + var stateDelegate: StatefulPluggableDelegate? + var serviceDelegate: ServiceDelegate? + var rawState: RawStateValue = [:] + required init() {} + required init?(rawState: RawStateValue) {} + let isOnboarded = true + var available: ServiceDescriptor { ServiceDescriptor(identifier: pluginIdentifier, localizedTitle: localizedTitle) } + } + + static var preview: ServicesViewModel { + return ServicesViewModel(showServices: true, + availableServices: { [FakeService1().available, FakeService2().available] }, + activeServices: { [FakeService1()] }) + } +} diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift new file mode 100644 index 0000000000..d4b48766b3 --- /dev/null +++ b/Loop/View Models/SettingsViewModel.swift @@ -0,0 +1,190 @@ +// +// SettingsViewModel.swift +// LoopUI +// +// Created by Rick Pasetto on 6/25/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import Combine +import LoopCore +import LoopKit +import LoopKitUI +import SwiftUI +import HealthKit + +public class DeviceViewModel: ObservableObject { + public typealias DeleteTestingDataFunc = () -> Void + + let isSetUp: () -> Bool + let image: () -> UIImage? + let name: () -> String + let deleteTestingDataFunc: () -> DeleteTestingDataFunc? + let didTap: () -> Void + let didTapAdd: (_ device: T) -> Void + var isTestingDevice: Bool { + return deleteTestingDataFunc() != nil + } + + @Published var availableDevices: [T] + + public init(image: @escaping () -> UIImage? = { nil }, + name: @escaping () -> String = { "" }, + isSetUp: @escaping () -> Bool = { false }, + availableDevices: [T] = [], + deleteTestingDataFunc: @escaping () -> DeleteTestingDataFunc? = { nil }, + onTapped: @escaping () -> Void = { }, + didTapAddDevice: @escaping (T) -> Void = { _ in } + ) { + self.image = image + self.name = name + self.availableDevices = availableDevices + self.isSetUp = isSetUp + self.deleteTestingDataFunc = deleteTestingDataFunc + self.didTap = onTapped + self.didTapAdd = didTapAddDevice + } +} + +public typealias CGMManagerViewModel = DeviceViewModel +public typealias PumpManagerViewModel = DeviceViewModel + +public protocol SettingsViewModelDelegate: AnyObject { + func dosingEnabledChanged(_: Bool) + func dosingStrategyChanged(_: AutomaticDosingStrategy) + func didTapIssueReport() + var closedLoopDescriptiveText: String? { get } +} + +public class SettingsViewModel: ObservableObject { + + let alertPermissionsChecker: AlertPermissionsChecker + + let alertMuter: AlertMuter + + let versionUpdateViewModel: VersionUpdateViewModel + + private weak var delegate: SettingsViewModelDelegate? + + func didTapIssueReport() { + delegate?.didTapIssueReport() + } + + var availableSupports: [SupportUI] + let pumpManagerSettingsViewModel: PumpManagerViewModel + let cgmManagerSettingsViewModel: CGMManagerViewModel + let servicesViewModel: ServicesViewModel + let criticalEventLogExportViewModel: CriticalEventLogExportViewModel + let therapySettings: () -> TherapySettings + let sensitivityOverridesEnabled: Bool + let isOnboardingComplete: Bool + let therapySettingsViewModelDelegate: TherapySettingsViewModelDelegate? + + @Published var isClosedLoopAllowed: Bool + + var closedLoopDescriptiveText: String? { + return delegate?.closedLoopDescriptiveText + } + + + @Published var automaticDosingStrategy: AutomaticDosingStrategy { + didSet { + delegate?.dosingStrategyChanged(automaticDosingStrategy) + } + } + + var closedLoopPreference: Bool { + didSet { + delegate?.dosingEnabledChanged(closedLoopPreference) + } + } + + var showDeleteTestData: Bool { + availableSupports.contains(where: { $0.showsDeleteTestDataUI }) + } + + lazy private var cancellables = Set() + + public init(alertPermissionsChecker: AlertPermissionsChecker, + alertMuter: AlertMuter, + versionUpdateViewModel: VersionUpdateViewModel, + pumpManagerSettingsViewModel: PumpManagerViewModel, + cgmManagerSettingsViewModel: CGMManagerViewModel, + servicesViewModel: ServicesViewModel, + criticalEventLogExportViewModel: CriticalEventLogExportViewModel, + therapySettings: @escaping () -> TherapySettings, + sensitivityOverridesEnabled: Bool, + initialDosingEnabled: Bool, + isClosedLoopAllowed: Published.Publisher, + automaticDosingStrategy: AutomaticDosingStrategy, + availableSupports: [SupportUI], + isOnboardingComplete: Bool, + therapySettingsViewModelDelegate: TherapySettingsViewModelDelegate?, + delegate: SettingsViewModelDelegate? + ) { + self.alertPermissionsChecker = alertPermissionsChecker + self.alertMuter = alertMuter + self.versionUpdateViewModel = versionUpdateViewModel + self.pumpManagerSettingsViewModel = pumpManagerSettingsViewModel + self.cgmManagerSettingsViewModel = cgmManagerSettingsViewModel + self.servicesViewModel = servicesViewModel + self.criticalEventLogExportViewModel = criticalEventLogExportViewModel + self.therapySettings = therapySettings + self.sensitivityOverridesEnabled = sensitivityOverridesEnabled + self.closedLoopPreference = initialDosingEnabled + self.isClosedLoopAllowed = false + self.automaticDosingStrategy = automaticDosingStrategy + self.availableSupports = availableSupports + self.isOnboardingComplete = isOnboardingComplete + self.therapySettingsViewModelDelegate = therapySettingsViewModelDelegate + self.delegate = delegate + + // This strangeness ensures the composed ViewModels' (ObservableObjects') changes get reported to this ViewModel (ObservableObject) + alertPermissionsChecker.objectWillChange.sink { [weak self] in + self?.objectWillChange.send() + } + .store(in: &cancellables) + alertMuter.objectWillChange.sink { [weak self] in + self?.objectWillChange.send() + } + .store(in: &cancellables) + pumpManagerSettingsViewModel.objectWillChange.sink { [weak self] in + self?.objectWillChange.send() + } + .store(in: &cancellables) + cgmManagerSettingsViewModel.objectWillChange.sink { [weak self] in + self?.objectWillChange.send() + } + .store(in: &cancellables) + + isClosedLoopAllowed + .assign(to: \.isClosedLoopAllowed, on: self) + .store(in: &cancellables) + } +} + +// For previews only +extension SettingsViewModel { + fileprivate class FakeClosedLoopAllowedPublisher { + @Published var mockIsClosedLoopAllowed: Bool = false + } + + static var preview: SettingsViewModel { + return SettingsViewModel(alertPermissionsChecker: AlertPermissionsChecker(), + alertMuter: AlertMuter(), + versionUpdateViewModel: VersionUpdateViewModel(supportManager: nil, guidanceColors: GuidanceColors()), + pumpManagerSettingsViewModel: DeviceViewModel(), + cgmManagerSettingsViewModel: DeviceViewModel(), + servicesViewModel: ServicesViewModel.preview, + criticalEventLogExportViewModel: CriticalEventLogExportViewModel(exporterFactory: MockCriticalEventLogExporterFactory()), + therapySettings: { TherapySettings() }, + sensitivityOverridesEnabled: false, + initialDosingEnabled: true, + isClosedLoopAllowed: FakeClosedLoopAllowedPublisher().$mockIsClosedLoopAllowed, + automaticDosingStrategy: .automaticBolus, + availableSupports: [], + isOnboardingComplete: false, + therapySettingsViewModelDelegate: nil, + delegate: nil) + } +} diff --git a/Loop/View Models/SimpleBolusViewModel.swift b/Loop/View Models/SimpleBolusViewModel.swift new file mode 100644 index 0000000000..016c1518fb --- /dev/null +++ b/Loop/View Models/SimpleBolusViewModel.swift @@ -0,0 +1,463 @@ +// +// SimpleBolusViewModel.swift +// Loop +// +// Created by Pete Schwamb on 9/29/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import Foundation +import HealthKit +import LoopKit +import LoopKitUI +import os.log +import SwiftUI +import LoopCore +import Intents +import LocalAuthentication + +protocol SimpleBolusViewModelDelegate: AnyObject { + + func addGlucose(_ samples: [NewGlucoseSample], completion: @escaping (Swift.Result<[StoredGlucoseSample], Error>) -> Void) + + func addCarbEntry(_ carbEntry: NewCarbEntry, replacing replacingEntry: StoredCarbEntry? , + completion: @escaping (_ result: Result) -> Void) + + func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) + + func enactBolus(units: Double, activationType: BolusActivationType) + + func insulinOnBoard(at date: Date, completion: @escaping (_ result: DoseStoreResult) -> Void) + + func computeSimpleBolusRecommendation(at date: Date, mealCarbs: HKQuantity?, manualGlucose: HKQuantity?) -> BolusDosingDecision? + + var displayGlucosePreference: DisplayGlucosePreference { get } + + var maximumBolus: Double { get } + + var suspendThreshold: HKQuantity { get } +} + +class SimpleBolusViewModel: ObservableObject { + + var authenticate: AuthenticationChallenge = LocalAuthentication.deviceOwnerCheck + + enum Alert: Int { + case carbEntryPersistenceFailure + case manualGlucoseEntryPersistenceFailure + case infoPopup + } + + @Published var activeAlert: Alert? + + enum Notice: Int { + case carbohydrateEntryTooLarge + case glucoseBelowRecommendationLimit + case glucoseBelowSuspendThreshold + case glucoseOutOfAllowedInputRange + case glucoseWarning + case maxBolusExceeded + case recommendationExceedsMaxBolus + } + + @Published var activeNotice: Notice? + + var isNoticeVisible: Bool { return activeNotice != nil } + + @Published var recommendedBolus: String = "–" + + @Published var activeInsulin: String? + + @Published var enteredCarbString: String = "" { + didSet { + if let enteredCarbs = Self.carbAmountFormatter.number(from: enteredCarbString)?.doubleValue, enteredCarbs > 0 { + carbQuantity = HKQuantity(unit: .gram(), doubleValue: enteredCarbs) + } else { + carbQuantity = nil + } + updateRecommendation() + } + } + + var displayMealEntry: Bool + + + // needed to detect change in display glucose unit when returning to the app + private var cachedDisplayGlucoseUnit: HKUnit + + var manualGlucoseString: String { + get { + if cachedDisplayGlucoseUnit != displayGlucoseUnit { + cachedDisplayGlucoseUnit = displayGlucoseUnit + guard let manualGlucoseQuantity = manualGlucoseQuantity else { + _manualGlucoseString = "" + return _manualGlucoseString + } + self._manualGlucoseString = delegate.displayGlucosePreference.format(manualGlucoseQuantity, includeUnit: false) + } + + return _manualGlucoseString + } + set { + _manualGlucoseString = newValue + } + } + + private func updateNotice() { + + if let carbs = self.carbQuantity { + guard carbs <= LoopConstants.maxCarbEntryQuantity else { + activeNotice = .carbohydrateEntryTooLarge + return + } + } + + if let bolus = bolus { + guard bolus.doubleValue(for: .internationalUnit()) <= delegate.maximumBolus else { + activeNotice = .maxBolusExceeded + return + } + } + + let isAddingCarbs: Bool + if let carbQuantity = carbQuantity, carbQuantity.doubleValue(for: .gram()) > 0 { + isAddingCarbs = true + } else { + isAddingCarbs = false + } + + let minRecommendationGlucose = + isAddingCarbs ? + LoopConstants.simpleBolusCalculatorMinGlucoseMealBolusRecommendation : + LoopConstants.simpleBolusCalculatorMinGlucoseBolusRecommendation + + switch manualGlucoseQuantity { + case let .some(g) where !LoopConstants.validManualGlucoseEntryRange.contains(g): + activeNotice = .glucoseOutOfAllowedInputRange + case let g? where g < minRecommendationGlucose: + activeNotice = .glucoseBelowRecommendationLimit + case let g? where g < LoopConstants.simpleBolusCalculatorGlucoseWarningLimit: + activeNotice = .glucoseWarning + case let g? where g < suspendThreshold: + activeNotice = .glucoseBelowSuspendThreshold + default: + if let recommendation = recommendation, recommendation > delegate.maximumBolus { + activeNotice = .recommendationExceedsMaxBolus + } else { + activeNotice = nil + } + } + + } + + @Published private var _manualGlucoseString: String = "" { + didSet { + guard let manualGlucoseValue = delegate.displayGlucosePreference.formatter.numberFormatter.number(from: _manualGlucoseString)?.doubleValue + else { + manualGlucoseQuantity = nil + return + } + + // if needed update manualGlucoseQuantity and related activeNotice + if manualGlucoseQuantity == nil || + _manualGlucoseString != delegate.displayGlucosePreference.format(manualGlucoseQuantity!, includeUnit: false) + { + manualGlucoseQuantity = HKQuantity(unit: cachedDisplayGlucoseUnit, doubleValue: manualGlucoseValue) + updateNotice() + } + } + } + + @Published var enteredBolusString: String { + didSet { + if let enteredBolusAmount = Self.doseAmountFormatter.number(from: enteredBolusString)?.doubleValue, enteredBolusAmount > 0 { + bolus = HKQuantity(unit: .internationalUnit(), doubleValue: enteredBolusAmount) + } else { + bolus = nil + } + updateNotice() + } + } + + private var carbQuantity: HKQuantity? = nil + + private var manualGlucoseQuantity: HKQuantity? = nil { + didSet { + updateRecommendation() + } + } + + private var bolus: HKQuantity? = nil + + var bolusRecommended: Bool { + if let bolus = bolus, bolus.doubleValue(for: .internationalUnit()) > 0 { + return true + } + return false + } + + var displayGlucoseUnit: HKUnit { return delegate.displayGlucosePreference.unit } + + var suspendThreshold: HKQuantity { return delegate.suspendThreshold } + + private var recommendation: Double? = nil { + didSet { + if let recommendation = recommendation { + recommendedBolus = Self.doseAmountFormatter.string(from: recommendation)! + enteredBolusString = Self.doseAmountFormatter.string(from: min(recommendation, delegate.maximumBolus))! + } else { + recommendedBolus = ("–") // do not localize this, comment: "String denoting lack of a recommended bolus amount in the simple bolus calculator" + enteredBolusString = Self.doseAmountFormatter.string(from: 0.0)! + } + } + } + + private var dosingDecision: BolusDosingDecision? + + private var recommendationDate: Date? + + private static let doseAmountFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.maximumFractionDigits = 1 + return formatter + }() + + private static let carbAmountFormatter: NumberFormatter = { + let quantityFormatter = QuantityFormatter(for: .gram()) + return quantityFormatter.numberFormatter + }() + + enum ActionButtonAction { + case saveWithoutBolusing + case saveAndDeliver + case enterBolus + case deliver + } + + var hasDataToSave: Bool { + return manualGlucoseQuantity != nil || carbQuantity != nil + } + + var hasBolusEntryReadyToDeliver: Bool { + return bolus != nil + } + + var actionButtonAction: ActionButtonAction { + switch (hasDataToSave, hasBolusEntryReadyToDeliver) { + case (true, true): return .saveAndDeliver + case (true, false): return .saveWithoutBolusing + case (false, true): return .deliver + case (false, false): return .enterBolus + } + } + + var actionButtonDisabled: Bool { + switch activeNotice { + case .glucoseOutOfAllowedInputRange, .maxBolusExceeded, .carbohydrateEntryTooLarge: + return true + default: + return false + } + } + + var carbPlaceholder: String { + Self.carbAmountFormatter.string(from: 0.0)! + } + + private let delegate: SimpleBolusViewModelDelegate + private let log = OSLog(category: "SimpleBolusViewModel") + + private lazy var bolusVolumeFormatter = QuantityFormatter(for: .internationalUnit()) + + var maximumBolusAmountString: String { + let maxBolusQuantity = HKQuantity(unit: .internationalUnit(), doubleValue: delegate.maximumBolus) + return bolusVolumeFormatter.string(from: maxBolusQuantity)! + } + + init(delegate: SimpleBolusViewModelDelegate, displayMealEntry: Bool) { + self.delegate = delegate + self.displayMealEntry = displayMealEntry + cachedDisplayGlucoseUnit = delegate.displayGlucosePreference.unit + enteredBolusString = Self.doseAmountFormatter.string(from: 0.0)! + updateRecommendation() + dosingDecision = BolusDosingDecision(for: .simpleBolus) + } + + func updateRecommendation() { + let recommendationDate = Date() + + if let carbs = self.carbQuantity { + guard carbs <= LoopConstants.maxCarbEntryQuantity else { + recommendation = nil + return + } + } + + if let glucose = manualGlucoseQuantity { + guard LoopConstants.validManualGlucoseEntryRange.contains(glucose) else { + recommendation = nil + return + } + } + + if carbQuantity != nil || manualGlucoseQuantity != nil { + dosingDecision = delegate.computeSimpleBolusRecommendation(at: recommendationDate, mealCarbs: carbQuantity, manualGlucose: manualGlucoseQuantity) + if let decision = dosingDecision, let bolusRecommendation = decision.manualBolusRecommendation { + recommendation = bolusRecommendation.recommendation.amount + } else { + recommendation = nil + } + + if let decision = dosingDecision, let insulinOnBoard = decision.insulinOnBoard, insulinOnBoard.value > 0, manualGlucoseQuantity != nil { + activeInsulin = Self.doseAmountFormatter.string(from: insulinOnBoard.value) + } else { + activeInsulin = nil + } + self.recommendationDate = recommendationDate + } else { + dosingDecision = nil + recommendation = nil + activeInsulin = nil + self.recommendationDate = nil + } + } + + func saveAndDeliver(completion: @escaping (Bool) -> Void) { + + let saveDate = Date() + + // Authenticate the bolus before saving anything + func authenticateIfNeeded(_ completion: @escaping (Bool) -> Void) { + if let bolus = bolus, bolus.doubleValue(for: .internationalUnit()) > 0 { + let message = String(format: NSLocalizedString("Authenticate to Bolus %@ Units", comment: "The message displayed during a device authentication prompt for bolus specification"), enteredBolusString) + authenticate(message) { + switch $0 { + case .success: + completion(true) + case .failure: + completion(false) + } + } + } else { + completion(true) + } + } + + func saveManualGlucose(_ completion: @escaping (Bool) -> Void) { + if let manualGlucoseQuantity = manualGlucoseQuantity { + let manualGlucoseSample = NewGlucoseSample(date: saveDate, + quantity: manualGlucoseQuantity, + condition: nil, // All manual glucose entries are assumed to have no condition. + trend: nil, // All manual glucose entries are assumed to have no trend. + trendRate: nil, // All manual glucose entries are assumed to have no trend rate. + isDisplayOnly: false, + wasUserEntered: true, + syncIdentifier: UUID().uuidString) + delegate.addGlucose([manualGlucoseSample]) { result in + DispatchQueue.main.async { + switch result { + case .failure(let error): + self.presentAlert(.manualGlucoseEntryPersistenceFailure) + self.log.error("Failed to add manual glucose entry: %{public}@", String(describing: error)) + completion(false) + case .success(let storedSamples): + self.dosingDecision?.manualGlucoseSample = storedSamples.first + completion(true) + } + } + } + } else { + completion(true) + } + } + + func saveCarbs(_ completion: @escaping (Bool) -> Void) { + if let carbs = carbQuantity { + + let interaction = INInteraction(intent: NewCarbEntryIntent(), response: nil) + interaction.donate { [weak self] (error) in + if let error = error { + self?.log.error("Failed to donate intent: %{public}@", String(describing: error)) + } + } + + let carbEntry = NewCarbEntry(date: saveDate, quantity: carbs, startDate: saveDate, foodType: nil, absorptionTime: nil) + + delegate.addCarbEntry(carbEntry, replacing: nil) { result in + DispatchQueue.main.async { + switch result { + case .failure(let error): + self.presentAlert(.carbEntryPersistenceFailure) + self.log.error("Failed to add carb entry: %{public}@", String(describing: error)) + completion(false) + case .success(let storedEntry): + self.dosingDecision?.carbEntry = storedEntry + completion(true) + } + } + } + } else { + completion(true) + } + } + + func enactBolus() { + if let bolusVolume = bolus?.doubleValue(for: .internationalUnit()), bolusVolume > 0 { + delegate.enactBolus(units: bolusVolume, activationType: .activationTypeFor(recommendedAmount: recommendation, bolusAmount: bolusVolume)) + dosingDecision?.manualBolusRequested = bolusVolume + } + } + + func saveBolusDecision() { + if let decision = dosingDecision, let recommendationDate = recommendationDate { + delegate.storeManualBolusDosingDecision(decision, withDate: recommendationDate) + } + } + + func finishWithResult(_ success: Bool) { + saveBolusDecision() + completion(success) + } + + authenticateIfNeeded { (success) in + if success { + saveManualGlucose { (success) in + if success { + saveCarbs { (success) in + if success { + enactBolus() + } + finishWithResult(success) + } + } else { + finishWithResult(false) + } + } + } else { + finishWithResult(false) + } + } + } + + private func presentAlert(_ alert: Alert) { + dispatchPrecondition(condition: .onQueue(.main)) + + // As of iOS 13.6 / Xcode 11.6, swapping out an alert while one is active crashes SwiftUI. + guard activeAlert == nil else { + return + } + + activeAlert = alert + } + + func restoreUserActivityState(_ activity: NSUserActivity) { + if let entry = activity.newCarbEntry { + carbQuantity = entry.quantity + } + } +} + +extension SimpleBolusViewModel.Alert: Identifiable { + var id: Self { self } +} diff --git a/Loop/View Models/VersionUpdateViewModel.swift b/Loop/View Models/VersionUpdateViewModel.swift new file mode 100644 index 0000000000..fa2b87e6c5 --- /dev/null +++ b/Loop/View Models/VersionUpdateViewModel.swift @@ -0,0 +1,81 @@ +// +// VersionUpdateViewModel.swift +// Loop +// +// Created by Rick Pasetto on 10/4/21. +// Copyright © 2021 LoopKit Authors. All rights reserved. +// + +import Combine +import Foundation +import LoopKit +import SwiftUI +import LoopKitUI + +public class VersionUpdateViewModel: ObservableObject { + + @Published var versionUpdate: VersionUpdate? + + var softwareUpdateAvailable: Bool { + return versionUpdate?.softwareUpdateAvailable ?? false + } + + @ViewBuilder + var icon: some View { + switch versionUpdate { + case .required, .recommended: + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(warningColor) + default: + EmptyView() + } + } + + func footer(appName: String) -> String { + switch versionUpdate { + case .required, .recommended: + return String(format: NSLocalizedString("A new version of %@ is available and is recommended to continue using the app.", comment: "Software update available section footer (1: app name)"), appName) + case .available: + return String(format: NSLocalizedString("A new version of %@ is available.", comment: "Required software update section footer (1: app name)"), appName) + default: + return "" + } + } + + @ViewBuilder + var softwareUpdateView: some View { + supportManager?.softwareUpdateView(guidanceColors: guidanceColors) + } + + var warningColor: Color { + switch versionUpdate { + case .required: return guidanceColors.critical + case .recommended: return guidanceColors.warning + default: return .primary + } + } + + private weak var supportManager: SupportManager? + private let guidanceColors: GuidanceColors + + lazy private var cancellables = Set() + + init(supportManager: SupportManager? = nil, guidanceColors: GuidanceColors) { + self.supportManager = supportManager + self.guidanceColors = guidanceColors + + NotificationCenter.default.publisher(for: .SoftwareUpdateAvailable) + .sink { [weak self] _ in + self?.update() + } + .store(in: &cancellables) + + update() + } + + public func update() { + Task { @MainActor in + versionUpdate = await supportManager?.checkVersion() + } + } +} diff --git a/Loop/Views/AddEditFavoriteFoodView.swift b/Loop/Views/AddEditFavoriteFoodView.swift new file mode 100644 index 0000000000..0e2d9ebaa4 --- /dev/null +++ b/Loop/Views/AddEditFavoriteFoodView.swift @@ -0,0 +1,173 @@ +// +// AddEditFavoriteFoodView.swift +// Loop +// +// Created by Noah Brauner on 7/31/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopKitUI + +struct AddEditFavoriteFoodView: View { + @Environment(\.dismiss) var dismiss + + @StateObject private var viewModel: AddEditFavoriteFoodViewModel + + @State private var expandedRow: Row? + @State private var showHowAbsorptionTimeWorks = false + + private var isNewEntry = true + + /// Initializer for adding a new favorite food or editing a `StoredFavoriteFood` + init(originalFavoriteFood: StoredFavoriteFood? = nil, onSave: @escaping (NewFavoriteFood) -> Void) { + self._viewModel = StateObject(wrappedValue: AddEditFavoriteFoodViewModel(originalFavoriteFood: originalFavoriteFood, onSave: onSave)) + self.isNewEntry = originalFavoriteFood == nil + } + + /// Initializer for presenting the `AddEditFavoriteFoodView` prepopulated from the `CarbEntryView` + init(carbsQuantity: Double?, foodType: String, absorptionTime: TimeInterval, onSave: @escaping (NewFavoriteFood) -> Void) { + self._viewModel = StateObject(wrappedValue: AddEditFavoriteFoodViewModel(carbsQuantity: carbsQuantity, foodType: foodType, absorptionTime: absorptionTime, onSave: onSave)) + } + + var body: some View { + if isNewEntry { + NavigationView { + content + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + dismissButton + } + + ToolbarItem(placement: .navigationBarTrailing) { + saveButton + } + } + .navigationBarTitle(String(localized: "New Favorite Food", comment: "Title of new favorite food screen"), displayMode: .inline) + .onAppear { + expandedRow = .name + } + } + } + else { + content + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + if viewModel.updatedFavoriteFood != nil { + dismissButton + } + } + + ToolbarItem(placement: .navigationBarTrailing) { + saveButton + } + } + .navigationBarBackButtonHidden(viewModel.updatedFavoriteFood != nil) + .navigationBarTitle(viewModel.originalFavoriteFood?.title ?? "", displayMode: .inline) + } + } + + private var content: some View { + ZStack { + Color(.systemGroupedBackground) + .edgesIgnoringSafeArea(.all) + + ScrollView { + card + .padding(.top, 8) + + saveActionButton + } + } + .alert(item: $viewModel.alert, content: alert(for:)) + .sheet(isPresented: $showHowAbsorptionTimeWorks) { + HowAbsorptionTimeWorksView() + } + } + + private var card: some View { + VStack(spacing: 10) { + let nameFocused: Binding = Binding(get: { expandedRow == .name }, set: { expandedRow = $0 ? .name : nil }) + let carbQuantityFocused: Binding = Binding(get: { expandedRow == .carbQuantity }, set: { expandedRow = $0 ? .carbQuantity : nil }) + let foodTypeFocused: Binding = Binding(get: { expandedRow == .foodType }, set: { expandedRow = $0 ? .foodType : nil }) + let absorptionTimeFocused: Binding = Binding(get: { expandedRow == .absorptionTime }, set: { expandedRow = $0 ? .absorptionTime : nil }) + + TextFieldRow(text: $viewModel.name, isFocused: nameFocused, title: String(localized: "Name", comment: "Label for name row on add favorite food screen"), placeholder: String(localized: "Apple", comment: "Default name on add favorite food screen")) + + CardSectionDivider() + + CarbQuantityRow(quantity: $viewModel.carbsQuantity, isFocused: carbQuantityFocused, title: String(localized: "Carb Quantity", comment: "Label for carb quantity row on add favorite food screen"), preferredCarbUnit: viewModel.preferredCarbUnit) + + CardSectionDivider() + + EmojiRow(text: $viewModel.foodType, isFocused: foodTypeFocused, emojiType: .food, title: String(localized: "Food Type", comment: "Label for food type entry on add favorite food screen")) + + CardSectionDivider() + + AbsorptionTimePickerRow(absorptionTime: $viewModel.absorptionTime, isFocused: absorptionTimeFocused, validDurationRange: viewModel.absorptionRimesRange, showHowAbsorptionTimeWorks: $showHowAbsorptionTimeWorks) + .padding(.bottom, 2) + } + .padding(.vertical, 12) + .padding(.horizontal) + .background(CardBackground()) + .padding(.horizontal) + } + + private func alert(for alert: AddEditFavoriteFoodViewModel.Alert) -> SwiftUI.Alert { + switch alert { + case .maxQuantityExceded: + let message = String( + format: NSLocalizedString("The maximum allowed amount is %@ grams.", comment: "Alert body displayed for quantity greater than max (1: maximum quantity in grams)"), + NumberFormatter.localizedString(from: NSNumber(value: viewModel.maxCarbEntryQuantity.doubleValue(for: viewModel.preferredCarbUnit)), number: .none) + ) + let okMessage = NSLocalizedString("com.loudnate.LoopKit.errorAlertActionTitle", value: "OK", comment: "The title of the action used to dismiss an error alert") + return SwiftUI.Alert( + title: Text("Large Meal Entered", comment: "Title of the warning shown when a large meal was entered"), + message: Text(message), + dismissButton: .cancel(Text(okMessage), action: viewModel.clearAlert) + ) + case .warningQuantityValidation: + let message = String( + format: NSLocalizedString("Did you intend to enter %1$@ grams as the amount of carbohydrates for this meal?", comment: "Alert body when entered carbohydrates is greater than threshold (1: entered quantity in grams)"), + NumberFormatter.localizedString(from: NSNumber(value: viewModel.carbsQuantity ?? 0), number: .none) + ) + return SwiftUI.Alert( + title: Text("Large Meal Entered", comment: "Title of the warning shown when a large meal was entered"), + message: Text(message), + primaryButton: .default(Text("No, edit amount", comment: "The title of the action used when rejecting the the amount of carbohydrates entered."), action: viewModel.clearAlert), + secondaryButton: .cancel(Text("Yes", comment: "The title of the action used when confirming entered amount of carbohydrates."), action: viewModel.clearAlertAndSave) + ) + } + } +} + +extension AddEditFavoriteFoodView { + private var dismissButton: some View { + Button(action: dismiss.callAsFunction) { + Text("Cancel") + } + } + + private var saveActionButton: some View { + Button(action: viewModel.save) { + Text("Save") + } + .buttonStyle(ActionButtonStyle()) + .padding() + .disabled(viewModel.updatedFavoriteFood == nil) + } + + private var saveButton: some View { + Button(action: viewModel.save) { + Text("Save") + } + .disabled(viewModel.updatedFavoriteFood == nil) + } +} + +extension AddEditFavoriteFoodView { + enum Row { + case name, carbQuantity, foodType, absorptionTime + } +} diff --git a/Loop/Views/AlertManagementView.swift b/Loop/Views/AlertManagementView.swift new file mode 100644 index 0000000000..94e542a6ab --- /dev/null +++ b/Loop/Views/AlertManagementView.swift @@ -0,0 +1,273 @@ +// +// AlertManagementView.swift +// Loop +// +// Created by Nathaniel Hamming on 2022-09-09. +// Copyright © 2022 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopCore +import LoopKit +import LoopKitUI +import HealthKit + +struct AlertManagementView: View { + @Environment(\.appName) private var appName + @Environment(\.guidanceColors) private var guidanceColors + + @ObservedObject private var checker: AlertPermissionsChecker + @ObservedObject private var alertMuter: AlertMuter + + @State private var showMuteAlertOptions: Bool = false + @State private var showHowMuteAlertWork: Bool = false + + private var formatter: DateComponentsFormatter = { + let formatter = DateComponentsFormatter() + formatter.unitsStyle = .full + formatter.allowedUnits = [.hour, .minute] + return formatter + }() + + private var formattedSelectedDuration: Binding { + Binding( + get: { formatter.string(from: alertMuter.configuration.duration)! }, + set: { newValue in + guard let selectedDurationIndex = formatterDurations.firstIndex(of: newValue) + else { return } + DispatchQueue.main.async { + // avoid publishing during view update + alertMuter.configuration.startTime = Date() + alertMuter.configuration.duration = AlertMuter.allowedDurations[selectedDurationIndex] + } + } + ) + } + + private var formatterDurations: [String] { + AlertMuter.allowedDurations.compactMap { formatter.string(from: $0) } + } + + private var missedMealNotificationsEnabled: Binding { + Binding( + get: { UserDefaults.standard.missedMealNotificationsEnabled }, + set: { enabled in + UserDefaults.standard.missedMealNotificationsEnabled = enabled + } + ) + } + + public init(checker: AlertPermissionsChecker, alertMuter: AlertMuter = AlertMuter()) { + self.checker = checker + self.alertMuter = alertMuter + } + + var body: some View { + List { + alertPermissionsSection + if FeatureFlags.criticalAlertsEnabled { + muteAlertsSection + } + if FeatureFlags.missedMealNotifications { + missedMealAlertSection + } + } + .navigationTitle(NSLocalizedString("Alert Management", comment: "Title of alert management screen")) + } + + private var footerView: some View { + VStack(alignment: .leading, spacing: 24) { + HStack(alignment: .top, spacing: 8) { + Image("phone") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 64, maxHeight: 64) + + VStack(alignment: .leading, spacing: 4) { + Text( + String( + format: NSLocalizedString( + "%1$@ APP SOUNDS", + comment: "App sounds title text (1: app name)" + ), + appName.uppercased() + ) + ) + + Text( + String( + format: NSLocalizedString( + "While mute alerts is on, all alerts from your %1$@ app including Critical and Time Sensitive alerts will temporarily display without sounds and will vibrate only.", + comment: "App sounds descriptive text (1: app name)" + ), + appName + ) + ) + } + } + + HStack(alignment: .top, spacing: 8) { + Image("hardware") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 64, maxHeight: 64) + + VStack(alignment: .leading, spacing: 4) { + Text("HARDWARE SOUNDS") + + Text("While mute alerts is on, your insulin pump and CGM hardware may still sound.") + } + } + + HStack(alignment: .top, spacing: 8) { + Image(systemName: "moon.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 64, maxHeight: 48) + .foregroundColor(.accentColor) + + VStack(alignment: .leading, spacing: 4) { + Text("IOS FOCUS MODES") + + Text( + String( + format: NSLocalizedString( + "If iOS Focus Mode is ON and Mute Alerts is OFF, Critical Alerts will still be delivered and non-Critical Alerts will be silenced until %1$@ is added to each Focus mode as an Allowed App.", + comment: "Focus modes descriptive text (1: app name)" + ), + appName + ) + ) + } + } + } + .padding(.top) + } + + private var alertPermissionsSection: some View { + Section(footer: DescriptiveText(label: String(format: NSLocalizedString("Notifications give you important %1$@ app information without requiring you to open the app.", comment: "Alert Permissions descriptive text (1: app name)"), appName))) { + NavigationLink(destination: + NotificationsCriticalAlertPermissionsView(mode: .flow, checker: checker)) + { + HStack { + Text(NSLocalizedString("Alert Permissions", comment: "Alert Permissions button text")) + if checker.showWarning || + checker.notificationCenterSettings.scheduledDeliveryEnabled { + Spacer() + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.critical) + } + } + } + + NavigationLink(destination: LiveActivityManagementView()) + { + Text(NSLocalizedString("Live activity", comment: "Alert Permissions live activity")) + } + } + } + + @ViewBuilder + private var muteAlertsSection: some View { + Section(footer: footerView) { + if !alertMuter.configuration.shouldMute { + howMuteAlertsWork + Button(action: { showMuteAlertOptions = true }) { + HStack { + muteAlertIcon + Text(NSLocalizedString("Mute All Alerts", comment: "Label for button to mute all alerts")) + } + } + .actionSheet(isPresented: $showMuteAlertOptions) { + muteAlertOptionsActionSheet + } + } else { + Button(action: alertMuter.unmuteAlerts) { + HStack { + unmuteAlertIcon + Text(NSLocalizedString("Tap to Unmute Alerts", comment: "Label for button to unmute all alerts")) + } + } + HStack { + Text(NSLocalizedString("All alerts muted until", comment: "Label for when mute alert will end")) + Spacer() + Text(alertMuter.formattedEndTime) + .foregroundColor(.secondary) + } + } + } + } + + private var muteAlertIcon: some View { + Image(systemName: "speaker.slash.fill") + .foregroundColor(.white) + .padding(5) + .background(guidanceColors.warning) + .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) + } + + private var unmuteAlertIcon: some View { + Image(systemName: "speaker.wave.2.fill") + .foregroundColor(.white) + .padding(.vertical, 5) + .padding(.horizontal, 2) + .background(guidanceColors.warning) + .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) + } + + private var howMuteAlertsWork: some View { + Button(action: { showHowMuteAlertWork = true }) { + HStack { + Text(NSLocalizedString("Frequently asked questions about alerts", comment: "Label for link to see frequently asked questions")) + .font(.footnote) + .foregroundColor(.secondary) + Spacer() + Image(systemName: "info.circle") + .font(.body) + } + } + .sheet(isPresented: $showHowMuteAlertWork) { + HowMuteAlertWorkView() + } + } + + private var muteAlertOptionsActionSheet: ActionSheet { + var muteAlertDurationOptions: [SwiftUI.Alert.Button] = formatterDurations.map { muteAlertDuration in + .default(Text(muteAlertDuration), + action: { formattedSelectedDuration.wrappedValue = muteAlertDuration }) + } + muteAlertDurationOptions.append(.cancel()) + + return ActionSheet( + title: Text(NSLocalizedString("Mute All Alerts Temporarily", comment: "Title for mute alert duration selection action sheet")), + message: Text(NSLocalizedString("No alerts or alarms will sound while muted. Select how long you would you like to mute for.", comment: "Message for mute alert duration selection action sheet")), + buttons: muteAlertDurationOptions) + } + + private var missedMealAlertSection: some View { + Section(footer: DescriptiveText(label: NSLocalizedString("When enabled, Loop can notify you when it detects a meal that wasn't logged.", comment: "Description of missed meal notifications."))) { + Toggle(NSLocalizedString("Missed Meal Notifications", comment: "Title for missed meal notifications toggle"), isOn: missedMealNotificationsEnabled) + } + } +} + +extension UserDefaults { + private enum Key: String { + case missedMealNotificationsEnabled = "com.loopkit.Loop.MissedMealNotificationsEnabled" + } + + var missedMealNotificationsEnabled: Bool { + get { + return object(forKey: Key.missedMealNotificationsEnabled.rawValue) as? Bool ?? false + } + set { + set(newValue, forKey: Key.missedMealNotificationsEnabled.rawValue) + } + } +} + +struct AlertManagementView_Previews: PreviewProvider { + static var previews: some View { + AlertManagementView(checker: AlertPermissionsChecker(), alertMuter: AlertMuter()) + } +} diff --git a/Loop/Views/AuthenticationTableViewCell.swift b/Loop/Views/AuthenticationTableViewCell.swift deleted file mode 100644 index 7d8eb85504..0000000000 --- a/Loop/Views/AuthenticationTableViewCell.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// AuthenticationTableViewCell.swift -// Loop -// -// Created by Nate Racklyeft on 7/2/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import UIKit - -final class AuthenticationTableViewCell: UITableViewCell, NibLoadable { - - @IBOutlet weak var titleLabel: UILabel! - - @IBOutlet weak var textField: UITextField! - - override func setSelected(_ selected: Bool, animated: Bool) { - super.setSelected(selected, animated: animated) - - if selected { - textField.becomeFirstResponder() - } - } - - override func prepareForReuse() { - super.prepareForReuse() - - textField.delegate = nil - } - -} diff --git a/Loop/Views/AuthenticationTableViewCell.xib b/Loop/Views/AuthenticationTableViewCell.xib deleted file mode 100644 index 767707a222..0000000000 --- a/Loop/Views/AuthenticationTableViewCell.xib +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Loop/Views/BolusEntryView.swift b/Loop/Views/BolusEntryView.swift new file mode 100644 index 0000000000..fecd2365c4 --- /dev/null +++ b/Loop/Views/BolusEntryView.swift @@ -0,0 +1,494 @@ +// +// BolusEntryView.swift +// Loop +// +// Created by Michael Pangburn on 7/17/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import Combine +import HealthKit +import SwiftUI +import LoopKit +import LoopKitUI +import LoopUI + + +struct BolusEntryView: View { + @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference + @Environment(\.dismissAction) var dismiss + @Environment(\.appName) var appName + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + + @ObservedObject var viewModel: BolusEntryViewModel + + @State private var enteredBolusString = "" + @State private var isInteractingWithChart = false + @State private var editedBolusAmount = false + + @FocusState private var bolusFieldFocused: Bool + + private var accessoryClearance: CGFloat { + dynamicTypeSize.isAccessibilitySize ? 72 : 52 + } + + var body: some View { + GeometryReader { geometry in + VStack(spacing: 0) { + List { + self.chartSection + self.summarySection + } + .insetGroupedListStyle() + + } + .navigationBarTitle(self.title) + .supportedInterfaceOrientations(.portrait) + .alert(item: self.$viewModel.activeAlert, content: self.alert(for:)) + .onReceive(self.viewModel.$recommendedBolus) { recommendation in + // If the recommendation changes, and the user has not edited the bolus amount, update the bolus amount + let amount = recommendation?.doubleValue(for: .internationalUnit()) ?? 0 + if !editedBolusAmount { + var newEnteredBolusString: String + if amount == 0 { + newEnteredBolusString = "" + } else { + newEnteredBolusString = viewModel.formatBolusAmount(amount) + } + enteredBolusStringBinding.wrappedValue = newEnteredBolusString + } + } + .safeAreaInset(edge: .bottom, spacing: 0) { + if bolusFieldFocused { + // Reserve space so the toolbar doesn’t overlap the field + Color.clear.frame(height: accessoryClearance) + } else { + actionArea + } + } + } + } + + private var title: Text { + if viewModel.potentialCarbEntry == nil { + return Text("Bolus", comment: "Title for bolus entry screen") + } + return Text("Meal Bolus", comment: "Title for bolus entry screen when also entering carbs") + } + + private var chartSection: some View { + Section { + VStack(spacing: 8) { + HStack(spacing: 0) { + activeCarbsLabel + Spacer(minLength: 8) + activeInsulinLabel + } + + // Use a ZStack to allow horizontally clipping the predicted glucose chart, + // without clipping the point label on highlight, which draws outside the view's bounds. + ZStack(alignment: .topLeading) { + Text("Glucose", comment: "Title for predicted glucose chart on bolus screen") + .font(.subheadline) + .bold() + .frame(maxWidth: .infinity, alignment: .leading) + .opacity(isInteractingWithChart ? 0 : 1) + + predictedGlucoseChart + .padding(.horizontal, -4) + .padding(.top, UIFont.preferredFont(forTextStyle: .subheadline).lineHeight + 8) // Leave space for the 'Glucose' label + spacing + .clipped() + } + .frame(height: ceil(UIScreen.main.bounds.height / 4)) + + if !FeatureFlags.usePositiveMomentumAndRCForManualBoluses { + Divider() + Button(action: { + viewModel.activeAlert = .forecastInfo + }) { + HStack { + Text("Forecasted blood glucose may still be higher than target range.") + .font(.footnote) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + Image(systemName: "info.circle") + .font(.system(size: 25)) + .foregroundColor(.accentColor) + } + } + .buttonStyle(PlainButtonStyle()) + } + + } + .padding(.top, 12) + .padding(.bottom, 8) + } + } + + @ViewBuilder + private var activeCarbsLabel: some View { + LabeledQuantity( + label: Text("Active Carbs", comment: "Title describing quantity of still-absorbing carbohydrates"), + quantity: viewModel.activeCarbs, + unit: .gram() + ) + } + + @ViewBuilder + private var activeInsulinLabel: some View { + LabeledQuantity( + label: Text("Active Insulin", comment: "Title describing quantity of still-absorbing insulin"), + quantity: viewModel.activeInsulin, + unit: .internationalUnit(), + maxFractionDigits: 2 + ) + } + + private var predictedGlucoseChart: some View { + PredictedGlucoseChartView( + chartManager: viewModel.chartManager, + glucoseUnit: displayGlucosePreference.unit, + glucoseValues: viewModel.glucoseValues, + predictedGlucoseValues: viewModel.predictedGlucoseValues, + targetGlucoseSchedule: viewModel.targetGlucoseSchedule, + preMealOverride: viewModel.preMealOverride, + scheduleOverride: viewModel.scheduleOverride, + dateInterval: viewModel.chartDateInterval, + isInteractingWithChart: $isInteractingWithChart + ) + } + + private var summarySection: some View { + Section { + VStack(spacing: 16) { + titleText + .bold() + .frame(maxWidth: .infinity, alignment: .leading) + + if viewModel.isManualGlucoseEntryEnabled { + ManualGlucoseEntryRow(quantity: $viewModel.manualGlucoseQuantity) + } else if viewModel.potentialCarbEntry != nil { + potentialCarbEntryRow + } else { + recommendedBolusRow + } + } + .padding(.top, 8) + + if viewModel.isManualGlucoseEntryEnabled && viewModel.potentialCarbEntry != nil { + potentialCarbEntryRow + } + + if viewModel.isManualGlucoseEntryEnabled || viewModel.potentialCarbEntry != nil { + recommendedBolusRow + } + + bolusEntryRow + } + } + + private var titleText: Text { + return Text("Bolus Summary", comment: "Title for card displaying carb entry and bolus recommendation") + } + + private var glucoseFormatter: NumberFormatter { + QuantityFormatter(for: displayGlucosePreference.unit).numberFormatter + } + + + @ViewBuilder + private var potentialCarbEntryRow: some View { + if viewModel.carbEntryAmountAndEmojiString != nil && viewModel.carbEntryDateAndAbsorptionTimeString != nil { + HStack { + Text("Carb Entry", comment: "Label for carb entry row on bolus screen") + + Text(viewModel.carbEntryAmountAndEmojiString!) + .foregroundColor(Color(.carbTintColor)) + .modifier(LabelBackground()) + + Spacer() + + Text(viewModel.carbEntryDateAndAbsorptionTimeString!) + .foregroundColor(Color(.secondaryLabel)) + } + } + } + + private var recommendedBolusRow: some View { + HStack { + Text("Recommended Bolus", comment: "Label for recommended bolus row on bolus screen") + Spacer() + HStack(alignment: .firstTextBaseline) { + Text(viewModel.recommendedBolusString) + .font(.title) + .foregroundColor(Color(.label)) + bolusUnitsLabel + } + } + .accessibilityElement(children: .combine) + } + + private func didBeginEditing() { + if !editedBolusAmount { + enteredBolusStringBinding.wrappedValue = "" + editedBolusAmount = true + } + } + + private var bolusEntryRow: some View { + HStack { + Text("Bolus", comment: "Label for bolus entry row on bolus screen") + Spacer() + HStack(alignment: .firstTextBaseline) { + TextField(viewModel.formatBolusAmount(0.0), text: enteredBolusStringBinding) + .keyboardType(.decimalPad) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .font(.title) + .multilineTextAlignment(.trailing) + .foregroundColor(.loopAccent) + .focused($bolusFieldFocused) + .onChange(of: bolusFieldFocused) { focused in + if focused { + didBeginEditing() + } + } + .onChange(of: enteredBolusString) { newValue in + if newValue.count > 5 { + enteredBolusString = String(newValue.prefix(5)) + viewModel.updateEnteredBolus(enteredBolusString) + } + } + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button("Done") { bolusFieldFocused = false } + } + } + bolusUnitsLabel + } + } + .accessibilityElement(children: .combine) + } + + private var bolusUnitsLabel: some View { + Text(QuantityFormatter(for: .internationalUnit()).localizedUnitStringWithPlurality()) + .foregroundColor(Color(.secondaryLabel)) + } + + private var enteredBolusStringBinding: Binding { + Binding( + get: { enteredBolusString }, + set: { newValue in + viewModel.updateEnteredBolus(newValue) + enteredBolusString = newValue + } + ) + } + + private var actionArea: some View { + VStack(spacing: 0) { + if viewModel.isNoticeVisible { + warning(for: viewModel.activeNotice!) + .padding([.top, .horizontal]) + .transition(AnyTransition.opacity.combined(with: .move(edge: .bottom))) + } + + if viewModel.isManualGlucosePromptVisible { + enterManualGlucoseButton + .transition(AnyTransition.opacity.combined(with: .move(edge: .bottom))) + } + + actionButton + } + .padding(.bottom) // FIXME: unnecessary on iPhone 8 size devices + .background(Color(.secondarySystemGroupedBackground).shadow(radius: 5)) + } + + private func warning(for notice: BolusEntryViewModel.Notice) -> some View { + switch notice { + case .predictedGlucoseBelowSuspendThreshold(suspendThreshold: let suspendThreshold): + let suspendThresholdString = displayGlucosePreference.format(suspendThreshold) + return WarningView( + title: Text("No Bolus Recommended", comment: "Title for bolus screen notice when no bolus is recommended"), + caption: Text("Your glucose is below or predicted to go below your glucose safety limit, \(suspendThresholdString).", comment: "Caption for bolus screen notice when no bolus is recommended due to prediction dropping below glucose safety limit") + ) + case .staleGlucoseData: + return WarningView( + title: Text("No Recent Glucose Data", comment: "Title for bolus screen notice when glucose data is missing or stale"), + caption: Text("Enter a blood glucose from a meter for a recommended bolus amount.", comment: "Caption for bolus screen notice when glucose data is missing or stale") + ) + case .futureGlucoseData: + return WarningView( + title: Text("Invalid Future Glucose", comment: "Title for bolus screen notice when glucose data is in the future"), + caption: Text("Check your device time and/or remove any invalid data from Apple Health.", comment: "Caption for bolus screen notice when glucose data is in the future") + ) + case .stalePumpData: + return WarningView( + title: Text("No Recent Pump Data", comment: "Title for bolus screen notice when pump data is missing or stale"), + caption: Text(String(format: NSLocalizedString("Your pump data is stale. %1$@ cannot recommend a bolus amount.", comment: "Caption for bolus screen notice when pump data is missing or stale"), appName)), + severity: .critical + ) + case .predictedGlucoseInRange, .glucoseBelowTarget: + return WarningView( + title: Text("No Bolus Recommended", comment: "Title for bolus screen notice when no bolus is recommended"), + caption: Text("Based on your predicted glucose, no bolus is recommended.", comment: "Caption for bolus screen notice when no bolus is recommended for the predicted glucose") + ) + } + } + + private var enterManualGlucoseButton: some View { + Button( + action: { + withAnimation { + self.viewModel.isManualGlucoseEntryEnabled = true + } + }, + label: { Text("Enter Fingerstick Glucose", comment: "Button text prompting manual glucose entry on bolus screen") } + ) + .buttonStyle(ActionButtonStyle(viewModel.primaryButton == .manualGlucoseEntry ? .primary : .secondary)) + .padding([.top, .horizontal]) + } + + private var actionButton: some View { + Button( + action: { + if self.viewModel.actionButtonAction == .enterBolus { + self.bolusFieldFocused = true + } else { + Task { + if await self.viewModel.didPressActionButton() { + dismiss() + } + } + } + }, + label: { + switch viewModel.actionButtonAction { + case .saveWithoutBolusing: + return Text("Save without Bolusing", comment: "Button text to save carbs and/or manual glucose entry without a bolus") + case .saveAndDeliver: + return Text("Save Carbs & Deliver", comment: "Button text to save carbs and/or manual glucose entry and deliver a bolus") + case .enterBolus: + return Text("Enter Bolus", comment: "Button text to begin entering a bolus") + case .deliver: + return Text("Deliver", comment: "Button text to deliver a bolus") + } + } + ) + .buttonStyle(ActionButtonStyle(viewModel.primaryButton == .actionButton ? .primary : .secondary)) + .disabled(viewModel.enacting) + .padding() + } + + private func alert(for alert: BolusEntryViewModel.Alert) -> SwiftUI.Alert { + switch alert { + case .recommendationChanged: + return SwiftUI.Alert( + title: Text("Bolus Recommendation Updated", comment: "Alert title for an updated bolus recommendation"), + message: Text("The bolus recommendation has updated. Please reconfirm the bolus amount.", comment: "Alert message for an updated bolus recommendation") + ) + case .maxBolusExceeded: + guard let maximumBolusAmountString = viewModel.maximumBolusAmountString else { + fatalError("Impossible to exceed max bolus without a configured max bolus") + } + return SwiftUI.Alert( + title: Text("Exceeds Maximum Bolus", comment: "Alert title for a maximum bolus validation error"), + message: Text("The maximum bolus amount is \(maximumBolusAmountString) U.", comment: "Alert message for a maximum bolus validation error (1: max bolus value)") + ) + case .bolusTooSmall: + return SwiftUI.Alert( + title: Text("Bolus Too Small", comment: "Alert title for a bolus too small validation error"), + message: Text("The bolus amount entered is smaller than the minimum deliverable.", comment: "Alert message for a bolus too small validation error") + ) + case .noPumpManagerConfigured: + return SwiftUI.Alert( + title: Text("No Pump Configured", comment: "Alert title for a missing pump error"), + message: Text("A pump must be configured before a bolus can be delivered.", comment: "Alert message for a missing pump error") + ) + case .noMaxBolusConfigured: + return SwiftUI.Alert( + title: Text("No Maximum Bolus Configured", comment: "Alert title for a missing maximum bolus setting error"), + message: Text("The maximum bolus setting must be configured before a bolus can be delivered.", comment: "Alert message for a missing maximum bolus setting error") + ) + case .carbEntryPersistenceFailure: + return SwiftUI.Alert( + title: Text("Unable to Save Carb Entry", comment: "Alert title for a carb entry persistence error"), + message: Text("An error occurred while trying to save your carb entry.", comment: "Alert message for a carb entry persistence error") + ) + case .manualGlucoseEntryOutOfAcceptableRange: + let acceptableLowerBound = displayGlucosePreference.format(LoopConstants.validManualGlucoseEntryRange.lowerBound) + let acceptableUpperBound = displayGlucosePreference.format(LoopConstants.validManualGlucoseEntryRange.upperBound) + return SwiftUI.Alert( + title: Text("Glucose Entry Out of Range", comment: "Alert title for a manual glucose entry out of range error"), + message: Text("A manual glucose entry must be between \(acceptableLowerBound) and \(acceptableUpperBound)", comment: "Alert message for a manual glucose entry out of range error") + ) + case .manualGlucoseEntryPersistenceFailure: + return SwiftUI.Alert( + title: Text("Unable to Save Manual Glucose Entry", comment: "Alert title for a manual glucose entry persistence error"), + message: Text("An error occurred while trying to save your manual glucose entry.", comment: "Alert message for a manual glucose entry persistence error") + ) + case .glucoseNoLongerStale: + return SwiftUI.Alert( + title: Text("Glucose Data Now Available", comment: "Alert title when glucose data returns while on bolus screen"), + message: Text("An updated bolus recommendation is available.", comment: "Alert message when glucose data returns while on bolus screen") + ) + case .forecastInfo: + return SwiftUI.Alert( + title: Text("Forecasted Glucose", comment: "Title for forecast explanation modal on bolus view"), + message: Text("The bolus dosing algorithm uses a more conservative estimate of forecasted blood glucose than what is used to adjust your basal rate.\n\nAs a result, your forecasted blood glucose after a bolus may still be higher than your target range.", comment: "Forecast explanation modal on bolus view") + ) + } + } +} + +struct LabeledQuantity: View { + var label: Text + var quantity: HKQuantity? + var unit: HKUnit + var maxFractionDigits: Int? + + var body: some View { + HStack(spacing: 4) { + label + .bold() + valueText + .foregroundColor(Color(.secondaryLabel)) + .fixedSize(horizontal: true, vertical: false) + } + .accessibilityElement(children: .combine) + .font(.subheadline) + .modifier(LabelBackground()) + } + + var valueText: Text { + guard let quantity = quantity else { + return Text(verbatim: "– –") + } + + let formatter = QuantityFormatter(for: unit) + + if let maxFractionDigits = maxFractionDigits { + formatter.numberFormatter.maximumFractionDigits = maxFractionDigits + } + + guard let string = formatter.string(from: quantity) else { + assertionFailure("Unable to format \(String(describing: quantity)) \(unit)") + return Text(verbatim: "") + } + + return Text(string) + } +} + +struct LabelBackground: ViewModifier { + func body(content: Content) -> some View { + content + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(Color(.systemGray6)) + ) + } +} diff --git a/Loop/Views/BolusProgressTableViewCell.swift b/Loop/Views/BolusProgressTableViewCell.swift new file mode 100644 index 0000000000..3752201d7d --- /dev/null +++ b/Loop/Views/BolusProgressTableViewCell.swift @@ -0,0 +1,126 @@ +// +// BolusProgressTableViewCell.swift +// LoopUI +// +// Created by Pete Schwamb on 3/11/19. +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit +import LoopUI +import HealthKit +import MKRingProgressView + + +public class BolusProgressTableViewCell: UITableViewCell { + @IBOutlet weak var progressLabel: UILabel! + + @IBOutlet weak var tapToStopLabel: UILabel! { + didSet { + tapToStopLabel.text = NSLocalizedString("Tap to Stop", comment: "Message presented in the status row instructing the user to tap this row to stop a bolus") + } + } + + @IBOutlet weak var stopSquare: UIView! { + didSet { + stopSquare.layer.cornerRadius = 2 + } + } + + @IBOutlet weak var progressIndicator: RingProgressView! + + public var totalUnits: Double? { + didSet { + updateProgress() + } + } + + public var deliveredUnits: Double? { + didSet { + updateProgress() + } + } + + private lazy var gradient = CAGradientLayer() + + private var doseTotalUnits: Double? + + private var disableUpdates: Bool = false + + lazy var insulinFormatter: QuantityFormatter = { + let formatter = QuantityFormatter(for: .internationalUnit()) + formatter.numberFormatter.minimumFractionDigits = 2 + return formatter + }() + + override public func awakeFromNib() { + super.awakeFromNib() + + gradient.frame = bounds + backgroundView?.layer.insertSublayer(gradient, at: 0) + updateColors() + } + + override public func layoutSubviews() { + super.layoutSubviews() + + gradient.frame = bounds + } + + public override func tintColorDidChange() { + super.tintColorDidChange() + updateColors() + } + + public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + updateColors() + } + + private func updateColors() { + progressIndicator.startColor = tintColor + progressIndicator.endColor = tintColor + stopSquare.backgroundColor = tintColor + gradient.colors = [ + UIColor.cellBackgroundColor.withAlphaComponent(0).cgColor, + UIColor.cellBackgroundColor.cgColor + ] + } + + private func updateProgress() { + guard !disableUpdates, let totalUnits = totalUnits else { + return + } + + let totalUnitsQuantity = HKQuantity(unit: .internationalUnit(), doubleValue: totalUnits) + let totalUnitsString = insulinFormatter.string(from: totalUnitsQuantity) ?? "" + + if let deliveredUnits = deliveredUnits { + let deliveredUnitsQuantity = HKQuantity(unit: .internationalUnit(), doubleValue: deliveredUnits) + let deliveredUnitsString = insulinFormatter.string(from: deliveredUnitsQuantity, includeUnit: false) ?? "" + + progressLabel.text = String(format: NSLocalizedString("Bolused %1$@ of %2$@", comment: "The format string for bolus progress. (1: delivered volume)(2: total volume)"), deliveredUnitsString, totalUnitsString) + + let progress = deliveredUnits / totalUnits + UIView.animate(withDuration: 0.3) { + self.progressIndicator.progress = progress + } + } else { + progressLabel.text = String(format: NSLocalizedString("Bolusing %1$@", comment: "The format string for bolus in progress showing total volume. (1: total volume)"), totalUnitsString) + } + } + + override public func prepareForReuse() { + super.prepareForReuse() + disableUpdates = true + deliveredUnits = 0 + disableUpdates = false + progressIndicator.progress = 0 + CATransaction.flush() + progressLabel.text = "" + } +} + +extension BolusProgressTableViewCell: NibLoadable { } diff --git a/Loop/Views/BolusProgressTableViewCell.xib b/Loop/Views/BolusProgressTableViewCell.xib new file mode 100644 index 0000000000..44dc259f2e --- /dev/null +++ b/Loop/Views/BolusProgressTableViewCell.xib @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Loop/Views/ButtonTableViewCell.swift b/Loop/Views/ButtonTableViewCell.swift deleted file mode 100644 index 1f68505469..0000000000 --- a/Loop/Views/ButtonTableViewCell.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// ButtonTableViewCell.swift -// Loop -// -// Created by Nate Racklyeft on 7/2/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import UIKit - - -final class ButtonTableViewCell: UITableViewCell, NibLoadable { - - @IBOutlet weak var button: UIButton! - - override func prepareForReuse() { - super.prepareForReuse() - - button.removeTarget(nil, action: nil, for: .touchUpInside) - } -} diff --git a/Loop/Views/ButtonTableViewCell.xib b/Loop/Views/ButtonTableViewCell.xib deleted file mode 100644 index 1ea37bb871..0000000000 --- a/Loop/Views/ButtonTableViewCell.xib +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Loop/Views/CarbEntryTableViewCell.swift b/Loop/Views/CarbEntryTableViewCell.swift new file mode 100644 index 0000000000..1d332808c9 --- /dev/null +++ b/Loop/Views/CarbEntryTableViewCell.swift @@ -0,0 +1,122 @@ +// +// CarbEntryTableViewCell.swift +// Loop +// +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +import UIKit + +class CarbEntryTableViewCell: UITableViewCell { + + @IBOutlet private weak var clampedProgressView: UIProgressView! + + @IBOutlet private weak var observedProgressView: UIProgressView! + + @IBOutlet weak var valueLabel: UILabel! + + @IBOutlet weak var dateLabel: UILabel! + + @IBOutlet private weak var observedValueLabel: UILabel! + + @IBOutlet private weak var observedDateLabel: UILabel! + + @IBOutlet private weak var disclosureImage: UIImageView! + + var isEditable: Bool = true { + didSet { + disclosureImage.isHidden = !isEditable + } + } + + var clampedProgress: Float { + get { + return clampedProgressView.progress + } + set { + clampedProgressView.progress = newValue + clampedProgressView.isHidden = clampedProgress <= 0 + } + } + + var observedProgress: Float { + get { + return observedProgressView.progress + } + set { + observedProgressView.progress = newValue + observedProgressView.isHidden = observedProgress <= 0 + } + } + + var observedValueText: String? { + get { + return observedValueLabel.text + } + set { + observedValueLabel.text = newValue + if newValue != nil { + observedValueLabel.superview?.isHidden = false + } + } + } + + var observedDateText: String? { + get { + return observedDateLabel.text + } + set { + observedDateLabel.text = newValue + if newValue != nil { + observedDateLabel.superview?.isHidden = false + } + } + } + + var observedValueTextColor: UIColor { + get { + return observedValueLabel.textColor + } + set { + observedValueLabel.textColor = newValue + } + } + + var observedDateTextColor: UIColor { + get { + return observedDateLabel.textColor + } + set { + observedDateLabel.textColor = newValue + } + } + + override func layoutSubviews() { + super.layoutSubviews() + + contentView.layoutMargins.left = separatorInset.left + contentView.layoutMargins.right = separatorInset.left + } + + override func awakeFromNib() { + super.awakeFromNib() + + resetViews() + } + + override func prepareForReuse() { + super.prepareForReuse() + + resetViews() + } + + private func resetViews() { + observedProgress = 0 + clampedProgress = 0 + valueLabel.text = nil + dateLabel.text = nil + observedValueText = nil + observedDateText = nil + observedValueLabel.superview?.isHidden = true + } +} diff --git a/Loop/Views/CarbEntryView.swift b/Loop/Views/CarbEntryView.swift new file mode 100644 index 0000000000..5831836fd6 --- /dev/null +++ b/Loop/Views/CarbEntryView.swift @@ -0,0 +1,319 @@ +// +// CarbEntryView.swift +// Loop +// +// Created by Noah Brauner on 7/19/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopKitUI +import HealthKit + +struct CarbEntryView: View, HorizontalSizeClassOverride { + @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference + @Environment(\.dismissAction) private var dismiss + + @ObservedObject var viewModel: CarbEntryViewModel + + @State private var expandedRow: Row? + + @State private var showHowAbsorptionTimeWorks = false + @State private var showAddFavoriteFood = false + + private let isNewEntry: Bool + + init(viewModel: CarbEntryViewModel) { + if viewModel.shouldBeginEditingQuantity { + expandedRow = .amountConsumed + } + isNewEntry = viewModel.originalCarbEntry == nil + self.viewModel = viewModel + } + + var body: some View { + if isNewEntry { + NavigationView { + let title = NSLocalizedString("carb-entry-title-add", value: "Add Carb Entry", comment: "The title of the view controller to create a new carb entry") + content + .navigationBarTitle(title, displayMode: .inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + dismissButton + } + + ToolbarItem(placement: .navigationBarTrailing) { + continueButton + } + } + + } + } + else { + content + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + continueButton + } + } + } + } + + private var content: some View { + ZStack { + Color(.systemGroupedBackground) + .edgesIgnoringSafeArea(.all) + + ScrollView { + warningsCard + + mainCard + .padding(.top, 8) + + continueActionButton + + if isNewEntry, FeatureFlags.allowExperimentalFeatures { + favoriteFoodsCard + } + + let isBolusViewActive = Binding(get: { viewModel.bolusViewModel != nil }, set: { _, _ in viewModel.bolusViewModel = nil }) + NavigationLink(destination: bolusView, isActive: isBolusViewActive) { + EmptyView() + } + .frame(width: 0, height: 0) + .opacity(0) + .accessibility(hidden: true) + } + } + .alert(item: $viewModel.alert, content: alert(for:)) + .sheet(isPresented: $showAddFavoriteFood, onDismiss: clearExpandedRow) { + AddEditFavoriteFoodView(carbsQuantity: $viewModel.carbsQuantity.wrappedValue, foodType: $viewModel.foodType.wrappedValue, absorptionTime: $viewModel.absorptionTime.wrappedValue, onSave: onFavoriteFoodSave(_:)) + } + .sheet(isPresented: $showHowAbsorptionTimeWorks) { + HowAbsorptionTimeWorksView() + } + } + + private var mainCard: some View { + VStack(spacing: 10) { + let amountConsumedFocused: Binding = Binding(get: { expandedRow == .amountConsumed }, set: { expandedRow = $0 ? .amountConsumed : nil }) + let timeFocused: Binding = Binding(get: { expandedRow == .time }, set: { expandedRow = $0 ? .time : nil }) + let foodTypeFocused: Binding = Binding(get: { expandedRow == .foodType }, set: { expandedRow = $0 ? .foodType : nil }) + let absorptionTimeFocused: Binding = Binding(get: { expandedRow == .absorptionTime }, set: { expandedRow = $0 ? .absorptionTime : nil }) + + CarbQuantityRow(quantity: $viewModel.carbsQuantity, isFocused: amountConsumedFocused, title: NSLocalizedString("Amount Consumed", comment: "Label for carb quantity entry row on carb entry screen"), preferredCarbUnit: viewModel.preferredCarbUnit) + + CardSectionDivider() + + DatePickerRow(date: $viewModel.time, isFocused: timeFocused, minimumDate: viewModel.minimumDate, maximumDate: viewModel.maximumDate) + + CardSectionDivider() + + FoodTypeRow(foodType: $viewModel.foodType, absorptionTime: $viewModel.absorptionTime, selectedDefaultAbsorptionTimeEmoji: $viewModel.selectedDefaultAbsorptionTimeEmoji, usesCustomFoodType: $viewModel.usesCustomFoodType, absorptionTimeWasEdited: $viewModel.absorptionTimeWasEdited, isFocused: foodTypeFocused, defaultAbsorptionTimes: viewModel.defaultAbsorptionTimes) + + CardSectionDivider() + + AbsorptionTimePickerRow(absorptionTime: $viewModel.absorptionTime, isFocused: absorptionTimeFocused, validDurationRange: viewModel.absorptionRimesRange, showHowAbsorptionTimeWorks: $showHowAbsorptionTimeWorks) + .padding(.bottom, 2) + } + .padding(.vertical, 12) + .padding(.horizontal) + .background(CardBackground()) + .padding(.horizontal) + } + + @ViewBuilder + private var bolusView: some View { + if let viewModel = viewModel.bolusViewModel { + BolusEntryView(viewModel: viewModel) + .environmentObject(displayGlucosePreference) + .environment(\.dismissAction, dismiss) + } + } + + private func clearExpandedRow() { + self.expandedRow = nil + } +} + +// MARK: - Warnings & Alerts +extension CarbEntryView { + private var warningsCard: some View { + ForEach(Array(viewModel.warnings).sorted(by: { $0.priority < $1.priority })) { warning in + warningView(for: warning) + .padding(.vertical, 8) + .padding(.horizontal) + .background(CardBackground()) + .padding(.horizontal) + .padding(.top, 8) + } + } + + private func warningView(for warning: CarbEntryViewModel.Warning) -> some View { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(triangleColor(for: warning)) + + Text(warningText(for: warning)) + .font(.caption) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func triangleColor(for warning: CarbEntryViewModel.Warning) -> Color { + switch warning { + case .entryIsMissedMeal: + return .critical + case .overrideInProgress: + return .warning + } + } + + private func warningText(for warning: CarbEntryViewModel.Warning) -> String { + switch warning { + case .entryIsMissedMeal: + return NSLocalizedString("Loop has detected an missed meal and estimated its size. Edit the carb amount to match the amount of any carbs you may have eaten.", comment: "Warning displayed when user is adding a meal from an missed meal notification") + case .overrideInProgress: + return NSLocalizedString("An active override is modifying your carb ratio and insulin sensitivity. If you don't want this to affect your bolus calculation and projected glucose, consider turning off the override.", comment: "Warning to ensure the carb entry is accurate during an override") + } + } + + private func alert(for alert: CarbEntryViewModel.Alert) -> SwiftUI.Alert { + switch alert { + case .maxQuantityExceded: + let message = String( + format: NSLocalizedString("The maximum allowed amount is %@ grams.", comment: "Alert body displayed for quantity greater than max (1: maximum quantity in grams)"), + NumberFormatter.localizedString(from: NSNumber(value: viewModel.maxCarbEntryQuantity.doubleValue(for: viewModel.preferredCarbUnit)), number: .none) + ) + let okMessage = NSLocalizedString("com.loudnate.LoopKit.errorAlertActionTitle", value: "OK", comment: "The title of the action used to dismiss an error alert") + return SwiftUI.Alert( + title: Text("Large Meal Entered", comment: "Title of the warning shown when a large meal was entered"), + message: Text(message), + dismissButton: .cancel(Text(okMessage), action: viewModel.clearAlert) + ) + case .warningQuantityValidation: + let message = String( + format: NSLocalizedString("Did you intend to enter %1$@ grams as the amount of carbohydrates for this meal?", comment: "Alert body when entered carbohydrates is greater than threshold (1: entered quantity in grams)"), + NumberFormatter.localizedString(from: NSNumber(value: viewModel.carbsQuantity ?? 0), number: .none) + ) + return SwiftUI.Alert( + title: Text("Large Meal Entered", comment: "Title of the warning shown when a large meal was entered"), + message: Text(message), + primaryButton: .default(Text("No, edit amount", comment: "The title of the action used when rejecting the the amount of carbohydrates entered."), action: viewModel.clearAlert), + secondaryButton: .cancel(Text("Yes", comment: "The title of the action used when confirming entered amount of carbohydrates."), action: viewModel.clearAlertAndContinueToBolus) + ) + } + } +} + +// MARK: - Favorite Foods Card +extension CarbEntryView { + private var favoriteFoodsCard: some View { + VStack(alignment: .leading, spacing: 6) { + Text("FAVORITE FOODS", comment: "The section title for Carb entry screen where Favorite Foods can be selected") + .font(.footnote) + .foregroundColor(.secondary) + .padding(.horizontal, 26) + + VStack(spacing: 10) { + if !viewModel.favoriteFoods.isEmpty { + VStack { + HStack { + Text("Choose Favorite:", comment: "The label for the row where you choose saved Favorite Food") + + let selectedFavorite = favoritedFoodTextFromIndex(viewModel.selectedFavoriteFoodIndex) + Text(selectedFavorite) + .minimumScaleFactor(0.8) + .frame(maxWidth: .infinity, alignment: .trailing) + } + + if expandedRow == .favoriteFoodSelection { + Picker(String(""), selection: $viewModel.selectedFavoriteFoodIndex) { + ForEach(-1.. String { + if index == -1 { + return String(localized: "None", comment: "Indicates no favorite food is selected") + } + else { + let food = viewModel.favoriteFoods[index] + return "\(food.name) \(food.foodType)" + } + } + + private func saveAsFavoriteFood() { + self.showAddFavoriteFood = true + } + + private func onFavoriteFoodSave(_ food: NewFavoriteFood) { + clearExpandedRow() + self.showAddFavoriteFood = false + viewModel.onFavoriteFoodSave(food) + } +} + +// MARK: - Other UI Elements +extension CarbEntryView { + private var dismissButton: some View { + Button(action: dismiss) { + Text("Cancel", comment: "Button label for cancel") + } + } + + private var continueButton: some View { + Button(action: viewModel.continueToBolus) { + Text("Continue", comment: "Button label for continue") + } + .disabled(viewModel.continueButtonDisabled) + } + + private var continueActionButton: some View { + Button(action: viewModel.continueToBolus) { + Text("Continue", comment: "Button label for continue") + } + .buttonStyle(ActionButtonStyle()) + .padding() + .disabled(viewModel.continueButtonDisabled) + } + +} + +extension CarbEntryView { + enum Row { + case amountConsumed, time, foodType, absorptionTime, favoriteFoodSelection + } +} diff --git a/Loop/Views/ChartContentView.swift b/Loop/Views/ChartContentView.swift deleted file mode 100644 index 9d2cbfe3b0..0000000000 --- a/Loop/Views/ChartContentView.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// ChartContentView.swift -// Loop -// -// Created by Nate Racklyeft on 9/14/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import UIKit - -class ChartContentView: UIView { - - override func layoutSubviews() { - super.layoutSubviews() - - if chartView == nil || chartView!.frame != bounds { - chartView = chartGenerator?(bounds) - } else if chartView!.superview == nil { - addSubview(chartView!) - } - } - - func reloadChart() { - chartView = nil - setNeedsLayout() - } - - var chartGenerator: ((CGRect) -> UIView?)? { - didSet { - chartView = nil - setNeedsLayout() - } - } - - private var chartView: UIView? { - didSet { - if let view = oldValue { - view.removeFromSuperview() - } - - if let view = chartView { - self.addSubview(view) - } - } - } - -} diff --git a/Loop/Views/ChartPointsScatterDownTrianglesLayer.swift b/Loop/Views/ChartPointsScatterDownTrianglesLayer.swift deleted file mode 100644 index 8dce720d83..0000000000 --- a/Loop/Views/ChartPointsScatterDownTrianglesLayer.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// ChartPointsScatterDownTrianglesLayer.swift -// Loop -// -// Created by Nate Racklyeft on 9/28/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import SwiftCharts - - -class ChartPointsScatterDownTrianglesLayer: ChartPointsScatterLayer { - - required init(xAxis: ChartAxisLayer, yAxis: ChartAxisLayer, innerFrame: CGRect, chartPoints: [T], displayDelay: Float, itemSize: CGSize, itemFillColor: UIColor) { - super.init(xAxis: xAxis, yAxis: yAxis, innerFrame: innerFrame, chartPoints: chartPoints, displayDelay: displayDelay, itemSize: itemSize, itemFillColor: itemFillColor) - } - - override func drawChartPointModel(context: CGContext, chartPointModel: ChartPointLayerModel) { - let w = self.itemSize.width - let h = self.itemSize.height - - let path = CGMutablePath() - path.move(to: CGPoint(x: chartPointModel.screenLoc.x, y: chartPointModel.screenLoc.y + h / 2)) - path.addLine(to: CGPoint(x: chartPointModel.screenLoc.x + w / 2, y: chartPointModel.screenLoc.y - h / 2)) - path.addLine(to: CGPoint(x: chartPointModel.screenLoc.x - w / 2, y: chartPointModel.screenLoc.y - h / 2)) - path.closeSubpath() - - context.setFillColor(self.itemFillColor.cgColor) - context.addPath(path) - context.fillPath() - } -} diff --git a/Loop/Views/ChartPointsTouchHighlightLayerViewCache.swift b/Loop/Views/ChartPointsTouchHighlightLayerViewCache.swift deleted file mode 100644 index e436d5e7c6..0000000000 --- a/Loop/Views/ChartPointsTouchHighlightLayerViewCache.swift +++ /dev/null @@ -1,105 +0,0 @@ -// -// StatusChartHighlightLayer.swift -// Naterade -// -// Created by Nathan Racklyeft on 2/28/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import Foundation -import SwiftCharts - - -final class ChartPointsTouchHighlightLayerViewCache { - private lazy var containerView = UIView(frame: .zero) - - private lazy var xAxisOverlayView = UIView() - - private lazy var point = ChartPointEllipseView(center: .zero, diameter: 16) - - private lazy var labelY: UILabel = { - let label = UILabel() - label.font = UIFont.monospacedDigitSystemFont(ofSize: 15, weight: UIFontWeightBold) - - return label - }() - - private lazy var labelX: UILabel = { - let label = UILabel() - label.font = UIFont.preferredFont(forTextStyle: UIFontTextStyle.caption1) - label.textColor = UIColor.secondaryLabelColor - - return label - }() - - private(set) var highlightLayer: ChartPointsTouchHighlightLayer! - - init(xAxis: ChartAxisLayer, yAxis: ChartAxisLayer, innerFrame: CGRect, chartPoints: [ChartPoint], tintColor: UIColor, labelCenterY: CGFloat, gestureRecognizer: UIPanGestureRecognizer? = nil) { - - highlightLayer = ChartPointsTouchHighlightLayer( - xAxis: xAxis, - yAxis: yAxis, - innerFrame: innerFrame, - chartPoints: chartPoints, - gestureRecognizer: gestureRecognizer, - modelFilter: { (screenLoc, chartPointModels) -> ChartPointLayerModel? in - if let index = chartPointModels.map({ $0.screenLoc.x }).findClosestElementIndex(matching: screenLoc.x) { - return chartPointModels[index] - } else { - return nil - } - }, - viewGenerator: { [unowned self] (chartPointModel, layer, chart) -> UIView? in - let containerView = self.containerView - containerView.frame = chart.bounds - containerView.alpha = 1 // This is animated to 0 when touch last ended - - let xAxisOverlayView = self.xAxisOverlayView - if xAxisOverlayView.superview == nil { - xAxisOverlayView.frame = xAxis.rect.offsetBy(dx: 0, dy: 1) - xAxisOverlayView.backgroundColor = UIColor.white - xAxisOverlayView.isOpaque = true - containerView.addSubview(xAxisOverlayView) - } - - let point = self.point - point.center = chartPointModel.screenLoc - if point.superview == nil { - point.fillColor = tintColor.withAlphaComponent(0.5) - containerView.addSubview(point) - } - - if let text = chartPointModel.chartPoint.y.labels.first?.text { - let label = self.labelY - - label.text = text - label.sizeToFit() - label.center.y = innerFrame.origin.y - 21 - label.center.x = chartPointModel.screenLoc.x - label.frame.origin.x = min(max(label.frame.origin.x, innerFrame.origin.x), innerFrame.maxX - label.frame.size.width) - label.frame.origin.makeIntegralInPlaceWithDisplayScale(chart.view.traitCollection.displayScale) - - if label.superview == nil { - label.textColor = tintColor - - containerView.addSubview(label) - } - } - - if let text = chartPointModel.chartPoint.x.labels.first?.text { - let label = self.labelX - label.text = text - label.sizeToFit() - label.center = CGPoint(x: chartPointModel.screenLoc.x, y: xAxisOverlayView.center.y) - label.frame.origin.makeIntegralInPlaceWithDisplayScale(chart.view.traitCollection.displayScale) - - if label.superview == nil { - containerView.addSubview(label) - } - } - - return containerView - } - ) - } -} diff --git a/Loop/Views/ChartTableViewCell.swift b/Loop/Views/ChartTableViewCell.swift deleted file mode 100644 index 295c8ca202..0000000000 --- a/Loop/Views/ChartTableViewCell.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// ChartTableViewCell.swift -// Naterade -// -// Created by Nathan Racklyeft on 2/19/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import UIKit - - -final class ChartTableViewCell: UITableViewCell { - - @IBOutlet weak var chartContentView: ChartContentView! - - @IBOutlet weak var titleLabel: UILabel? - - @IBOutlet weak var subtitleLabel: UILabel? - - override func prepareForReuse() { - super.prepareForReuse() - - chartContentView.chartGenerator = nil - } - - func reloadChart() { - chartContentView.reloadChart() - } -} diff --git a/Loop/Views/CircleMaskView.swift b/Loop/Views/CircleMaskView.swift new file mode 100644 index 0000000000..3b4c8898ef --- /dev/null +++ b/Loop/Views/CircleMaskView.swift @@ -0,0 +1,18 @@ +// +// CircleMaskView.swift +// Loop +// +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +import UIKit + +class CircleMaskView: UIView { + + override func layoutSubviews() { + super.layoutSubviews() + + self.layer.cornerRadius = self.frame.height / 2 + } + +} diff --git a/Loop/Views/CriticalEventLogExportView.swift b/Loop/Views/CriticalEventLogExportView.swift new file mode 100644 index 0000000000..e61eb93ee3 --- /dev/null +++ b/Loop/Views/CriticalEventLogExportView.swift @@ -0,0 +1,140 @@ +// +// CriticalEventLogExportView.swift +// Loop +// +// Created by Darin Krauss on 7/10/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopKitUI + +struct CriticalEventLogExportView: View { + @Environment(\.presentationMode) var presentationMode + + @ObservedObject var viewModel: CriticalEventLogExportViewModel + + var body: some View { + Group { + Spacer() + if !viewModel.showingSuccess { + exportingView + } else { + exportedView + } + Spacer() + Spacer() + } + .navigationBarTitle(Text("Critical Event Logs", comment: "Critical event log export title"), displayMode: .automatic) + .navigationBarBackButtonHidden(true) + .navigationBarItems(leading: cancelButton) + .onAppear { self.viewModel.export() } + .alert(isPresented: $viewModel.showingError) { + errorAlert + } + } + + private var cancelButton: some View { + Button(action: { + self.viewModel.cancel() + self.presentationMode.wrappedValue.dismiss() + }) { + Text("Cancel", comment: "Cancel export button title") + } + } + + private var exportingView: some View { + VStack { + Text("Preparing Critical Event Logs", comment: "Preparing critical event log text") + .bold() + ProgressView(progress: CGFloat(viewModel.progress)) + .accentColor(.loopAccent) + .padding() + Text(viewModel.remainingDuration ?? " ") // Vertical alignment hack + } + } + + private var exportedView: some View { + VStack { + Image(systemName: "checkmark.circle.fill") + .resizable() + .frame(width: 50, height: 50) + .foregroundColor(.loopAccent) + .padding() + Text("Critical Event Log Ready", comment: "Critical event log ready text") + .bold() + } + .sheet(isPresented: $viewModel.showingShare, onDismiss: { + self.viewModel.cancel() + self.presentationMode.wrappedValue.dismiss() + }) { + ActivityViewController(activityItems: self.viewModel.activityItems, applicationActivities: nil) + } + } + + private var errorAlert: SwiftUI.Alert { + Alert(title: Text("Error Exporting Logs", comment: "Critical event log export error alert title"), + message: Text("Critical Event Logs were not able to be exported.", comment: "Critical event log export error alert message"), + primaryButton: errorAlertPrimaryButton, + secondaryButton: errorAlertSecondaryButton) + } + + private var errorAlertPrimaryButton: SwiftUI.Alert.Button { + .cancel() { + self.viewModel.cancel() + self.presentationMode.wrappedValue.dismiss() + } + } + + private var errorAlertSecondaryButton: SwiftUI.Alert.Button { + .default(Text("Try Again", comment: "Critical event log export error alert try again button")) { + self.viewModel.export() + } + } +} + +fileprivate struct ActivityViewController: UIViewControllerRepresentable { + var activityItems: [Any] + var applicationActivities: [UIActivity]? = nil + + func makeUIViewController(context: UIViewControllerRepresentableContext) -> UIActivityViewController { + return UIActivityViewController(activityItems: activityItems, applicationActivities: applicationActivities) + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: UIViewControllerRepresentableContext) {} +} + +public struct CriticalEventLogExportView_Previews: PreviewProvider { + public static var previews: some View { + let exportingViewModel = CriticalEventLogExportViewModel(exporterFactory: MockCriticalEventLogExporterFactory()) + exportingViewModel.progress = 0.5 + exportingViewModel.remainingDuration = "About 3 minutes remaining" + let exportedViewModel = CriticalEventLogExportViewModel(exporterFactory: MockCriticalEventLogExporterFactory()) + exportedViewModel.showingSuccess = true + return Group { + CriticalEventLogExportView(viewModel: exportingViewModel) + .colorScheme(.light) + .previewDevice(PreviewDevice(rawValue: "iPhone SE 2")) + .previewDisplayName("Exporting - iPhone SE 2 - Light") + CriticalEventLogExportView(viewModel: exportingViewModel) + .colorScheme(.dark) + .previewDevice(PreviewDevice(rawValue: "iPhone XS Max")) + .previewDisplayName("Exporting - iPhone XS Max - Dark") + CriticalEventLogExportView(viewModel: exportedViewModel) + .colorScheme(.light) + .previewDevice(PreviewDevice(rawValue: "iPhone SE 2")) + .previewDisplayName("Exported - iPhone SE 2 - Light") + } + } +} + +class MockCriticalEventLogExporterFactory: CriticalEventLogExporterFactory { + func createExporter(to url: URL) -> CriticalEventLogExporter { MockCriticalEventLogExporter() } +} + +class MockCriticalEventLogExporter: CriticalEventLogExporter { + var delegate: CriticalEventLogExporterDelegate? + var progress: Progress = Progress.discreteProgress(totalUnitCount: 0) + func export(now: Date, completion: @escaping (Error?) -> Void) { completion(nil) } +} diff --git a/Loop/Views/DosingStrategySelectionView.swift b/Loop/Views/DosingStrategySelectionView.swift new file mode 100644 index 0000000000..f447b032b6 --- /dev/null +++ b/Loop/Views/DosingStrategySelectionView.swift @@ -0,0 +1,71 @@ +// +// DosingStrategySelectionView.swift +// Loop +// +// Created by Pete Schwamb on 1/16/21. +// Copyright © 2021 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopCore +import LoopKit +import LoopKitUI + +public struct DosingStrategySelectionView: View { + + @Binding private var automaticDosingStrategy: AutomaticDosingStrategy + + @State private var internalDosingStrategy: AutomaticDosingStrategy + + public init(automaticDosingStrategy: Binding) { + self._automaticDosingStrategy = automaticDosingStrategy + self._internalDosingStrategy = State(initialValue: automaticDosingStrategy.wrappedValue) + } + + public var body: some View { + List { + Section { + options + } + .buttonStyle(PlainButtonStyle()) // Disable row highlighting on selection + } + .insetGroupedListStyle() + } + + public var options: some View { + ForEach(AutomaticDosingStrategy.allCases, id: \.self) { strategy in + CheckmarkListItem( + title: Text(strategy.title), + description: Text(strategy.informationalText), + isSelected: Binding( + get: { self.automaticDosingStrategy == strategy }, + set: { isSelected in + if isSelected { + self.automaticDosingStrategy = strategy + self.internalDosingStrategy = strategy // Hack to force update. :( + } + } + ) + ) + .padding(.vertical, 4) + } + } +} + +extension AutomaticDosingStrategy { + var informationalText: String { + switch self { + case .tempBasalOnly: + return NSLocalizedString("Loop will set temporary basal rates to increase and decrease insulin delivery.", comment: "Description string for temp basal only dosing strategy") + case .automaticBolus: + return NSLocalizedString("Loop will automatically bolus when insulin needs are above scheduled basal, and will use temporary basal rates when needed to reduce insulin delivery below scheduled basal.", comment: "Description string for automatic bolus dosing strategy") + } + } + +} + +struct DosingStrategySelectionView_Previews: PreviewProvider { + static var previews: some View { + DosingStrategySelectionView(automaticDosingStrategy: .constant(.automaticBolus)) + } +} diff --git a/Loop/Views/FavoriteFoodDetailView.swift b/Loop/Views/FavoriteFoodDetailView.swift new file mode 100644 index 0000000000..8c76e1fe8f --- /dev/null +++ b/Loop/Views/FavoriteFoodDetailView.swift @@ -0,0 +1,73 @@ +// +// FavoriteFoodDetailView.swift +// Loop +// +// Created by Noah Brauner on 8/2/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import HealthKit + +public struct FavoriteFoodDetailView: View { + let food: StoredFavoriteFood? + let onFoodDelete: (StoredFavoriteFood) -> Void + + @State private var isConfirmingDelete = false + + let carbFormatter: QuantityFormatter + let absorptionTimeFormatter: DateComponentsFormatter + let preferredCarbUnit: HKUnit + + public init(food: StoredFavoriteFood?, onFoodDelete: @escaping (StoredFavoriteFood) -> Void, isConfirmingDelete: Bool = false, carbFormatter: QuantityFormatter, absorptionTimeFormatter: DateComponentsFormatter, preferredCarbUnit: HKUnit = HKUnit.gram()) { + self.food = food + self.onFoodDelete = onFoodDelete + self.isConfirmingDelete = isConfirmingDelete + self.carbFormatter = carbFormatter + self.absorptionTimeFormatter = absorptionTimeFormatter + self.preferredCarbUnit = preferredCarbUnit + } + + public var body: some View { + if let food { + List { + Section("Information") { + VStack(spacing: 16) { + let rows: [(field: String, value: String)] = [ + (String(localized: "Name", comment: "Label for name row on add favorite food screen"), food.name), + (String(localized: "Carb Quantity", comment: "Label for carb quantity row on add favorite food screen"), food.carbsString(formatter: carbFormatter)), + (String(localized:"Food Type", comment: "Label for food type entry on add favorite food screen"), food.foodType), + (String(localized: "Absorption Time", comment: "Label for food absorption entry on add favorite food screen"), food.absorptionTimeString(formatter: absorptionTimeFormatter)) + ] + ForEach(rows, id: \.field) { row in + HStack { + Text(row.field) + .font(.subheadline) + Spacer() + Text(row.value) + .font(.subheadline) + } + } + } + } + .listRowInsets(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12)) + + Button(role: .destructive, action: { isConfirmingDelete.toggle() }) { + Text("Delete Food") + .frame(maxWidth: .infinity, alignment: .center) // Align text in center + } + } + .alert(isPresented: $isConfirmingDelete) { + Alert( + title: Text("Delete “\(food.name)”?"), + message: Text("Are you sure you want to delete this food?"), + primaryButton: .cancel(), + secondaryButton: .destructive(Text("Delete"), action: { onFoodDelete(food) }) + ) + } + .insetGroupedListStyle() + .navigationTitle(food.title) + } + } +} diff --git a/Loop/Views/FavoriteFoodsView.swift b/Loop/Views/FavoriteFoodsView.swift new file mode 100644 index 0000000000..d3042208d8 --- /dev/null +++ b/Loop/Views/FavoriteFoodsView.swift @@ -0,0 +1,130 @@ +// +// FavoriteFoodsView.swift +// Loop +// +// Created by Noah Brauner on 7/12/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopKitUI + +struct FavoriteFoodsView: View { + @Environment(\.dismissAction) private var dismiss + + @StateObject private var viewModel = FavoriteFoodsViewModel() + + @State private var foodToConfirmDeleteId: String? = nil + @State private var editMode: EditMode = .inactive + + var body: some View { + NavigationView { + VStack { + List { + if viewModel.favoriteFoods.isEmpty { + Section { + Text("Selecting a favorite food in the carb entry screen automatically fills in the carb quantity, food type, and absorption time fields! Tap the add button below to create your first favorite food!") + } + } + else { + Section(header: listHeader) { + ForEach(viewModel.favoriteFoods) { food in + FavoriteFoodListRow(food: food, foodToConfirmDeleteId: $foodToConfirmDeleteId, onFoodTap: onFoodTap(_:), onFoodDelete: viewModel.onFoodDelete(_:), carbFormatter: viewModel.carbFormatter, absorptionTimeFormatter: viewModel.absorptionTimeFormatter, preferredCarbUnit: viewModel.preferredCarbUnit) + .environment(\.editMode, self.$editMode) + .listRowInsets(EdgeInsets()) + } + .onMove(perform: viewModel.onFoodReorder(from:to:)) + .moveDisabled(!editMode.isEditing) + .deleteDisabled(true) + } + } + + Section { + addFoodButton + .listRowInsets(EdgeInsets()) + } + } + .insetGroupedListStyle() + + + NavigationLink(destination: AddEditFavoriteFoodView(originalFavoriteFood: viewModel.selectedFood, onSave: viewModel.onFoodSave(_:)), isActive: $viewModel.isEditViewActive) { + EmptyView() + } + + NavigationLink(destination: FavoriteFoodDetailView(food: viewModel.selectedFood, onFoodDelete: viewModel.onFoodDelete(_:), carbFormatter: viewModel.carbFormatter, absorptionTimeFormatter: viewModel.absorptionTimeFormatter, preferredCarbUnit: viewModel.preferredCarbUnit), isActive: $viewModel.isDetailViewActive) { + EmptyView() + } + } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + dismissButton + } + } + .navigationBarTitle(String(localized: "Favorite Foods", comment: "Title for Favorite Foods view"), displayMode: .large) + } + .sheet(isPresented: $viewModel.isAddViewActive) { + AddEditFavoriteFoodView(onSave: viewModel.onFoodSave(_:)) + } + .onChange(of: editMode) { newValue in + if !newValue.isEditing { + foodToConfirmDeleteId = nil + } + } + } + + private func onFoodTap(_ food: StoredFavoriteFood) { + viewModel.selectedFood = food + if editMode.isEditing { + viewModel.isEditViewActive = true + } + else { + viewModel.isDetailViewActive = true + } + } +} + +extension FavoriteFoodsView { + private var listHeader: some View { + HStack { + Text("All Favorites", comment: "section header for list of existing FavoriteFoods") + .font(.title3) + .fontWeight(.semibold) + .textCase(nil) + .foregroundColor(.primary) + + Spacer() + + editButton + } + .listRowInsets(EdgeInsets(top: 20, leading: 4, bottom: 10, trailing: 4)) + } + + private var dismissButton: some View { + Button(action: dismiss) { + Text("Done") + } + } + + private var editButton: some View { + Button(action: { + withAnimation(.easeInOut(duration: 0.3)) { + editMode.toggle() + } + }) { + Text(editMode.title) + .textCase(nil) + } + } + + private var addFoodButton: some View { + Button(action: viewModel.addFoodTapped) { + HStack { + Image(systemName: "plus.circle.fill") + + Text("Add a new favorite food", comment: "Button label to open new favorite food view") + } + } + .buttonStyle(ActionButtonStyle()) + } +} diff --git a/Loop/Views/GlucoseBasedApplicationFactorSelectionView.swift b/Loop/Views/GlucoseBasedApplicationFactorSelectionView.swift new file mode 100644 index 0000000000..0d88e65dfa --- /dev/null +++ b/Loop/Views/GlucoseBasedApplicationFactorSelectionView.swift @@ -0,0 +1,58 @@ +// +// GlucoseBasedApplicationFactorSelectionView.swift +// Loop +// +// Created by Jonas Björkert on 2023-06-04. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation +import SwiftUI +import LoopKit +import LoopKitUI + +public struct GlucoseBasedApplicationFactorSelectionView: View { + @Binding var isGlucoseBasedApplicationFactorEnabled: Bool + var automaticDosingStrategy: AutomaticDosingStrategy + + public init(isGlucoseBasedApplicationFactorEnabled: Binding, automaticDosingStrategy: AutomaticDosingStrategy) { + self.automaticDosingStrategy = automaticDosingStrategy + self._isGlucoseBasedApplicationFactorEnabled = isGlucoseBasedApplicationFactorEnabled + } + + public var body: some View { + ScrollView { + VStack(spacing: 10) { + Text(NSLocalizedString("Glucose Based Partial Application", comment: "Title for glucose based partial application experiment description")) + .font(.headline) + .padding(.bottom, 20) + + Divider() + + if automaticDosingStrategy == .automaticBolus { + Text(NSLocalizedString("Loop normally gives 40% of your predicted insulin needs each dosing cycle.\n\nWhen the Glucose Based Partial Application experiment is enabled, Loop will vary the percentage of recommended bolus delivered each cycle with glucose level.\n\nNear correction range, it will use 20% (similar to Temp Basal), and gradually increase to a maximum of 80% at high glucose (200 mg/dL, 11.1 mmol/L).\n\nPlease be aware that during fast rising glucose, such as after an unannounced meal, this feature, combined with velocity and retrospective correction effects, may result in a larger dose than your ISF would call for.", comment: "Description of Glucose Based Partial Application toggle.")) + .foregroundColor(.secondary) + Divider() + + HStack { + Toggle(NSLocalizedString("Enable Glucose Based Partial Application", comment: "Title for Glucose Based Partial Application toggle"), isOn: $isGlucoseBasedApplicationFactorEnabled) + Spacer() + } + .padding(.top, 20) + } else { + Text(NSLocalizedString("This option only applies when Loop's Dosing Strategy is set to Automatic Bolus.", comment: "String shown when glucose based partial application cannot be enabled because dosing strategy is not set to Automatic Bolus")) + } + } + .padding() + } + .navigationBarTitleDisplayMode(.inline) + } +} + +struct GlucoseBasedApplicationFactorSelectionView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + GlucoseBasedApplicationFactorSelectionView(isGlucoseBasedApplicationFactorEnabled: .constant(true), automaticDosingStrategy: .automaticBolus) + } + } +} diff --git a/Loop/Views/HUDViewTableViewCell.swift b/Loop/Views/HUDViewTableViewCell.swift new file mode 100644 index 0000000000..7dc949cfe7 --- /dev/null +++ b/Loop/Views/HUDViewTableViewCell.swift @@ -0,0 +1,15 @@ +// +// HUDViewTableViewCell.swift +// Loop +// +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +import UIKit +import LoopUI + +class HUDViewTableViewCell: UITableViewCell { + + @IBOutlet var hudView: StatusBarHUDView! + +} diff --git a/Loop/Views/HeaderValuesTableViewCell.swift b/Loop/Views/HeaderValuesTableViewCell.swift new file mode 100644 index 0000000000..1b4c974dce --- /dev/null +++ b/Loop/Views/HeaderValuesTableViewCell.swift @@ -0,0 +1,19 @@ +// +// HeaderValuesTableViewCell.swift +// Loop +// +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +import UIKit + +class HeaderValuesTableViewCell: UITableViewCell { + + @IBOutlet weak var COBValueLabel: UILabel! + + @IBOutlet weak var COBDateLabel: UILabel! + + @IBOutlet weak var totalValueLabel: UILabel! + + @IBOutlet weak var totalDateLabel: UILabel! +} diff --git a/Loop/Views/HowAbsorptionTimeWorksView.swift b/Loop/Views/HowAbsorptionTimeWorksView.swift new file mode 100644 index 0000000000..7ac01080e2 --- /dev/null +++ b/Loop/Views/HowAbsorptionTimeWorksView.swift @@ -0,0 +1,33 @@ +// +// HowAbsorptionTimeWorksView.swift +// Loop +// +// Created by Noah Brauner on 7/28/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +struct HowAbsorptionTimeWorksView: View { + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationView { + List { + Section { + Text("Choose a longer absorption time for larger meals, or those containing fats and proteins. This is only guidance to the algorithm and need not be exact.", comment: "Carb entry section footer text explaining absorption time") + } + } + .navigationTitle("Absorption Time") + .toolbar { + dismissButton + } + } + } + + private var dismissButton: some View { + Button(action: dismiss.callAsFunction) { + Text("Close") + } + } +} diff --git a/Loop/Views/HowMuteAlertWorkView.swift b/Loop/Views/HowMuteAlertWorkView.swift new file mode 100644 index 0000000000..08443a6b80 --- /dev/null +++ b/Loop/Views/HowMuteAlertWorkView.swift @@ -0,0 +1,151 @@ +// +// HowMuteAlertWorkView.swift +// Loop +// +// Created by Nathaniel Hamming on 2022-12-09. +// Copyright © 2022 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKitUI + +struct HowMuteAlertWorkView: View { + @Environment(\.dismissAction) private var dismiss + @Environment(\.guidanceColors) private var guidanceColors + @Environment(\.appName) private var appName + + var body: some View { + NavigationView { + List { + VStack(alignment: .leading, spacing: 24) { + VStack(alignment: .leading, spacing: 8) { + Text("What are examples of Critical and Time Sensitive alerts?") + .bold() + + Text("iOS Critical Alerts and Time Sensitive Alerts are types of Apple notifications. They are used for high-priority events. Some examples include:") + } + + HStack { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + Text("Critical Alerts") + .bold() + + Text("Urgent Low") + .bulleted() + Text("Sensor Failed") + .bulleted() + Text("Reservoir Empty") + .bulleted() + Text("Pump Expired") + .bulleted() + } + + VStack(alignment: .leading, spacing: 4) { + Text("Time Sensitive Alerts") + .bold() + + Text("High Glucose") + .bulleted() + Text("Transmitter Low Battery") + .bulleted() + } + } + + Spacer() + } + .font(.footnote) + .foregroundColor(.black.opacity(0.6)) + .padding() + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(Color(.systemFill), lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + + VStack(alignment: .leading, spacing: 8) { + Text( + String( + format: NSLocalizedString( + "How can I temporarily silence all %1$@ app sounds?", + comment: "Title text for temporarily silencing all sounds (1: app name)" + ), + appName + ) + ) + .bold() + + Text( + String( + format: NSLocalizedString( + "Use the Mute Alerts feature. It allows you to temporarily silence all of your alerts and alarms via the %1$@ app, including Critical Alerts and Time Sensitive Alerts.", + comment: "Description text for temporarily silencing all sounds (1: app name)" + ), + appName + ) + ) + } + + VStack(alignment: .leading, spacing: 8) { + Text("How can I silence non-Critical Alerts?") + .bold() + + Text( + String( + format: NSLocalizedString( + "Turn off the volume on your iOS device or add %1$@ as an allowed app to each Focus Mode. Time Sensitive and Critical Alerts will still sound, but non-Critical Alerts will be silenced.", + comment: "Description text for temporarily silencing non-critical alerts (1: app name)" + ), + appName + ) + ) + } + + VStack(alignment: .leading, spacing: 8) { + Text("How can I silence only Time Sensitive and Non-Critical alerts?") + .bold() + + Text( + String( + format: NSLocalizedString( + "For safety purposes, you should allow Critical Alerts, Time Sensitive and Notification Permissions (non-critical alerts) on your device to continue using %1$@ and cannot turn off individual alarms.", + comment: "Description text for silencing time sensitive and non-critical alerts (1: app name)" + ), + appName + ) + ) + } + } + .padding(.vertical, 8) + } + .insetGroupedListStyle() + .navigationTitle(NSLocalizedString("Managing Alerts", comment: "View title for how mute alerts work")) + .navigationBarItems(trailing: closeButton) + } + } + + private var closeButton: some View { + Button(action: dismiss) { + Text(NSLocalizedString("Close", comment: "Button title to close view")) + } + } +} + +private extension Text { + func bulleted(color: Color = .accentColor.opacity(0.5)) -> some View { + HStack(spacing: 16) { + Image(systemName: "circle.fill") + .resizable() + .frame(width: 8, height: 8) + .foregroundColor(color) + + self + } + } +} + +struct HowMuteAlertWorkView_Previews: PreviewProvider { + static var previews: some View { + HowMuteAlertWorkView() + } +} diff --git a/Loop/Views/IconTitleSubtitleTableViewCell.swift b/Loop/Views/IconTitleSubtitleTableViewCell.swift new file mode 100644 index 0000000000..5ecc166614 --- /dev/null +++ b/Loop/Views/IconTitleSubtitleTableViewCell.swift @@ -0,0 +1,53 @@ +// +// IconTitleSubtitleTableViewCell.swift +// Loop +// +// Created by Darin Krauss on 8/19/21. +// Copyright © 2021 LoopKit Authors. All rights reserved. +// + +import UIKit + +class IconTitleSubtitleTableViewCell: UITableViewCell { + + @IBOutlet weak var iconImageView: UIImageView! + + @IBOutlet weak var titleLabel: UILabel! + + @IBOutlet weak var subtitleLabel: UILabel! { + didSet { + subtitleLabel.textColor = UIColor.secondaryLabel + } + } + + override func layoutSubviews() { + super.layoutSubviews() + + updateColors() + gradient.frame = bounds + } + + private lazy var gradient = CAGradientLayer() + + override func awakeFromNib() { + super.awakeFromNib() + + gradient.frame = bounds + backgroundView?.layer.insertSublayer(gradient, at: 0) + + updateColors() + } + + public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + updateColors() + } + + private func updateColors() { + gradient.colors = [ + UIColor.cellBackgroundColor.withAlphaComponent(0).cgColor, + UIColor.cellBackgroundColor.cgColor + ] + } +} diff --git a/Loop/Views/IntegralRetrospectiveCorrectionSelectionView.swift b/Loop/Views/IntegralRetrospectiveCorrectionSelectionView.swift new file mode 100644 index 0000000000..8556e4fed6 --- /dev/null +++ b/Loop/Views/IntegralRetrospectiveCorrectionSelectionView.swift @@ -0,0 +1,43 @@ +// +// IntegralRetrospectiveCorrectionSelectionView.swift +// Loop +// +// Created by Jonas Björkert on 2023-06-04. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// +import Foundation +import SwiftUI +import LoopKit +import LoopKitUI + +public struct IntegralRetrospectiveCorrectionSelectionView: View { + @Binding var isIntegralRetrospectiveCorrectionEnabled: Bool + + public var body: some View { + ScrollView { + VStack(spacing: 10) { + Text(NSLocalizedString("Integral Retrospective Correction", comment: "Title for integral retrospective correction experiment description")) + .font(.headline) + .padding(.bottom, 20) + + Divider() + + Text(NSLocalizedString("Integral Retrospective Correction (IRC) is an extension of the standard Retrospective Correction (RC) algorithm component in Loop, which adjusts the forecast based on the history of discrepancies between predicted and actual glucose levels.\n\nIn contrast to RC, which looks at discrepancies over the last 30 minutes, with IRC, the history of discrepancies adds up over time. So continued positive discrepancies over time will result in increased dosing. If the discrepancies are negative over time, Loop will reduce dosing further.", comment: "Description of Integral Retrospective Correction toggle.")) + .foregroundColor(.secondary) + Divider() + + Toggle(NSLocalizedString("Enable Integral Retrospective Correction", comment: "Title for Integral Retrospective Correction toggle"), isOn: $isIntegralRetrospectiveCorrectionEnabled) + .padding(.top, 20) + } + .padding() + } + .navigationBarTitleDisplayMode(.inline) + } + +} + +struct IntegralRetrospectiveCorrectionSelectionView_Previews: PreviewProvider { + static var previews: some View { + IntegralRetrospectiveCorrectionSelectionView(isIntegralRetrospectiveCorrectionEnabled: .constant(true)) + } +} diff --git a/Loop/Views/LiveActivityBottomRowManagerView.swift b/Loop/Views/LiveActivityBottomRowManagerView.swift new file mode 100644 index 0000000000..85a45b017f --- /dev/null +++ b/Loop/Views/LiveActivityBottomRowManagerView.swift @@ -0,0 +1,133 @@ +// +// LiveActivityBottomRowManagerView.swift +// Loop +// +// Created by Bastiaan Verhaar on 06/07/2024. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import LoopKitUI +import LoopCore +import SwiftUI + +struct LiveActivityBottomRowManagerView: View { + @Environment(\.presentationMode) var presentationMode: Binding + + // The maximum items in the bottom row + private let maxSize = 4 + + @State var showAdd: Bool = false + @State var configuration: [BottomRowConfiguration] + @State private var previousConfiguration: [BottomRowConfiguration] + @State private var isDirty = false + + init() { + configuration = (UserDefaults.standard.liveActivity ?? LiveActivitySettings()).bottomRowConfiguration + previousConfiguration = (UserDefaults.standard.liveActivity ?? LiveActivitySettings()).bottomRowConfiguration + } + + var addItem: ActionSheet { + var buttons: [ActionSheet.Button] = BottomRowConfiguration.all.map { item in + ActionSheet.Button.default(Text(item.description())) { + configuration.append(item) + + isDirty = configuration != previousConfiguration + } + } + buttons.append(.cancel(Text(NSLocalizedString("Cancel", comment: "Button text to cancel")))) + + return ActionSheet(title: Text(NSLocalizedString("Add item to bottom row", comment: "Title for Add item")), buttons: buttons) + } + + var body: some View { + List { + ForEach($configuration, id: \.self) { item in + HStack { + deleteButton + .onTapGesture { + onDelete(item.wrappedValue) + isDirty = configuration != previousConfiguration + } + Text(item.wrappedValue.description()) + + Spacer() + editBars + } + } + .onMove(perform: onReorder) + .deleteDisabled(true) + + Section { + Button(action: onSave) { + Text(NSLocalizedString("Save", comment: "")) + } + .disabled(!isDirty) + .buttonStyle(ActionButtonStyle()) + .listRowInsets(EdgeInsets()) + } + } + .onAppear { + configuration = (UserDefaults.standard.liveActivity ?? LiveActivitySettings()).bottomRowConfiguration + previousConfiguration = (UserDefaults.standard.liveActivity ?? LiveActivitySettings()).bottomRowConfiguration + } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button( + action: { showAdd = true }, + label: { Image(systemName: "plus") } + ) + .disabled(configuration.count >= self.maxSize) + } + } + .actionSheet(isPresented: $showAdd, content: { addItem }) + .insetGroupedListStyle() + .navigationBarTitle(Text(NSLocalizedString("Bottom row", comment: "Live activity Bottom row configuration title"))) + } + + @ViewBuilder + private var deleteButton: some View { + ZStack { + Color.red + .clipShape(RoundedRectangle(cornerRadius: 12.5)) + .frame(width: 20, height: 20) + + Image(systemName: "minus") + .foregroundColor(.white) + } + .contentShape(Rectangle()) + } + + @ViewBuilder + private var editBars: some View { + Image(systemName: "line.3.horizontal") + .foregroundColor(Color(UIColor.tertiaryLabel)) + .font(.title2) + } + + private func onSave() { + var settings = UserDefaults.standard.liveActivity ?? LiveActivitySettings() + settings.bottomRowConfiguration = configuration + + UserDefaults.standard.liveActivity = settings + NotificationCenter.default.post(name: .LiveActivitySettingsChanged, object: settings) + + self.presentationMode.wrappedValue.dismiss() + } + + func onReorder(from: IndexSet, to: Int) { + withAnimation { + configuration.move(fromOffsets: from, toOffset: to) + isDirty = configuration != previousConfiguration + } + } + + func onDelete(_ item: BottomRowConfiguration) { + withAnimation { + _ = configuration.remove(item) + } + } +} + +#Preview { + LiveActivityBottomRowManagerView() +} diff --git a/Loop/Views/LiveActivityManagementView.swift b/Loop/Views/LiveActivityManagementView.swift new file mode 100644 index 0000000000..bdf87dc553 --- /dev/null +++ b/Loop/Views/LiveActivityManagementView.swift @@ -0,0 +1,140 @@ +// +// LiveActivityManagementView.swift +// Loop +// +// Created by Bastiaan Verhaar on 04/07/2024. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKitUI +import LoopCore +import HealthKit + +struct LiveActivityManagementView: View { + @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference + @StateObject private var viewModel = LiveActivityManagementViewModel() + @State private var previousViewModel = LiveActivityManagementViewModel() + + @State private var isDirty = false + + var body: some View { + VStack { + List { + Section { + Toggle(NSLocalizedString("Enabled", comment: "Title for enable live activity toggle"), isOn: $viewModel.enabled) + .onChange(of: viewModel.enabled) { _ in + self.isDirty = previousViewModel.enabled != viewModel.enabled + } + + ExpandableSetting( + isEditing: $viewModel.isEditingMode, + leadingValueContent: { + Text(NSLocalizedString("Mode", comment: "Title for mode live activity toggle")) + .foregroundStyle(viewModel.isEditingMode ? .blue : .primary) + }, + trailingValueContent: { + Text(viewModel.mode.name()) + .foregroundStyle(viewModel.isEditingMode ? .blue : .primary) + }, + expandedContent: { + ResizeablePicker(selection: self.$viewModel.mode.animation(), + data: LiveActivityMode.all, + formatter: { $0.name() }) + } + ) + .onChange(of: viewModel.mode) { _ in + self.isDirty = previousViewModel.mode != viewModel.mode + } + } + + Section { + if viewModel.mode == .large { + Toggle(NSLocalizedString("Add predictive line", comment: "Title for predictive line toggle"), isOn: $viewModel.addPredictiveLine) + .transition(.move(edge: viewModel.mode == .large ? .top : .bottom)) + .onChange(of: viewModel.addPredictiveLine) { _ in + self.isDirty = previousViewModel.addPredictiveLine != viewModel.addPredictiveLine + } + } + + Toggle(NSLocalizedString("Use BG coloring", comment: "Title for BG coloring"), isOn: $viewModel.useLimits) + .transition(.move(edge: viewModel.mode == .large ? .top : .bottom)) + .onChange(of: viewModel.useLimits) { _ in + self.isDirty = previousViewModel.useLimits != viewModel.useLimits + } + + if self.displayGlucosePreference.unit == .millimolesPerLiter { + TextInput(label: "Upper limit", value: $viewModel.upperLimitChartMmol) + .transition(.move(edge: viewModel.useLimits ? .top : .bottom)) + .onChange(of: viewModel.upperLimitChartMmol) { _ in + self.isDirty = previousViewModel.upperLimitChartMmol != viewModel.upperLimitChartMmol + } + TextInput(label: "Lower limit", value: $viewModel.lowerLimitChartMmol) + .transition(.move(edge: viewModel.useLimits ? .top : .bottom)) + .onChange(of: viewModel.lowerLimitChartMmol) { _ in + self.isDirty = previousViewModel.lowerLimitChartMmol != viewModel.lowerLimitChartMmol + } + } else { + TextInput(label: "Upper limit", value: $viewModel.upperLimitChartMg) + .transition(.move(edge: viewModel.useLimits ? .top : .bottom)) + .onChange(of: viewModel.upperLimitChartMg) { _ in + self.isDirty = previousViewModel.upperLimitChartMg != viewModel.upperLimitChartMg + } + TextInput(label: "Lower limit", value: $viewModel.lowerLimitChartMg) + .transition(.move(edge: viewModel.useLimits ? .top : .bottom)) + .onChange(of: viewModel.lowerLimitChartMg) { _ in + self.isDirty = previousViewModel.lowerLimitChartMg != viewModel.lowerLimitChartMg + } + } + } + + Section { + NavigationLink( + destination: LiveActivityBottomRowManagerView(), + label: { Text(NSLocalizedString("Bottom row configuration", comment: "Title for Bottom row configuration")) } + ) + } + } + .animation(.easeInOut, value: UUID()) + .insetGroupedListStyle() + + Spacer() + Button(action: save) { + Text(NSLocalizedString("Save", comment: "")) + } + .buttonStyle(ActionButtonStyle()) + .disabled(!isDirty) + .padding([.bottom, .horizontal]) + } + .navigationBarTitle(Text(NSLocalizedString("Live activity", comment: "Live activity screen title"))) + } + + @ViewBuilder + private func TextInput(label: String, value: Binding) -> some View { + HStack { + Text(NSLocalizedString(label, comment: "no comment")) + Spacer() + TextField("", value: value, format: .number) + .multilineTextAlignment(.trailing) + Text(self.displayGlucosePreference.unit.localizedShortUnitString) + } + } + + private func save() { + var settings = UserDefaults.standard.liveActivity ?? LiveActivitySettings() + settings.enabled = viewModel.enabled + settings.mode = viewModel.mode + settings.addPredictiveLine = viewModel.addPredictiveLine + settings.useLimits = viewModel.useLimits + settings.upperLimitChartMmol = viewModel.upperLimitChartMmol + settings.lowerLimitChartMmol = viewModel.lowerLimitChartMmol + settings.upperLimitChartMg = viewModel.upperLimitChartMg + settings.lowerLimitChartMg = viewModel.lowerLimitChartMg + + UserDefaults.standard.liveActivity = settings + NotificationCenter.default.post(name: .LiveActivitySettingsChanged, object: settings) + + self.isDirty = false + previousViewModel = LiveActivityManagementViewModel() + } +} diff --git a/Loop/Views/ManualEntryDoseView.swift b/Loop/Views/ManualEntryDoseView.swift new file mode 100644 index 0000000000..4c55b6be28 --- /dev/null +++ b/Loop/Views/ManualEntryDoseView.swift @@ -0,0 +1,254 @@ +// +// ManualEntryDoseView.swift +// Loop +// +// Created by Pete Schwamb on 12/29/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import Combine +import HealthKit +import SwiftUI +import LoopKit +import LoopKitUI +import LoopUI + + +struct ManualEntryDoseView: View { + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + + @ObservedObject var viewModel: ManualEntryDoseViewModel + + @State private var enteredBolusString = "" + @State private var isInteractingWithChart = false + + @FocusState private var bolusFieldFocused: Bool + + @Environment(\.dismissAction) var dismiss + + private var accessoryClearance: CGFloat { + dynamicTypeSize.isAccessibilitySize ? 72 : 52 + } + + var body: some View { + GeometryReader { geometry in + VStack(spacing: 0) { + List { + self.chartSection + self.summarySection + } + .insetGroupedListStyle() + } + .navigationBarTitle(self.title) + .supportedInterfaceOrientations(.portrait) + .safeAreaInset(edge: .bottom, spacing: 0) { + if bolusFieldFocused { + // Reserve space so the toolbar doesn’t overlap the field + Color.clear.frame(height: accessoryClearance) + } else { + actionArea + } + } + } + } + + private var title: Text { + return Text("Log Dose", comment: "Title for dose logging screen") + } + + private var chartSection: some View { + Section { + VStack(spacing: 8) { + HStack(spacing: 0) { + activeCarbsLabel + Spacer(minLength: 8) + activeInsulinLabel + } + + // Use a ZStack to allow horizontally clipping the predicted glucose chart, + // without clipping the point label on highlight, which draws outside the view's bounds. + ZStack(alignment: .topLeading) { + Text("Glucose", comment: "Title for predicted glucose chart on bolus screen") + .font(.subheadline) + .bold() + .frame(maxWidth: .infinity, alignment: .leading) + .opacity(isInteractingWithChart ? 0 : 1) + + predictedGlucoseChart + .padding(.horizontal, -4) + .padding(.top, UIFont.preferredFont(forTextStyle: .subheadline).lineHeight + 8) // Leave space for the 'Glucose' label + spacing + .clipped() + } + .frame(height: ceil(UIScreen.main.bounds.height / 4)) + } + .padding(.top, 12) + .padding(.bottom, 8) + } + } + + @ViewBuilder + private var activeCarbsLabel: some View { + LabeledQuantity( + label: Text("Active Carbs", comment: "Title describing quantity of still-absorbing carbohydrates"), + quantity: viewModel.activeCarbs, + unit: .gram() + ) + } + + @ViewBuilder + private var activeInsulinLabel: some View { + LabeledQuantity( + label: Text("Active Insulin", comment: "Title describing quantity of still-absorbing insulin"), + quantity: viewModel.activeInsulin, + unit: .internationalUnit(), + maxFractionDigits: 2 + ) + } + + private var predictedGlucoseChart: some View { + PredictedGlucoseChartView( + chartManager: viewModel.chartManager, + glucoseUnit: viewModel.glucoseUnit, + glucoseValues: viewModel.glucoseValues, + predictedGlucoseValues: viewModel.predictedGlucoseValues, + targetGlucoseSchedule: viewModel.targetGlucoseSchedule, + preMealOverride: viewModel.preMealOverride, + scheduleOverride: viewModel.scheduleOverride, + dateInterval: viewModel.chartDateInterval, + isInteractingWithChart: $isInteractingWithChart + ) + } + + private var summarySection: some View { + Section { + VStack(spacing: 16) { + titleText + .bold() + .frame(maxWidth: .infinity, alignment: .leading) + + datePicker + } + .padding(.top, 8) + + insulinTypePicker + + bolusEntryRow + } + } + + private var titleText: Text { + return Text("Dose Summary", comment: "Title for card to log dose") + } + + private var glucoseFormatter: NumberFormatter { + QuantityFormatter(for: viewModel.glucoseUnit).numberFormatter + } + + private static let doseAmountFormatter: NumberFormatter = { + let quantityFormatter = QuantityFormatter(for: .internationalUnit()) + return quantityFormatter.numberFormatter + }() + + private var insulinTypePicker: some View { + ExpandablePicker( + with: viewModel.insulinTypePickerOptions, + selectedValue: $viewModel.selectedInsulinType, + label: NSLocalizedString("Insulin Type", comment: "Insulin type label") + ) + } + private var datePicker: some View { + // Allow 6 hours before & after due to longest DIA + ZStack(alignment: .topLeading) { + DatePicker( + String(""), + selection: $viewModel.selectedDoseDate, + in: Date().addingTimeInterval(-.hours(6))...Date().addingTimeInterval(.hours(6)), + displayedComponents: [.date, .hourAndMinute] + ) + .pickerStyle(WheelPickerStyle()) + + Text(NSLocalizedString("Date", comment: "Date picker label")) + } + } + + + private var bolusEntryRow: some View { + HStack { + Text("Bolus", comment: "Label for bolus entry row on bolus screen") + Spacer() + HStack(alignment: .firstTextBaseline) { + TextField(Self.doseAmountFormatter.string(from: 0.0)!, text: typedBolusEntry) + .keyboardType(.decimalPad) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .font(.title) + .multilineTextAlignment(.trailing) + .foregroundColor(.loopAccent) + .focused($bolusFieldFocused) + .onChange(of: enteredBolusString) { newValue in + if newValue.count > 5 { + enteredBolusString = String(newValue.prefix(5)) + } + } + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button("Done") { bolusFieldFocused = false } + } + } + bolusUnitsLabel + } + } + .accessibilityElement(children: .combine) + } + + private var bolusUnitsLabel: some View { + Text(QuantityFormatter(for: .internationalUnit()).localizedUnitStringWithPlurality()) + .foregroundColor(Color(.secondaryLabel)) + } + + private var typedBolusEntry: Binding { + Binding( + get: { self.enteredBolusString }, + set: { newValue in + self.viewModel.enteredBolus = HKQuantity(unit: .internationalUnit(), doubleValue: Self.doseAmountFormatter.number(from: newValue)?.doubleValue ?? 0) + self.enteredBolusString = newValue + } + ) + } + + private var enteredBolusAmount: Double { + Self.doseAmountFormatter.number(from: enteredBolusString)?.doubleValue ?? 0 + } + + private var actionButtonDisabled: Bool { + enteredBolusAmount <= 0 + } + + private var actionArea: some View { + VStack(spacing: 0) { + actionButton.disabled(actionButtonDisabled) + } + .padding(.bottom) // FIXME: unnecessary on iPhone 8 size devices + .background(Color(.secondarySystemGroupedBackground).shadow(radius: 5)) + } + + private var actionButton: some View { + Button( + action: { + self.viewModel.saveManualDose(onSuccess: self.dismiss) + }, + label: { + return Text("Log Dose", comment: "Button text to log a dose") + } + ) + .buttonStyle(ActionButtonStyle(.primary)) + .padding() + } +} + +extension InsulinType: @retroactive Labeled { + public var label: String { + return title + } +} diff --git a/Loop/Views/ManualGlucoseEntryRow.swift b/Loop/Views/ManualGlucoseEntryRow.swift new file mode 100644 index 0000000000..3850637a5e --- /dev/null +++ b/Loop/Views/ManualGlucoseEntryRow.swift @@ -0,0 +1,72 @@ +// +// ManualGlucoseEntryRow.swift +// Loop +// +// Created by Pete Schwamb on 12/8/22. +// Copyright © 2022 LoopKit Authors. All rights reserved. +// + +import Foundation +import SwiftUI +import LoopKit +import LoopKitUI +import Combine +import HealthKit + +struct ManualGlucoseEntryRow: View { + @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference + + @State private var valueText = "" + + @Binding var quantity: HKQuantity? + + @State private var isManualGlucoseEntryRowVisible = false + + @FocusState private var fieldIsFocused: Bool + + var body: some View { + HStack { + Text("Fingerstick Glucose", comment: "Label for manual glucose entry row on bolus screen") + Spacer() + + HStack(alignment: .firstTextBaseline) { + DismissibleKeyboardTextField( + text: $valueText, + placeholder: "– – –", + font: .heavy(.title1), + textAlignment: .right, + keyboardType: .decimalPad, + shouldBecomeFirstResponder: isManualGlucoseEntryRowVisible, + maxLength: 4, + doneButtonColor: .loopAccent + ) + .onChange(of: valueText, perform: { value in + if let manualGlucoseValue = displayGlucosePreference.formatter.numberFormatter.number(from: valueText)?.doubleValue { + quantity = HKQuantity(unit: displayGlucosePreference.unit, doubleValue: manualGlucoseValue) + } else { + quantity = nil + } + }) + .onChange(of: displayGlucosePreference.unit, perform: { value in + unitsChanged() + }) + + Text(displayGlucosePreference.formatter.localizedUnitStringWithPlurality()) + .foregroundColor(Color(.secondaryLabel)) + } + } + .onKeyboardStateChange { state in + if state.animationDuration > 0 { + DispatchQueue.main.asyncAfter(deadline: .now() + state.animationDuration) { + self.isManualGlucoseEntryRowVisible = true + } + } + } + } + + func unitsChanged() { + if let quantity = quantity { + valueText = displayGlucosePreference.format(quantity, includeUnit: false) + } + } +} diff --git a/Loop/Views/NotificationsCriticalAlertPermissionsView.swift b/Loop/Views/NotificationsCriticalAlertPermissionsView.swift new file mode 100644 index 0000000000..b9e1552036 --- /dev/null +++ b/Loop/Views/NotificationsCriticalAlertPermissionsView.swift @@ -0,0 +1,162 @@ +// +// NotificationsCriticalAlertPermissionsView.swift +// LoopUI +// +// Created by Rick Pasetto on 6/11/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import LoopKitUI +import SwiftUI + +public struct NotificationsCriticalAlertPermissionsView: View { + @Environment(\.dismissAction) private var dismiss + @Environment(\.appName) private var appName + + private let backButtonText: String + @ObservedObject private var checker: AlertPermissionsChecker + + // TODO: This screen is used in both the 'old Settings UI' and the 'new Settings UI'. This is temporary. + // In the old UI, it is a "top level" navigation view. In the new UI, it is just part of the "flow". This + // enum tries to make this clear, for now. + public enum PresentationMode { + case topLevel, flow + } + private let mode: PresentationMode + + public init(backButtonText: String = "", mode: PresentationMode = .topLevel, checker: AlertPermissionsChecker) { + self.backButtonText = backButtonText + self.checker = checker + self.mode = mode + } + + public var body: some View { + switch mode { + case .flow: content() + case .topLevel: navigationContent() + } + } + + private func navigationContent() -> some View { + return NavigationView { + content() + } + } + + private func content() -> some View { + List { + Section(footer: DescriptiveText(label: String(format: NSLocalizedString(""" + Notifications give you important %1$@ app information without requiring you to open the app. + + Keep these turned ON in your phone’s settings to ensure you receive %1$@ Notifications, Critical Alerts, and Time Sensitive Notifications. + """, comment: "Alert Permissions descriptive text (1: app name)"), appName))) + { + manageNotifications + notificationsEnabledStatus + if #available(iOS 15.0, *) { + if !checker.notificationCenterSettings.notificationsDisabled { + notificationDelivery + } + } + criticalAlertsStatus + if #available(iOS 15.0, *) { + if !checker.notificationCenterSettings.notificationsDisabled { + timeSensitiveStatus + } + } + } + notificationAndCriticalAlertPermissionSupportSection + } + .insetGroupedListStyle() + .navigationBarTitle(Text(NSLocalizedString("Alert Permissions", comment: "Notification & Critical Alert Permissions screen title"))) + } +} + +extension NotificationsCriticalAlertPermissionsView { + + @ViewBuilder + private func onOff(_ val: Bool) -> some View { + if val { + Text("On", comment: "Notification Setting Status is On") + } else { + HStack { + Image(systemName: "exclamationmark.triangle.fill").foregroundColor(.critical) + Text("Off", comment: "Notification Setting Status is Off") + } + } + } + + private var manageNotifications: some View { + Button( action: { AlertPermissionsChecker.gotoSettings() } ) { + HStack { + Text(NSLocalizedString("Manage Permissions in Settings", comment: "Manage Permissions in Settings button text")) + Spacer() + Image(systemName: "chevron.right").foregroundColor(.gray).font(.footnote) + } + } + .accentColor(.primary) + } + + private var notificationsEnabledStatus: some View { + HStack { + Text("Notifications", comment: "Notifications Status text") + Spacer() + onOff(!checker.notificationCenterSettings.notificationsDisabled) + } + } + + private var criticalAlertsStatus: some View { + HStack { + Text("Critical Alerts", comment: "Critical Alerts Status text") + Spacer() + onOff(!checker.notificationCenterSettings.criticalAlertsDisabled) + } + } + + @available(iOS 15.0, *) + private var timeSensitiveStatus: some View { + HStack { + Text("Time Sensitive Notifications", comment: "Time Sensitive Status text") + Spacer() + onOff(!checker.notificationCenterSettings.timeSensitiveNotificationsDisabled) + } + } + + @available(iOS 15.0, *) + private var notificationDelivery: some View { + HStack { + Text("Notification Delivery", comment: "Notification Delivery Status text") + Spacer() + if checker.notificationCenterSettings.scheduledDeliveryEnabled { + Image(systemName: "exclamationmark.triangle.fill").foregroundColor(.critical) + Text("Scheduled", comment: "Scheduled Delivery status text") + } else { + Text("Immediate", comment: "Immediate Delivery status text") + } + } + } + + private var notificationAndCriticalAlertPermissionSupportSection: some View { + Section(header: SectionHeader(label: NSLocalizedString("Support", comment: "Section title for Support"))) { + NavigationLink(destination: Text("Get help with Alert Permissions")) { + Text(NSLocalizedString("Get help with Alert Permissions", comment: "Get help with Alert Permissions support button text")) + } + } + } +} + + +struct NotificationsCriticalAlertPermissionsView_Previews: PreviewProvider { + static var previews: some View { + return Group { + NotificationsCriticalAlertPermissionsView(checker: AlertPermissionsChecker()) + .colorScheme(.light) + .previewDevice(PreviewDevice(rawValue: "iPhone SE")) + .previewDisplayName("SE light") + NotificationsCriticalAlertPermissionsView(checker: AlertPermissionsChecker()) + .colorScheme(.dark) + .previewDevice(PreviewDevice(rawValue: "iPhone XS Max")) + .previewDisplayName("XS Max dark") + } + } +} diff --git a/Loop/Views/OverrideBadgeView.swift b/Loop/Views/OverrideBadgeView.swift new file mode 100644 index 0000000000..83cc42a1fe --- /dev/null +++ b/Loop/Views/OverrideBadgeView.swift @@ -0,0 +1,64 @@ +// +// OverrideBadgeView.swift +// Loop +// +// Created by Michael Pangburn on 2/19/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import UIKit + + +@IBDesignable +final class OverrideBadgeView: UIView { + private let emojiLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 24) + return label + }() + + var emoji: String? { + get { emojiLabel.text } + set { + emojiLabel.text = newValue + invalidateIntrinsicContentSize() + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + private func setup() { + backgroundColor = UIColor.glucoseTintColor.withAlphaComponent(0.7) + addSubview(emojiLabel) + emojiLabel.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + emojiLabel.centerXAnchor.constraint(equalTo: centerXAnchor), + emojiLabel.centerYAnchor.constraint(equalTo: centerYAnchor) + ]) + } + + override func layoutSubviews() { + super.layoutSubviews() + layer.cornerRadius = min(frame.width, frame.height) / 2 + } + + override var intrinsicContentSize: CGSize { + var size = emojiLabel.intrinsicContentSize + let borderMargin: CGFloat = 3 + size.width += 2 * borderMargin + size.height += 2 * borderMargin + return size + } + + override func prepareForInterfaceBuilder() { + invalidateIntrinsicContentSize() + } +} diff --git a/Loop/Views/PotentialCarbEntryTableViewCell.swift b/Loop/Views/PotentialCarbEntryTableViewCell.swift new file mode 100644 index 0000000000..fac1d8060b --- /dev/null +++ b/Loop/Views/PotentialCarbEntryTableViewCell.swift @@ -0,0 +1,39 @@ +// +// PotentialCarbEntryTableViewCell.swift +// Loop +// +// Created by Michael Pangburn on 12/27/19. +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import UIKit + + +class PotentialCarbEntryTableViewCell: UITableViewCell { + @IBOutlet weak var valueLabel: UILabel! + @IBOutlet weak var dateLabel: UILabel! + + override func layoutSubviews() { + super.layoutSubviews() + + contentView.layoutMargins.left = separatorInset.left + contentView.layoutMargins.right = separatorInset.left + } + + override func awakeFromNib() { + super.awakeFromNib() + + resetViews() + } + + override func prepareForReuse() { + super.prepareForReuse() + + resetViews() + } + + private func resetViews() { + valueLabel.text = nil + dateLabel.text = nil + } +} diff --git a/Loop/Views/PredictedGlucoseChartView.swift b/Loop/Views/PredictedGlucoseChartView.swift new file mode 100644 index 0000000000..b7e34a3bdb --- /dev/null +++ b/Loop/Views/PredictedGlucoseChartView.swift @@ -0,0 +1,96 @@ +// +// PredictedGlucoseChartView.swift +// Loop +// +// Created by Michael Pangburn on 7/22/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import HealthKit +import SwiftUI +import LoopKit +import LoopKitUI +import LoopUI + + +struct PredictedGlucoseChartView: UIViewRepresentable { + let chartManager: ChartsManager + var glucoseUnit: HKUnit + var glucoseValues: [GlucoseValue] + var predictedGlucoseValues: [GlucoseValue] + var targetGlucoseSchedule: GlucoseRangeSchedule? + var preMealOverride: TemporaryScheduleOverride? + var scheduleOverride: TemporaryScheduleOverride? + var dateInterval: DateInterval + + @Binding var isInteractingWithChart: Bool + + func makeUIView(context: Context) -> ChartContainerView { + let view = ChartContainerView() + view.chartGenerator = { [chartManager] frame in + chartManager.chart(atIndex: 0, frame: frame)?.view + } + + let gestureRecognizer = UILongPressGestureRecognizer() + gestureRecognizer.minimumPressDuration = 0.1 + gestureRecognizer.addTarget(context.coordinator, action: #selector(Coordinator.handlePan(_:))) + chartManager.gestureRecognizer = gestureRecognizer + view.addGestureRecognizer(gestureRecognizer) + + return view + } + + func updateUIView(_ chartContainerView: ChartContainerView, context: Context) { + chartManager.invalidateChart(atIndex: 0) + chartManager.startDate = dateInterval.start + chartManager.maxEndDate = dateInterval.end + chartManager.updateEndDate(dateInterval.end) + predictedGlucoseChart.glucoseUnit = glucoseUnit + predictedGlucoseChart.targetGlucoseSchedule = targetGlucoseSchedule + predictedGlucoseChart.preMealOverride = preMealOverride + predictedGlucoseChart.scheduleOverride = scheduleOverride + predictedGlucoseChart.setGlucoseValues(glucoseValues) + predictedGlucoseChart.setPredictedGlucoseValues(predictedGlucoseValues) + chartManager.prerender() + chartContainerView.reloadChart() + } + + var predictedGlucoseChart: PredictedGlucoseChart { + guard chartManager.charts.count == 1, let predictedGlucoseChart = chartManager.charts.first as? PredictedGlucoseChart else { + fatalError("Expected exactly one predicted glucose chart in ChartsManager") + } + + return predictedGlucoseChart + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + final class Coordinator { + var parent: PredictedGlucoseChartView + + init(_ parent: PredictedGlucoseChartView) { + self.parent = parent + } + + @objc func handlePan(_ recognizer: UIPanGestureRecognizer) { + switch recognizer.state { + case .began: + withAnimation(.easeInOut(duration: 0.2)) { + parent.isInteractingWithChart = true + } + case .cancelled, .ended, .failed: + // Workaround: applying the delay on the animation directly does not delay the disappearance of the touch indicator. + // FIXME: No animation is applied to the disappearance of the touch indicator; it simply disappears. + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { [weak self] in + withAnimation(.easeInOut(duration: 0.5)) { + self?.parent.isInteractingWithChart = false + } + } + default: + break + } + } + } +} diff --git a/Loop/Views/PredictionInputEffectTableViewCell.swift b/Loop/Views/PredictionInputEffectTableViewCell.swift index e9736b4708..fe42f3d5e6 100644 --- a/Loop/Views/PredictionInputEffectTableViewCell.swift +++ b/Loop/Views/PredictionInputEffectTableViewCell.swift @@ -14,14 +14,21 @@ class PredictionInputEffectTableViewCell: UITableViewCell { @IBOutlet weak var subtitleLabel: UILabel! + override func layoutSubviews() { + super.layoutSubviews() + + contentView.layoutMargins.left = separatorInset.left + contentView.layoutMargins.right = separatorInset.left + } + var enabled: Bool = true { didSet { if enabled { titleLabel.textColor = UIColor.darkText subtitleLabel.textColor = UIColor.darkText } else { - titleLabel.textColor = UIColor.secondaryLabelColor - subtitleLabel.textColor = UIColor.secondaryLabelColor + titleLabel.textColor = UIColor.secondaryLabel + subtitleLabel.textColor = UIColor.secondaryLabel } } } diff --git a/Loop/Views/PredictionSettingTableViewCell.swift b/Loop/Views/PredictionSettingTableViewCell.swift new file mode 100644 index 0000000000..76a51c3111 --- /dev/null +++ b/Loop/Views/PredictionSettingTableViewCell.swift @@ -0,0 +1,16 @@ +// +// PredictionSettingTableViewCell.swift +// Loop +// +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import LoopKitUI + +class PredictionSettingTableViewCell: SwitchTableViewCell { + + @IBOutlet public weak var titleLabel: UILabel? + + @IBOutlet public weak var subtitleLabel: UILabel? + +} diff --git a/Loop/Views/SettingsView+algorithmExperimentsSection.swift b/Loop/Views/SettingsView+algorithmExperimentsSection.swift new file mode 100644 index 0000000000..795fbdd860 --- /dev/null +++ b/Loop/Views/SettingsView+algorithmExperimentsSection.swift @@ -0,0 +1,106 @@ +// +// SettingsView+algorithmExperimentsSection.swift +// Loop +// +// Created by Jonas Björkert on 2023-06-03. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation +import SwiftUI +import LoopKit +import LoopKitUI + +extension SettingsView { + internal var algorithmExperimentsSection: some View { + NavigationLink(NSLocalizedString("Algorithm Experiments", comment: "The title of the Algorithm Experiments section in settings")) { + ExperimentsSettingsView(automaticDosingStrategy: viewModel.automaticDosingStrategy) + } + } +} + +public struct ExperimentRow: View { + var name: String + var enabled: Bool + + public var body: some View { + HStack { + Text(name) + .foregroundColor(.primary) + Spacer() + Text(enabled ? "On" : "Off") + .foregroundColor(enabled ? .red : .secondary) + } + .padding() + .background(Color(UIColor.secondarySystemBackground)) + .foregroundColor(.accentColor) + .cornerRadius(10) + } +} + +public struct ExperimentsSettingsView: View { + @AppStorage(UserDefaults.Key.GlucoseBasedApplicationFactorEnabled.rawValue) private var isGlucoseBasedApplicationFactorEnabled = false + @AppStorage(UserDefaults.Key.IntegralRetrospectiveCorrectionEnabled.rawValue) private var isIntegralRetrospectiveCorrectionEnabled = false + var automaticDosingStrategy: AutomaticDosingStrategy + + public var body: some View { + ScrollView { + VStack(alignment: .center, spacing: 12) { + Text(NSLocalizedString("Algorithm Experiments", comment: "Navigation title for algorithms experiments screen")) + .font(.headline) + VStack { + Text("⚠️").font(.largeTitle) + Text("Caution") + } + Divider() + VStack(alignment: .leading, spacing: 12) { + Text(NSLocalizedString("Algorithm Experiments are optional modifications to the Loop Algorithm. These modifications are less tested than the standard Loop Algorithm, so please use carefully.", comment: "Algorithm Experiments description.")) + Text(NSLocalizedString("In future versions of Loop these experiments may change, end up as standard parts of the Loop Algorithm, or be removed from Loop entirely. Please follow along in the Loop Zulip chat to stay informed of possible changes to these features.", comment: "Algorithm Experiments description second paragraph.")) + } + .foregroundColor(.secondary) + + Divider() + NavigationLink(destination: GlucoseBasedApplicationFactorSelectionView(isGlucoseBasedApplicationFactorEnabled: $isGlucoseBasedApplicationFactorEnabled, automaticDosingStrategy: automaticDosingStrategy)) { + ExperimentRow( + name: NSLocalizedString("Glucose Based Partial Application", comment: "Title of glucose based partial application experiment"), + enabled: isGlucoseBasedApplicationFactorEnabled && automaticDosingStrategy == .automaticBolus) + } + NavigationLink(destination: IntegralRetrospectiveCorrectionSelectionView(isIntegralRetrospectiveCorrectionEnabled: $isIntegralRetrospectiveCorrectionEnabled)) { + ExperimentRow( + name: NSLocalizedString("Integral Retrospective Correction", comment: "Title of integral retrospective correction experiment"), + enabled: isIntegralRetrospectiveCorrectionEnabled) + } + Spacer() + } + .padding() + } + .navigationBarTitleDisplayMode(.inline) + } +} + + +extension UserDefaults { + fileprivate enum Key: String { + case GlucoseBasedApplicationFactorEnabled = "com.loopkit.algorithmExperiments.glucoseBasedApplicationFactorEnabled" + case IntegralRetrospectiveCorrectionEnabled = "com.loopkit.algorithmExperiments.integralRetrospectiveCorrectionEnabled" + } + + var glucoseBasedApplicationFactorEnabled: Bool { + get { + bool(forKey: Key.GlucoseBasedApplicationFactorEnabled.rawValue) as Bool + } + set { + set(newValue, forKey: Key.GlucoseBasedApplicationFactorEnabled.rawValue) + } + } + + var integralRetrospectiveCorrectionEnabled: Bool { + get { + bool(forKey: Key.IntegralRetrospectiveCorrectionEnabled.rawValue) as Bool + } + set { + set(newValue, forKey: Key.IntegralRetrospectiveCorrectionEnabled.rawValue) + } + } + +} diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift new file mode 100644 index 0000000000..aa0da33134 --- /dev/null +++ b/Loop/Views/SettingsView.swift @@ -0,0 +1,637 @@ +// +// SettingsView.swift +// LoopUI +// +// Created by Rick Pasetto on 6/24/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import LoopKit +import LoopKitUI +import MockKit +import SwiftUI +import HealthKit + +public struct SettingsView: View { + @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference + @Environment(\.dismissAction) private var dismiss + @Environment(\.appName) private var appName + @Environment(\.guidanceColors) private var guidanceColors + @Environment(\.carbTintColor) private var carbTintColor + @Environment(\.glucoseTintColor) private var glucoseTintColor + @Environment(\.insulinTintColor) private var insulinTintColor + + @ObservedObject var viewModel: SettingsViewModel + @ObservedObject var versionUpdateViewModel: VersionUpdateViewModel + + enum Destination { + enum Alert: String, Identifiable { + var id: String { + rawValue + } + + case deleteCGMData + case deletePumpData + } + + enum ActionSheet: String, Identifiable { + var id: String { + rawValue + } + + case cgmPicker + case pumpPicker + case servicePicker + } + + enum Sheet: String, Identifiable { + var id: String { + rawValue + } + + case favoriteFoods + } + } + + @State private var actionSheet: Destination.ActionSheet? + @State private var alert: Destination.Alert? + @State private var sheet: Destination.Sheet? + + var localizedAppNameAndVersion: String + + public init(viewModel: SettingsViewModel, localizedAppNameAndVersion: String) { + self.viewModel = viewModel + self.versionUpdateViewModel = viewModel.versionUpdateViewModel + self.localizedAppNameAndVersion = localizedAppNameAndVersion + } + + public var body: some View { + NavigationView { + List { + Group { + loopSection + if versionUpdateViewModel.softwareUpdateAvailable { + softwareUpdateSection + } + if FeatureFlags.automaticBolusEnabled { + dosingStrategySection + } + alertManagementSection + if viewModel.pumpManagerSettingsViewModel.isSetUp() { + configurationSection + } + deviceSettingsSection + if FeatureFlags.allowExperimentalFeatures { + favoriteFoodsSection + } + if (viewModel.pumpManagerSettingsViewModel.isTestingDevice || viewModel.cgmManagerSettingsViewModel.isTestingDevice) && viewModel.showDeleteTestData { + deleteDataSection + } + } + Group { + if viewModel.servicesViewModel.showServices { + servicesSection + } + + ForEach(customSections) { customSectionName in + menuItemsForSection(name: customSectionName) + } + + supportSection + + if let profileExpiration = BuildDetails.default.profileExpiration, FeatureFlags.profileExpirationSettingsViewEnabled { + appExpirationSection(profileExpiration: profileExpiration) + } + } + } + .insetGroupedListStyle() + .navigationBarTitle(Text(NSLocalizedString("Settings", comment: "Settings screen title"))) + .navigationBarItems(trailing: dismissButton) + .actionSheet(item: $actionSheet) { actionSheet in + switch actionSheet { + case .cgmPicker: + return ActionSheet( + title: Text("Add CGM", comment: "The title of the CGM chooser in settings"), + buttons: cgmChoices + ) + case .pumpPicker: + return ActionSheet( + title: Text("Add Pump", comment: "The title of the pump chooser in settings"), + buttons: pumpChoices + ) + case .servicePicker: + return ActionSheet( + title: Text("Add Service", comment: "The title of the add service action sheet in settings"), + buttons: serviceChoices + ) + } + } + .alert(item: $alert) { alert in + switch alert { + case .deleteCGMData: + return makeDeleteAlert(for: self.viewModel.cgmManagerSettingsViewModel) + case .deletePumpData: + return makeDeleteAlert(for: self.viewModel.pumpManagerSettingsViewModel) + } + } + .sheet(item: $sheet) { sheet in + switch sheet { + case .favoriteFoods: + FavoriteFoodsView() + } + } + } + .navigationViewStyle(.stack) + } + + private func menuItemsForSection(name: String) -> some View { + Section(header: SectionHeader(label: name)) { + ForEach(pluginMenuItems.filter {$0.section.customLocalizedTitle == name}) { item in + item.view + } + } + } + + private var customSections: [String] { + pluginMenuItems.compactMap { item in + if case .custom(let name) = item.section { + return name + } else { + return nil + } + } + } + + private var closedLoopToggleState: Binding { + Binding( + get: { self.viewModel.isClosedLoopAllowed && self.viewModel.closedLoopPreference }, + set: { self.viewModel.closedLoopPreference = $0 } + ) + } +} + +extension String: Identifiable { + public typealias ID = Int + public var id: Int { + return hash + } +} + +struct PluginMenuItem: Identifiable { + var id: String { + return pluginIdentifier + String(describing: offset) + } + + let section: SettingsMenuSection + let view: Content + let pluginIdentifier: String + let offset: Int +} + +extension SettingsView { + + private var dismissButton: some View { + Button(action: dismiss) { + Text("Done").bold() + } + } + + private var loopSection: some View { + Section(header: SectionHeader(label: localizedAppNameAndVersion)) { + Toggle(isOn: closedLoopToggleState) { + VStack(alignment: .leading) { + Text("Closed Loop", comment: "The title text for the looping enabled switch cell") + .padding(.vertical, 3) + if !viewModel.isOnboardingComplete { + DescriptiveText(label: NSLocalizedString("Closed Loop requires Setup to be Complete", comment: "The description text for the looping enabled switch cell when onboarding is not complete")) + } else if let closedLoopDescriptiveText = viewModel.closedLoopDescriptiveText { + DescriptiveText(label: closedLoopDescriptiveText) + } + } + .fixedSize(horizontal: false, vertical: true) + } + .disabled(!viewModel.isOnboardingComplete || !viewModel.isClosedLoopAllowed) + } + } + + private var softwareUpdateSection: some View { + Section(footer: Text(viewModel.versionUpdateViewModel.footer(appName: appName))) { + NavigationLink(destination: viewModel.versionUpdateViewModel.softwareUpdateView) { + Text(NSLocalizedString("Software Update", comment: "Software update button link text")) + Spacer() + viewModel.versionUpdateViewModel.icon + } + } + } + + private var dosingStrategySection: some View { + Section(header: SectionHeader(label: NSLocalizedString("Dosing Strategy", comment: "The title of the Dosing Strategy section in settings"))) { + + NavigationLink(destination: DosingStrategySelectionView(automaticDosingStrategy: $viewModel.automaticDosingStrategy)) + { + HStack { + Text(viewModel.automaticDosingStrategy.title) + } + } + } + } + + @ViewBuilder + private var alertWarning: some View { + if viewModel.alertPermissionsChecker.showWarning || viewModel.alertPermissionsChecker.notificationCenterSettings.scheduledDeliveryEnabled { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.critical) + } else if viewModel.alertMuter.configuration.shouldMute { + Image(systemName: "speaker.slash.fill") + .foregroundColor(.white) + .padding(5) + .background(guidanceColors.warning) + .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous)) + } + } + + private var alertManagementSection: some View { + Section { + NavigationLink(destination: AlertManagementView(checker: viewModel.alertPermissionsChecker, alertMuter: viewModel.alertMuter)) { + LargeButton( + action: {}, + includeArrow: false, + imageView: Image(systemName: "bell.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 30), + secondaryImageView: alertWarning, + label: NSLocalizedString("Alert Management", comment: "Alert Permissions button text"), + descriptiveText: NSLocalizedString("Alert Permissions and Mute Alerts", comment: "Alert Permissions descriptive text") + ) + } + } + } + + private var therapySettingsView: some View { + TherapySettingsView( + mode: .settings, + viewModel: TherapySettingsViewModel( + therapySettings: viewModel.therapySettings(), + sensitivityOverridesEnabled: FeatureFlags.sensitivityOverridesEnabled, + adultChildInsulinModelSelectionEnabled: FeatureFlags.adultChildInsulinModelSelectionEnabled, + delegate: viewModel.therapySettingsViewModelDelegate + ) + ) + .environmentObject(displayGlucosePreference) + .environment(\.dismissAction, self.dismiss) + .environment(\.appName, self.appName) + .environment(\.chartColorPalette, .primary) + .environment(\.carbTintColor, self.carbTintColor) + .environment(\.glucoseTintColor, self.glucoseTintColor) + .environment(\.guidanceColors, self.guidanceColors) + .environment(\.insulinTintColor, self.insulinTintColor) + } + + private var configurationSection: some View { + Section(header: SectionHeader(label: NSLocalizedString("Configuration", comment: "The title of the Configuration section in settings"))) { + NavigationLink(destination: therapySettingsView) { + LargeButton(action: { }, + includeArrow: false, + imageView: Image("Therapy Icon"), + label: NSLocalizedString("Therapy Settings", comment: "Title text for button to Therapy Settings"), + descriptiveText: NSLocalizedString("Diabetes Treatment", comment: "Descriptive text for Therapy Settings")) + } + + ForEach(pluginMenuItems.filter {$0.section == .configuration}) { item in + item.view + } + + if FeatureFlags.allowAlgorithmExperiments { + algorithmExperimentsSection + } + } + } + + private var pluginMenuItems: [PluginMenuItem] { + self.viewModel.availableSupports.flatMap { plugin in + plugin.configurationMenuItems().enumerated().map { index, item in + PluginMenuItem(section: item.section, view: item.view, pluginIdentifier: plugin.pluginIdentifier, offset: index) + } + } + } + + private var deviceSettingsSection: some View { + Section { + pumpSection + cgmSection + } + } + + @ViewBuilder + private var pumpSection: some View { + if viewModel.pumpManagerSettingsViewModel.isSetUp() { + LargeButton(action: self.viewModel.pumpManagerSettingsViewModel.didTap, + includeArrow: true, + imageView: deviceImage(uiImage: viewModel.pumpManagerSettingsViewModel.image()), + label: viewModel.pumpManagerSettingsViewModel.name(), + descriptiveText: NSLocalizedString("Insulin Pump", comment: "Descriptive text for Insulin Pump")) + } else if viewModel.isOnboardingComplete { + LargeButton(action: { actionSheet = .pumpPicker }, + includeArrow: false, + imageView: plusImage, + label: NSLocalizedString("Add Pump", comment: "Title text for button to add pump device"), + descriptiveText: NSLocalizedString("Tap here to set up a pump", comment: "Descriptive text for button to add pump device")) + } + } + + private var pumpChoices: [ActionSheet.Button] { + var result = viewModel.pumpManagerSettingsViewModel.availableDevices.map { availableDevice in + ActionSheet.Button.default(Text(availableDevice.localizedTitle)) { + self.viewModel.pumpManagerSettingsViewModel.didTapAdd(availableDevice) + } + } + result.append(.cancel()) + return result + } + + @ViewBuilder + private var cgmSection: some View { + if viewModel.cgmManagerSettingsViewModel.isSetUp() { + LargeButton(action: self.viewModel.cgmManagerSettingsViewModel.didTap, + includeArrow: true, + imageView: deviceImage(uiImage: viewModel.cgmManagerSettingsViewModel.image()), + label: viewModel.cgmManagerSettingsViewModel.name(), + descriptiveText: NSLocalizedString("Continuous Glucose Monitor", comment: "Descriptive text for Continuous Glucose Monitor")) + } else { + LargeButton(action: { actionSheet = .cgmPicker }, + includeArrow: false, + imageView: plusImage, + label: NSLocalizedString("Add CGM", comment: "Title text for button to add CGM device"), + descriptiveText: NSLocalizedString("Tap here to set up a CGM", comment: "Descriptive text for button to add CGM device")) + } + } + + private var favoriteFoodsSection: some View { + Section { + LargeButton(action: { sheet = .favoriteFoods }, + includeArrow: true, + imageView: Image("Favorite Foods Icon").renderingMode(.template).foregroundColor(carbTintColor), + label: "Favorite Foods", + descriptiveText: "Simplify Carb Entry") + } + } + + private var cgmChoices: [ActionSheet.Button] { + var result = viewModel.cgmManagerSettingsViewModel.availableDevices + .sorted(by: {$0.localizedTitle < $1.localizedTitle}) + .map { availableDevice in + ActionSheet.Button.default(Text(availableDevice.localizedTitle)) { + self.viewModel.cgmManagerSettingsViewModel.didTapAdd(availableDevice) + } + } + result.append(.cancel()) + return result + } + + private var servicesSection: some View { + Section(header: SectionHeader(label: NSLocalizedString("Services", comment: "The title of the services section in settings"))) { + ForEach(viewModel.servicesViewModel.activeServices().indices, id: \.self) { index in + LargeButton(action: { self.viewModel.servicesViewModel.didTapService(index) }, + includeArrow: true, + imageView: self.serviceImage(uiImage: (self.viewModel.servicesViewModel.activeServices()[index] as? ServiceUI)?.image), + label: self.viewModel.servicesViewModel.activeServices()[index].localizedTitle, + descriptiveText: "") + } + if viewModel.servicesViewModel.inactiveServices().count > 0 { + LargeButton(action: { actionSheet = .servicePicker }, + includeArrow: false, + imageView: plusImage, + label: NSLocalizedString("Add Service", comment: "The title of the add service button in settings"), + descriptiveText: NSLocalizedString("Tap here to set up a Service", comment: "The descriptive text of the add service button in settings")) + } + } + } + + private var serviceChoices: [ActionSheet.Button] { + var result = viewModel.servicesViewModel.inactiveServices().map { availableService in + ActionSheet.Button.default(Text(availableService.localizedTitle)) { + self.viewModel.servicesViewModel.didTapAddService(availableService) + } + } + result.append(.cancel()) + return result + } + + private var deleteDataSection: some View { + Section { + if viewModel.pumpManagerSettingsViewModel.isTestingDevice { + Button(action: { alert = .deletePumpData }) { + HStack { + Spacer() + Text("Delete Testing Pump Data").accentColor(.destructive) + Spacer() + } + } + } + if viewModel.cgmManagerSettingsViewModel.isTestingDevice { + Button(action: { alert = .deleteCGMData }) { + HStack { + Spacer() + Text("Delete Testing CGM Data").accentColor(.destructive) + Spacer() + } + } + } + } + } + + private func makeDeleteAlert(for model: DeviceViewModel) -> SwiftUI.Alert { + return SwiftUI.Alert(title: Text("Delete Testing Data"), + message: Text("Are you sure you want to delete all your \(model.name()) Data?\n(This action is not reversible)", comment: "Confirmation before you delete all your Simulated Test Devices data"), + primaryButton: .cancel(), + secondaryButton: .destructive(Text("Delete"), action: model.deleteTestingDataFunc())) + } + + private var supportSection: some View { + Section(header: SectionHeader(label: NSLocalizedString("Support", comment: "The title of the support section in settings"))) { + Button(action: { + self.viewModel.didTapIssueReport() + }) { + Text("Issue Report", comment: "The title text for the issue report menu item") + } + + ForEach(pluginMenuItems.filter( { $0.section == .support })) { + $0.view + } + + NavigationLink(destination: CriticalEventLogExportView(viewModel: viewModel.criticalEventLogExportViewModel)) { + Text(NSLocalizedString("Export Critical Event Logs", comment: "The title of the export critical event logs in support")) + } + } + } + + /* + DIY loop specific component to show users the amount of time remaining on their build before a rebuild is necessary. + */ + private func appExpirationSection(profileExpiration: Date) -> some View { + let expirationDate = AppExpirationAlerter.calculateExpirationDate(profileExpiration: profileExpiration) + let isTestFlight = AppExpirationAlerter.isTestFlightBuild() + let nearExpiration = AppExpirationAlerter.isNearExpiration(expirationDate: expirationDate) + let profileExpirationMsg = AppExpirationAlerter.createProfileExpirationSettingsMessage(expirationDate: expirationDate) + let readableExpirationTime = Self.dateFormatter.string(from: expirationDate) + + if isTestFlight { + return createAppExpirationSection( + headerLabel: NSLocalizedString("TestFlight", comment: "Settings app TestFlight section"), + footerLabel: NSLocalizedString("TestFlight expires ", comment: "Time that build expires") + readableExpirationTime, + expirationLabel: NSLocalizedString("TestFlight Expiration", comment: "Settings TestFlight expiration view"), + updateURL: "https://loopkit.github.io/loopdocs/gh-actions/gh-update/", + nearExpiration: nearExpiration, + expirationMessage: profileExpirationMsg + ) + } else { + return createAppExpirationSection( + headerLabel: NSLocalizedString("App Profile", comment: "Settings app profile section"), + footerLabel: NSLocalizedString("Profile expires ", comment: "Time that profile expires") + readableExpirationTime, + expirationLabel: NSLocalizedString("Profile Expiration", comment: "Settings App Profile expiration view"), + updateURL: "https://loopkit.github.io/loopdocs/build/updating/", + nearExpiration: nearExpiration, + expirationMessage: profileExpirationMsg + ) + } + } + + private func createAppExpirationSection(headerLabel: String, footerLabel: String, expirationLabel: String, updateURL: String, nearExpiration: Bool, expirationMessage: String) -> some View { + return Section( + header: SectionHeader(label: headerLabel), + footer: Text(footerLabel) + ) { + if nearExpiration { + Text(expirationMessage).foregroundColor(.red) + } else { + HStack { + Text(expirationLabel) + Spacer() + Text(expirationMessage).foregroundColor(Color.secondary) + } + } + Button(action: { + UIApplication.shared.open(URL(string: updateURL)!) + }) { + Text(NSLocalizedString("How to update (LoopDocs)", comment: "The title text for how to update")) + } + } + } + + private static var dateFormatter: DateFormatter = { + let dateFormatter = DateFormatter() + dateFormatter.dateStyle = .long + dateFormatter.timeStyle = .short + return dateFormatter // formats date like "February 4, 2023 at 2:35 PM" + }() + + private var plusImage: some View { + Image(systemName: "plus.circle") + .resizable() + .scaledToFit() + .accentColor(Color(.systemGray)) + .padding(EdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)) + } + + @ViewBuilder + private func deviceImage(uiImage: UIImage?) -> some View { + if let uiImage = uiImage { + Image(uiImage: uiImage) + .renderingMode(.original) + .resizable() + .scaledToFit() + } else { + Spacer() + } + } + + @ViewBuilder + private func serviceImage(uiImage: UIImage?) -> some View { + deviceImage(uiImage: uiImage) + } +} + +fileprivate struct LargeButton: View { + + let action: () -> Void + var includeArrow: Bool + let imageView: Content + let secondaryImageView: SecondaryContent + let label: String + let descriptiveText: String + + init( + action: @escaping () -> Void, + includeArrow: Bool = true, + imageView: Content, + secondaryImageView: SecondaryContent = EmptyView(), + label: String, + descriptiveText: String + ) { + self.action = action + self.includeArrow = includeArrow + self.imageView = imageView + self.secondaryImageView = secondaryImageView + self.label = label + self.descriptiveText = descriptiveText + } + + // TODO: The design doesn't show this, but do we need to consider different values here for different size classes? + private let spacing: CGFloat = 15 + private let imageWidth: CGFloat = 60 + private let imageHeight: CGFloat = 60 + private let secondaryImageWidth: CGFloat = 30 + private let secondaryImageHeight: CGFloat = 30 + private let topBottomPadding: CGFloat = 10 + + public var body: some View { + Button(action: action) { + HStack { + HStack(spacing: spacing) { + imageView.frame(maxWidth: imageWidth, maxHeight: imageHeight) + VStack(alignment: .leading) { + Text(label) + .foregroundColor(.primary) + DescriptiveText(label: descriptiveText) + } + } + + if !(secondaryImageView is EmptyView) || includeArrow { + Spacer() + } + + if !(secondaryImageView is EmptyView) { + secondaryImageView.frame(width: secondaryImageWidth, height: secondaryImageHeight) + } + + if includeArrow { + // TODO: Ick. I can't use a NavigationLink because we're not Navigating, but this seems worse somehow. + Image(systemName: "chevron.right").foregroundColor(.gray).font(.footnote) + } + } + .padding(EdgeInsets(top: topBottomPadding, leading: 0, bottom: topBottomPadding, trailing: 0)) + } + } +} + +public struct SettingsView_Previews: PreviewProvider { + + public static var previews: some View { + let displayGlucosePreference = DisplayGlucosePreference(displayGlucoseUnit: .milligramsPerDeciliter) + let viewModel = SettingsViewModel.preview + return Group { + SettingsView(viewModel: viewModel, localizedAppNameAndVersion: "Loop Demo V1") + .colorScheme(.light) + .previewDevice(PreviewDevice(rawValue: "iPhone SE 2")) + .previewDisplayName("SE light") + .environmentObject(displayGlucosePreference) + + SettingsView(viewModel: viewModel, localizedAppNameAndVersion: "Loop Demo V1") + .colorScheme(.dark) + .previewDevice(PreviewDevice(rawValue: "iPhone 11 Pro Max")) + .previewDisplayName("11 Pro dark") + .environmentObject(displayGlucosePreference) + } + } +} diff --git a/Loop/Views/SimpleBolusView.swift b/Loop/Views/SimpleBolusView.swift new file mode 100644 index 0000000000..044b3dc41e --- /dev/null +++ b/Loop/Views/SimpleBolusView.swift @@ -0,0 +1,425 @@ +// +// SimpleBolusView.swift +// Loop +// +// Created by Pete Schwamb on 9/23/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKit +import LoopKitUI +import HealthKit +import LoopCore + +struct SimpleBolusView: View { + @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference + @Environment(\.dismissAction) var dismiss + + @State private var shouldBolusEntryBecomeFirstResponder = false + @State private var isKeyboardVisible = false + @State private var isClosedLoopOffInformationalModalVisible = false + + @ObservedObject var viewModel: SimpleBolusViewModel + + private var enteredManualGlucose: Binding { + Binding( + get: { return viewModel.manualGlucoseString }, + set: { newValue in viewModel.manualGlucoseString = newValue } + ) + } + + init(viewModel: SimpleBolusViewModel) { + self.viewModel = viewModel + } + + var title: String { + if viewModel.displayMealEntry { + return NSLocalizedString("Simple Meal Calculator", comment: "Title of simple bolus view when displaying meal entry") + } else { + return NSLocalizedString("Simple Bolus Calculator", comment: "Title of simple bolus view when not displaying meal entry") + } + } + + var body: some View { + GeometryReader { geometry in + VStack(spacing: 0) { + List() { + self.infoSection + self.summarySection + } + // As of iOS 13, we can't programmatically scroll to the Bolus entry text field. This ugly hack scoots the + // list up instead, so the summarySection is visible and the keyboard shows when you tap "Enter Bolus". + // Unfortunately, after entry, the field scoots back down and remains hidden. So this is not a great solution. + // TODO: Fix this in Xcode 12 when we're building for iOS 14. + .padding(.top, self.shouldAutoScroll(basedOn: geometry) ? -200 : 0) + .insetGroupedListStyle() + .navigationBarTitle(Text(self.title), displayMode: .inline) + + self.actionArea + .frame(height: self.isKeyboardVisible ? 0 : nil) + .opacity(self.isKeyboardVisible ? 0 : 1) + } + .onKeyboardStateChange { state in + self.isKeyboardVisible = state.height > 0 + + if state.height == 0 { + // Ensure tapping 'Enter Bolus' can make the text field the first responder again + self.shouldBolusEntryBecomeFirstResponder = false + } + } + .keyboardAware() + .edgesIgnoringSafeArea(self.isKeyboardVisible ? [] : .bottom) + .alert(item: self.$viewModel.activeAlert, content: self.alert(for:)) + } + } + + private func formatGlucose(_ quantity: HKQuantity) -> String { + return displayGlucosePreference.format(quantity) + } + + private func shouldAutoScroll(basedOn geometry: GeometryProxy) -> Bool { + // Taking a guess of 640 to cover iPhone SE, iPod Touch, and other smaller devices. + // Devices such as the iPhone 11 Pro Max do not need to auto-scroll. + shouldBolusEntryBecomeFirstResponder && geometry.size.height < 640 + } + + private var infoSection: some View { + HStack { + Image("Open Loop") + Text("When out of Closed Loop mode, the app uses a simplified bolus calculator like a typical pump.") + .font(.footnote) + .foregroundColor(.secondary) + infoButton + } + } + + private var infoButton: some View { + Button( + action: { + self.viewModel.activeAlert = .infoPopup + }, + label: { + Image(systemName: "info.circle") + .font(.system(size: 25)) + .foregroundColor(.accentColor) + } + ) + } + + private var summarySection: some View { + Section { + if viewModel.displayMealEntry { + carbEntryRow + } + glucoseEntryRow + recommendedBolusRow + bolusEntryRow + } + } + + private var carbEntryRow: some View { + HStack(alignment: .center) { + Text("Carbohydrates", comment: "Label for carbohydrates entry row on simple bolus screen") + Spacer() + HStack { + DismissibleKeyboardTextField( + text: $viewModel.enteredCarbString, + placeholder: viewModel.carbPlaceholder, + textAlignment: .right, + keyboardType: .decimalPad, + maxLength: 5, + doneButtonColor: .loopAccent + ) + carbUnitsLabel + } + .padding([.top, .bottom], 5) + .fixedSize() + .modifier(LabelBackground()) + } + } + + private var glucoseEntryRow: some View { + HStack { + Text("Current Glucose", comment: "Label for glucose entry row on simple bolus screen") + Spacer() + HStack(alignment: .firstTextBaseline) { + DismissibleKeyboardTextField( + text: enteredManualGlucose, + placeholder: "– – –", + font: .heavy(.title1), + textAlignment: .right, + keyboardType: .decimalPad, + maxLength: 4, + doneButtonColor: .loopAccent + ) + + glucoseUnitsLabel + } + .fixedSize() + .modifier(LabelBackground()) + } + } + + private var recommendedBolusRow: some View { + VStack(alignment: .leading) { + HStack { + Text("Recommended Bolus", comment: "Label for recommended bolus row on simple bolus screen") + Spacer() + HStack(alignment: .firstTextBaseline) { + Text(viewModel.recommendedBolus) + .font(.title) + .foregroundColor(Color(.label)) + .padding([.top, .bottom], 4) + bolusUnitsLabel + } + } + .padding(.trailing, 8) + if let activeInsulin = viewModel.activeInsulin { + HStack(alignment: .center, spacing: 3) { + Text("Adjusted for") + .font(.footnote) + .foregroundColor(.secondary) + Text("Active Insulin") + .font(.footnote) + .bold() + Text(activeInsulin) + .font(.footnote) + .bold() + .foregroundColor(.secondary) + bolusUnitsLabel + .font(.footnote) + .bold() + } + } + } + } + + private var bolusEntryRow: some View { + HStack { + Text("Bolus", comment: "Label for bolus entry row on simple bolus screen") + Spacer() + HStack(alignment: .firstTextBaseline) { + DismissibleKeyboardTextField( + text: $viewModel.enteredBolusString, + placeholder: "", + font: .preferredFont(forTextStyle: .title1), + textColor: .loopAccent, + textAlignment: .right, + keyboardType: .decimalPad, + shouldBecomeFirstResponder: shouldBolusEntryBecomeFirstResponder, + maxLength: 5, + doneButtonColor: .loopAccent + ) + + bolusUnitsLabel + } + .fixedSize() + .modifier(LabelBackground()) + } + } + + private var carbUnitsLabel: some View { + Text(QuantityFormatter(for: .gram()).localizedUnitStringWithPlurality()) + } + + private var glucoseUnitsLabel: some View { + Text(displayGlucosePreference.formatter.localizedUnitStringWithPlurality()) + .fixedSize() + .foregroundColor(Color(.secondaryLabel)) + } + + private var bolusUnitsLabel: Text { + Text(QuantityFormatter(for: .internationalUnit()).localizedUnitStringWithPlurality()) + .foregroundColor(Color(.secondaryLabel)) + } + + private var actionArea: some View { + VStack(spacing: 0) { + if viewModel.isNoticeVisible { + warning(for: viewModel.activeNotice!) + .padding([.top, .horizontal]) + .transition(AnyTransition.opacity.combined(with: .move(edge: .bottom))) + } + actionButton + } + .background(Color(.secondarySystemGroupedBackground).shadow(radius: 5)) + } + + private var actionButton: some View { + Button( + action: { + if self.viewModel.actionButtonAction == .enterBolus { + self.shouldBolusEntryBecomeFirstResponder = true + } else { + self.viewModel.saveAndDeliver { (success) in + if success { + self.dismiss() + } + } + + } + }, + label: { + switch viewModel.actionButtonAction { + case .saveWithoutBolusing: + return Text("Save without Bolusing", comment: "Button text to save carbs and/or manual glucose entry without a bolus") + case .saveAndDeliver: + return Text("Save Carbs & Deliver", comment: "Button text to save carbs and/or manual glucose entry and deliver a bolus") + case .enterBolus: + return Text("Enter Bolus", comment: "Button text to begin entering a bolus") + case .deliver: + return Text("Deliver", comment: "Button text to deliver a bolus") + } + } + ) + .disabled(viewModel.actionButtonDisabled) + .buttonStyle(ActionButtonStyle(.primary)) + .padding() + } + + private func alert(for alert: SimpleBolusViewModel.Alert) -> SwiftUI.Alert { + switch alert { + case .carbEntryPersistenceFailure: + return SwiftUI.Alert( + title: Text("Unable to Save Carb Entry", comment: "Alert title for a carb entry persistence error"), + message: Text("An error occurred while trying to save your carb entry.", comment: "Alert message for a carb entry persistence error") + ) + case .manualGlucoseEntryPersistenceFailure: + return SwiftUI.Alert( + title: Text("Unable to Save Manual Glucose Entry", comment: "Alert title for a manual glucose entry persistence error"), + message: Text("An error occurred while trying to save your manual glucose entry.", comment: "Alert message for a manual glucose entry persistence error") + ) + case .infoPopup: + return closedLoopOffInformationalModal() + } + + } + + private func warning(for notice: SimpleBolusViewModel.Notice) -> some View { + + switch notice { + case .glucoseBelowSuspendThreshold: + let title: Text + if viewModel.bolusRecommended { + title = Text("Low Glucose", comment: "Title for bolus screen warning when glucose is below suspend threshold, but a bolus is recommended") + } else { + title = Text("No Bolus Recommended", comment: "Title for bolus screen warning when glucose is below suspend threshold, and a bolus is not recommended") + } + let suspendThresholdString = formatGlucose(viewModel.suspendThreshold) + return WarningView( + title: title, + caption: Text(String(format: NSLocalizedString("Your glucose is below your glucose safety limit, %1$@.", comment: "Format string for bolus screen warning when no bolus is recommended due input value below glucose safety limit. (1: suspendThreshold)"), suspendThresholdString)) + ) + case .glucoseWarning: + let warningThresholdString = formatGlucose(LoopConstants.simpleBolusCalculatorGlucoseWarningLimit) + return WarningView( + title: Text("Low Glucose", comment: "Title for bolus screen warning when glucose is below glucose warning limit."), + caption: Text(String(format: NSLocalizedString("Your glucose is below %1$@. Are you sure you want to bolus?", comment: "Format string for simple bolus screen warning when glucose is below glucose warning limit."), warningThresholdString)) + ) + case .glucoseBelowRecommendationLimit: + let caption: String + if viewModel.displayMealEntry { + caption = NSLocalizedString("Your glucose is low. Eat carbs and consider waiting to bolus until your glucose is in a safe range.", comment: "Format string for meal bolus screen warning when no bolus is recommended due to glucose input value below recommendation threshold") + } else { + caption = NSLocalizedString("Your glucose is low. Eat carbs and monitor closely.", comment: "Bolus screen warning when no bolus is recommended due to glucose input value below recommendation threshold for meal bolus") + } + return WarningView( + title: Text("No Bolus Recommended", comment: "Title for bolus screen warning when no bolus is recommended"), + caption: Text(caption) + ) + case .glucoseOutOfAllowedInputRange: + let glucoseMinString = formatGlucose(LoopConstants.validManualGlucoseEntryRange.lowerBound) + let glucoseMaxString = formatGlucose(LoopConstants.validManualGlucoseEntryRange.upperBound) + return WarningView( + title: Text("Glucose Entry Out of Range", comment: "Title for bolus screen warning when glucose entry is out of range"), + caption: Text(String(format: NSLocalizedString("A manual glucose entry must be between %1$@ and %2$@.", comment: "Warning for simple bolus when glucose entry is out of range. (1: upper bound) (2: lower bound)"), glucoseMinString, glucoseMaxString))) + case .maxBolusExceeded: + return WarningView( + title: Text("Maximum Bolus Exceeded", comment: "Title for bolus screen warning when max bolus is exceeded"), + caption: Text(String(format: NSLocalizedString("Your maximum bolus amount is %1$@.", comment: "Warning for simple bolus when max bolus is exceeded. (1: maximum bolus)"), viewModel.maximumBolusAmountString ))) + case .recommendationExceedsMaxBolus: + return WarningView( + title: Text("Recommended Bolus Exceeds Maximum Bolus", comment: "Title for bolus screen warning when recommended bolus exceeds max bolus"), + caption: Text(String(format: NSLocalizedString("Your recommended bolus exceeds your maximum bolus amount of %1$@.", comment: "Warning for simple bolus when recommended bolus exceeds max bolus. (1: maximum bolus)"), viewModel.maximumBolusAmountString ))) + case .carbohydrateEntryTooLarge: + let maximumCarbohydrateString = QuantityFormatter(for: .gram()).string(from: LoopConstants.maxCarbEntryQuantity)! + return WarningView( + title: Text("Carbohydrate Entry Too Large", comment: "Title for bolus screen warning when carbohydrate entry is too large"), + caption: Text(String(format: NSLocalizedString("The maximum amount allowed is %1$@.", comment: "Warning for simple bolus when carbohydrate entry is too large. (1: maximum carbohydrate entry)"), maximumCarbohydrateString))) + } + } + + private func closedLoopOffInformationalModal() -> SwiftUI.Alert { + return SwiftUI.Alert( + title: Text("Closed Loop OFF", comment: "Alert title for closed loop off informational modal"), + message: Text(String(format: NSLocalizedString("%1$@ is operating with Closed Loop in the OFF position. Your pump and CGM will continue operating, but the app will not adjust dosing automatically.", comment: "Alert message for closed loop off informational modal. (1: app name)"), Bundle.main.bundleDisplayName)) + ) + } + +} + + +struct SimpleBolusCalculatorView_Previews: PreviewProvider { + class MockSimpleBolusViewDelegate: SimpleBolusViewModelDelegate { + func addGlucose(_ samples: [NewGlucoseSample], completion: @escaping (Swift.Result<[StoredGlucoseSample], Error>) -> Void) { + completion(.success([])) + } + + func addCarbEntry(_ carbEntry: NewCarbEntry, replacing replacingEntry: StoredCarbEntry?, completion: @escaping (Result) -> Void) { + + let storedCarbEntry = StoredCarbEntry( + startDate: carbEntry.startDate, + quantity: carbEntry.quantity, + uuid: UUID(), + provenanceIdentifier: UUID().uuidString, + syncIdentifier: UUID().uuidString, + syncVersion: 1, + foodType: carbEntry.foodType, + absorptionTime: carbEntry.absorptionTime, + createdByCurrentApp: true, + userCreatedDate: Date(), + userUpdatedDate: nil) + completion(.success(storedCarbEntry)) + } + + func enactBolus(units: Double, activationType: BolusActivationType) { + } + + func insulinOnBoard(at date: Date, completion: @escaping (DoseStoreResult) -> Void) { + completion(.success(InsulinValue(startDate: date, value: 2.0))) + } + + func computeSimpleBolusRecommendation(at date: Date, mealCarbs: HKQuantity?, manualGlucose: HKQuantity?) -> BolusDosingDecision? { + var decision = BolusDosingDecision(for: .simpleBolus) + decision.manualBolusRecommendation = ManualBolusRecommendationWithDate(recommendation: ManualBolusRecommendation(amount: 3, pendingInsulin: 0), + date: Date()) + return decision + } + + func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) { + } + + var displayGlucosePreference: DisplayGlucosePreference { + return DisplayGlucosePreference(displayGlucoseUnit: .milligramsPerDeciliter) + } + + var maximumBolus: Double { + return 6 + } + + var suspendThreshold: HKQuantity { + return HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 75) + } + } + + static var viewModel: SimpleBolusViewModel = SimpleBolusViewModel(delegate: MockSimpleBolusViewDelegate(), displayMealEntry: true) + + static var previews: some View { + NavigationView { + SimpleBolusView(viewModel: viewModel) + } + .previewDevice("iPod touch (7th generation)") + .environmentObject(DisplayGlucosePreference(displayGlucoseUnit: .milligramsPerDeciliter)) + } +} diff --git a/Loop/Views/SwitchTableViewCell.swift b/Loop/Views/SwitchTableViewCell.swift deleted file mode 100644 index 0e39d762cc..0000000000 --- a/Loop/Views/SwitchTableViewCell.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// SwitchTableViewCell.swift -// Naterade -// -// Created by Nathan Racklyeft on 3/13/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import UIKit - - -final class SwitchTableViewCell: UITableViewCell { - - @IBOutlet weak var titleLabel: UILabel! - - @IBOutlet weak var subtitleLabel: UILabel? - - @IBOutlet weak var `switch`: UISwitch? - - override func layoutSubviews() { - super.layoutSubviews() - - contentView.layoutMargins.left = separatorInset.left - contentView.layoutMargins.right = separatorInset.left - } - - override func prepareForReuse() { - super.prepareForReuse() - - `switch`?.removeTarget(nil, action: nil, for: .valueChanged) - } - -} diff --git a/Loop/Views/TitleSubtitleTableViewCell.swift b/Loop/Views/TitleSubtitleTableViewCell.swift index ab993a3d15..d9e24e7185 100644 --- a/Loop/Views/TitleSubtitleTableViewCell.swift +++ b/Loop/Views/TitleSubtitleTableViewCell.swift @@ -14,13 +14,14 @@ class TitleSubtitleTableViewCell: UITableViewCell { @IBOutlet weak var subtitleLabel: UILabel! { didSet { - subtitleLabel.textColor = UIColor.secondaryLabelColor + subtitleLabel.textColor = UIColor.secondaryLabel } } override func layoutSubviews() { super.layoutSubviews() + updateColors() gradient.frame = bounds } @@ -30,8 +31,21 @@ class TitleSubtitleTableViewCell: UITableViewCell { super.awakeFromNib() gradient.frame = bounds - gradient.colors = [UIColor.white.cgColor, UIColor.cellBackgroundColor.cgColor] backgroundView?.layer.insertSublayer(gradient, at: 0) + + updateColors() } + public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + updateColors() + } + + private func updateColors() { + gradient.colors = [ + UIColor.cellBackgroundColor.withAlphaComponent(0).cgColor, + UIColor.cellBackgroundColor.cgColor + ] + } } diff --git a/Loop/Views/TitleSubtitleTextFieldTableViewCell.swift b/Loop/Views/TitleSubtitleTextFieldTableViewCell.swift new file mode 100644 index 0000000000..7acc582e20 --- /dev/null +++ b/Loop/Views/TitleSubtitleTextFieldTableViewCell.swift @@ -0,0 +1,14 @@ +// +// TitleSubtitleTextFieldTableViewCell.swift +// Loop +// +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +import UIKit + +class TitleSubtitleTextFieldTableViewCell: PredictionInputEffectTableViewCell { + + @IBOutlet weak var textField: UITextField! + +} diff --git a/Loop/Views/ValidatingIndicatorView.swift b/Loop/Views/ValidatingIndicatorView.swift deleted file mode 100644 index 92e6bfa03e..0000000000 --- a/Loop/Views/ValidatingIndicatorView.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// ValidatingIndicatorView.swift -// Loop -// -// Created by Nate Racklyeft on 7/2/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import UIKit - -private let Margin: CGFloat = 8 - - -final class ValidatingIndicatorView: UIView { - - let indicatorView = UIActivityIndicatorView(activityIndicatorStyle: .gray) - - let label = UILabel() - - override init(frame: CGRect) { - super.init(frame: frame) - label.font = UIFont.preferredFont(forTextStyle: UIFontTextStyle.headline) - label.text = NSLocalizedString("Verifying", comment: "Label indicating validation is occurring") - label.sizeToFit() - - addSubview(indicatorView) - addSubview(label) - - self.frame.size = intrinsicContentSize - - setNeedsLayout() - - indicatorView.startAnimating() - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func layoutSubviews() { - super.layoutSubviews() - - // Center the label in the bounds so it appears aligned, then let the indicator view hang from the left side - label.frame = bounds - indicatorView.center.y = bounds.midY - indicatorView.frame.origin.x = -indicatorView.frame.size.width - Margin - } - - override var intrinsicContentSize : CGSize { - return label.intrinsicContentSize - } -} diff --git a/Loop/gallery.ckcomplication/A307227B-6EFF-4242-A538-2C9AC617E041.json b/Loop/gallery.ckcomplication/A307227B-6EFF-4242-A538-2C9AC617E041.json deleted file mode 100644 index 58580ff1b3..0000000000 --- a/Loop/gallery.ckcomplication/A307227B-6EFF-4242-A538-2C9AC617E041.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "class" : "CLKComplicationTemplateModularSmallStackText", - "highlightLine2" : false, - "line2TextProvider" : { - "class" : "CLKLocalizableSimpleTextProvider", - "text" : "mg\/dL" - }, - "line1TextProvider" : { - "shortText" : "--", - "class" : "CLKSimpleTextProvider", - "text" : "--", - "accessibilityLabel" : "No glucose value available" - }, - "version" : 30000 -} \ No newline at end of file diff --git a/Loop/gallery.ckcomplication/Base.lproj/ckcomplication.strings b/Loop/gallery.ckcomplication/Base.lproj/ckcomplication.strings deleted file mode 100644 index 63987e6900..0000000000 --- a/Loop/gallery.ckcomplication/Base.lproj/ckcomplication.strings +++ /dev/null @@ -1,10 +0,0 @@ -/* - ckcomplication.strings - Loop - - Created by Nate Racklyeft on 9/18/16. - Copyright © 2016 Nathan Racklyeft. All rights reserved. -*/ - -/* The complication template example unit string */ -"mg/dL" = "mg/dL" diff --git a/Loop/gallery.ckcomplication/complicationManifest.json b/Loop/gallery.ckcomplication/complicationManifest.json deleted file mode 100644 index f7de58b5c8..0000000000 --- a/Loop/gallery.ckcomplication/complicationManifest.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "supported complication families" : { - "0" : "A307227B-6EFF-4242-A538-2C9AC617E041.json" - }, - "client ID" : "com.loudnate.Loop.watchkitapp.watchkitextension" -} \ No newline at end of file diff --git a/Loop/it.lproj/InfoPlist.strings b/Loop/it.lproj/InfoPlist.strings deleted file mode 100644 index dc49399421..0000000000 --- a/Loop/it.lproj/InfoPlist.strings +++ /dev/null @@ -1,2 +0,0 @@ -/* (No Commment) */ -"CFBundleDisplayName" = "Loop"; diff --git a/Loop/it.lproj/Localizable.strings b/Loop/it.lproj/Localizable.strings deleted file mode 100644 index da6ad0ecf1..0000000000 --- a/Loop/it.lproj/Localizable.strings +++ /dev/null @@ -1,365 +0,0 @@ -/* Format string for carb ratio average. (1: value)(2: carb unit) - Format string for insulin sensitivity average (1: value)(2: glucose unit) */ -"%1$@ %2$@/U" = "%1$@ %2$@/U"; - -/* Low reservoir alert format string. (1: Number of units remaining) */ -"%1$@ U left" = "%1$@ U left"; - -/* Low reservoir alert with time remaining format string. (1: Number of units remaining)(2: approximate time remaining) */ -"%1$@ U left: %2$@" = "%1$@ U left: %2$@"; - -/* The format for recommended temp basal rate and time. (1: localized rate number)(2: localized time) */ -"%1$@ U/hour @ %2$@" = "%1$@ U/ora @ %2$@"; - -/* Accessbility format value describing glucose: (1: glucose number)(2: glucose time) */ -"%1$@ at %2$@" = "%1$@ a %2$@"; - -/* Accessibility format string describing the basal rate. (1: localized basal rate value)(2: last updated time) */ -"%1$@ units per hour at %2$@" = "%1$@ unità per ora a %2$@"; - -/* Accessibility format string for (1: localized volume)(2: time) */ -"%1$@ units remaining at %2$@" = "%1$@ unità residua a %2$@"; - -/* The format string for the app name and version number. (1: bundle name)(2: bundle version) */ -"%1$@ v%2$@" = "%1$@ v%2$@"; - -/* Format string for glucose target range. (1: Min target)(2: Max target)(3: glucose unit) */ -"%1$@ – %2$@ %3$@" = "%1$@ – %2$@ %3$@"; - -/* The format string describing the basal rate. */ -"%@ U" = "%@ U"; - -/* The subtitle format describing total insulin. (1: localized insulin total) */ -"%@ U Total" = "%@ U Totali"; - -/* The notification alert describing a possible bolus failure. The substitution parameter is the size of the bolus in units. */ -"%@ U bolus may have failed." = "%@ U bolo potrebbe essere fallito."; - -/* Format string describing the time interval since the last completion date. (1: The localized date components */ -"%@ ago" = "%@ fa"; - -/* Format string for reservoir volume. (1: The localized volume) */ -"%@U" = "%@U"; - -/* Description of the prediction input effect for glucose momentum */ -"15 min glucose regression coefficient (b₁), continued with decay over 30 min" = "15 min coefficiente di regressione del glucosio (b₁), prosegue con decadimento sopra 30 min"; - -/* Description of the prediction input effect for retrospective correction */ -"30 min comparison of glucose prediction vs actual, continued with decay over 60 min" = "30 min confronto di previsione del glucosio vs reale, prosegue con decadimento sopra 60 min"; - -/* The title of the amplitude API key credential - The title of the mLab API Key credential */ -"API Key" = "API Key"; - -/* The title of the nightscout API secret credential */ -"API Secret" = "API Secret"; - -/* The title of the Carbs On-Board graph */ -"Active Carbohydrates" = "Carboidrati Attivi"; - -/* The title of the Insulin On-Board graph */ -"Active Insulin" = "Insulina Attiva"; - -/* The title of the button to add the credentials for a service */ -"Add Account" = "Aggiungi Account"; - -/* The label of the carb entry button */ -"Add Meal" = "Aggiungi Pasto"; - -/* The title of the section containing algorithm settings */ -"Algorithm Settings" = "Impostazioni Algoritmo"; - -/* The title of the Amplitude service */ -"Amplitude" = "Amplitude"; - -/* The message displayed during a device authentication prompt for bolus specification */ -"Authenticate to Bolus %@ Units" = "Autenticazione dei Boli %@ Unità"; - -/* The title of the basal rate profile screen - The title text for the basal rate schedule */ -"Basal Rates" = "Impostazione Basale"; - -/* The label of the bolus entry button - The notification title for a bolus failure */ -"Bolus" = "Bolo"; - -/* The title of the alert controller displayed after a bolus attempt fails */ -"Bolus Failed" = "Bolo Fallito"; - -/* The title of the cancel action in an action sheet */ -"Cancel" = "Cancella"; - -/* The title of the carb ratios schedule screen - The title text for the carb ratio schedule */ -"Carb Ratios" = "Rapporti g/U"; - -/* Title of the prediction input effect for carbohydrates */ -"Carbohydrates" = "Carboidrati"; - -/* Description of the prediction input effect for carbohydrates. (1: The glucose unit string) */ -"Carbs Absorbed (g) ÷ Carb Ratio (g/U) × Insulin Sensitivity (%1$@/U)" = "Carbs Assorbiti (g) ÷ Carb Rapporti (g/U) × Sensibilità Insulinica (%1$@/U)"; - -/* The notification alert describing a low pump battery */ -"Change the pump battery immediately" = "Cambiare immediatamente la batteria della pompa"; - -/* The notification alert describing an empty pump reservoir */ -"Change the pump reservoir now" = "Cambiare il serbatoio della pompa"; - -/* The title text for the looping enabled switch cell */ -"Closed Loop" = "Loop Chiuso"; - -/* Accessibility hint describing completion HUD for a closed loop */ -"Closed loop" = "Loop chiuso"; - -/* The title of the configuration section in settings */ -"Configuration" = "Configurazione"; - -/* The title of the mLab database name credential */ -"Database" = "Database"; - -/* The title of the button to remove the credentials for a service */ -"Delete Account" = "Cancella Account"; - -/* The title of the Dexcom Share service */ -"Dexcom Share" = "Dexcom Share"; - -/* The action hint of the workout mode toggle button when enabled */ -"Disables" = "Disabilitato"; - -/* The action button title to dismiss an error message */ -"Dismiss" = "Rimuovere"; - -/* Title of the switch which toggles retrospective correction effects */ -"Enable Retrospective Correction" = "Abilita Correzione Retrospettiva"; - -/* The detail text describing an enabled setting */ -"Enabled" = "Abilita"; - -/* The action hint of the workout mode toggle button when disabled */ -"Enables" = "Abilita"; - -/* The placeholder text instructing users how to enter an insulin action duration */ -"Enter a number of hours" = "Inserire un numero di ore"; - -/* The placeholder text instructing users how to enter a maximum bolus */ -"Enter a number of units" = "Inserire un numero di unità"; - -/* The placeholder text instructing users how to enter a maximum basal rate */ -"Enter a rate in units per hour" = "Inserire un tasso di unità per ora"; - -/* The placeholder text instructing users how to enter a pump ID */ -"Enter the 6-digit pump ID" = "Inserire 6-numeri ID pompa"; - -/* The placeholder text instructing users how to enter a pump ID */ -"Enter the 6-digit transmitter ID" = "Inserire 6-numeri ID trasmettitore"; - -/* Describing the pump history insulin data source */ -"Event History" = "Cronologia Eventi"; - -/* The subtitle format describing eventual glucose. (1: localized glucose value description) */ -"Eventually %@" = "Probabile Glic. %@"; - -/* The title of the alert describing a maximum bolus validation error */ -"Exceeds Maximum Bolus" = "Superare Bolo Massimo"; - -/* Glucose trend down */ -"Falling" = "Abbassamento"; - -/* Glucose trend down-down */ -"Falling fast" = "Abbassamento veloce"; - -/* Glucose trend down-down-down */ -"Falling very fast" = "Abbassamento molto veloce"; - -/* Glucose trend flat */ -"Flat" = "Piatto"; - -/* The format string used to describe a finite workout targets duration */ -"For %1$@" = "Per %1$@"; - -/* The title text for the G4 Share Receiver enabled switch cell */ -"G4 Share Receiver" = "G4 Ricevitore Share"; - -/* The title text for the Dexcom G5 transmitter ID config value */ -"G5 Transmitter ID" = "G5 ID Trasmettitore"; - -/* The title of the glucose and prediction graph */ -"Glucose" = "Glicemie"; - -/* Title of the prediction input effect for glucose momentum */ -"Glucose Momentum" = "Glicemia Effetto"; - -/* The title of a target alert action specifying an indefinitely long workout targets duration */ -"Indefinitely" = "Indefinitivamente"; - -/* Title of the prediction input effect for insulin */ -"Insulin" = "Insulina"; - -/* Description of the prediction input effect for insulin */ -"Insulin Absorbed (U) × Insulin Sensitivity (%1$@/U)" = "Assorbimento Insulinico (U) × Sensibilità Insulinica (%1$@/U)"; - -/* The title text for the insulin action duration value */ -"Insulin Action Duration" = "Azione dell'Insulina"; - -/* The title of the insulin delivery graph */ -"Insulin Delivery" = "Insulina Somministrata"; - -/* The title of the insulin sensitivities schedule screen - The title text for the insulin sensitivity schedule */ -"Insulin Sensitivities" = "Sensibilità Insulinica"; - -/* Instructions on selecting an insulin data source */ -"Insulin delivery can be determined from the pump by either interpreting the event history or comparing the reservoir volume over time. Reading event history allows for a more accurate status graph and uploading up-to-date treatment data to Nightscout, at the cost of faster pump battery drain and the possibility of a higher radio error rate compared to reading only reservoir volume. If the selected source cannot be used for any reason, the system will attempt to fall back to the other option." = "L'insulina somministrata può essere determinata dalla pompa sia interpretando la cronologia eventi o confrontando il volume del serbatoio nel tempo. Lettura della cronologia eventi consente un diagramma di stato più accurato e caricando i dati aggiornati dai trattamenti di Nightscout, a costo di esaurimento della batteria pompa più veloce e la possibilità di un tasso di errore radiofonica superiore rispetto alla sola lettura del volume del serbatoio. Se la sorgente selezionata non può essere utilizzato per qualsiasi ragione, il sistema tenterà di ripiegare sull'altra opzione."; - -/* The title text for the issue report cell */ -"Issue Report" = "Segnalazione"; - -/* Format string describing retrospective glucose prediction comparison. (1: Previous glucose)(2: Predicted glucose)(3: Actual glucose) */ -"Last comparison: %1$@ → %2$@ vs %3$@" = "Last comparison: %1$@ → %2$@ vs %3$@"; - -/* Glucose HUD accessibility hint */ -"Launches CGM app" = "Lancia CGM app"; - -/* The loading message for the diagnostic report screen */ -"Loading..." = "Carica..."; - -/* The notification title for a loop failure */ -"Loop Failure" = "Loop Fallito"; - -/* The notification alert describing a long-lasting loop failure. The substitution parameter is the time interval since the last loop */ -"Loop has not completed successfully in %@" = "Loop non completato con successo in %@"; - -/* Accessbility format label describing the time interval since the last completion date. (1: The localized date components) */ -"Loop ran %@ ago" = "Loop funziona %@ fa"; - -/* The recovery message displayed after a bolus attempt fails - The recovery message displayed after a carb entry send attempt fails */ -"Make sure your iPhone is nearby and try again" = "Assicurati che il tuo iPhone si trovi nelle vicinanze e riprova"; - -/* The title text for the maximum basal rate value */ -"Maximum Basal Rate" = "Profilo Basale Massimo"; - -/* The title text for the maximum bolus value */ -"Maximum Bolus" = "Bolo Massimo"; - -/* Sensor state description for the non-valid state */ -"Needs Attention" = "Esige Attenzione"; - -/* Accessibility label component for glucose HUD describing an invalid state */ -"Needs attention" = "Esige Attenzione"; - -/* The title of the Nightscout service */ -"Nightscout" = "Nightscout"; - -/* Sensor state description for the valid state */ -"OK" = "OK"; - -/* Accessbility hint describing completion HUD for an open loop */ -"Open loop" = "Loop Aperto"; - -/* The title of the Dexcom share password credential */ -"Password" = "Password"; - -/* The title text for the preferred insulin data source config */ -"Preferred Data Source" = "Fonte Dati"; - -/* The notification title for a low pump battery */ -"Pump Battery Low" = "Batteria Pompa Bassa"; - -/* The title text for the pump ID config value */ -"Pump ID" = "ID pompa"; - -/* The notification title for an empty pump reservoir */ -"Pump Reservoir Empty" = "Serbatoio Pompa vuoto"; - -/* The notification title for a low pump reservoir */ -"Pump Reservoir Low" = "Serbatoio Pompa Basso"; - -/* The label and value showing the recommended bolus */ -"Rec: %@ U" = "Rec: %@ U"; - -/* Accessibility hint describing recommended bolus units */ -"Recommended Bolus: %@ Units" = "Bolo consigliato: %@ Unità"; - -/* The default placeholder string for a credential */ -"Required" = "Necessario"; - -/* Describing the reservoir insulin data source */ -"Reservoir" = "Serbatoio"; - -/* Title of the prediction input effect for retrospective correction */ -"Retrospective Correction" = "Correzione Retroattiva"; - -/* The title of the notification action to retry a bolus command */ -"Retry" = "Riprovare"; - -/* Glucose trend up */ -"Rising" = "Crescente"; - -/* Glucose trend up-up */ -"Rising fast" = "Crescente veloce"; - -/* Glucose trend up-up-up */ -"Rising very fast" = "Crescente molto veloce"; - -/* The title of the alert controller displayed after a carb entry send attempt fails */ -"Send Failed" = "Invio Fallito"; - -/* The title of the services section in settings */ -"Services" = "Servizi"; - -/* The label of the settings button */ -"Settings" = "Impostazioni"; - -/* The title of the nightscout site URL credential */ -"Site URL" = "Sito URL"; - -/* The empty-state text for a configuration value */ -"Tap to set" = "Imposta"; - -/* The title of the glucose target range schedule screen - The title text for the glucose target range schedule */ -"Target Range" = "Intervallo Glicemico"; - -/* Body of the alert describing a maximum bolus validation error. (1: The localized max bolus value) */ -"The maximum bolus amount is %@ Units" = "La quantità massima di bolo è %@ Unità"; - -/* Instructions on where to find the pump ID on a Minimed pump */ -"The pump ID can be found printed on the back, or near the bottom of the STATUS/Esc screen. It is the strictly numerical portion of the serial number (shown as SN or S/N)." = "L'ID della pompa si trova stampato sul retro, o vicino al fondo della schermata STATO/Esc. È la parte strettamente numerica del numero di serie (indicato come SN o S/N)."; - -/* Instructions on where to find the transmitter ID */ -"The transmitter ID can be found printed on the back of the device, on the side of the box it came in, and from within the settings menus of the G5 receiver and mobile app." = "L'ID trasmettitore può essere trovato stampato sul retro del dispositivo, sul lato della scatola, e all'interno del menu impostazione del ricevitore G5 all'interno dell'applicazione mobile."; - -/* The description of the switch which toggles retrospective correction effects */ -"This will more aggresively increase or decrease basal delivery when glucose movement doesn't match the carbohydrate and insulin-based model." = "Questo sarà più aggressivo all'aumentare o al diminuire della basale quando il movimento di glucosio non corrisponde al modello di carboidrati e al modello insulino-basale"; - -/* The unit string for units per hour */ -"U/hour" = "U/ora"; - -/* The unit string for units */ -"Units" = "Unità"; - -/* Accessibility value for an unknown value */ -"Unknown" = "Sconosciuto"; - -/* The title of the alert controller used to select a duration for workout targets */ -"Use Workout Glucose Targets" = "Allenarsi con gli Obiettivi di Glucosio"; - -/* The title of the Dexcom share username credential */ -"Username" = "Nome Utente"; - -/* Label indicating validation is occurring */ -"Verifying" = "Verificare"; - -/* Acessibility label describing completion HUD waiting for first run */ -"Waiting for first run" = "In attesa di prima esecuzione"; - -/* The label of the workout mode toggle button */ -"Workout Mode" = "Modalità di allenamento"; - -/* The unit string for hours */ -"hours" = "ore"; - -/* The title of the mLab service */ -"mLab" = "mlab"; - diff --git a/Loop/it.lproj/Main.strings b/Loop/it.lproj/Main.strings deleted file mode 100644 index a1877b453a..0000000000 --- a/Loop/it.lproj/Main.strings +++ /dev/null @@ -1,123 +0,0 @@ -/* Class = "UILabel"; text = "399"; ObjectID = "01f-Du-MVi"; */ -"01f-Du-MVi.text" = "399"; - -/* Class = "UINavigationItem"; title = "Status"; ObjectID = "3kU-n2-fha"; */ -"3kU-n2-fha.title" = "Stato"; - -/* Class = "UILabel"; text = "Pump ID"; ObjectID = "5TX-kX-nBo"; */ -"5TX-kX-nBo.text" = "ID Pumpa"; - -/* Class = "UILabel"; text = "3.5 U/hour @ 12:12 PM"; ObjectID = "5gz-kZ-iF1"; */ -"5gz-kZ-iF1.text" = "3.5 U/ora @ 12:12 PM"; - -/* Class = "UILabel"; text = "Bolus"; ObjectID = "5oA-6d-ZTL"; */ -"5oA-6d-ZTL.text" = "Bolo"; - -/* Class = "UITextField"; accessibilityLabel = "Bolus Amount"; ObjectID = "7LT-50-ZzK"; */ -"7LT-50-ZzK.accessibilityLabel" = "Importa Boli"; - -/* Class = "UITextField"; placeholder = "0.0"; ObjectID = "7LT-50-ZzK"; */ -"7LT-50-ZzK.placeholder" = "0.0"; - -/* Class = "UIView"; accessibilityLabel = "Net Basal Rate"; ObjectID = "BEG-xe-e6y"; */ -"BEG-xe-e6y.accessibilityLabel" = "Profilo Basale"; - -/* Class = "UILabel"; accessibilityLabel = "Units"; ObjectID = "BR0-dr-Fj2"; */ -"BR0-dr-Fj2.accessibilityLabel" = "Unità"; - -/* Class = "UILabel"; text = "U"; ObjectID = "BR0-dr-Fj2"; */ -"BR0-dr-Fj2.text" = "U"; - -/* Class = "UILabel"; text = "Future glucose is predicted by combining the effects of multiple inputs. Use this tool to toggle various inputs to see how they compare to the final prediction."; ObjectID = "D4C-I2-dhA"; */ -"D4C-I2-dhA.text" = "La glicemia futura é predetta adottando una combinazione di multiple funzioni. Utilizzare questo strumento per attivare o meno le varie funzioni per valutare come esse influiscono sul valore glicemico predetto."; - -/* Class = "UILabel"; text = "10:09 AM"; ObjectID = "DKc-Kc-dgR"; */ -"DKc-Kc-dgR.text" = "10:09 AM"; - -/* Class = "UILabel"; text = "DEVICES"; ObjectID = "DyC-Sv-qP8"; */ -"DyC-Sv-qP8.text" = "DISPOSITIVI"; - -/* Class = "UILabel"; text = "eventually 92 mg/dL"; ObjectID = "E41-FN-nkk"; */ -"E41-FN-nkk.text" = "Probabile glic. 92 mg/dL"; - -/* Class = "UILabel"; text = "44U"; ObjectID = "I7M-3l-Pf0"; */ -"I7M-3l-Pf0.text" = "44U"; - -/* Class = "UILabel"; text = "100%"; ObjectID = "LaA-uX-OgX"; */ -"LaA-uX-OgX.text" = "100%"; - -/* Class = "UILabel"; text = "!"; ObjectID = "Nps-jD-4lb"; */ -"Nps-jD-4lb.text" = "!"; - -/* Class = "UILabel"; text = "Label"; ObjectID = "OFA-qT-ZAg"; */ -"OFA-qT-ZAg.text" = "Etichetta"; - -/* Class = "UITableViewController"; title = "Predicted Glucose"; ObjectID = "PA3-sP-cWY"; */ -"PA3-sP-cWY.title" = "Glicemia Predetta"; - -/* Class = "UILabel"; text = "eventually 92 mg/dL"; ObjectID = "Rse-x8-amW"; */ -"Rse-x8-amW.text" = "Probabile glic. 92 mg/dL"; - -/* Class = "UILabel"; text = "10:09 AM"; ObjectID = "TfZ-En-Kls"; */ -"TfZ-En-Kls.text" = "10:09 AM"; - -/* Class = "UILabel"; text = "10:09 AM"; ObjectID = "Wkb-m1-PVZ"; */ -"Wkb-m1-PVZ.text" = "10:09 AM"; - -/* Class = "UILabel"; text = "-0.85 U"; ObjectID = "Xq9-7P-H39"; */ -"Xq9-7P-H39.text" = "-0.85 U"; - -/* Class = "UIButton"; normalTitle = "Deliver"; ObjectID = "Ya0-9b-ZAS"; */ -"Ya0-9b-ZAS.normalTitle" = "Invia"; - -/* Class = "UINavigationItem"; title = "Bolus"; ObjectID = "aiu-ZA-zVa"; */ -"aiu-ZA-zVa.title" = "Bolo"; - -/* Class = "UILabel"; text = "Label"; ObjectID = "bIL-Ub-qYp"; */ -"bIL-Ub-qYp.text" = "Etichetta"; - -/* Class = "UILabel"; text = "Label"; ObjectID = "d6m-qV-wWi"; */ -"d6m-qV-wWi.text" = "Etichetta"; - -/* Class = "UINavigationItem"; title = "Settings"; ObjectID = "dmB-PQ-B44"; */ -"dmB-PQ-B44.title" = "Impostazioni"; - -/* Class = "UIView"; accessibilityLabel = "Battery Level"; ObjectID = "ecE-wE-iZx"; */ -"ecE-wE-iZx.accessibilityLabel" = "Livello Batteria"; - -/* Class = "UIView"; accessibilityLabel = "Glucose"; ObjectID = "hJo-df-An0"; */ -"hJo-df-An0.accessibilityLabel" = "Glicemie"; - -/* Class = "UILabel"; text = "Recommended Basal"; ObjectID = "k3F-Na-7mn"; */ -"k3F-Na-7mn.text" = "Basale Raccomandata"; - -/* Class = "UILabel"; text = "mmol/L ↗︎"; ObjectID = "kbE-jN-1cR"; */ -"kbE-jN-1cR.text" = "mmol/L ↗︎"; - -/* Class = "UILabel"; text = "4 min ago"; ObjectID = "krM-Ky-OTb"; */ -"krM-Ky-OTb.text" = "4 min fa"; - -/* Class = "UILabel"; text = "Tap to set"; ObjectID = "m9c-SQ-djE"; */ -"m9c-SQ-djE.text" = "Imposta"; - -/* Class = "UILabel"; accessibilityLabel = "Units"; ObjectID = "mVz-dr-xLU"; */ -"mVz-dr-xLU.accessibilityLabel" = "Unità"; - -/* Class = "UILabel"; text = "U"; ObjectID = "mVz-dr-xLU"; */ -"mVz-dr-xLU.text" = "U"; - -/* Class = "UILabel"; text = "Title"; ObjectID = "moR-fS-DWR"; */ -"moR-fS-DWR.text" = "Titolo"; - -/* Class = "UILabel"; text = "Glucose"; ObjectID = "tuw-av-A3x"; */ -"tuw-av-A3x.text" = "Glicemie"; - -/* Class = "UIView"; accessibilityLabel = "Reservoir Volume"; ObjectID = "uVa-hu-HBZ"; */ -"uVa-hu-HBZ.accessibilityLabel" = "Volume Serbatoio"; - -/* Class = "UILabel"; text = "Recommended"; ObjectID = "ywT-OR-NnU"; */ -"ywT-OR-NnU.text" = "Bolo Consigliato"; - -/* Class = "UILabel"; text = "Title"; ObjectID = "zbc-87-wxZ"; */ -"zbc-87-wxZ.text" = "Titolo"; - diff --git a/Loop/main.swift b/Loop/main.swift new file mode 100644 index 0000000000..296fb3545c --- /dev/null +++ b/Loop/main.swift @@ -0,0 +1,21 @@ +// +// main.swift +// Loop +// +// Created by Rick Pasetto on 10/27/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import UIKit + +#if FORCE_ENGLISH +UserDefaults.standard.set(["en"], forKey: "AppleLanguages") +UserDefaults.standard.synchronize() +#endif + +UIApplicationMain( + CommandLine.argc, + CommandLine.unsafeArgv, + NSStringFromClass(UIApplication.self), + NSStringFromClass(AppDelegate.self) +) diff --git a/Loop/mul.lproj/LaunchScreen.xcstrings b/Loop/mul.lproj/LaunchScreen.xcstrings new file mode 100644 index 0000000000..fa56d16199 --- /dev/null +++ b/Loop/mul.lproj/LaunchScreen.xcstrings @@ -0,0 +1,7 @@ +{ + "sourceLanguage" : "en", + "strings" : { + + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/Loop/mul.lproj/Main.xcstrings b/Loop/mul.lproj/Main.xcstrings new file mode 100644 index 0000000000..473a6d13bc --- /dev/null +++ b/Loop/mul.lproj/Main.xcstrings @@ -0,0 +1,3671 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "3kU-n2-fha.title" : { + "comment" : "Class = \"UINavigationItem\"; title = \"Status\"; ObjectID = \"3kU-n2-fha\";", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الحالة" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Status" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Status" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Status" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Status" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estado" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tila" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Statut" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "מצב" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stato" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ステータス" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Status" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Status" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Status" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estado" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stare" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Статус" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stav" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Status" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Durum" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tình trạng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "状态" + } + } + } + }, + "5gz-kZ-iF1.text" : { + "comment" : "Class = \"UILabel\"; text = \"3.5 U/hour @ 12:12 PM\"; ObjectID = \"5gz-kZ-iF1\";", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "3.5 وحدة/ساعة @ 12:12 مساء" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "3.5 E/time @ 12:12" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "3.5 IE/h @ 12:12" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "3.5 U/hour @ 12:12 PM" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "3.5 U/hora @ 12:12 PM" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "3.5 U/h @ 12:12" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "3.5 U/heure @ 12:12" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "3.5 U/hour @ 12:12 PM" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "3.5 U/ora @ 12:12 PM" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "3.5 U/時 @ 12:12 PM" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "3,5 E/time @ 12:12" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "3,5 E/uur @ 12:12 uur" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "3.5 J/godzinę @ 12:12 PM" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "3.5 U/hora @ 12:12 PM" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "3.5 U/oră @ 12:12 PM" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "3,5 ед./час @ 12:12 PM" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "3,5 i/hod o 12:12" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "3.5 E/h kl. 12:12" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "3.5 Ü/saat @ 12:12 PM" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "3.5 U/hour @ 12:12 PM" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "3.5u/小时 @ 12:12 PM" + } + } + } + }, + "87H-N1-0vJ.text" : { + "comment" : "Class = \"UILabel\"; text = \"Predicted\"; ObjectID = \"87H-N1-0vJ\";", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تنبأ" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forventet" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vorhergesagt" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Predicted" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Proyectada" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ennustettu" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Prédit" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "חזוי" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Previsto" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "予想" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forventet" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voorspeld" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Przewidywane" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Prevista" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estimată" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Предсказываемый" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Predpoklad" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Förväntat" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tahmini" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Được dự đoán" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "预计" + } + } + } + }, + "aCb-Qs-bpu.text" : { + "comment" : "Class = \"UILabel\"; text = \"Detail\"; ObjectID = \"aCb-Qs-bpu\";", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تفاصيل" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detalje" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detail" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Detail" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detalle" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detail" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Détail" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detail" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dettaglio" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detail" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detalj" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detail" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detail" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detail" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detaliu" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detail" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detail" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detail" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detay" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detail" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "详细" + } + } + } + }, + "bIL-Ub-qYp.text" : { + "comment" : "Class = \"UILabel\"; text = \"Label\"; ObjectID = \"bIL-Ub-qYp\";", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "Label" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etiket" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beschriftung" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Label" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etiqueta" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nimiö" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Label" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Label" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etichetta" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ラベル" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etikett" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etiket" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etykieta" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rótulo" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etichetă" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ярлык" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Label" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etiket" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Label" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "标签" + } + } + } + }, + "bq4-98-cQU.text" : { + "comment" : "Class = \"UILabel\"; text = \"Glucose Change\"; ObjectID = \"bq4-98-cQU\";", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تغير قراءات السكر" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blodsukkerændring" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blutzucker-Änderung" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Glucose Change" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cambio de Glucosa" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glukoosin muutos" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Variation de la glycémie" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glucose Change" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modifica carboidrati" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "グルコース変動" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blodsukker endring" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glucoseverandering" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zmiana poziomu glukozy" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Variação de Glicose" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modificarea glicemiei" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Изменение глюкозы" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zmena glykémie" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glukosförändring" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "KŞ Değişimi" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mức đường huyết thay đổi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "葡萄糖变化" + } + } + } + }, + "d3X-AN-tA5.text" : { + "comment" : "Class = \"UILabel\"; text = \"g Total\"; ObjectID = \"d3X-AN-tA5\";", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "g المجموع" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "g Total" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "g insgesamt" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "g Total" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "gr Totales" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "g yhteensä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "g total" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "g Total" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "g Totali" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "g 合計" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "g Totalt" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "g Totaal" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "g łącznie" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "g Total" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "g în total" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "всего грамм" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "g Celkom" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "g totalt" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "gr Toplam" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "g Tổng cộng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "克 总计" + } + } + } + }, + "D4C-I2-dhA.text" : { + "comment" : "Class = \"UILabel\"; text = \"Future glucose is predicted by combining the effects of multiple inputs. Use this tool to toggle various inputs to see how they compare to the final prediction.\"; ObjectID = \"D4C-I2-dhA\";", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "Future glucose is predicted by combining the effects of multiple inputs. Use this tool to toggle various inputs to see how they compare to the final prediction." + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fremtidige blodsukre er beregnet ved at kombinere effekterne af mange inputs. Brug dette værktøj til at vælge mellem forskellige inputs, for at se hvordan de passer med den endelige forudsigelse." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der Blutzuckerverlauf wird vorhergesagt, indem die Effekte mehrerer Eingaben kombiniert werden. Verwende dieses Tool, um verschiedene Eingaben ein- und auszuschalten, um zu sehen, wie sie die Vorhersage beeinflussen." + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Future glucose is predicted by combining the effects of multiple inputs. Use this tool to toggle various inputs to see how they compare to the final prediction." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "La glucosa futura se predice combinando los efectos de diversos datos de entrada. Utiliza esta herramienta para cambiar datos de entrada y ver como varía la predicción final." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tulevia glukoosiarvoja ennustetaan yhdistämällä useiden eri tekijöiden vaikutuksia. Tämän työkalun avulla voit havainnoida, kuinka ne vaikuttavat lopulliseen ennusteeseen laittamalla niitä päälle/pois." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "La glycémie future est prédite en combinant les effets de plusieurs entrées. Utilisez cet outil pour basculer entre différentes entrées et comparer leur valeur à la prédiction finale." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Future glucose is predicted by combining the effects of multiple inputs. Use this tool to toggle various inputs to see how they compare to the final prediction." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "La glicemia futura viene prevista combinando gli effetti di più input. Utilizza questo strumento per alternare diversi input e confrontarli con la previsione finale." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "予想グルコースは複数のインプットの効果を組み合わせて算出されます。このツールでは様々なインプットを切り替えて最終予想にどのように関わっているか見ることができます。" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fremtidig glukose er spådd ved å kombinere effekten av flere innganger. Bruk dette verktøyet til å veksle mellom ulike innganger for å se hvordan de er sammenlignet med den endelige prediksjonen." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "De toekomstige glucosewaarde wordt voorspeld door de effecten van meerdere inputwaarden te combineren. Gebruik dit hulpmiddel om verschillende inputwaarden aan en uit te zetten om ze zo te kunnen vergelijken met de uiteindelijke voorspelde glucosewaarde." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Poziom glukozy jest przewidywany dzięki syntezie wielu wprowadzonych danych z różnych źródeł. Użyj tego narzędzia, aby przełączyć różne źródła i zobaczyć jak wpływają na ostateczny wynik." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "A glicose futura é prevista combinando os efeitos de múltiplas entradas. Use esta ferramenta para alternar várias entradas para ver como elas se comparam à previsão final." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glicemia este estimată prin combinarea unui număr de date sursă. Folosiți acest instrument pentru a controla diverse surse de date și a felului în care influențează estimarea." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Будущие значения СК предсказываются на основе определения эффекта множественных факторов. Пользуйтесь этим инструментом для перехода между разными вводными чтобы увидеть как они сопоставимы с окончательным предсказанием" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Budúca glykémia sa predpovedá kombináciou účinkov viacerých vstupov. Pomocou tohto nástroja môžete prepínať rôzne vstupy, aby ste videli, ako vplývajú v porovnaní s konečnou predpoveďou." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Förväntat glukosvärde beräknas genom kombination av flera inmatningar. Använd det här verktyget för att jämföra hur de påverkar det förväntade resultatet." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gelecekteki KŞ, çoklu girdilerin etkilerini birleştirerek tahmin edilir. Son tahminle nasıl karşılaştırıldıklarını görmek üzere çeşitli girdiler arasında geçiş yapmak için bu aracı kullanın." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glucose trong tương lai được dự đoán bằng cách kết hợp các tác động của nhiều dữ liệu đầu vào. Sử dụng công cụ này để chuyển đổi các đầu vào khác nhau để xem cách chúng so sánh với dự đoán cuối cùng." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未来血糖根据您输入的多种参数计算,选中不同选项可以观察不同输入对最终血糖预测的影响" + } + } + } + }, + "d6m-qV-wWi.text" : { + "comment" : "Class = \"UILabel\"; text = \"Label\"; ObjectID = \"d6m-qV-wWi\";", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "Label" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etiket" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beschriftung" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Label" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etiqueta" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nimiö" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Label" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Label" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etichetta" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ラベル" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etikett" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etiket" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etykieta" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rótulo" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etichetă" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ярлык" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Label" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etiket" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Label" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "标签" + } + } + } + }, + "E41-FN-nkk.text" : { + "comment" : "Class = \"UILabel\"; text = \"eventually 92 mg/dL\"; ObjectID = \"E41-FN-nkk\";", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "في النهاية 92 mg/dL" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Med tiden 5 mmol/L" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "voraussichtlich 92 mg/dL" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "eventually 92 mg/dL" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "eventualmente 92 mg/dL" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "ennuste 92 mg/dL" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "finalement 92 mg/dL" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "eventually 92 mg/dL" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "alla fine 92 mg/dL" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "予想 92 mg/dL" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "etterhvert 92 mg/dL" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "uiteindelijk 5,1 mmol/L" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "docelowo 92 mg/dL" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "eventualmente 92 mg/dL" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "estimată să ajungă la 92 mg/dL" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "в конечном итоге 92 мг/дл" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "pravdepodobne 92mg/dl" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Förväntat 5,1 mmol/l" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nihai KŞ 92 mg/dL" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "kết quả là 92 mg/dL" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "最终血糖为92 毫克/分升" + } + } + } + }, + "EAn-Ja-S1d.text" : { + "comment" : "Class = \"UILabel\"; text = \"Observed\"; ObjectID = \"EAn-Ja-S1d\";", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "مُلاحظ" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Observeret" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beobachtet" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Observed" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Observado" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Havaittu" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Observé" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Observed" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Osservato" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "観察" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Observert" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Waargenomen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Obserwowany" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Observada" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Observată" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Наблюдается" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pozorované" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Observerad" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gözlendi" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Được quan sát" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "观察值" + } + } + } + }, + "fWV-jg-ICt.text" : { + "comment" : "Class = \"UILabel\"; text = \"3.5 U/hour @ 12:12 PM\"; ObjectID = \"fWV-jg-ICt\";", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "3.5 وحدة/ساعة @ 12:12 مساء" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "3.5 E/time @ 12:12" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "3,5 IE/h @ 12:12" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "3.5 U/hour @ 12:12 PM" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "3.5 U/hora @ 12:12 PM" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "3.5 U/h @ 12:12" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "3.5 U/heure @ 12:12" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "3.5 U/hour @ 12:12 PM" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "3.5 U/ora @ 12:12 PM" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "3.5 U/時 @ 12:12 PM" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "3,5 E/time @ 12:12" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "3,5 E/uur @ 12:12 uur" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "3.5 J/godzinę @ 12:12 PM" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "3.5 U/hora @ 12:12 PM" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "3.5 U/oră @ 12:12 PM" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "3,5 ед./час @ 12:12 PM" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "3,5 i/hod o 12:12" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "3.5 E/h kl. 12:12" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "3.5 Ü/saat @ 12:12 PM" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "3.5 U/hour @ 12:12 PM" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "3.5u/小时 @ 12:12 PM" + } + } + } + }, + "hZZ-2S-lrd.title" : { + "comment" : "Class = \"UITableViewController\"; title = \"Carbohydrate Effects\"; ObjectID = \"hZZ-2S-lrd\";", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تأثيرات الكربوهيدرات" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kulhydrateffekter" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kohlenhydrat-Effekte" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Carbohydrate Effects" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Efecto de Carbohidratos" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hiilihydraattivaikutus" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Effets des glucides" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carbohydrate Effects" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Effetti dei carboidrati" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "糖質効果" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Karbohydrat effekt" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Koolhydraateffect" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Efekty wywołane przez węglowodany" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Efeitos dos Carboidratos" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Efectul carbohidraților" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Действие углеводов" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Účinky sacharidov" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kolhydrateffekter" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Karbonhidrat Etkileri" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carbohydrate Effects" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : " 碳水化合物的影响" + } + } + } + }, + "IxU-As-glo.text" : { + "comment" : "Class = \"UILabel\"; text = \"Observed changes in glucose, subtracting changes modeled from insulin delivery, can be used to estimate carbohydrate absorption.\"; ObjectID = \"IxU-As-glo\";", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "التغيرات الملاحظة على سكر الدم وتغيرات الخصم المشكل من توصيل الأنسولين بالإمكان استخدامها لتقدير امتصاص الكربوهيدرات." + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "De observerede ændringer i glukose, fratrukket de ændringer, der er modelleret ud fra insulintilførslen, kan bruges til at estimere kulhydratabsorptionen." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die beobachtete Blutzuckerveränderung abzüglich der modellbasierten Veränderung kann verwendet werden, um die Kohlenhydratresorption abzuschätzen." + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Observed changes in glucose, subtracting changes modeled from insulin delivery, can be used to estimate carbohydrate absorption." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Los cambios observados en glucosa sustrayendo los cambios modelados para la entrega de insulina pueden ser utilizados para estimar la absorción de carbohidratos." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Havaittua glukoosin muutosta, josta on vähennetty insuliinin mallinnettu vaikutus, voidaan käyttää hiilihydraattien imeytymisen arviointiin." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les variations observées dans la glycémie, soustrayant les variations modélisées d'administration d'insuline peuvent être utilisées pour estimer l'absorption des glucides." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Observed changes in glucose, subtracting changes modeled from insulin delivery, can be used to estimate carbohydrate absorption." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Per valutare l'assorbimento dei carboidrati, è possibile utilizzare i cambiamenti osservati nella glicemia, sottraendo i cambiamenti modellati dall'erogazione d'insulina." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "観察されたグルコース値の変動から、インスリン注入のモデルによる変動を引くことにより、糖質の吸収を推定することができます。" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Observerte endringer i glukose, subtrahering av endringer modellert fra insulintilførsel, kan brukes til å estimere karbohydratabsorpsjon." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Waargenomen glucoseveranderingen, minus de veranderingen die al door insulinetoediening hebben plaatsgevonden, kunnen worden gebruikt om de koolhydraatopname in te schatten." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Obserwowane zmiany w poziomie glukozy i uwzględnianie zmian podaży insuliny mogą być użyte do oszacowania czasu absorpcji węglowodanów." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alterações observadas na glicose, subtraindo alterações modeladas da administração de insulina, podem ser usadas para estimar a absorção de carboidratos." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modificările observate ale glicemiei, eliminând modificările modelate din administrarea insulinei, pot fi folosite pentru a estima absorbția carbohidraților." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Наблюдаются изменения СК, вычитаются изменения смоделированные на подаваемом инсулине, могут использоваться для оценки усвоения углеводов" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Observerad glukosförändring, med borttagen förändring modellerad från insulindoser, kan användas för att uppskatta kolhydratabsorptionen." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İnsülin iletiminden modellenen değişiklikler çıkarılarak KŞ değerlerinde gözlemlenen değişiklikler, karbonhidrat emilimini tahmin etmek için kullanılabilir." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Những thay đổi quan sát được trong glucose, trừ đi những thay đổi được mô hình hóa từ việc cung cấp insulin, có thể được sử dụng để ước tính sự hấp thụ carbohydrate." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "观察到的葡萄糖变化,减去以胰岛素递送为模型的变化,可用于估计碳水化合物吸收。" + } + } + } + }, + "J7x-W5-gwo.text" : { + "comment" : "Class = \"UILabel\"; text = \"Detail\"; ObjectID = \"J7x-W5-gwo\";", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تفاصيل" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detalje" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detail" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Detail" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detalle" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detail" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Détail" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detail" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dettaglio" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detail" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detalj" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detail" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detail" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detail" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detaliu" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detail" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detail" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detail" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detay" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detail" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "详情" + } + } + } + }, + "k3F-Na-7mn.text" : { + "comment" : "Class = \"UILabel\"; text = \"Recommended Basal\"; ObjectID = \"k3F-Na-7mn\";", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الضخ المستمر المقترح" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anbefalet basal" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Empfohlene Basalrate" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Recommended Basal" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basal Recomendada" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suositeltu basaali" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recommandation basal" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recommended Basal" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basale raccomandata" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "推奨基礎分泌量" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anbefalt Basal" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aanbevolen Basaal" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zalecana baza" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basal Recomendada" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bazala recomandată" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Рекомендуемый базал" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Odporúčaný bazal" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rekommenderad basaldos" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Önerilen Bazal" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Khuyến nghị liều Basal" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "推荐基础率" + } + } + } + }, + "Krd-Aa-ret.text" : { + "comment" : "Class = \"UILabel\"; text = \"Label\"; ObjectID = \"Krd-Aa-ret\";", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "Label" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etiket" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beschriftung" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Label" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etiqueta" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nimiö" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Label" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Label" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etichetta" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ラベル" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etikett" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etiket" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etykieta" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rótulo" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etichetă" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ярлык" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Label" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etiket" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Label" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "标签" + } + } + } + }, + "OFA-qT-ZAg.text" : { + "comment" : "Class = \"UILabel\"; text = \"Label\"; ObjectID = \"OFA-qT-ZAg\";", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "Label" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etiket" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beschriftung" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Label" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etiqueta" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nimiö" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Label" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Label" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etichetta" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ラベル" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etikett" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etiket" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etykieta" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rótulo" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etichetă" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ярлык" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Label" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etiket" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Label" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "标签" + } + } + } + }, + "PA3-sP-cWY.title" : { + "comment" : "Class = \"UITableViewController\"; title = \"Predicted Glucose\"; ObjectID = \"PA3-sP-cWY\";", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "سكر الدم المتوقع" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forventet blodsukker" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vorhergesagter Blutzucker" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Predicted Glucose" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glucosa Proyectada" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ennustettu glukoosi" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Prédiction de glycémie" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Predicted Glucose" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glicemia prevista" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "予想グルコース" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forventet Blodsukker" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voorspelde bloedsuiker" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Przewidywany poziom cukru" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glicose Prevista" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glicemie estimată" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Предсказываемый СК" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Predpokladaná glykémia" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Förväntat glukosvärde" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tahmini KŞ" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đường huyết được dự đoán" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "预测的血糖" + } + } + } + }, + "Rse-x8-amW.text" : { + "comment" : "Class = \"UILabel\"; text = \"eventually 92 mg/dL\"; ObjectID = \"Rse-x8-amW\";", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "في النهاية 92 mg/dL" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Med tiden 5 mmol/L" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "voraussichtlich 92 mg/dL" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "eventually 92 mg/dL" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "eventualmente 92 mg/dL" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "ennuste 92 mg/dL" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "finalement 92 mg/dL" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "eventually 92 mg/dL" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "alla fine 92 mg/dL" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "予想 92 mg/dL" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "etterhvert 92 mg/dL" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "uiteindelijk 5,1 mmol/L" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "docelowo 92 mg/dL" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "eventualmente 92 mg/dL" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "estimată să ajungă la 92 mg/dL" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "в конечном итоге 92 мг/дл" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "pravdepodobne 92mg/dl" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Förväntat 5,1 mmol/l" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nihai KŞ 92 mg/dL" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "kết quả là 92 mg/dL" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "最终血糖为92 毫克/分升" + } + } + } + }, + "SQx-au-ZcM.text" : { + "comment" : "Class = \"UILabel\"; text = \"g Active Carbs\"; ObjectID = \"SQx-au-ZcM\";", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "g كارب نشط" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "g aktive kulhydrater" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "g aktive Kohlenhydrate" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "g COB" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "gr Carbohidratos Activos" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "g Active Carbs" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "g glucides actifs" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "g Active Carbs" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "g carboidrati attivi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "g 残存糖質" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "g Aktive karbohydrater" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "g Actieve Kh" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "g Aktywne węglowodany" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "g Active Carbs" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "g CLB" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "г активных углеводов" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "g Aktívne sacharidy" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "g Active Carbs" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "gr Aktif Karb." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "g Active Carbs" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "活性碳水化合物 克" + } + } + } + }, + "tuw-av-A3x.text" : { + "comment" : "Class = \"UILabel\"; text = \"Glucose\"; ObjectID = \"tuw-av-A3x\";", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "قراءات السكر" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glukóza" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glukose" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blutzucker" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Glucose" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glucosa" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glukoosi" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glycémie" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glucose" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "शुगर" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glicemia" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "血糖値" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Blodsukker" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glucose" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glukoza" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glicose" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glicemie" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Глюкоза" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glykémia" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glukos" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kan şekeri" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đường huyết" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "葡萄糖" + } + } + } + }, + "ufi-Kj-33k.text" : { + "comment" : "Class = \"UILabel\"; text = \"Label\"; ObjectID = \"ufi-Kj-33k\";", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "Label" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etiket" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beschriftung" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Label" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etiqueta" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nimiö" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Label" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Label" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etichetta" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ラベル" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etikett" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etiket" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etykieta" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rótulo" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etichetă" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ярлык" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Label" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etiket" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Label" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "标签" + } + } + } + }, + "Vpi-5b-bY5.title" : { + "comment" : "Class = \"UINavigationItem\"; title = \"Carbohydrates\"; ObjectID = \"Vpi-5b-bY5\";", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الكربوهيدرات" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kulhydrater" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kohlenhydrate" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Carbohydrates" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carbohidratos" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hiilihydraatit" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glucides" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carbohydrates" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carboidrati" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "残存糖質" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Karbohydrater" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Koolhydraten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Węglowodany" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carboidratos" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carbohidrați" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Углеводы" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sacharidy" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kolhydrater" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Karbonhidratlar" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carbohydrates" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "碳水化合物" + } + } + } + }, + "xhx-PI-bBI.text" : { + "comment" : "Class = \"UILabel\"; text = \"Recommended Basal\"; ObjectID = \"xhx-PI-bBI\";", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الضخ المستمر المقترح" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anbefalet basal" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Empfohlene Basalrate" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Recommended Basal" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basal Recomendada" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suositeltu basaali" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recommandation basal" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recommended Basal" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basale raccomandata" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "推奨基礎分泌量" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anbefalt Basal" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aanbevolen Basaal" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zalecana baza" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basal Recomendada" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bazala recomandată" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Рекомендуемый базал" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Odporúčaný bazal" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rekommenderad basaldos" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Önerilen Bazal" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Khuyến nghị liều Basal" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "推荐基础率" + } + } + } + }, + "yn7-2M-jZz.text" : { + "comment" : "Class = \"UILabel\"; text = \"0\"; ObjectID = \"yn7-2M-jZz\";", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "0" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "0" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "0" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "0" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "0" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "0" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "0" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "0" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "0" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "0" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "0" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "0" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "0" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "0" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "0" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "0" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "0" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "0" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "0" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "0" + } + } + } + }, + "zbc-87-wxZ.text" : { + "comment" : "Class = \"UILabel\"; text = \"Title\"; ObjectID = \"zbc-87-wxZ\";", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "عنوان" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Titel" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Titel" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Title" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Título" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Title" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Titre" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Title" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Titolo" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Title" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tittel" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Titel" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Title" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Title" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Title" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Title" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Názov" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Title" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Başlık" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Title" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "标题" + } + } + } + }, + "zvZ-uf-zMX.text" : { + "comment" : "Class = \"UILabel\"; text = \"0\"; ObjectID = \"zvZ-uf-zMX\";", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "0" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "0" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "0" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "0" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "0" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "0" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "0" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "0" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "0" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "0" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "0" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "0" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "0" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "0" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "0" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "0" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "0" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "0" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "0" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "0" + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/LoopCore/HKUnit.swift b/LoopCore/HKUnit.swift new file mode 100644 index 0000000000..7f9a5e3009 --- /dev/null +++ b/LoopCore/HKUnit.swift @@ -0,0 +1,32 @@ +// +// HKUnit.swift +// Naterade +// +// Created by Nathan Racklyeft on 1/17/16. +// Copyright © 2016 Nathan Racklyeft. All rights reserved. +// + +import HealthKit + + +extension HKUnit { + public static let milligramsPerDeciliter: HKUnit = { + return HKUnit.gramUnit(with: .milli).unitDivided(by: .literUnit(with: .deci)) + }() + + public static let millimolesPerLiter: HKUnit = { + return HKUnit.moleUnit(with: .milli, molarMass: HKUnitMolarMassBloodGlucose).unitDivided(by: .liter()) + }() + + public static let milligramsPerDeciliterPerMinute: HKUnit = { + return HKUnit.milligramsPerDeciliter.unitDivided(by: .minute()) + }() + + public static let millimolesPerLiterPerMinute: HKUnit = { + return HKUnit.millimolesPerLiter.unitDivided(by: .minute()) + }() + + public static let internationalUnitsPerHour: HKUnit = { + return HKUnit.internationalUnit().unitDivided(by: .hour()) + }() +} diff --git a/LoopCore/IdentifiableClass.swift b/LoopCore/IdentifiableClass.swift new file mode 100644 index 0000000000..c5da386e94 --- /dev/null +++ b/LoopCore/IdentifiableClass.swift @@ -0,0 +1,21 @@ +// +// IdentifiableClass.swift +// Naterade +// +// Created by Nathan Racklyeft on 5/22/16. +// Copyright © 2016 Nathan Racklyeft. All rights reserved. +// + +import Foundation + + +public protocol IdentifiableClass: AnyObject { + static var className: String { get } +} + + +extension IdentifiableClass { + public static var className: String { + return NSStringFromClass(self).components(separatedBy: ".").last! + } +} diff --git a/LoopCore/Info.plist b/LoopCore/Info.plist new file mode 100644 index 0000000000..6db01d4593 --- /dev/null +++ b/LoopCore/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + FMWK + CFBundleShortVersionString + $(LOOP_MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + + diff --git a/LoopCore/LiveActivitySettings.swift b/LoopCore/LiveActivitySettings.swift new file mode 100644 index 0000000000..ef37a88f28 --- /dev/null +++ b/LoopCore/LiveActivitySettings.swift @@ -0,0 +1,154 @@ +// +// LiveActivitySettings.swift +// LoopCore +// +// Created by Bastiaan Verhaar on 04/07/2024. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import Foundation + +public enum BottomRowConfiguration: Codable { + case iob + case cob + case basal + case currentBg + case eventualBg + case deltaBg + case updatedAt + + static let defaults: [BottomRowConfiguration] = [.currentBg, .iob, .cob, .updatedAt] + public static let all: [BottomRowConfiguration] = [.iob, .cob, .basal, .currentBg, .eventualBg, .deltaBg, .updatedAt] + + public func name() -> String { + switch self { + case .iob: + return NSLocalizedString("Active Insulin", comment: "") + case .cob: + return NSLocalizedString("Active Carbs", comment: "") + case .basal: + return NSLocalizedString("Basal", comment: "") + case .currentBg: + return NSLocalizedString("Current BG", comment: "") + case .eventualBg: + return NSLocalizedString("Eventual BG", comment: "") + case .deltaBg: + return NSLocalizedString("Delta", comment: "") + case .updatedAt: + return NSLocalizedString("Updated", comment: "") + } + } + + public func description() -> String { + switch self { + case .iob: + return NSLocalizedString("Active Insulin", comment: "") + case .cob: + return NSLocalizedString("Active Carbohydrates", comment: "") + case .basal: + return NSLocalizedString("Basal", comment: "") + case .currentBg: + return NSLocalizedString("Current Glucose", comment: "") + case .eventualBg: + return NSLocalizedString("Eventually", comment: "") + case .deltaBg: + return NSLocalizedString("Delta", comment: "") + case .updatedAt: + return NSLocalizedString("Updated at", comment: "") + } + } +} + +public enum LiveActivityMode: Codable, CustomStringConvertible { + case large + case small + + public static let all: [LiveActivityMode] = [.large, .small] + public var description: String { + NSLocalizedString("In which mode do you want to render the Live Activity", comment: "") + } + + public func name() -> String { + switch self { + case .large: + return NSLocalizedString("Large", comment: "") + case .small: + return NSLocalizedString("Small", comment: "") + } + } +} + +public struct LiveActivitySettings: Codable, Equatable { + public var enabled: Bool + public var mode: LiveActivityMode + public var addPredictiveLine: Bool + public var useLimits: Bool + public var upperLimitChartMmol: Double + public var lowerLimitChartMmol: Double + public var upperLimitChartMg: Double + public var lowerLimitChartMg: Double + public var bottomRowConfiguration: [BottomRowConfiguration] + + private enum CodingKeys: String, CodingKey { + case enabled + case mode + case addPredictiveLine + case bottomRowConfiguration + case useLimits + case upperLimitChartMmol + case lowerLimitChartMmol + case upperLimitChartMg + case lowerLimitChartMg + } + + private static let defaultUpperLimitMmol = Double(10) + private static let defaultLowerLimitMmol = Double(4) + private static let defaultUpperLimitMg = Double(180) + private static let defaultLowerLimitMg = Double(72) + + public init(from decoder:Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + + self.enabled = try values.decode(Bool.self, forKey: .enabled) + self.mode = try values.decodeIfPresent(LiveActivityMode.self, forKey: .mode) ?? .large + self.addPredictiveLine = try values.decode(Bool.self, forKey: .addPredictiveLine) + self.useLimits = try values.decode(Bool.self, forKey: .useLimits) + self.upperLimitChartMmol = try values.decode(Double?.self, forKey: .upperLimitChartMmol) ?? LiveActivitySettings.defaultUpperLimitMmol + self.lowerLimitChartMmol = try values.decode(Double?.self, forKey: .lowerLimitChartMmol) ?? LiveActivitySettings.defaultLowerLimitMmol + self.upperLimitChartMg = try values.decode(Double?.self, forKey: .upperLimitChartMg) ?? LiveActivitySettings.defaultUpperLimitMg + self.lowerLimitChartMg = try values.decode(Double?.self, forKey: .lowerLimitChartMg) ?? LiveActivitySettings.defaultLowerLimitMg + self.bottomRowConfiguration = try values.decode([BottomRowConfiguration].self, forKey: .bottomRowConfiguration) + } + + public init() { + self.enabled = true + self.mode = .large + self.addPredictiveLine = true + self.useLimits = true + self.upperLimitChartMmol = LiveActivitySettings.defaultUpperLimitMmol + self.lowerLimitChartMmol = LiveActivitySettings.defaultLowerLimitMmol + self.upperLimitChartMg = LiveActivitySettings.defaultUpperLimitMg + self.lowerLimitChartMg = LiveActivitySettings.defaultLowerLimitMg + self.bottomRowConfiguration = BottomRowConfiguration.defaults + } + + public static func == (lhs: LiveActivitySettings, rhs: LiveActivitySettings) -> Bool { + return lhs.addPredictiveLine == rhs.addPredictiveLine && + lhs.mode == rhs.mode && + lhs.useLimits == rhs.useLimits && + lhs.lowerLimitChartMmol == rhs.lowerLimitChartMmol && + lhs.upperLimitChartMmol == rhs.upperLimitChartMmol && + lhs.lowerLimitChartMg == rhs.lowerLimitChartMg && + lhs.upperLimitChartMg == rhs.upperLimitChartMg + } + + public static func != (lhs: LiveActivitySettings, rhs: LiveActivitySettings) -> Bool { + return lhs.addPredictiveLine != rhs.addPredictiveLine || + lhs.mode != rhs.mode || + lhs.useLimits != rhs.useLimits || + lhs.lowerLimitChartMmol != rhs.lowerLimitChartMmol || + lhs.upperLimitChartMmol != rhs.upperLimitChartMmol || + lhs.lowerLimitChartMg != rhs.lowerLimitChartMg || + lhs.upperLimitChartMg != rhs.upperLimitChartMg + } +} diff --git a/LoopCore/Localizable.xcstrings b/LoopCore/Localizable.xcstrings new file mode 100644 index 0000000000..28eac3f1a7 --- /dev/null +++ b/LoopCore/Localizable.xcstrings @@ -0,0 +1,365 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "%1$@ v%2$@" : { + "comment" : "The format string for the app name and version number. (1: bundle name)(2: bundle version)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v %2$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ версии %2$@" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v %2$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + } + } + }, + "Active Carbohydrates" : { + "comment" : "Description of a bottom row configuration option for active carbohydrates.", + "isCommentAutoGenerated" : true + }, + "Active Carbs" : { + "comment" : "Name of the \"Active Carbs\" option in the Live Activity settings.", + "isCommentAutoGenerated" : true + }, + "Active Insulin" : { + "comment" : "Name of the active insulin value in the bottom row.", + "isCommentAutoGenerated" : true + }, + "Automatic Bolus" : { + "comment" : "Title string for automatic bolus dosing strategy", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatisk bolus" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatischer Bolus" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolo automático" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automaattinen bolus" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus Automatique" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolo automatico" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatisk bolus" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatisch Bolussen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatyczny bolus" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus automat" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Автоматические болюсы" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatisk bolus" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Otomatik Bolus" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自动大剂量" + } + } + } + }, + "Basal" : { + "comment" : "Name of the basal insulin rate.", + "isCommentAutoGenerated" : true + }, + "Current BG" : { + "comment" : "Name of a bottom row item that shows the current blood glucose level.", + "isCommentAutoGenerated" : true + }, + "Current Glucose" : { + "comment" : "Description of a bottom row item that shows the current glucose level.", + "isCommentAutoGenerated" : true + }, + "Delta" : { + "comment" : "The name of the delta BG value.", + "isCommentAutoGenerated" : true + }, + "Eventual BG" : { + "comment" : "Description of a bottom row configuration option for displaying the eventual glucose value.", + "isCommentAutoGenerated" : true + }, + "Eventually" : { + "comment" : "Description of a glucose value that is expected to change in the future.", + "isCommentAutoGenerated" : true + }, + "In which mode do you want to render the Live Activity" : { + "comment" : "Description of a setting that allows the user to choose between a large or small display of the Live Activity.", + "isCommentAutoGenerated" : true + }, + "Large" : { + "comment" : "Name of the \"Large\" Live Activity mode.", + "isCommentAutoGenerated" : true + }, + "Small" : { + "comment" : "Name of the \"Small\" Live Activity mode.", + "isCommentAutoGenerated" : true + }, + "Temp Basal Only" : { + "comment" : "Title string for temp basal only dosing strategy", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kun midlertidig basal" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nur temporäre Basalrate" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Solo basal temporal" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vain tilapäinen basaali" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basal temporaire uniquement" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Solo basale temporanea" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kun midlertidig basal" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alleen Tijdelijk Basaal" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tymczasowa dawka podstawowa" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Doar bazală temporară" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Только ВБС (без автоболюсов)" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Endast temporär basal" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sadece Geçici Bazal" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "仅临时基础率" + } + } + } + }, + "Updated" : { + "comment" : "Name of the \"Updated\" option in the Live Activity settings.", + "isCommentAutoGenerated" : true + }, + "Updated at" : { + "comment" : "Label for the date and time when the last update was made.", + "isCommentAutoGenerated" : true + } + }, + "version" : "1.1" +} \ No newline at end of file diff --git a/LoopCore/LocalizedString.swift b/LoopCore/LocalizedString.swift new file mode 100644 index 0000000000..14206ef981 --- /dev/null +++ b/LoopCore/LocalizedString.swift @@ -0,0 +1,21 @@ +// +// LocalizedString.swift +// Loop +// +// Created by Pete Schwamb on 1/29/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation + +private class FrameworkBundle { + static let main = Bundle(for: FrameworkBundle.self) +} + +func LocalizedString(_ key: String, tableName: String? = nil, value: String? = nil, comment: String) -> String { + if let value = value { + return NSLocalizedString(key, tableName: tableName, bundle: FrameworkBundle.main, value: value, comment: comment) + } else { + return NSLocalizedString(key, tableName: tableName, bundle: FrameworkBundle.main, comment: comment) + } +} diff --git a/LoopCore/LoopCompletionFreshness.swift b/LoopCore/LoopCompletionFreshness.swift new file mode 100644 index 0000000000..baa2cd7232 --- /dev/null +++ b/LoopCore/LoopCompletionFreshness.swift @@ -0,0 +1,52 @@ +// +// LoopCompletionFreshness.swift +// Loop +// +// Created by Pete Schwamb on 1/17/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import Foundation + +public enum LoopCompletionFreshness { + case fresh + case aging + case stale + + public var maxAge: TimeInterval? { + switch self { + case .fresh: + return TimeInterval(minutes: 6) + case .aging: + return TimeInterval(minutes: 16) + case .stale: + return nil + } + } + + public init(age: TimeInterval?) { + guard let age = age else { + self = .stale + return + } + + switch age { + case let t where t <= LoopCompletionFreshness.fresh.maxAge!: + self = .fresh + case let t where t <= LoopCompletionFreshness.aging.maxAge!: + self = .aging + default: + self = .stale + } + } + + public init(lastCompletion: Date?, at date: Date = Date()) { + guard let lastCompletion = lastCompletion else { + self = .stale + return + } + + self = LoopCompletionFreshness(age: date.timeIntervalSince(lastCompletion)) + } + +} diff --git a/LoopCore/LoopCore.h b/LoopCore/LoopCore.h new file mode 100644 index 0000000000..619115ff00 --- /dev/null +++ b/LoopCore/LoopCore.h @@ -0,0 +1,18 @@ +// +// LoopCore.h +// LoopCore +// +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +#import + +//! Project version number for LoopCore. +FOUNDATION_EXPORT double LoopCoreVersionNumber; + +//! Project version string for LoopCore. +FOUNDATION_EXPORT const unsigned char LoopCoreVersionString[]; + +// In this header, you should import all the public headers of your framework using statements like #import + + diff --git a/LoopCore/LoopCoreConstants.swift b/LoopCore/LoopCoreConstants.swift new file mode 100644 index 0000000000..d56f2ab9b6 --- /dev/null +++ b/LoopCore/LoopCoreConstants.swift @@ -0,0 +1,24 @@ +// +// LoopCoreConstants.swift +// LoopCore +// +// Created by Pete Schwamb on 10/16/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit + +public enum LoopCoreConstants { + /// The amount of time since a given date that input data should be considered valid + public static let inputDataRecencyInterval = TimeInterval(minutes: 15) + + /// The amount of time in the future a glucose value should be considered valid + public static let futureGlucoseDataInterval = TimeInterval(minutes: 5) + + public static let defaultCarbAbsorptionTimes: CarbStore.DefaultAbsorptionTimes = (fast: .minutes(30), medium: .hours(3), slow: .hours(5)) + + /// How much historical glucose to include in a dosing decision + /// Somewhat arbitrary, but typical maximum visible in bolus glucose preview + public static let dosingDecisionHistoricalGlucoseInterval = TimeInterval(hours: 2) +} diff --git a/LoopCore/LoopSettings.swift b/LoopCore/LoopSettings.swift new file mode 100644 index 0000000000..82ad76b6cc --- /dev/null +++ b/LoopCore/LoopSettings.swift @@ -0,0 +1,301 @@ +// +// LoopSettings.swift +// Loop +// +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +import HealthKit +import LoopKit + +public extension AutomaticDosingStrategy { + var title: String { + switch self { + case .tempBasalOnly: + return LocalizedString("Temp Basal Only", comment: "Title string for temp basal only dosing strategy") + case .automaticBolus: + return LocalizedString("Automatic Bolus", comment: "Title string for automatic bolus dosing strategy") + } + } +} + +public struct LoopSettings: Equatable { + public var isScheduleOverrideInfiniteWorkout: Bool { + guard let scheduleOverride = scheduleOverride else { return false } + return scheduleOverride.context == .legacyWorkout && scheduleOverride.duration.isInfinite + } + + public var dosingEnabled = false + + public var glucoseTargetRangeSchedule: GlucoseRangeSchedule? + + public var insulinSensitivitySchedule: InsulinSensitivitySchedule? + + public var basalRateSchedule: BasalRateSchedule? + + public var carbRatioSchedule: CarbRatioSchedule? + + public var preMealTargetRange: ClosedRange? + + public var legacyWorkoutTargetRange: ClosedRange? + + public var overridePresets: [TemporaryScheduleOverridePreset] = [] + + public var scheduleOverride: TemporaryScheduleOverride? { + didSet { + if let newValue = scheduleOverride, newValue.context == .preMeal { + preconditionFailure("The `scheduleOverride` field should not be used for a pre-meal target range override; use `preMealOverride` instead") + } + + if scheduleOverride?.context == .legacyWorkout { + preMealOverride = nil + } + } + } + + public var preMealOverride: TemporaryScheduleOverride? { + didSet { + if let newValue = preMealOverride, newValue.context != .preMeal || newValue.settings.insulinNeedsScaleFactor != nil { + preconditionFailure("The `preMealOverride` field should be used only for a pre-meal target range override") + } + + if preMealOverride != nil, scheduleOverride?.context == .legacyWorkout { + scheduleOverride = nil + } + } + } + + public var maximumBasalRatePerHour: Double? + + public var maximumBolus: Double? + + public var suspendThreshold: GlucoseThreshold? = nil + + public var automaticDosingStrategy: AutomaticDosingStrategy = .tempBasalOnly + + public var defaultRapidActingModel: ExponentialInsulinModelPreset? + + public var glucoseUnit: HKUnit? { + return glucoseTargetRangeSchedule?.unit + } + + public init( + dosingEnabled: Bool = false, + glucoseTargetRangeSchedule: GlucoseRangeSchedule? = nil, + insulinSensitivitySchedule: InsulinSensitivitySchedule? = nil, + basalRateSchedule: BasalRateSchedule? = nil, + carbRatioSchedule: CarbRatioSchedule? = nil, + preMealTargetRange: ClosedRange? = nil, + legacyWorkoutTargetRange: ClosedRange? = nil, + overridePresets: [TemporaryScheduleOverridePreset]? = nil, + scheduleOverride: TemporaryScheduleOverride? = nil, + preMealOverride: TemporaryScheduleOverride? = nil, + maximumBasalRatePerHour: Double? = nil, + maximumBolus: Double? = nil, + suspendThreshold: GlucoseThreshold? = nil, + automaticDosingStrategy: AutomaticDosingStrategy = .tempBasalOnly, + defaultRapidActingModel: ExponentialInsulinModelPreset? = nil + ) { + self.dosingEnabled = dosingEnabled + self.glucoseTargetRangeSchedule = glucoseTargetRangeSchedule + self.insulinSensitivitySchedule = insulinSensitivitySchedule + self.basalRateSchedule = basalRateSchedule + self.carbRatioSchedule = carbRatioSchedule + self.preMealTargetRange = preMealTargetRange + self.legacyWorkoutTargetRange = legacyWorkoutTargetRange + self.overridePresets = overridePresets ?? [] + self.scheduleOverride = scheduleOverride + self.preMealOverride = preMealOverride + self.maximumBasalRatePerHour = maximumBasalRatePerHour + self.maximumBolus = maximumBolus + self.suspendThreshold = suspendThreshold + self.automaticDosingStrategy = automaticDosingStrategy + self.defaultRapidActingModel = defaultRapidActingModel + } +} + +extension LoopSettings { + public func effectiveGlucoseTargetRangeSchedule(presumingMealEntry: Bool = false) -> GlucoseRangeSchedule? { + + let preMealOverride = presumingMealEntry ? nil : self.preMealOverride + + let currentEffectiveOverride: TemporaryScheduleOverride? + switch (preMealOverride, scheduleOverride) { + case (let preMealOverride?, nil): + currentEffectiveOverride = preMealOverride + case (nil, let scheduleOverride?): + currentEffectiveOverride = scheduleOverride + case (let preMealOverride?, let scheduleOverride?): + currentEffectiveOverride = preMealOverride.scheduledEndDate > Date() + ? preMealOverride + : scheduleOverride + case (nil, nil): + currentEffectiveOverride = nil + } + + if let effectiveOverride = currentEffectiveOverride { + return glucoseTargetRangeSchedule?.applyingOverride(effectiveOverride) + } else { + return glucoseTargetRangeSchedule + } + } + + public func scheduleOverrideEnabled(at date: Date = Date()) -> Bool { + return scheduleOverride?.isActive(at: date) == true + } + + public func nonPreMealOverrideEnabled(at date: Date = Date()) -> Bool { + return scheduleOverride?.isActive(at: date) == true + } + + public func preMealTargetEnabled(at date: Date = Date()) -> Bool { + return preMealOverride?.isActive(at: date) == true + } + + public func futureOverrideEnabled(relativeTo date: Date = Date()) -> Bool { + guard let scheduleOverride = scheduleOverride else { return false } + return scheduleOverride.startDate > date + } + + public mutating func enablePreMealOverride(at date: Date = Date(), for duration: TimeInterval) { + preMealOverride = makePreMealOverride(beginningAt: date, for: duration) + } + + private func makePreMealOverride(beginningAt date: Date = Date(), for duration: TimeInterval) -> TemporaryScheduleOverride? { + guard let preMealTargetRange = preMealTargetRange else { + return nil + } + return TemporaryScheduleOverride( + context: .preMeal, + settings: TemporaryScheduleOverrideSettings(targetRange: preMealTargetRange), + startDate: date, + duration: .finite(duration), + enactTrigger: .local, + syncIdentifier: UUID() + ) + } + + public mutating func enableLegacyWorkoutOverride(at date: Date = Date(), for duration: TimeInterval) { + scheduleOverride = legacyWorkoutOverride(beginningAt: date, for: duration) + preMealOverride = nil + } + + public mutating func legacyWorkoutOverride(beginningAt date: Date = Date(), for duration: TimeInterval) -> TemporaryScheduleOverride? { + guard let legacyWorkoutTargetRange = legacyWorkoutTargetRange else { + return nil + } + + return TemporaryScheduleOverride( + context: .legacyWorkout, + settings: TemporaryScheduleOverrideSettings(targetRange: legacyWorkoutTargetRange), + startDate: date, + duration: duration.isInfinite ? .indefinite : .finite(duration), + enactTrigger: .local, + syncIdentifier: UUID() + ) + } + + public mutating func clearOverride(matching context: TemporaryScheduleOverride.Context? = nil) { + if context == .preMeal { + preMealOverride = nil + return + } + + guard let scheduleOverride = scheduleOverride else { return } + + if let context = context { + if scheduleOverride.context == context { + self.scheduleOverride = nil + } + } else { + self.scheduleOverride = nil + } + } +} + +extension LoopSettings: RawRepresentable { + public typealias RawValue = [String: Any] + private static let version = 1 + fileprivate static let codingGlucoseUnit = HKUnit.milligramsPerDeciliter + + public init?(rawValue: RawValue) { + guard + let version = rawValue["version"] as? Int, + version == LoopSettings.version + else { + return nil + } + + if let dosingEnabled = rawValue["dosingEnabled"] as? Bool { + self.dosingEnabled = dosingEnabled + } + + if let glucoseRangeScheduleRawValue = rawValue["glucoseTargetRangeSchedule"] as? GlucoseRangeSchedule.RawValue { + self.glucoseTargetRangeSchedule = GlucoseRangeSchedule(rawValue: glucoseRangeScheduleRawValue) + + // Migrate the glucose range schedule override targets + if let overrideRangesRawValue = glucoseRangeScheduleRawValue["overrideRanges"] as? [String: DoubleRange.RawValue] { + if let preMealTargetRawValue = overrideRangesRawValue["preMeal"] { + self.preMealTargetRange = DoubleRange(rawValue: preMealTargetRawValue)?.quantityRange(for: LoopSettings.codingGlucoseUnit) + } + if let legacyWorkoutTargetRawValue = overrideRangesRawValue["workout"] { + self.legacyWorkoutTargetRange = DoubleRange(rawValue: legacyWorkoutTargetRawValue)?.quantityRange(for: LoopSettings.codingGlucoseUnit) + } + } + } + + if let rawPreMealTargetRange = rawValue["preMealTargetRange"] as? DoubleRange.RawValue { + self.preMealTargetRange = DoubleRange(rawValue: rawPreMealTargetRange)?.quantityRange(for: LoopSettings.codingGlucoseUnit) + } + + if let rawLegacyWorkoutTargetRange = rawValue["legacyWorkoutTargetRange"] as? DoubleRange.RawValue { + self.legacyWorkoutTargetRange = DoubleRange(rawValue: rawLegacyWorkoutTargetRange)?.quantityRange(for: LoopSettings.codingGlucoseUnit) + } + + if let rawPresets = rawValue["overridePresets"] as? [TemporaryScheduleOverridePreset.RawValue] { + self.overridePresets = rawPresets.compactMap(TemporaryScheduleOverridePreset.init(rawValue:)) + } + + if let rawPreMealOverride = rawValue["preMealOverride"] as? TemporaryScheduleOverride.RawValue { + self.preMealOverride = TemporaryScheduleOverride(rawValue: rawPreMealOverride) + } + + if let rawOverride = rawValue["scheduleOverride"] as? TemporaryScheduleOverride.RawValue { + self.scheduleOverride = TemporaryScheduleOverride(rawValue: rawOverride) + } + + self.maximumBasalRatePerHour = rawValue["maximumBasalRatePerHour"] as? Double + + self.maximumBolus = rawValue["maximumBolus"] as? Double + + if let rawThreshold = rawValue["minimumBGGuard"] as? GlucoseThreshold.RawValue { + self.suspendThreshold = GlucoseThreshold(rawValue: rawThreshold) + } + + if let rawDosingStrategy = rawValue["dosingStrategy"] as? AutomaticDosingStrategy.RawValue, + let automaticDosingStrategy = AutomaticDosingStrategy(rawValue: rawDosingStrategy) + { + self.automaticDosingStrategy = automaticDosingStrategy + } + } + + public var rawValue: RawValue { + var raw: RawValue = [ + "version": LoopSettings.version, + "dosingEnabled": dosingEnabled, + "overridePresets": overridePresets.map { $0.rawValue } + ] + + raw["glucoseTargetRangeSchedule"] = glucoseTargetRangeSchedule?.rawValue + raw["preMealTargetRange"] = preMealTargetRange?.doubleRange(for: LoopSettings.codingGlucoseUnit).rawValue + raw["legacyWorkoutTargetRange"] = legacyWorkoutTargetRange?.doubleRange(for: LoopSettings.codingGlucoseUnit).rawValue + raw["preMealOverride"] = preMealOverride?.rawValue + raw["scheduleOverride"] = scheduleOverride?.rawValue + raw["maximumBasalRatePerHour"] = maximumBasalRatePerHour + raw["maximumBolus"] = maximumBolus + raw["minimumBGGuard"] = suspendThreshold?.rawValue + raw["dosingStrategy"] = automaticDosingStrategy.rawValue + + return raw + } +} diff --git a/LoopCore/MissedMealNotification.swift b/LoopCore/MissedMealNotification.swift new file mode 100644 index 0000000000..2cbf61b0e5 --- /dev/null +++ b/LoopCore/MissedMealNotification.swift @@ -0,0 +1,20 @@ +// +// MissedMealNotification.swift +// Loop +// +// Created by Anna Quinlan on 11/18/22. +// Copyright © 2022 LoopKit Authors. All rights reserved. +// + +import Foundation + +/// Information about a missed meal notification +public struct MissedMealNotification: Equatable, Codable { + public let deliveryTime: Date + public let carbAmount: Double + + public init(deliveryTime: Date, carbAmount: Double) { + self.deliveryTime = deliveryTime + self.carbAmount = carbAmount + } +} diff --git a/LoopCore/NSUserDefaults.swift b/LoopCore/NSUserDefaults.swift new file mode 100644 index 0000000000..dacf2ecdc7 --- /dev/null +++ b/LoopCore/NSUserDefaults.swift @@ -0,0 +1,200 @@ +// +// NSUserDefaults.swift +// Naterade +// +// Created by Nathan Racklyeft on 8/30/15. +// Copyright © 2015 Nathan Racklyeft. All rights reserved. +// + +import Foundation +import LoopKit +import HealthKit + + +extension UserDefaults { + + private enum Key: String { + case overrideHistory = "com.loopkit.overrideHistory" + case lastBedtimeQuery = "com.loopkit.Loop.lastBedtimeQuery" + case bedtime = "com.loopkit.Loop.bedtime" + case lastProfileExpirationAlertDate = "com.loopkit.Loop.lastProfileExpirationAlertDate" + case allowDebugFeatures = "com.loopkit.Loop.allowDebugFeatures" + case allowExperimentalFeatures = "com.loopkit.Loop.allowExperimentalFeatures" + case allowSimulators = "com.loopkit.Loop.allowSimulators" + case LastMissedMealNotification = "com.loopkit.Loop.lastMissedMealNotification" + case userRequestedLoopReset = "com.loopkit.Loop.userRequestedLoopReset" + case liveActivity = "com.loopkit.Loop.liveActivity" + } + + public static let appGroup = UserDefaults(suiteName: Bundle.main.appGroupSuiteName) + + public var legacyBasalRateSchedule: BasalRateSchedule? { + get { + if let rawValue = dictionary(forKey: "com.loudnate.Naterade.BasalRateSchedule") { + return BasalRateSchedule(rawValue: rawValue) + } else { + return nil + } + } + } + + public var legacyCarbRatioSchedule: CarbRatioSchedule? { + get { + if let rawValue = dictionary(forKey: "com.loudnate.Naterade.CarbRatioSchedule") { + return CarbRatioSchedule(rawValue: rawValue) + } else { + return nil + } + } + } + + public var legacyDefaultRapidActingModel: ExponentialInsulinModelPreset? { + get { + if let rawValue = string(forKey: "com.loopkit.Loop.defaultRapidActingModel") { + return ExponentialInsulinModelPreset(rawValue: rawValue) + } + + return nil + } + } + + public var legacyLoopSettings: LoopSettings? { + get { + if let rawValue = dictionary(forKey: "com.loopkit.Loop.loopSettings") { + return LoopSettings(rawValue: rawValue) + } else { + return nil + } + } + } + + public var legacyInsulinSensitivitySchedule: InsulinSensitivitySchedule? { + get { + if let rawValue = dictionary(forKey: "com.loudnate.Naterade.InsulinSensitivitySchedule") { + return InsulinSensitivitySchedule(rawValue: rawValue) + } else { + return nil + } + } + } + + public var overrideHistory: TemporaryScheduleOverrideHistory? { + get { + if let rawValue = object(forKey: Key.overrideHistory.rawValue) as? TemporaryScheduleOverrideHistory.RawValue { + return TemporaryScheduleOverrideHistory(rawValue: rawValue) + } else { + return nil + } + } + set { + set(newValue?.rawValue, forKey: Key.overrideHistory.rawValue) + } + } + + public var lastBedtimeQuery: Date? { + get { + return object(forKey: Key.lastBedtimeQuery.rawValue) as? Date + } + set { + set(newValue, forKey: Key.lastBedtimeQuery.rawValue) + } + } + + public var bedtime: Date? { + get { + return object(forKey: Key.bedtime.rawValue) as? Date + } + set { + set(newValue, forKey: Key.bedtime.rawValue) + } + } + + public var lastProfileExpirationAlertDate: Date? { + get { + return object(forKey: Key.lastProfileExpirationAlertDate.rawValue) as? Date + } + set { + set(newValue, forKey: Key.lastProfileExpirationAlertDate.rawValue) + } + } + + public var lastMissedMealNotification: MissedMealNotification? { + get { + let decoder = JSONDecoder() + guard let data = object(forKey: Key.LastMissedMealNotification.rawValue) as? Data else { + return nil + } + return try? decoder.decode(MissedMealNotification.self, from: data) + } + set { + do { + if let newValue = newValue { + let encoder = JSONEncoder() + let data = try encoder.encode(newValue) + set(data, forKey: Key.LastMissedMealNotification.rawValue) + } else { + set(nil, forKey: Key.LastMissedMealNotification.rawValue) + } + } catch { + assertionFailure("Unable to encode MissedMealNotification") + } + } + } + + public var allowDebugFeatures: Bool { + get { + bool(forKey: Key.allowDebugFeatures.rawValue) + } + set { + set(newValue, forKey: Key.allowDebugFeatures.rawValue) + } + } + + public var allowExperimentalFeatures: Bool { + return bool(forKey: Key.allowExperimentalFeatures.rawValue) + } + + public var allowSimulators: Bool { + return bool(forKey: Key.allowSimulators.rawValue) + } + + public var userRequestedLoopReset: Bool { + get { + bool(forKey: Key.userRequestedLoopReset.rawValue) + } + set { + setValue(newValue, forKey: Key.userRequestedLoopReset.rawValue) + } + } + + public var liveActivity: LiveActivitySettings? { + get { + let decoder = JSONDecoder() + guard let data = object(forKey: Key.liveActivity.rawValue) as? Data else { + return nil + } + return try? decoder.decode(LiveActivitySettings.self, from: data) + } + set { + do { + if let newValue = newValue { + let encoder = JSONEncoder() + let data = try encoder.encode(newValue) + set(data, forKey: Key.liveActivity.rawValue) + } else { + set(nil, forKey: Key.liveActivity.rawValue) + } + } catch { + assertionFailure("Unable to encode MissedMealNotification") + } + } + } + + public func removeLegacyLoopSettings() { + removeObject(forKey: "com.loudnate.Naterade.BasalRateSchedule") + removeObject(forKey: "com.loudnate.Naterade.CarbRatioSchedule") + removeObject(forKey: "com.loudnate.Naterade.InsulinSensitivitySchedule") + removeObject(forKey: "com.loopkit.Loop.defaultRapidActingModel") + removeObject(forKey: "com.loopkit.Loop.loopSettings") + } +} diff --git a/LoopCore/PersistedProperty.swift b/LoopCore/PersistedProperty.swift new file mode 100644 index 0000000000..2da3b1dda4 --- /dev/null +++ b/LoopCore/PersistedProperty.swift @@ -0,0 +1,72 @@ +// +// PersistedProperty.swift +// Loop +// +// Created by Pete Schwamb on 5/29/22. +// Copyright © 2022 LoopKit Authors. All rights reserved. +// + +import Foundation +import os.log + +@propertyWrapper public struct PersistedProperty { + let key: String + let storageURL: URL + + private let log = OSLog(subsystem: "com.loopkit.Loop", category: "PersistedProperty") + + public init(key: String, shared: Bool = false) { + self.key = key + + let documents: URL + + if shared { + let appGroup = Bundle.main.appGroupSuiteName + guard let directoryURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else { + preconditionFailure("Could not get a container directory URL. Please ensure App Groups are set up correctly in entitlements.") + } + documents = directoryURL.appendingPathComponent("com.loopkit.LoopKit", isDirectory: true) + + } else { + guard let localDocuments = try? FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) else { + preconditionFailure("Could not get a documents directory URL.") + } + documents = localDocuments + } + storageURL = documents.appendingPathComponent(key + ".plist") + } + + public var wrappedValue: Value? { + get { + do { + let data = try Data(contentsOf: storageURL) + os_log(.info, "Reading %{public}@ from %{public}@", key, storageURL.absoluteString) + guard let value = try PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? Value else { + os_log(.error, "Unexpected type for %{public}@", key) + return nil + } + return value + } catch { + os_log(.error, "Error reading %{public}@: %{public}@", key, error.localizedDescription) + } + return nil + } + set { + guard let newValue = newValue else { + do { + try FileManager.default.removeItem(at: storageURL) + } catch { + os_log(.error, "Error deleting %{public}@: %{public}@", key, error.localizedDescription) + } + return + } + do { + let data = try PropertyListSerialization.data(fromPropertyList: newValue, format: .binary, options: 0) + try data.write(to: storageURL, options: .atomic) + os_log(.info, "Wrote %{public}@ to %{public}@", key, storageURL.absoluteString) + } catch { + os_log(.error, "Error saving %{public}@: %{public}@", key, error.localizedDescription) + } + } + } +} diff --git a/LoopCore/PersistenceController.swift b/LoopCore/PersistenceController.swift new file mode 100644 index 0000000000..84c40b6113 --- /dev/null +++ b/LoopCore/PersistenceController.swift @@ -0,0 +1,33 @@ +// +// PersistenceController.swift +// Loop +// +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +import LoopKit + + +extension PersistenceController { + public class func controllerInAppGroupDirectory(isReadOnly: Bool = false) -> PersistenceController { + let appGroup = Bundle.main.appGroupSuiteName + guard let directoryURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else { + assertionFailure("Could not get a container directory URL. Please ensure App Groups are set up correctly in entitlements.") + return self.init(directoryURL: URL(fileURLWithPath: "/")) + } + + let isReadOnly = isReadOnly || Bundle.main.isAppExtension + + return self.init(directoryURL: directoryURL.appendingPathComponent("com.loopkit.LoopKit", isDirectory: true), isReadOnly: isReadOnly) + } + + public class func controllerInLocalDirectory() -> PersistenceController { + guard let directoryURL = try? FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) else { + fatalError("Could not access the document directory of the current process") + } + + let isReadOnly = Bundle.main.isAppExtension + + return self.init(directoryURL: directoryURL.appendingPathComponent("com.loopkit.LoopKit"), isReadOnly: isReadOnly) + } +} diff --git a/LoopCore/PotentialCarbEntryUserInfo.swift b/LoopCore/PotentialCarbEntryUserInfo.swift new file mode 100644 index 0000000000..701ee028f9 --- /dev/null +++ b/LoopCore/PotentialCarbEntryUserInfo.swift @@ -0,0 +1,46 @@ +// +// PotentialCarbEntryUserInfo.swift +// Naterade +// +// Created by Nathan Racklyeft on 1/23/16. +// Copyright © 2016 Nathan Racklyeft. All rights reserved. +// + +import Foundation +import LoopKit + + +public struct PotentialCarbEntryUserInfo { + public let carbEntry: NewCarbEntry + + public init(carbEntry: NewCarbEntry) { + self.carbEntry = carbEntry + } +} + + +extension PotentialCarbEntryUserInfo: RawRepresentable { + public typealias RawValue = [String: Any] + + static let version = 1 + public static let name = "PotentialCarbEntryUserInfo" + + public init?(rawValue: RawValue) { + guard rawValue["v"] as? Int == type(of: self).version && rawValue["name"] as? String == PotentialCarbEntryUserInfo.name, + let value = rawValue["ce"] as? NewCarbEntry.RawValue, + let carbEntry = NewCarbEntry(rawValue: value) + else { + return nil + } + + self.carbEntry = carbEntry + } + + public var rawValue: RawValue { + return [ + "v": type(of: self).version, + "name": PotentialCarbEntryUserInfo.name, + "ce": carbEntry.rawValue, + ] + } +} diff --git a/LoopCore/Result.swift b/LoopCore/Result.swift new file mode 100644 index 0000000000..580595159d --- /dev/null +++ b/LoopCore/Result.swift @@ -0,0 +1,12 @@ +// +// Result.swift +// Loop +// +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + + +public enum Result { + case success(T) + case failure(Error) +} diff --git a/LoopTests/CriticalEventLogTests.swift b/LoopTests/CriticalEventLogTests.swift new file mode 100644 index 0000000000..9114e83d15 --- /dev/null +++ b/LoopTests/CriticalEventLogTests.swift @@ -0,0 +1,34 @@ +// +// CriticalEventLogTests.swift +// LoopTests +// +// Created by Darin Krauss on 8/26/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit + +class MockOutputStream: DataOutputStream { + var error: Error? = nil + var data: Data = Data() + var finished = false + + var streamError: Error? { return error } + + func write(_ data: Data) throws { + if let error = self.error { + throw error + } + self.data.append(data) + } + + func finish(sync: Bool) throws { + if let error = self.error { + throw error + } + finished = true + } + + var string: String { String(data: data, encoding: .utf8)! } +} diff --git a/LoopTests/DiagnosticLogTests.swift b/LoopTests/DiagnosticLogTests.swift new file mode 100644 index 0000000000..ab2c976d3d --- /dev/null +++ b/LoopTests/DiagnosticLogTests.swift @@ -0,0 +1,151 @@ +// +// DiagnosticLogTests.swift +// LoopKitTests +// +// Created by Darin Krauss on 8/23/19. +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import XCTest +import os.log +import LoopKit +@testable import Loop + +class DiagnosticLogTests: XCTestCase { + + fileprivate var testLogging: TestLogging! + + override func setUp() { + testLogging = TestLogging() + SharedLogging.instance = testLogging + } + + override func tearDown() { + SharedLogging.instance = nil + testLogging = nil + } + + func testInitializer() { + XCTAssertNotNil(DiagnosticLog(subsystem: "subsystem", category: "category")) + } + + func testDebugWithoutArguments() { + let diagnosticLog = DiagnosticLog(subsystem: "debug subsystem", category: "debug category") + + diagnosticLog.debug("debug message without arguments") + + XCTAssertEqual(testLogging.message.description, "debug message without arguments") + XCTAssertEqual(testLogging.subsystem, "debug subsystem") + XCTAssertEqual(testLogging.category, "debug category") + XCTAssertEqual(testLogging.type, .debug) + XCTAssertEqual(testLogging.args.count, 0) + } + + func testDebugWithArguments() { + let diagnosticLog = DiagnosticLog(subsystem: "debug subsystem", category: "debug category") + + diagnosticLog.debug("debug message with arguments", "a") + + XCTAssertEqual(testLogging.message.description, "debug message with arguments") + XCTAssertEqual(testLogging.subsystem, "debug subsystem") + XCTAssertEqual(testLogging.category, "debug category") + XCTAssertEqual(testLogging.type, .debug) + XCTAssertEqual(testLogging.args.count, 1) + } + + func testInfoWithoutArguments() { + let diagnosticLog = DiagnosticLog(subsystem: "info subsystem", category: "info category") + + diagnosticLog.info("info message without arguments") + + XCTAssertEqual(testLogging.message.description, "info message without arguments") + XCTAssertEqual(testLogging.subsystem, "info subsystem") + XCTAssertEqual(testLogging.category, "info category") + XCTAssertEqual(testLogging.type, .info) + XCTAssertEqual(testLogging.args.count, 0) + } + + func testInfoWithArguments() { + let diagnosticLog = DiagnosticLog(subsystem: "info subsystem", category: "info category") + + diagnosticLog.info("info message with arguments", "a", "b") + + XCTAssertEqual(testLogging.message.description, "info message with arguments") + XCTAssertEqual(testLogging.subsystem, "info subsystem") + XCTAssertEqual(testLogging.category, "info category") + XCTAssertEqual(testLogging.type, .info) + XCTAssertEqual(testLogging.args.count, 2) + } + + func testDefaultWithoutArguments() { + let diagnosticLog = DiagnosticLog(subsystem: "default subsystem", category: "default category") + + diagnosticLog.default("default message without arguments") + + XCTAssertEqual(testLogging.message.description, "default message without arguments") + XCTAssertEqual(testLogging.subsystem, "default subsystem") + XCTAssertEqual(testLogging.category, "default category") + XCTAssertEqual(testLogging.type, .default) + XCTAssertEqual(testLogging.args.count, 0) + } + + func testDefaultWithArguments() { + let diagnosticLog = DiagnosticLog(subsystem: "default subsystem", category: "default category") + + diagnosticLog.default("default message with arguments", "a", "b", "c") + + XCTAssertEqual(testLogging.message.description, "default message with arguments") + XCTAssertEqual(testLogging.subsystem, "default subsystem") + XCTAssertEqual(testLogging.category, "default category") + XCTAssertEqual(testLogging.type, .default) + XCTAssertEqual(testLogging.args.count, 3) + } + + func testErrorWithoutArguments() { + let diagnosticLog = DiagnosticLog(subsystem: "error subsystem", category: "error category") + + diagnosticLog.error("error message without arguments") + + XCTAssertEqual(testLogging.message.description, "error message without arguments") + XCTAssertEqual(testLogging.subsystem, "error subsystem") + XCTAssertEqual(testLogging.category, "error category") + XCTAssertEqual(testLogging.type, .error) + XCTAssertEqual(testLogging.args.count, 0) + } + + func testErrorWithArguments() { + let diagnosticLog = DiagnosticLog(subsystem: "error subsystem", category: "error category") + + diagnosticLog.error("error message with arguments", "a", "b", "c", "d") + + XCTAssertEqual(testLogging.message.description, "error message with arguments") + XCTAssertEqual(testLogging.subsystem, "error subsystem") + XCTAssertEqual(testLogging.category, "error category") + XCTAssertEqual(testLogging.type, .error) + XCTAssertEqual(testLogging.args.count, 4) + } + +} + +fileprivate class TestLogging: Logging { + + var message: StaticString! + + var subsystem: String! + + var category: String! + + var type: OSLogType! + + var args: [CVarArg]! + + init() {} + + func log (_ message: StaticString, subsystem: String, category: String, type: OSLogType, _ args: [CVarArg]) { + self.message = message + self.subsystem = subsystem + self.category = category + self.type = type + self.args = args + } +} diff --git a/LoopTests/Fixtures/basal_profile.json b/LoopTests/Fixtures/basal_profile.json new file mode 100644 index 0000000000..0f1ca81114 --- /dev/null +++ b/LoopTests/Fixtures/basal_profile.json @@ -0,0 +1,14 @@ +[ + { + "i": 0, + "start": "00:00:00", + "rate": 1, + "minutes": 0 + }, + { + "i": 1, + "start": "15:00:00", + "rate": 0.85, + "minutes": 900 + } +] diff --git a/LoopTests/Fixtures/counteraction_effect_falling_glucose.json b/LoopTests/Fixtures/counteraction_effect_falling_glucose.json new file mode 100644 index 0000000000..a8ce5191b5 --- /dev/null +++ b/LoopTests/Fixtures/counteraction_effect_falling_glucose.json @@ -0,0 +1,20 @@ +[ + { + "value" : -4.5999999999999988, + "startDate" : "2015-10-25T19:15:00", + "unit" : "mg\/min·dL", + "endDate" : "2015-10-25T19:20:00" + }, + { + "value" : -2.5999999999999996, + "startDate" : "2015-10-25T19:20:00", + "unit" : "mg\/min·dL", + "endDate" : "2015-10-25T19:25:00" + }, + { + "value" : -0.59999999999999998, + "startDate" : "2015-10-25T19:25:00", + "unit" : "mg\/min·dL", + "endDate" : "2015-10-25T19:30:00" + } +] diff --git a/LoopTests/Fixtures/dynamic_glucose_effect_partially_observed.json b/LoopTests/Fixtures/dynamic_glucose_effect_partially_observed.json new file mode 100644 index 0000000000..a1f40e0751 --- /dev/null +++ b/LoopTests/Fixtures/dynamic_glucose_effect_partially_observed.json @@ -0,0 +1,372 @@ +[ + { + "amount" : 0, + "unit" : "mg\/dL", + "date" : "2015-10-25T19:30:00" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-25T19:35:00", + "amount" : 0 + }, + { + "unit" : "mg\/dL", + "amount" : 0, + "date" : "2015-10-25T19:40:00" + }, + { + "amount" : 25.000000000000004, + "date" : "2015-10-25T19:45:00", + "unit" : "mg\/dL" + }, + { + "date" : "2015-10-25T19:50:00", + "amount" : 40, + "unit" : "mg\/dL" + }, + { + "amount" : 50.000000000000007, + "unit" : "mg\/dL", + "date" : "2015-10-25T19:55:00" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-25T20:00:00", + "amount" : 65 + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-25T20:05:00", + "amount" : 90 + }, + { + "date" : "2015-10-25T20:10:00", + "amount" : 94.399999999999991, + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "amount" : 98.983333333333334, + "date" : "2015-10-25T20:15:00" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-25T20:20:00", + "amount" : 103.56666666666668 + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-25T20:25:00", + "amount" : 108.15000000000001 + }, + { + "date" : "2015-10-25T20:30:00", + "unit" : "mg\/dL", + "amount" : 112.73333333333333 + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-25T20:35:00", + "amount" : 117.31666666666668 + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-25T20:40:00", + "amount" : 121.90000000000001 + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-25T20:45:00", + "amount" : 126.48333333333333 + }, + { + "date" : "2015-10-25T20:50:00", + "amount" : 131.06666666666666, + "unit" : "mg\/dL" + }, + { + "amount" : 135.65000000000001, + "unit" : "mg\/dL", + "date" : "2015-10-25T20:55:00" + }, + { + "date" : "2015-10-25T21:00:00", + "unit" : "mg\/dL", + "amount" : 140.23333333333335 + }, + { + "amount" : 144.81666666666666, + "unit" : "mg\/dL", + "date" : "2015-10-25T21:05:00" + }, + { + "amount" : 149.40000000000001, + "date" : "2015-10-25T21:10:00", + "unit" : "mg\/dL" + }, + { + "date" : "2015-10-25T21:15:00", + "amount" : 153.98333333333335, + "unit" : "mg\/dL" + }, + { + "date" : "2015-10-25T21:20:00", + "amount" : 158.56666666666666, + "unit" : "mg\/dL" + }, + { + "date" : "2015-10-25T21:25:00", + "unit" : "mg\/dL", + "amount" : 163.15000000000001 + }, + { + "unit" : "mg\/dL", + "amount" : 167.73333333333335, + "date" : "2015-10-25T21:30:00" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-25T21:35:00", + "amount" : 172.31666666666669 + }, + { + "date" : "2015-10-25T21:40:00", + "unit" : "mg\/dL", + "amount" : 176.90000000000001 + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-25T21:45:00", + "amount" : 181.48333333333335 + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-25T21:50:00", + "amount" : 186.06666666666669 + }, + { + "amount" : 190.65000000000001, + "date" : "2015-10-25T21:55:00", + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-25T22:00:00", + "amount" : 195.23333333333335 + }, + { + "date" : "2015-10-25T22:05:00", + "amount" : 199.81666666666669, + "unit" : "mg\/dL" + }, + { + "date" : "2015-10-25T22:10:00", + "amount" : 204.40000000000001, + "unit" : "mg\/dL" + }, + { + "date" : "2015-10-25T22:15:00", + "amount" : 208.98333333333335, + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "amount" : 213.56666666666669, + "date" : "2015-10-25T22:20:00" + }, + { + "amount" : 218.15000000000001, + "date" : "2015-10-25T22:25:00", + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-25T22:30:00", + "amount" : 220 + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-25T22:35:00", + "amount" : 220 + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-25T22:40:00", + "amount" : 220 + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-25T22:45:00", + "amount" : 220 + }, + { + "date" : "2015-10-25T22:50:00", + "amount" : 220, + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-25T22:55:00", + "amount" : 220 + }, + { + "amount" : 220, + "unit" : "mg\/dL", + "date" : "2015-10-25T23:00:00" + }, + { + "amount" : 220, + "date" : "2015-10-25T23:05:00", + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-25T23:10:00", + "amount" : 220 + }, + { + "unit" : "mg\/dL", + "amount" : 220, + "date" : "2015-10-25T23:15:00" + }, + { + "date" : "2015-10-25T23:20:00", + "amount" : 220, + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-25T23:25:00", + "amount" : 220 + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-25T23:30:00", + "amount" : 220 + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-25T23:35:00", + "amount" : 220 + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-25T23:40:00", + "amount" : 220 + }, + { + "date" : "2015-10-25T23:45:00", + "amount" : 220, + "unit" : "mg\/dL" + }, + { + "amount" : 220, + "unit" : "mg\/dL", + "date" : "2015-10-25T23:50:00" + }, + { + "amount" : 220, + "date" : "2015-10-25T23:55:00", + "unit" : "mg\/dL" + }, + { + "amount" : 220, + "unit" : "mg\/dL", + "date" : "2015-10-26T00:00:00" + }, + { + "amount" : 220, + "date" : "2015-10-26T00:05:00", + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-26T00:10:00", + "amount" : 220 + }, + { + "unit" : "mg\/dL", + "amount" : 220, + "date" : "2015-10-26T00:15:00" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-26T00:20:00", + "amount" : 220 + }, + { + "amount" : 220, + "date" : "2015-10-26T00:25:00", + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-26T00:30:00", + "amount" : 220 + }, + { + "amount" : 220, + "date" : "2015-10-26T00:35:00", + "unit" : "mg\/dL" + }, + { + "date" : "2015-10-26T00:40:00", + "unit" : "mg\/dL", + "amount" : 220 + }, + { + "date" : "2015-10-26T00:45:00", + "amount" : 220, + "unit" : "mg\/dL" + }, + { + "date" : "2015-10-26T00:50:00", + "amount" : 220, + "unit" : "mg\/dL" + }, + { + "date" : "2015-10-26T00:55:00", + "unit" : "mg\/dL", + "amount" : 220 + }, + { + "date" : "2015-10-26T01:00:00", + "unit" : "mg\/dL", + "amount" : 220 + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-26T01:05:00", + "amount" : 220 + }, + { + "unit" : "mg\/dL", + "amount" : 220, + "date" : "2015-10-26T01:10:00" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-26T01:15:00", + "amount" : 220 + }, + { + "amount" : 220, + "unit" : "mg\/dL", + "date" : "2015-10-26T01:20:00" + }, + { + "unit" : "mg\/dL", + "amount" : 220, + "date" : "2015-10-26T01:25:00" + }, + { + "amount" : 220, + "date" : "2015-10-26T01:30:00", + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-26T01:35:00", + "amount" : 220 + } +] diff --git a/LoopTests/Fixtures/flat_and_stable/flat_and_stable_carb_effect.json b/LoopTests/Fixtures/flat_and_stable/flat_and_stable_carb_effect.json new file mode 100644 index 0000000000..0637a088a0 --- /dev/null +++ b/LoopTests/Fixtures/flat_and_stable/flat_and_stable_carb_effect.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/LoopTests/Fixtures/flat_and_stable/flat_and_stable_counteraction_effect.json b/LoopTests/Fixtures/flat_and_stable/flat_and_stable_counteraction_effect.json new file mode 100644 index 0000000000..28e66e4932 --- /dev/null +++ b/LoopTests/Fixtures/flat_and_stable/flat_and_stable_counteraction_effect.json @@ -0,0 +1,230 @@ +[ + { + "startDate": "2020-08-11T17:25:13", + "endDate": "2020-08-11T17:30:13", + "unit": "mg\/min·dL", + "value": -0.17427698848393616 + }, + { + "startDate": "2020-08-11T17:30:13", + "endDate": "2020-08-11T17:35:13", + "unit": "mg\/min·dL", + "value": -0.172884893111717 + }, + { + "startDate": "2020-08-11T17:35:13", + "endDate": "2020-08-11T17:40:13", + "unit": "mg\/min·dL", + "value": -0.16620062119698026 + }, + { + "startDate": "2020-08-11T17:40:13", + "endDate": "2020-08-11T17:45:13", + "unit": "mg\/min·dL", + "value": -0.15239126546960888 + }, + { + "startDate": "2020-08-11T17:45:13", + "endDate": "2020-08-11T17:50:13", + "unit": "mg\/min·dL", + "value": -0.13844620387243192 + }, + { + "startDate": "2020-08-11T17:50:13", + "endDate": "2020-08-11T17:55:13", + "unit": "mg\/min·dL", + "value": -0.12440053903505803 + }, + { + "startDate": "2020-08-11T17:55:13", + "endDate": "2020-08-11T18:00:13", + "unit": "mg\/min·dL", + "value": -0.1102033787233404 + }, + { + "startDate": "2020-08-11T18:00:13", + "endDate": "2020-08-11T18:05:13", + "unit": "mg\/min·dL", + "value": -0.09582040633985235 + }, + { + "startDate": "2020-08-11T18:05:13", + "endDate": "2020-08-11T18:10:13", + "unit": "mg\/min·dL", + "value": -0.08123290693932182 + }, + { + "startDate": "2020-08-11T18:10:13", + "endDate": "2020-08-11T18:15:13", + "unit": "mg\/min·dL", + "value": -0.06643676319414542 + }, + { + "startDate": "2020-08-11T18:15:13", + "endDate": "2020-08-11T18:20:13", + "unit": "mg\/min·dL", + "value": -0.051441423013083416 + }, + { + "startDate": "2020-08-11T18:20:13", + "endDate": "2020-08-11T18:25:13", + "unit": "mg\/min·dL", + "value": -0.0362688411105418 + }, + { + "startDate": "2020-08-11T18:25:13", + "endDate": "2020-08-11T18:30:13", + "unit": "mg\/min·dL", + "value": -0.020952397377567107 + }, + { + "startDate": "2020-08-11T18:30:13", + "endDate": "2020-08-11T18:35:13", + "unit": "mg\/min·dL", + "value": -0.005535795415598254 + }, + { + "startDate": "2020-08-11T18:35:13", + "endDate": "2020-08-11T18:40:13", + "unit": "mg\/min·dL", + "value": 0.009928054942067454 + }, + { + "startDate": "2020-08-11T18:40:13", + "endDate": "2020-08-11T18:45:13", + "unit": "mg\/min·dL", + "value": 0.02537816688081129 + }, + { + "startDate": "2020-08-11T18:45:13", + "endDate": "2020-08-11T18:50:13", + "unit": "mg\/min·dL", + "value": 0.040746613021907935 + }, + { + "startDate": "2020-08-11T18:50:13", + "endDate": "2020-08-11T18:55:13", + "unit": "mg\/min·dL", + "value": 0.05595966408835151 + }, + { + "startDate": "2020-08-11T18:55:13", + "endDate": "2020-08-11T19:00:13", + "unit": "mg\/min·dL", + "value": 0.07093892464198123 + }, + { + "startDate": "2020-08-11T19:00:13", + "endDate": "2020-08-11T19:05:13", + "unit": "mg\/min·dL", + "value": 0.08560246050964196 + }, + { + "startDate": "2020-08-11T19:05:13", + "endDate": "2020-08-11T19:10:13", + "unit": "mg\/min·dL", + "value": 0.09986591236653002 + }, + { + "startDate": "2020-08-11T19:10:13", + "endDate": "2020-08-11T19:15:13", + "unit": "mg\/min·dL", + "value": 0.11364358985065513 + }, + { + "startDate": "2020-08-11T19:15:13", + "endDate": "2020-08-11T19:20:13", + "unit": "mg\/min·dL", + "value": 0.12684954054338973 + }, + { + "startDate": "2020-08-11T19:20:13", + "endDate": "2020-08-11T19:25:13", + "unit": "mg\/min·dL", + "value": 0.13939858816698666 + }, + { + "startDate": "2020-08-11T19:25:13", + "endDate": "2020-08-11T19:30:13", + "unit": "mg\/min·dL", + "value": 0.15120733442007542 + }, + { + "startDate": "2020-08-11T19:30:13", + "endDate": "2020-08-11T19:35:13", + "unit": "mg\/min·dL", + "value": 0.16219511899486355 + }, + { + "startDate": "2020-08-11T19:35:13", + "endDate": "2020-08-11T19:40:13", + "unit": "mg\/min·dL", + "value": 0.17228493249382398 + }, + { + "startDate": "2020-08-11T19:40:13", + "endDate": "2020-08-11T19:45:13", + "unit": "mg\/min·dL", + "value": 0.1814042771871964 + }, + { + "startDate": "2020-08-11T19:45:13", + "endDate": "2020-08-11T19:50:13", + "unit": "mg\/min·dL", + "value": 0.18948597082306992 + }, + { + "startDate": "2020-08-11T19:50:13", + "endDate": "2020-08-11T19:55:13", + "unit": "mg\/min·dL", + "value": 0.196468889016708 + }, + { + "startDate": "2020-08-11T19:55:13", + "endDate": "2020-08-11T20:00:13", + "unit": "mg\/min·dL", + "value": 0.20229864210263385 + }, + { + "startDate": "2020-08-11T20:00:13", + "endDate": "2020-08-11T20:05:13", + "unit": "mg\/min·dL", + "value": 0.2069281827278072 + }, + { + "startDate": "2020-08-11T20:05:13", + "endDate": "2020-08-11T20:10:13", + "unit": "mg\/min·dL", + "value": 0.21031834089428644 + }, + { + "startDate": "2020-08-11T20:10:13", + "endDate": "2020-08-11T20:15:13", + "unit": "mg\/min·dL", + "value": 0.21243828362120673 + }, + { + "startDate": "2020-08-11T20:15:13", + "endDate": "2020-08-11T20:20:13", + "unit": "mg\/min·dL", + "value": 0.213265896884441 + }, + { + "startDate": "2020-08-11T20:20:13", + "endDate": "2020-08-11T20:25:13", + "unit": "mg\/min·dL", + "value": 0.212788088004482 + }, + { + "startDate": "2020-08-11T20:25:13", + "endDate": "2020-08-11T20:32:50", + "unit": "mg\/min·dL", + "value": 0.17396858033976298 + }, + { + "startDate": "2020-08-11T20:32:50", + "endDate": "2020-08-11T20:45:02", + "unit": "mg\/min·dL", + "value": 0.18555611348135584 + } +] diff --git a/LoopTests/Fixtures/flat_and_stable/flat_and_stable_insulin_effect.json b/LoopTests/Fixtures/flat_and_stable/flat_and_stable_insulin_effect.json new file mode 100644 index 0000000000..e83d91e34b --- /dev/null +++ b/LoopTests/Fixtures/flat_and_stable/flat_and_stable_insulin_effect.json @@ -0,0 +1,377 @@ +[ + { + "date": "2020-08-11T20:50:00", + "amount": -0.21997829342610006, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T20:55:00", + "amount": -0.4261395410590354, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T21:00:00", + "amount": -0.7096583179105603, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T21:05:00", + "amount": -1.0621881093826662, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T21:10:00", + "amount": -1.4740341427597377, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T21:15:00", + "amount": -1.9363888584472242, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T21:20:00", + "amount": -2.441263560467393, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T21:25:00", + "amount": -2.9814248393095815, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T21:30:00", + "amount": -3.5503354629325354, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T21:35:00", + "amount": -4.142099441439137, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T21:40:00", + "amount": -4.751410989493849, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T21:45:00", + "amount": -5.373507127973413, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T21:50:00", + "amount": -6.004123682698768, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T21:55:00", + "amount": -6.639454453454031, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T22:00:00", + "amount": -7.276113340916081, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T22:05:00", + "amount": -7.911099232651796, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T22:10:00", + "amount": -8.541763462042216, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T22:15:00", + "amount": -9.165779665913185, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T22:20:00", + "amount": -9.7811158778376, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T22:25:00", + "amount": -10.386008704568662, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T22:30:00", + "amount": -10.97893944290868, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T22:35:00", + "amount": -11.558612003552255, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T22:40:00", + "amount": -12.12393251710345, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T22:45:00", + "amount": -12.673990505588074, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T22:50:00", + "amount": -13.20804151039699, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T22:55:00", + "amount": -13.725491074735217, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:00:00", + "amount": -14.225879985343203, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:05:00", + "amount": -14.708870684528089, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:10:00", + "amount": -15.174234769419765, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:15:00", + "amount": -15.62184150087279, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:20:00", + "amount": -16.05164724959357, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:25:00", + "amount": -16.463685811903716, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:30:00", + "amount": -16.858059532075337, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:35:00", + "amount": -17.234931172410647, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:40:00", + "amount": -17.594516476204813, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:45:00", + "amount": -17.93707737244358, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:50:00", + "amount": -18.26291577456192, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:55:00", + "amount": -18.572367928841064, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:00:00", + "amount": -18.86579927106296, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:05:00", + "amount": -19.14359975288623, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:10:00", + "amount": -19.406179602068192, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:15:00", + "amount": -19.65396548314523, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:20:00", + "amount": -19.887397027509305, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:25:00", + "amount": -20.106923703991654, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:30:00", + "amount": -20.313002003095814, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:35:00", + "amount": -20.506092909919293, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:40:00", + "amount": -20.686659642575187, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:45:00", + "amount": -20.855165634580125, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:50:00", + "amount": -21.012072741219335, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:55:00", + "amount": -21.157839651341906, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:00:00", + "amount": -21.292920487384542, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:05:00", + "amount": -21.41776357767731, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:10:00", + "amount": -21.532810386255537, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:15:00", + "amount": -21.638494586493437, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:20:00", + "amount": -21.735241265892345, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:25:00", + "amount": -21.823466250304694, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:30:00", + "amount": -21.903575536757817, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:35:00", + "amount": -21.975964824864416, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:40:00", + "amount": -22.041019137572135, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:45:00", + "amount": -22.099112522717494, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:50:00", + "amount": -22.150607827512605, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:55:00", + "amount": -22.19585653870966, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:00:00", + "amount": -22.235198681761787, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:05:00", + "amount": -22.268962772831713, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:10:00", + "amount": -22.297465817994798, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:15:00", + "amount": -22.32101335444279, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:20:00", + "amount": -22.33989952892162, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:25:00", + "amount": -22.354407209032342, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:30:00", + "amount": -22.364808123391935, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:35:00", + "amount": -22.371363026991066, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:40:00", + "amount": -22.374909853783546, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:45:00", + "amount": -22.37661999205696, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:50:00", + "amount": -22.377128476655095, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:55:00", + "amount": -22.377194743725912, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T03:00:00", + "amount": -22.37719474401739, + "unit": "mg/dL" + } +] \ No newline at end of file diff --git a/LoopTests/Fixtures/flat_and_stable/flat_and_stable_momentum_effect.json b/LoopTests/Fixtures/flat_and_stable/flat_and_stable_momentum_effect.json new file mode 100644 index 0000000000..0637a088a0 --- /dev/null +++ b/LoopTests/Fixtures/flat_and_stable/flat_and_stable_momentum_effect.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/LoopTests/Fixtures/flat_and_stable/flat_and_stable_predicted_glucose.json b/LoopTests/Fixtures/flat_and_stable/flat_and_stable_predicted_glucose.json new file mode 100644 index 0000000000..a969a34495 --- /dev/null +++ b/LoopTests/Fixtures/flat_and_stable/flat_and_stable_predicted_glucose.json @@ -0,0 +1,382 @@ +[ + { + "date": "2020-08-11T20:45:02", + "unit": "mg/dL", + "amount": 123.42849966275706 + }, + { + "date": "2020-08-11T20:50:00", + "unit": "mg/dL", + "amount": 124.26018046469977 + }, + { + "date": "2020-08-11T20:55:00", + "unit": "mg/dL", + "amount": 124.81009267337839 + }, + { + "date": "2020-08-11T21:00:00", + "unit": "mg/dL", + "amount": 125.20704000720727 + }, + { + "date": "2020-08-11T21:05:00", + "unit": "mg/dL", + "amount": 125.4593689807844 + }, + { + "date": "2020-08-11T21:10:00", + "unit": "mg/dL", + "amount": 125.57677436682542 + }, + { + "date": "2020-08-11T21:15:00", + "unit": "mg/dL", + "amount": 125.56806372492487 + }, + { + "date": "2020-08-11T21:20:00", + "unit": "mg/dL", + "amount": 125.44122575106047 + }, + { + "date": "2020-08-11T21:25:00", + "unit": "mg/dL", + "amount": 125.2034938547429 + }, + { + "date": "2020-08-11T21:30:00", + "unit": "mg/dL", + "amount": 124.86140526801341 + }, + { + "date": "2020-08-11T21:35:00", + "unit": "mg/dL", + "amount": 124.42085598076912 + }, + { + "date": "2020-08-11T21:40:00", + "unit": "mg/dL", + "amount": 123.88715177834555 + }, + { + "date": "2020-08-11T21:45:00", + "unit": "mg/dL", + "amount": 123.26505563986599 + }, + { + "date": "2020-08-11T21:50:00", + "unit": "mg/dL", + "amount": 122.63443908514064 + }, + { + "date": "2020-08-11T21:55:00", + "unit": "mg/dL", + "amount": 121.99910831438538 + }, + { + "date": "2020-08-11T22:00:00", + "unit": "mg/dL", + "amount": 121.36244942692333 + }, + { + "date": "2020-08-11T22:05:00", + "unit": "mg/dL", + "amount": 120.72746353518762 + }, + { + "date": "2020-08-11T22:10:00", + "unit": "mg/dL", + "amount": 120.0967993057972 + }, + { + "date": "2020-08-11T22:15:00", + "unit": "mg/dL", + "amount": 119.47278310192624 + }, + { + "date": "2020-08-11T22:20:00", + "unit": "mg/dL", + "amount": 118.85744689000182 + }, + { + "date": "2020-08-11T22:25:00", + "unit": "mg/dL", + "amount": 118.25255406327076 + }, + { + "date": "2020-08-11T22:30:00", + "unit": "mg/dL", + "amount": 117.65962332493075 + }, + { + "date": "2020-08-11T22:35:00", + "unit": "mg/dL", + "amount": 117.07995076428718 + }, + { + "date": "2020-08-11T22:40:00", + "unit": "mg/dL", + "amount": 116.51463025073598 + }, + { + "date": "2020-08-11T22:45:00", + "unit": "mg/dL", + "amount": 115.96457226225135 + }, + { + "date": "2020-08-11T22:50:00", + "unit": "mg/dL", + "amount": 115.43052125744244 + }, + { + "date": "2020-08-11T22:55:00", + "unit": "mg/dL", + "amount": 114.91307169310421 + }, + { + "date": "2020-08-11T23:00:00", + "unit": "mg/dL", + "amount": 114.41268278249623 + }, + { + "date": "2020-08-11T23:05:00", + "unit": "mg/dL", + "amount": 113.92969208331135 + }, + { + "date": "2020-08-11T23:10:00", + "unit": "mg/dL", + "amount": 113.46432799841968 + }, + { + "date": "2020-08-11T23:15:00", + "unit": "mg/dL", + "amount": 113.01672126696666 + }, + { + "date": "2020-08-11T23:20:00", + "unit": "mg/dL", + "amount": 112.58691551824587 + }, + { + "date": "2020-08-11T23:25:00", + "unit": "mg/dL", + "amount": 112.17487695593573 + }, + { + "date": "2020-08-11T23:30:00", + "unit": "mg/dL", + "amount": 111.78050323576412 + }, + { + "date": "2020-08-11T23:35:00", + "unit": "mg/dL", + "amount": 111.4036315954288 + }, + { + "date": "2020-08-11T23:40:00", + "unit": "mg/dL", + "amount": 111.04404629163464 + }, + { + "date": "2020-08-11T23:45:00", + "unit": "mg/dL", + "amount": 110.70148539539588 + }, + { + "date": "2020-08-11T23:50:00", + "unit": "mg/dL", + "amount": 110.37564699327754 + }, + { + "date": "2020-08-11T23:55:00", + "unit": "mg/dL", + "amount": 110.0661948389984 + }, + { + "date": "2020-08-12T00:00:00", + "unit": "mg/dL", + "amount": 109.7727634967765 + }, + { + "date": "2020-08-12T00:05:00", + "unit": "mg/dL", + "amount": 109.49496301495324 + }, + { + "date": "2020-08-12T00:10:00", + "unit": "mg/dL", + "amount": 109.23238316577128 + }, + { + "date": "2020-08-12T00:15:00", + "unit": "mg/dL", + "amount": 108.98459728469425 + }, + { + "date": "2020-08-12T00:20:00", + "unit": "mg/dL", + "amount": 108.75116574033018 + }, + { + "date": "2020-08-12T00:25:00", + "unit": "mg/dL", + "amount": 108.53163906384783 + }, + { + "date": "2020-08-12T00:30:00", + "unit": "mg/dL", + "amount": 108.32556076474367 + }, + { + "date": "2020-08-12T00:35:00", + "unit": "mg/dL", + "amount": 108.1324698579202 + }, + { + "date": "2020-08-12T00:40:00", + "unit": "mg/dL", + "amount": 107.95190312526431 + }, + { + "date": "2020-08-12T00:45:00", + "unit": "mg/dL", + "amount": 107.78339713325937 + }, + { + "date": "2020-08-12T00:50:00", + "unit": "mg/dL", + "amount": 107.62649002662016 + }, + { + "date": "2020-08-12T00:55:00", + "unit": "mg/dL", + "amount": 107.48072311649759 + }, + { + "date": "2020-08-12T01:00:00", + "unit": "mg/dL", + "amount": 107.34564228045495 + }, + { + "date": "2020-08-12T01:05:00", + "unit": "mg/dL", + "amount": 107.22079919016218 + }, + { + "date": "2020-08-12T01:10:00", + "unit": "mg/dL", + "amount": 107.10575238158395 + }, + { + "date": "2020-08-12T01:15:00", + "unit": "mg/dL", + "amount": 107.00006818134605 + }, + { + "date": "2020-08-12T01:20:00", + "unit": "mg/dL", + "amount": 106.90332150194715 + }, + { + "date": "2020-08-12T01:25:00", + "unit": "mg/dL", + "amount": 106.8150965175348 + }, + { + "date": "2020-08-12T01:30:00", + "unit": "mg/dL", + "amount": 106.73498723108167 + }, + { + "date": "2020-08-12T01:35:00", + "unit": "mg/dL", + "amount": 106.66259794297508 + }, + { + "date": "2020-08-12T01:40:00", + "unit": "mg/dL", + "amount": 106.59754363026737 + }, + { + "date": "2020-08-12T01:45:00", + "unit": "mg/dL", + "amount": 106.53945024512201 + }, + { + "date": "2020-08-12T01:50:00", + "unit": "mg/dL", + "amount": 106.4879549403269 + }, + { + "date": "2020-08-12T01:55:00", + "unit": "mg/dL", + "amount": 106.44270622912984 + }, + { + "date": "2020-08-12T02:00:00", + "unit": "mg/dL", + "amount": 106.40336408607772 + }, + { + "date": "2020-08-12T02:05:00", + "unit": "mg/dL", + "amount": 106.36959999500779 + }, + { + "date": "2020-08-12T02:10:00", + "unit": "mg/dL", + "amount": 106.34109694984471 + }, + { + "date": "2020-08-12T02:15:00", + "unit": "mg/dL", + "amount": 106.31754941339672 + }, + { + "date": "2020-08-12T02:20:00", + "unit": "mg/dL", + "amount": 106.2986632389179 + }, + { + "date": "2020-08-12T02:25:00", + "unit": "mg/dL", + "amount": 106.28415555880717 + }, + { + "date": "2020-08-12T02:30:00", + "unit": "mg/dL", + "amount": 106.27375464444758 + }, + { + "date": "2020-08-12T02:35:00", + "unit": "mg/dL", + "amount": 106.26719974084844 + }, + { + "date": "2020-08-12T02:40:00", + "unit": "mg/dL", + "amount": 106.26365291405597 + }, + { + "date": "2020-08-12T02:45:00", + "unit": "mg/dL", + "amount": 106.26194277578256 + }, + { + "date": "2020-08-12T02:50:00", + "unit": "mg/dL", + "amount": 106.26143429118443 + }, + { + "date": "2020-08-12T02:55:00", + "unit": "mg/dL", + "amount": 106.26136802411361 + }, + { + "date": "2020-08-12T03:00:00", + "unit": "mg/dL", + "amount": 106.26136802382213 + } +] \ No newline at end of file diff --git a/LoopTests/Fixtures/high_and_falling/high_and_falling_carb_effect.json b/LoopTests/Fixtures/high_and_falling/high_and_falling_carb_effect.json new file mode 100644 index 0000000000..0637a088a0 --- /dev/null +++ b/LoopTests/Fixtures/high_and_falling/high_and_falling_carb_effect.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/LoopTests/Fixtures/high_and_falling/high_and_falling_counteraction_effect.json b/LoopTests/Fixtures/high_and_falling/high_and_falling_counteraction_effect.json new file mode 100644 index 0000000000..3cd84a4d76 --- /dev/null +++ b/LoopTests/Fixtures/high_and_falling/high_and_falling_counteraction_effect.json @@ -0,0 +1,236 @@ +[ + { + "startDate": "2020-08-11T19:44:58", + "endDate": "2020-08-11T19:49:58", + "unit": "mg\/min·dL", + "value": 0.0 + }, + { + "startDate": "2020-08-11T19:49:58", + "endDate": "2020-08-11T19:54:58", + "unit": "mg\/min·dL", + "value": 0.0 + }, + { + "startDate": "2020-08-11T19:54:58", + "endDate": "2020-08-11T19:59:58", + "unit": "mg\/min·dL", + "value": 0.06065363877984119 + }, + { + "startDate": "2020-08-11T19:59:58", + "endDate": "2020-08-11T20:04:58", + "unit": "mg\/min·dL", + "value": 0.1829111566180655 + }, + { + "startDate": "2020-08-11T20:04:58", + "endDate": "2020-08-11T20:09:58", + "unit": "mg\/min·dL", + "value": 0.29002744966453 + }, + { + "startDate": "2020-08-11T20:09:58", + "endDate": "2020-08-11T20:14:58", + "unit": "mg\/min·dL", + "value": 0.38321365736330676 + }, + { + "startDate": "2020-08-11T20:14:58", + "endDate": "2020-08-11T20:19:58", + "unit": "mg\/min·dL", + "value": 0.4637144729903035 + }, + { + "startDate": "2020-08-11T20:19:58", + "endDate": "2020-08-11T20:24:58", + "unit": "mg\/min·dL", + "value": 0.5326798223434369 + }, + { + "startDate": "2020-08-11T20:24:58", + "endDate": "2020-08-11T20:29:58", + "unit": "mg\/min·dL", + "value": 0.5911714460685378 + }, + { + "startDate": "2020-08-11T20:29:58", + "endDate": "2020-08-11T20:34:58", + "unit": "mg\/min·dL", + "value": 0.6401690515783915 + }, + { + "startDate": "2020-08-11T20:34:58", + "endDate": "2020-08-11T20:39:58", + "unit": "mg\/min·dL", + "value": 0.6805760615235243 + }, + { + "startDate": "2020-08-11T20:39:58", + "endDate": "2020-08-11T20:44:58", + "unit": "mg\/min·dL", + "value": 0.7132249841389473 + }, + { + "startDate": "2020-08-11T20:44:58", + "endDate": "2020-08-11T20:49:58", + "unit": "mg\/min·dL", + "value": 0.7388824292522805 + }, + { + "startDate": "2020-08-11T20:49:58", + "endDate": "2020-08-11T20:54:58", + "unit": "mg\/min·dL", + "value": 0.758253792292099 + }, + { + "startDate": "2020-08-11T20:54:58", + "endDate": "2020-08-11T20:59:58", + "unit": "mg\/min·dL", + "value": 0.7719876272734658 + }, + { + "startDate": "2020-08-11T20:59:58", + "endDate": "2020-08-11T21:04:58", + "unit": "mg\/min·dL", + "value": 0.7806797284574882 + }, + { + "startDate": "2020-08-11T21:04:58", + "endDate": "2020-08-11T21:09:58", + "unit": "mg\/min·dL", + "value": 0.7848769391771567 + }, + { + "startDate": "2020-08-11T21:09:58", + "endDate": "2020-08-11T21:14:58", + "unit": "mg\/min·dL", + "value": 0.7850807051888878 + }, + { + "startDate": "2020-08-11T21:14:58", + "endDate": "2020-08-11T21:19:58", + "unit": "mg\/min·dL", + "value": 0.7817503888440966 + }, + { + "startDate": "2020-08-11T21:19:58", + "endDate": "2020-08-11T21:24:58", + "unit": "mg\/min·dL", + "value": 0.7753063593735205 + }, + { + "startDate": "2020-08-11T21:24:58", + "endDate": "2020-08-11T21:29:58", + "unit": "mg\/min·dL", + "value": 0.7661328736349247 + }, + { + "startDate": "2020-08-11T21:29:58", + "endDate": "2020-08-11T21:34:58", + "unit": "mg\/min·dL", + "value": 0.7545807607898111 + }, + { + "startDate": "2020-08-11T21:34:58", + "endDate": "2020-08-11T21:39:58", + "unit": "mg\/min·dL", + "value": 0.7409699235419351 + }, + { + "startDate": "2020-08-11T21:39:58", + "endDate": "2020-08-11T21:44:58", + "unit": "mg\/min·dL", + "value": 0.7255916677884272 + }, + { + "startDate": "2020-08-11T21:44:58", + "endDate": "2020-08-11T21:49:58", + "unit": "mg\/min·dL", + "value": 0.7087108717986296 + }, + { + "startDate": "2020-08-11T21:49:58", + "endDate": "2020-08-11T21:54:58", + "unit": "mg\/min·dL", + "value": 0.6905680053447725 + }, + { + "startDate": "2020-08-11T21:54:58", + "endDate": "2020-08-11T21:59:58", + "unit": "mg\/min·dL", + "value": 0.6713810085591916 + }, + { + "startDate": "2020-08-11T21:59:58", + "endDate": "2020-08-11T22:04:58", + "unit": "mg\/min·dL", + "value": 0.6513470396824913 + }, + { + "startDate": "2020-08-11T22:04:58", + "endDate": "2020-08-11T22:09:58", + "unit": "mg\/min·dL", + "value": 0.6306441002936196 + }, + { + "startDate": "2020-08-11T22:09:58", + "endDate": "2020-08-11T22:14:58", + "unit": "mg\/min·dL", + "value": 0.6094325460745351 + }, + { + "startDate": "2020-08-11T22:14:58", + "endDate": "2020-08-11T22:19:58", + "unit": "mg\/min·dL", + "value": 0.5878564906558068 + }, + { + "startDate": "2020-08-11T22:19:58", + "endDate": "2020-08-11T22:24:58", + "unit": "mg\/min·dL", + "value": 0.566045109614535 + }, + { + "startDate": "2020-08-11T22:24:58", + "endDate": "2020-08-11T22:29:58", + "unit": "mg\/min·dL", + "value": 0.5441138512497218 + }, + { + "startDate": "2020-08-11T22:29:58", + "endDate": "2020-08-11T22:34:58", + "unit": "mg\/min·dL", + "value": 0.5221655603410653 + }, + { + "startDate": "2020-08-11T22:34:58", + "endDate": "2020-08-11T22:39:58", + "unit": "mg\/min·dL", + "value": 0.5002915207035925 + }, + { + "startDate": "2020-08-11T22:39:58", + "endDate": "2020-08-11T22:44:58", + "unit": "mg\/min·dL", + "value": 0.47857242198147665 + }, + { + "startDate": "2020-08-11T22:44:58", + "endDate": "2020-08-11T22:49:44", + "unit": "mg\/min·dL", + "value": 0.0 + }, + { + "startDate": "2020-08-11T22:49:44", + "endDate": "2020-08-11T22:54:44", + "unit": "mg\/min·dL", + "value": 0.0 + }, + { + "startDate": "2020-08-11T22:54:44", + "endDate": "2020-08-11T22:59:45", + "unit": "mg\/min·dL", + "value": 0.060537504513367056 + } +] diff --git a/LoopTests/Fixtures/high_and_falling/high_and_falling_insulin_effect.json b/LoopTests/Fixtures/high_and_falling/high_and_falling_insulin_effect.json new file mode 100644 index 0000000000..bea7fb07a4 --- /dev/null +++ b/LoopTests/Fixtures/high_and_falling/high_and_falling_insulin_effect.json @@ -0,0 +1,377 @@ +[ + { + "date": "2020-08-11T23:00:00", + "amount": -0.30324421735766016, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:05:00", + "amount": -1.2074805603814895, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:10:00", + "amount": -2.6198776769809875, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:15:00", + "amount": -4.465672057725821, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:20:00", + "amount": -6.685266802723275, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:25:00", + "amount": -9.224809473113943, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:30:00", + "amount": -12.03541189572141, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:35:00", + "amount": -15.072766324251951, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:40:00", + "amount": -18.296788509858903, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:45:00", + "amount": -21.671285910499947, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:50:00", + "amount": -25.16364937991473, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:55:00", + "amount": -28.744566781673353, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:00:00", + "amount": -32.38775707198973, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:05:00", + "amount": -36.069723487241404, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:10:00", + "amount": -39.7695245587422, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:15:00", + "amount": -43.46856175861266, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:20:00", + "amount": -47.150382656903005, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:25:00", + "amount": -50.8004985417413, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:30:00", + "amount": -54.40621552148487, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:35:00", + "amount": -57.956478190913245, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:40:00", + "amount": -61.44172500265972, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:45:00", + "amount": -64.85375454057544, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:50:00", + "amount": -68.1856019437701, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:55:00", + "amount": -71.43142477888769, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:00:00", + "amount": -74.58639770394838, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:05:00", + "amount": -77.64661531000066, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:10:00", + "amount": -80.60900256705762, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:15:00", + "amount": -83.47123233849673, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:20:00", + "amount": -86.23164946343942, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:25:00", + "amount": -88.88920093973691, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:30:00", + "amount": -91.44337177121069, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:35:00", + "amount": -93.89412607185396, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:40:00", + "amount": -96.24185304691466, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:45:00", + "amount": -98.4873174962681, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:50:00", + "amount": -100.63161450934751, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:55:00", + "amount": -102.67612804323775, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:00:00", + "amount": -104.62249309644574, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:05:00", + "amount": -106.47256121042342, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:10:00", + "amount": -108.22836904922634, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:15:00", + "amount": -109.89210982481272, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:20:00", + "amount": -111.46610735150391, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:25:00", + "amount": -112.95279252810269, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:30:00", + "amount": -114.35468206016674, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:35:00", + "amount": -115.67435924802191, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:40:00", + "amount": -116.91445667832986, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:45:00", + "amount": -118.07764066845148, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:50:00", + "amount": -119.16659732352176, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:55:00", + "amount": -120.18402007612107, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T03:00:00", + "amount": -121.13259858773439, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T03:05:00", + "amount": -122.0150088998796, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T03:10:00", + "amount": -122.83390473089393, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T03:15:00", + "amount": -123.59190982193347, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T03:20:00", + "amount": -124.29161124279706, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T03:25:00", + "amount": -124.93555357476642, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T03:30:00", + "amount": -125.52623389378984, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T03:35:00", + "amount": -126.06609748305398, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T03:40:00", + "amount": -126.55753420931575, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T03:45:00", + "amount": -127.00287550232932, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T03:50:00", + "amount": -127.4043918813229, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T03:55:00", + "amount": -127.76429097678248, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T04:00:00", + "amount": -128.08471599980103, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T04:05:00", + "amount": -128.36774461497714, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T04:10:00", + "amount": -128.61538817630728, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T04:15:00", + "amount": -128.8295912887364, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T04:20:00", + "amount": -129.0122316610227, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T04:25:00", + "amount": -129.16512021834833, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T04:30:00", + "amount": -129.29000144569122, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T04:35:00", + "amount": -129.38855393536335, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T04:40:00", + "amount": -129.46239111434534, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T04:45:00", + "amount": -129.51306212910382, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T04:50:00", + "amount": -129.54205286749004, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T04:55:00", + "amount": -129.5507870990832, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T05:00:00", + "amount": -129.54961066748092, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T05:05:00", + "amount": -129.54931273055175, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T05:10:00", + "amount": -129.54930222233963, + "unit": "mg/dL" + } +] \ No newline at end of file diff --git a/LoopTests/Fixtures/high_and_falling/high_and_falling_momentum_effect.json b/LoopTests/Fixtures/high_and_falling/high_and_falling_momentum_effect.json new file mode 100644 index 0000000000..1166b913bb --- /dev/null +++ b/LoopTests/Fixtures/high_and_falling/high_and_falling_momentum_effect.json @@ -0,0 +1,27 @@ +[ + { + "date": "2020-08-11T22:55:00", + "unit": "mg/dL", + "amount": 0.0 + }, + { + "date": "2020-08-11T23:00:00", + "unit": "mg/dL", + "amount": 0.0 + }, + { + "date": "2020-08-11T23:05:00", + "unit": "mg/dL", + "amount": 0.0 + }, + { + "date": "2020-08-11T23:10:00", + "unit": "mg/dL", + "amount": 0.0 + }, + { + "date": "2020-08-11T23:15:00", + "unit": "mg/dL", + "amount": 0.0 + } +] \ No newline at end of file diff --git a/LoopTests/Fixtures/high_and_falling/high_and_falling_predicted_glucose.json b/LoopTests/Fixtures/high_and_falling/high_and_falling_predicted_glucose.json new file mode 100644 index 0000000000..61f60a5e6a --- /dev/null +++ b/LoopTests/Fixtures/high_and_falling/high_and_falling_predicted_glucose.json @@ -0,0 +1,382 @@ +[ + { + "date": "2020-08-11T22:59:45", + "unit": "mg/dL", + "amount": 200.0 + }, + { + "date": "2020-08-11T23:00:00", + "unit": "mg/dL", + "amount": 200.0 + }, + { + "date": "2020-08-11T23:05:00", + "unit": "mg/dL", + "amount": 200.0111032633726 + }, + { + "date": "2020-08-11T23:10:00", + "unit": "mg/dL", + "amount": 200.01924237216699 + }, + { + "date": "2020-08-11T23:15:00", + "unit": "mg/dL", + "amount": 199.63033966967689 + }, + { + "date": "2020-08-11T23:20:00", + "unit": "mg/dL", + "amount": 198.52739386494645 + }, + { + "date": "2020-08-11T23:25:00", + "unit": "mg/dL", + "amount": 196.9449788576418 + }, + { + "date": "2020-08-11T23:30:00", + "unit": "mg/dL", + "amount": 194.9319828209393 + }, + { + "date": "2020-08-11T23:35:00", + "unit": "mg/dL", + "amount": 192.53271350113278 + }, + { + "date": "2020-08-11T23:40:00", + "unit": "mg/dL", + "amount": 189.78725514706883 + }, + { + "date": "2020-08-11T23:45:00", + "unit": "mg/dL", + "amount": 186.73180030078979 + }, + { + "date": "2020-08-11T23:50:00", + "unit": "mg/dL", + "amount": 183.398958108556 + }, + { + "date": "2020-08-11T23:55:00", + "unit": "mg/dL", + "amount": 179.81804070679738 + }, + { + "date": "2020-08-12T00:00:00", + "unit": "mg/dL", + "amount": 176.174850416481 + }, + { + "date": "2020-08-12T00:05:00", + "unit": "mg/dL", + "amount": 172.49288400122933 + }, + { + "date": "2020-08-12T00:10:00", + "unit": "mg/dL", + "amount": 168.79308292972854 + }, + { + "date": "2020-08-12T00:15:00", + "unit": "mg/dL", + "amount": 165.09404572985807 + }, + { + "date": "2020-08-12T00:20:00", + "unit": "mg/dL", + "amount": 161.41222483156773 + }, + { + "date": "2020-08-12T00:25:00", + "unit": "mg/dL", + "amount": 157.76210894672943 + }, + { + "date": "2020-08-12T00:30:00", + "unit": "mg/dL", + "amount": 154.15639196698586 + }, + { + "date": "2020-08-12T00:35:00", + "unit": "mg/dL", + "amount": 150.6061292975575 + }, + { + "date": "2020-08-12T00:40:00", + "unit": "mg/dL", + "amount": 147.12088248581102 + }, + { + "date": "2020-08-12T00:45:00", + "unit": "mg/dL", + "amount": 143.7088529478953 + }, + { + "date": "2020-08-12T00:50:00", + "unit": "mg/dL", + "amount": 140.37700554470064 + }, + { + "date": "2020-08-12T00:55:00", + "unit": "mg/dL", + "amount": 137.13118270958304 + }, + { + "date": "2020-08-12T01:00:00", + "unit": "mg/dL", + "amount": 133.97620978452235 + }, + { + "date": "2020-08-12T01:05:00", + "unit": "mg/dL", + "amount": 130.91599217847008 + }, + { + "date": "2020-08-12T01:10:00", + "unit": "mg/dL", + "amount": 127.95360492141312 + }, + { + "date": "2020-08-12T01:15:00", + "unit": "mg/dL", + "amount": 125.091375149974 + }, + { + "date": "2020-08-12T01:20:00", + "unit": "mg/dL", + "amount": 122.33095802503131 + }, + { + "date": "2020-08-12T01:25:00", + "unit": "mg/dL", + "amount": 119.67340654873382 + }, + { + "date": "2020-08-12T01:30:00", + "unit": "mg/dL", + "amount": 117.11923571726004 + }, + { + "date": "2020-08-12T01:35:00", + "unit": "mg/dL", + "amount": 114.66848141661677 + }, + { + "date": "2020-08-12T01:40:00", + "unit": "mg/dL", + "amount": 112.32075444155608 + }, + { + "date": "2020-08-12T01:45:00", + "unit": "mg/dL", + "amount": 110.07528999220263 + }, + { + "date": "2020-08-12T01:50:00", + "unit": "mg/dL", + "amount": 107.93099297912322 + }, + { + "date": "2020-08-12T01:55:00", + "unit": "mg/dL", + "amount": 105.88647944523298 + }, + { + "date": "2020-08-12T02:00:00", + "unit": "mg/dL", + "amount": 103.940114392025 + }, + { + "date": "2020-08-12T02:05:00", + "unit": "mg/dL", + "amount": 102.09004627804731 + }, + { + "date": "2020-08-12T02:10:00", + "unit": "mg/dL", + "amount": 100.33423843924439 + }, + { + "date": "2020-08-12T02:15:00", + "unit": "mg/dL", + "amount": 98.67049766365801 + }, + { + "date": "2020-08-12T02:20:00", + "unit": "mg/dL", + "amount": 97.09650013696682 + }, + { + "date": "2020-08-12T02:25:00", + "unit": "mg/dL", + "amount": 95.60981496036804 + }, + { + "date": "2020-08-12T02:30:00", + "unit": "mg/dL", + "amount": 94.207925428304 + }, + { + "date": "2020-08-12T02:35:00", + "unit": "mg/dL", + "amount": 92.88824824044882 + }, + { + "date": "2020-08-12T02:40:00", + "unit": "mg/dL", + "amount": 91.64815081014088 + }, + { + "date": "2020-08-12T02:45:00", + "unit": "mg/dL", + "amount": 90.48496682001925 + }, + { + "date": "2020-08-12T02:50:00", + "unit": "mg/dL", + "amount": 89.39601016494898 + }, + { + "date": "2020-08-12T02:55:00", + "unit": "mg/dL", + "amount": 88.37858741234966 + }, + { + "date": "2020-08-12T03:00:00", + "unit": "mg/dL", + "amount": 87.43000890073634 + }, + { + "date": "2020-08-12T03:05:00", + "unit": "mg/dL", + "amount": 86.54759858859113 + }, + { + "date": "2020-08-12T03:10:00", + "unit": "mg/dL", + "amount": 85.7287027575768 + }, + { + "date": "2020-08-12T03:15:00", + "unit": "mg/dL", + "amount": 84.97069766653726 + }, + { + "date": "2020-08-12T03:20:00", + "unit": "mg/dL", + "amount": 84.27099624567367 + }, + { + "date": "2020-08-12T03:25:00", + "unit": "mg/dL", + "amount": 83.62705391370432 + }, + { + "date": "2020-08-12T03:30:00", + "unit": "mg/dL", + "amount": 83.0363735946809 + }, + { + "date": "2020-08-12T03:35:00", + "unit": "mg/dL", + "amount": 82.49651000541675 + }, + { + "date": "2020-08-12T03:40:00", + "unit": "mg/dL", + "amount": 82.00507327915498 + }, + { + "date": "2020-08-12T03:45:00", + "unit": "mg/dL", + "amount": 81.55973198614141 + }, + { + "date": "2020-08-12T03:50:00", + "unit": "mg/dL", + "amount": 81.15821560714784 + }, + { + "date": "2020-08-12T03:55:00", + "unit": "mg/dL", + "amount": 80.79831651168826 + }, + { + "date": "2020-08-12T04:00:00", + "unit": "mg/dL", + "amount": 80.4778914886697 + }, + { + "date": "2020-08-12T04:05:00", + "unit": "mg/dL", + "amount": 80.19486287349359 + }, + { + "date": "2020-08-12T04:10:00", + "unit": "mg/dL", + "amount": 79.94721931216345 + }, + { + "date": "2020-08-12T04:15:00", + "unit": "mg/dL", + "amount": 79.73301619973434 + }, + { + "date": "2020-08-12T04:20:00", + "unit": "mg/dL", + "amount": 79.55037582744804 + }, + { + "date": "2020-08-12T04:25:00", + "unit": "mg/dL", + "amount": 79.3974872701224 + }, + { + "date": "2020-08-12T04:30:00", + "unit": "mg/dL", + "amount": 79.27260604277951 + }, + { + "date": "2020-08-12T04:35:00", + "unit": "mg/dL", + "amount": 79.17405355310738 + }, + { + "date": "2020-08-12T04:40:00", + "unit": "mg/dL", + "amount": 79.1002163741254 + }, + { + "date": "2020-08-12T04:45:00", + "unit": "mg/dL", + "amount": 79.04954535936692 + }, + { + "date": "2020-08-12T04:50:00", + "unit": "mg/dL", + "amount": 79.02055462098069 + }, + { + "date": "2020-08-12T04:55:00", + "unit": "mg/dL", + "amount": 79.01182038938752 + }, + { + "date": "2020-08-12T05:00:00", + "unit": "mg/dL", + "amount": 79.01299682098981 + }, + { + "date": "2020-08-12T05:05:00", + "unit": "mg/dL", + "amount": 79.01329475791898 + }, + { + "date": "2020-08-12T05:10:00", + "unit": "mg/dL", + "amount": 79.0133052661311 + } +] \ No newline at end of file diff --git a/LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_carb_effect.json b/LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_carb_effect.json new file mode 100644 index 0000000000..47d656b872 --- /dev/null +++ b/LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_carb_effect.json @@ -0,0 +1,322 @@ +[ + { + "date": "2020-08-11T21:15:00", + "unit": "mg/dL", + "amount": 0.0 + }, + { + "date": "2020-08-11T21:20:00", + "unit": "mg/dL", + "amount": 0.0 + }, + { + "date": "2020-08-11T21:25:00", + "unit": "mg/dL", + "amount": 0.0 + }, + { + "date": "2020-08-11T21:30:00", + "unit": "mg/dL", + "amount": 0.0 + }, + { + "date": "2020-08-11T21:35:00", + "unit": "mg/dL", + "amount": 0.0 + }, + { + "date": "2020-08-11T21:40:00", + "unit": "mg/dL", + "amount": 0.0 + }, + { + "date": "2020-08-11T21:45:00", + "unit": "mg/dL", + "amount": 0.0 + }, + { + "date": "2020-08-11T21:50:00", + "unit": "mg/dL", + "amount": 0.0 + }, + { + "date": "2020-08-11T21:55:00", + "unit": "mg/dL", + "amount": 0.0 + }, + { + "date": "2020-08-11T22:00:00", + "unit": "mg/dL", + "amount": 0.5054689190453953 + }, + { + "date": "2020-08-11T22:05:00", + "unit": "mg/dL", + "amount": 2.033246696823173 + }, + { + "date": "2020-08-11T22:10:00", + "unit": "mg/dL", + "amount": 3.5610244746009507 + }, + { + "date": "2020-08-11T22:15:00", + "unit": "mg/dL", + "amount": 5.088802252378729 + }, + { + "date": "2020-08-11T22:20:00", + "unit": "mg/dL", + "amount": 6.616580030156507 + }, + { + "date": "2020-08-11T22:25:00", + "unit": "mg/dL", + "amount": 8.144357807934284 + }, + { + "date": "2020-08-11T22:30:00", + "unit": "mg/dL", + "amount": 9.672135585712061 + }, + { + "date": "2020-08-11T22:35:00", + "unit": "mg/dL", + "amount": 11.199913363489841 + }, + { + "date": "2020-08-11T22:40:00", + "unit": "mg/dL", + "amount": 12.727691141267618 + }, + { + "date": "2020-08-11T22:45:00", + "unit": "mg/dL", + "amount": 14.255468919045395 + }, + { + "date": "2020-08-11T22:50:00", + "unit": "mg/dL", + "amount": 15.783246696823173 + }, + { + "date": "2020-08-11T22:55:00", + "unit": "mg/dL", + "amount": 17.311024474600952 + }, + { + "date": "2020-08-11T23:00:00", + "unit": "mg/dL", + "amount": 18.83880225237873 + }, + { + "date": "2020-08-11T23:05:00", + "unit": "mg/dL", + "amount": 20.366580030156506 + }, + { + "date": "2020-08-11T23:10:00", + "unit": "mg/dL", + "amount": 21.89435780793428 + }, + { + "date": "2020-08-11T23:15:00", + "unit": "mg/dL", + "amount": 23.422135585712063 + }, + { + "date": "2020-08-11T23:20:00", + "unit": "mg/dL", + "amount": 24.949913363489838 + }, + { + "date": "2020-08-11T23:25:00", + "unit": "mg/dL", + "amount": 26.477691141267616 + }, + { + "date": "2020-08-11T23:30:00", + "unit": "mg/dL", + "amount": 28.00546891904539 + }, + { + "date": "2020-08-11T23:35:00", + "unit": "mg/dL", + "amount": 29.533246696823177 + }, + { + "date": "2020-08-11T23:40:00", + "unit": "mg/dL", + "amount": 31.061024474600952 + }, + { + "date": "2020-08-11T23:45:00", + "unit": "mg/dL", + "amount": 32.58880225237873 + }, + { + "date": "2020-08-11T23:50:00", + "unit": "mg/dL", + "amount": 34.116580030156506 + }, + { + "date": "2020-08-11T23:55:00", + "unit": "mg/dL", + "amount": 35.644357807934284 + }, + { + "date": "2020-08-12T00:00:00", + "unit": "mg/dL", + "amount": 37.17213558571207 + }, + { + "date": "2020-08-12T00:05:00", + "unit": "mg/dL", + "amount": 38.69991336348984 + }, + { + "date": "2020-08-12T00:10:00", + "unit": "mg/dL", + "amount": 40.22769114126762 + }, + { + "date": "2020-08-12T00:15:00", + "unit": "mg/dL", + "amount": 41.7554689190454 + }, + { + "date": "2020-08-12T00:20:00", + "unit": "mg/dL", + "amount": 43.28324669682318 + }, + { + "date": "2020-08-12T00:25:00", + "unit": "mg/dL", + "amount": 44.81102447460095 + }, + { + "date": "2020-08-12T00:30:00", + "unit": "mg/dL", + "amount": 46.33880225237873 + }, + { + "date": "2020-08-12T00:35:00", + "unit": "mg/dL", + "amount": 47.86658003015651 + }, + { + "date": "2020-08-12T00:40:00", + "unit": "mg/dL", + "amount": 49.394357807934284 + }, + { + "date": "2020-08-12T00:45:00", + "unit": "mg/dL", + "amount": 50.922135585712056 + }, + { + "date": "2020-08-12T00:50:00", + "unit": "mg/dL", + "amount": 52.44991336348984 + }, + { + "date": "2020-08-12T00:55:00", + "unit": "mg/dL", + "amount": 53.97769114126762 + }, + { + "date": "2020-08-12T01:00:00", + "unit": "mg/dL", + "amount": 55.50546891904539 + }, + { + "date": "2020-08-12T01:05:00", + "unit": "mg/dL", + "amount": 57.03324669682318 + }, + { + "date": "2020-08-12T01:10:00", + "unit": "mg/dL", + "amount": 58.56102447460095 + }, + { + "date": "2020-08-12T01:15:00", + "unit": "mg/dL", + "amount": 60.08880225237873 + }, + { + "date": "2020-08-12T01:20:00", + "unit": "mg/dL", + "amount": 61.6165800301565 + }, + { + "date": "2020-08-12T01:25:00", + "unit": "mg/dL", + "amount": 63.144357807934284 + }, + { + "date": "2020-08-12T01:30:00", + "unit": "mg/dL", + "amount": 64.67213558571206 + }, + { + "date": "2020-08-12T01:35:00", + "unit": "mg/dL", + "amount": 66.19991336348984 + }, + { + "date": "2020-08-12T01:40:00", + "unit": "mg/dL", + "amount": 67.72769114126763 + }, + { + "date": "2020-08-12T01:45:00", + "unit": "mg/dL", + "amount": 69.2554689190454 + }, + { + "date": "2020-08-12T01:50:00", + "unit": "mg/dL", + "amount": 70.78324669682317 + }, + { + "date": "2020-08-12T01:55:00", + "unit": "mg/dL", + "amount": 72.31102447460096 + }, + { + "date": "2020-08-12T02:00:00", + "unit": "mg/dL", + "amount": 73.83880225237873 + }, + { + "date": "2020-08-12T02:05:00", + "unit": "mg/dL", + "amount": 75.3665800301565 + }, + { + "date": "2020-08-12T02:10:00", + "unit": "mg/dL", + "amount": 76.89435780793428 + }, + { + "date": "2020-08-12T02:15:00", + "unit": "mg/dL", + "amount": 78.42213558571207 + }, + { + "date": "2020-08-12T02:20:00", + "unit": "mg/dL", + "amount": 79.94991336348984 + }, + { + "date": "2020-08-12T02:25:00", + "unit": "mg/dL", + "amount": 81.47769114126761 + }, + { + "date": "2020-08-12T02:30:00", + "unit": "mg/dL", + "amount": 82.5 + } +] \ No newline at end of file diff --git a/LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_counteraction_effect.json b/LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_counteraction_effect.json new file mode 100644 index 0000000000..7032287fe7 --- /dev/null +++ b/LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_counteraction_effect.json @@ -0,0 +1,266 @@ +[ + { + "startDate": "2020-08-11T17:25:13", + "endDate": "2020-08-11T17:30:13", + "unit": "mg\/min·dL", + "value": -0.17427698848393616 + }, + { + "startDate": "2020-08-11T17:30:13", + "endDate": "2020-08-11T17:35:13", + "unit": "mg\/min·dL", + "value": -0.172884893111717 + }, + { + "startDate": "2020-08-11T17:35:13", + "endDate": "2020-08-11T17:40:13", + "unit": "mg\/min·dL", + "value": -0.16620062119698026 + }, + { + "startDate": "2020-08-11T17:40:13", + "endDate": "2020-08-11T17:45:13", + "unit": "mg\/min·dL", + "value": -0.15239126546960888 + }, + { + "startDate": "2020-08-11T17:45:13", + "endDate": "2020-08-11T17:50:13", + "unit": "mg\/min·dL", + "value": -0.13844620387243192 + }, + { + "startDate": "2020-08-11T17:50:13", + "endDate": "2020-08-11T17:55:13", + "unit": "mg\/min·dL", + "value": -0.12440053903505803 + }, + { + "startDate": "2020-08-11T17:55:13", + "endDate": "2020-08-11T18:00:13", + "unit": "mg\/min·dL", + "value": -0.1102033787233404 + }, + { + "startDate": "2020-08-11T18:00:13", + "endDate": "2020-08-11T18:05:13", + "unit": "mg\/min·dL", + "value": -0.09582040633985235 + }, + { + "startDate": "2020-08-11T18:05:13", + "endDate": "2020-08-11T18:10:13", + "unit": "mg\/min·dL", + "value": -0.08123290693932182 + }, + { + "startDate": "2020-08-11T18:10:13", + "endDate": "2020-08-11T18:15:13", + "unit": "mg\/min·dL", + "value": -0.06643676319414542 + }, + { + "startDate": "2020-08-11T18:15:13", + "endDate": "2020-08-11T18:20:13", + "unit": "mg\/min·dL", + "value": -0.051441423013083416 + }, + { + "startDate": "2020-08-11T18:20:13", + "endDate": "2020-08-11T18:25:13", + "unit": "mg\/min·dL", + "value": -0.0362688411105418 + }, + { + "startDate": "2020-08-11T18:25:13", + "endDate": "2020-08-11T18:30:13", + "unit": "mg\/min·dL", + "value": -0.020952397377567107 + }, + { + "startDate": "2020-08-11T18:30:13", + "endDate": "2020-08-11T18:35:13", + "unit": "mg\/min·dL", + "value": -0.005535795415598254 + }, + { + "startDate": "2020-08-11T18:35:13", + "endDate": "2020-08-11T18:40:13", + "unit": "mg\/min·dL", + "value": 0.009928054942067454 + }, + { + "startDate": "2020-08-11T18:40:13", + "endDate": "2020-08-11T18:45:13", + "unit": "mg\/min·dL", + "value": 0.02537816688081129 + }, + { + "startDate": "2020-08-11T18:45:13", + "endDate": "2020-08-11T18:50:13", + "unit": "mg\/min·dL", + "value": 0.040746613021907935 + }, + { + "startDate": "2020-08-11T18:50:13", + "endDate": "2020-08-11T18:55:13", + "unit": "mg\/min·dL", + "value": 0.05595966408835151 + }, + { + "startDate": "2020-08-11T18:55:13", + "endDate": "2020-08-11T19:00:13", + "unit": "mg\/min·dL", + "value": 0.07093892464198123 + }, + { + "startDate": "2020-08-11T19:00:13", + "endDate": "2020-08-11T19:05:13", + "unit": "mg\/min·dL", + "value": 0.08560246050964196 + }, + { + "startDate": "2020-08-11T19:05:13", + "endDate": "2020-08-11T19:10:13", + "unit": "mg\/min·dL", + "value": 0.09986591236653002 + }, + { + "startDate": "2020-08-11T19:10:13", + "endDate": "2020-08-11T19:15:13", + "unit": "mg\/min·dL", + "value": 0.11364358985065513 + }, + { + "startDate": "2020-08-11T19:15:13", + "endDate": "2020-08-11T19:20:13", + "unit": "mg\/min·dL", + "value": 0.12684954054338973 + }, + { + "startDate": "2020-08-11T19:20:13", + "endDate": "2020-08-11T19:25:13", + "unit": "mg\/min·dL", + "value": 0.13939858816698666 + }, + { + "startDate": "2020-08-11T19:25:13", + "endDate": "2020-08-11T19:30:13", + "unit": "mg\/min·dL", + "value": 0.15120733442007542 + }, + { + "startDate": "2020-08-11T19:30:13", + "endDate": "2020-08-11T19:35:13", + "unit": "mg\/min·dL", + "value": 0.16219511899486355 + }, + { + "startDate": "2020-08-11T19:35:13", + "endDate": "2020-08-11T19:40:13", + "unit": "mg\/min·dL", + "value": 0.17228493249382398 + }, + { + "startDate": "2020-08-11T19:40:13", + "endDate": "2020-08-11T19:45:13", + "unit": "mg\/min·dL", + "value": 0.1814042771871964 + }, + { + "startDate": "2020-08-11T19:45:13", + "endDate": "2020-08-11T19:50:13", + "unit": "mg\/min·dL", + "value": 0.18948597082306992 + }, + { + "startDate": "2020-08-11T19:50:13", + "endDate": "2020-08-11T19:55:13", + "unit": "mg\/min·dL", + "value": 0.196468889016708 + }, + { + "startDate": "2020-08-11T19:55:13", + "endDate": "2020-08-11T20:00:13", + "unit": "mg\/min·dL", + "value": 0.20229864210263385 + }, + { + "startDate": "2020-08-11T20:00:13", + "endDate": "2020-08-11T20:05:13", + "unit": "mg\/min·dL", + "value": 0.2069281827278072 + }, + { + "startDate": "2020-08-11T20:05:13", + "endDate": "2020-08-11T20:10:13", + "unit": "mg\/min·dL", + "value": 0.21031834089428644 + }, + { + "startDate": "2020-08-11T20:10:13", + "endDate": "2020-08-11T20:15:13", + "unit": "mg\/min·dL", + "value": 0.21243828362120673 + }, + { + "startDate": "2020-08-11T20:15:13", + "endDate": "2020-08-11T20:20:13", + "unit": "mg\/min·dL", + "value": 0.213265896884441 + }, + { + "startDate": "2020-08-11T20:20:13", + "endDate": "2020-08-11T20:25:13", + "unit": "mg\/min·dL", + "value": 0.212788088004482 + }, + { + "startDate": "2020-08-11T20:25:13", + "endDate": "2020-08-11T20:32:50", + "unit": "mg\/min·dL", + "value": 0.17396858033976298 + }, + { + "startDate": "2020-08-11T20:32:50", + "endDate": "2020-08-11T20:45:02", + "unit": "mg\/min·dL", + "value": 0.18555611348135584 + }, + { + "startDate": "2020-08-11T20:45:02", + "endDate": "2020-08-11T21:09:23", + "unit": "mg\/min·dL", + "value": 0.2025162808274117 + }, + { + "startDate": "2020-08-11T21:09:23", + "endDate": "2020-08-11T21:21:34", + "unit": "mg\/min·dL", + "value": 0.2789312761868744 + }, + { + "startDate": "2020-08-11T21:21:34", + "endDate": "2020-08-11T21:33:17", + "unit": "mg\/min·dL", + "value": 0.17878610561707597 + }, + { + "startDate": "2020-08-11T21:33:17", + "endDate": "2020-08-11T21:38:17", + "unit": "mg\/min·dL", + "value": 0.29216469125794187 + }, + { + "startDate": "2020-08-11T21:38:17", + "endDate": "2020-08-11T21:43:17", + "unit": "mg\/min·dL", + "value": 0.2807908049199831 + }, + { + "startDate": "2020-08-11T21:43:17", + "endDate": "2020-08-11T21:48:04", + "unit": "mg\/min·dL", + "value": 0.27828132940268346 + } +] diff --git a/LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_insulin_effect.json b/LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_insulin_effect.json new file mode 100644 index 0000000000..cd281f68d0 --- /dev/null +++ b/LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_insulin_effect.json @@ -0,0 +1,387 @@ +[ + { + "date": "2020-08-11T21:50:00", + "amount": -8.639981829288883, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T21:55:00", + "amount": -9.789850828431643, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T22:00:00", + "amount": -10.963763653811602, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T22:05:00", + "amount": -12.153219270860628, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T22:10:00", + "amount": -13.350959307658405, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T22:15:00", + "amount": -14.550659188660132, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T22:20:00", + "amount": -15.7467157330705, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T22:25:00", + "amount": -16.934186099027563, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T22:30:00", + "amount": -18.108731231758313, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T22:35:00", + "amount": -19.266563509430355, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T22:40:00", + "amount": -20.404398300145722, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T22:45:00", + "amount": -21.51940916202376, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T22:50:00", + "amount": -22.60918643567319, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T22:55:00", + "amount": -23.67169899462816, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:00:00", + "amount": -24.705258934584283, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:05:00", + "amount": -25.708488996579725, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:10:00", + "amount": -26.680292532680262, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:15:00", + "amount": -27.619825835301093, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:20:00", + "amount": -28.526472663081833, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:25:00", + "amount": -29.3998208072736, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:30:00", + "amount": -30.239640552942898, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:35:00", + "amount": -31.045864898988505, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:40:00", + "amount": -31.818571410045358, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:45:00", + "amount": -32.557965581850254, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:50:00", + "amount": -33.26436560960395, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:55:00", + "amount": -33.93818845631585, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:00:00", + "amount": -34.57993712509237, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:05:00", + "amount": -35.190189045857444, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:10:00", + "amount": -35.76958549310118, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:15:00", + "amount": -36.31882195696637, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:20:00", + "amount": -36.838639395326744, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:25:00", + "amount": -37.32981629950877, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:30:00", + "amount": -37.7931615109809, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:35:00", + "amount": -38.229507730702785, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:40:00", + "amount": -38.639705666908725, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:45:00", + "amount": -39.024618770914344, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:50:00", + "amount": -39.38511851409847, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:55:00", + "amount": -39.72208016254089, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:00:00", + "amount": -40.03637900890356, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:05:00", + "amount": -40.32888702404502, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:10:00", + "amount": -40.600469893564416, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:15:00", + "amount": -40.85198440699897, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:20:00", + "amount": -41.08427616975518, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:25:00", + "amount": -41.29817761005208, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:30:00", + "amount": -41.494506255204584, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:35:00", + "amount": -41.674063253484796, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:40:00", + "amount": -41.837632119579226, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:45:00", + "amount": -41.98597768331826, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:50:00", + "amount": -42.11984522289805, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:55:00", + "amount": -42.23995976525269, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:00:00", + "amount": -42.347025537572655, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:05:00", + "amount": -42.441725555209814, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:10:00", + "amount": -42.524721332367534, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:15:00", + "amount": -42.59665270305005, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:20:00", + "amount": -42.65813774074594, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:25:00", + "amount": -42.70977276624976, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:30:00", + "amount": -42.752132433888306, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:35:00", + "amount": -42.785769887219686, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:40:00", + "amount": -42.81180494139787, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:45:00", + "amount": -42.831680423307795, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:50:00", + "amount": -42.84629245946508, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:55:00", + "amount": -42.856651353619604, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T03:00:00", + "amount": -42.86358529644523, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T03:05:00", + "amount": -42.867860863240104, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T03:10:00", + "amount": -42.87013681511805, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T03:15:00", + "amount": -42.871030675309036, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T03:20:00", + "amount": -42.871120411104464, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T03:25:00", + "amount": -42.87094608563874, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T03:30:00", + "amount": -42.870799980845575, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T03:35:00", + "amount": -42.87068168789406, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T03:40:00", + "amount": -42.870589529145974, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T03:45:00", + "amount": -42.870521892591526, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T03:50:00", + "amount": -42.87047723091304, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T03:55:00", + "amount": -42.870454060415405, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T04:00:00", + "amount": -42.87044984787703, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T04:05:00", + "amount": -42.87044984787703, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T04:10:00", + "amount": -42.87044984787703, + "unit": "mg/dL" + } +] \ No newline at end of file diff --git a/LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_momentum_effect.json b/LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_momentum_effect.json new file mode 100644 index 0000000000..a8472461b2 --- /dev/null +++ b/LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_momentum_effect.json @@ -0,0 +1,27 @@ +[ + { + "date": "2020-08-11T21:45:00", + "unit": "mg/dL", + "amount": 0.0 + }, + { + "date": "2020-08-11T21:50:00", + "unit": "mg/dL", + "amount": 0.0596641 + }, + { + "date": "2020-08-11T21:55:00", + "unit": "mg/dL", + "amount": 0.233866 + }, + { + "date": "2020-08-11T22:00:00", + "unit": "mg/dL", + "amount": 0.408067 + }, + { + "date": "2020-08-11T22:05:00", + "unit": "mg/dL", + "amount": 0.582269 + } +] \ No newline at end of file diff --git a/LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_predicted_glucose.json b/LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_predicted_glucose.json new file mode 100644 index 0000000000..7dbe1a743c --- /dev/null +++ b/LoopTests/Fixtures/high_and_rising_with_cob/high_and_rising_with_cob_predicted_glucose.json @@ -0,0 +1,392 @@ +[ + { + "date": "2020-08-11T21:48:17", + "unit": "mg/dL", + "amount": 129.93174411197853 + }, + { + "date": "2020-08-11T21:50:00", + "unit": "mg/dL", + "amount": 129.99140823711906 + }, + { + "date": "2020-08-11T21:55:00", + "unit": "mg/dL", + "amount": 130.12765634266816 + }, + { + "date": "2020-08-11T22:00:00", + "unit": "mg/dL", + "amount": 130.32415384711314 + }, + { + "date": "2020-08-11T22:05:00", + "unit": "mg/dL", + "amount": 131.24594584675708 + }, + { + "date": "2020-08-11T22:10:00", + "unit": "mg/dL", + "amount": 132.27012597044103 + }, + { + "date": "2020-08-11T22:15:00", + "unit": "mg/dL", + "amount": 133.19318305239187 + }, + { + "date": "2020-08-11T22:20:00", + "unit": "mg/dL", + "amount": 134.02072027340495 + }, + { + "date": "2020-08-11T22:25:00", + "unit": "mg/dL", + "amount": 134.75768047534217 + }, + { + "date": "2020-08-11T22:30:00", + "unit": "mg/dL", + "amount": 135.4084027129766 + }, + { + "date": "2020-08-11T22:35:00", + "unit": "mg/dL", + "amount": 135.9766746081406 + }, + { + "date": "2020-08-11T22:40:00", + "unit": "mg/dL", + "amount": 136.46578079273215 + }, + { + "date": "2020-08-11T22:45:00", + "unit": "mg/dL", + "amount": 136.87854770863188 + }, + { + "date": "2020-08-11T22:50:00", + "unit": "mg/dL", + "amount": 137.31654821276024 + }, + { + "date": "2020-08-11T22:55:00", + "unit": "mg/dL", + "amount": 137.78181343158306 + }, + { + "date": "2020-08-11T23:00:00", + "unit": "mg/dL", + "amount": 138.2760312694047 + }, + { + "date": "2020-08-11T23:05:00", + "unit": "mg/dL", + "amount": 138.80057898518703 + }, + { + "date": "2020-08-11T23:10:00", + "unit": "mg/dL", + "amount": 139.35655322686426 + }, + { + "date": "2020-08-11T23:15:00", + "unit": "mg/dL", + "amount": 139.94479770202122 + }, + { + "date": "2020-08-11T23:20:00", + "unit": "mg/dL", + "amount": 140.56592865201824 + }, + { + "date": "2020-08-11T23:25:00", + "unit": "mg/dL", + "amount": 141.22035828560425 + }, + { + "date": "2020-08-11T23:30:00", + "unit": "mg/dL", + "amount": 141.90831631771272 + }, + { + "date": "2020-08-11T23:35:00", + "unit": "mg/dL", + "amount": 142.6298697494449 + }, + { + "date": "2020-08-11T23:40:00", + "unit": "mg/dL", + "amount": 143.38494101616584 + }, + { + "date": "2020-08-11T23:45:00", + "unit": "mg/dL", + "amount": 144.17332462213872 + }, + { + "date": "2020-08-11T23:50:00", + "unit": "mg/dL", + "amount": 144.9947023721628 + }, + { + "date": "2020-08-11T23:55:00", + "unit": "mg/dL", + "amount": 145.8486573032287 + }, + { + "date": "2020-08-12T00:00:00", + "unit": "mg/dL", + "amount": 146.73468641222996 + }, + { + "date": "2020-08-12T00:05:00", + "unit": "mg/dL", + "amount": 147.65221226924265 + }, + { + "date": "2020-08-12T00:10:00", + "unit": "mg/dL", + "amount": 148.6005935997767 + }, + { + "date": "2020-08-12T00:15:00", + "unit": "mg/dL", + "amount": 149.57913491368927 + }, + { + "date": "2020-08-12T00:20:00", + "unit": "mg/dL", + "amount": 150.58709525310667 + }, + { + "date": "2020-08-12T00:25:00", + "unit": "mg/dL", + "amount": 151.6236961267024 + }, + { + "date": "2020-08-12T00:30:00", + "unit": "mg/dL", + "amount": 152.68812869300805 + }, + { + "date": "2020-08-12T00:35:00", + "unit": "mg/dL", + "amount": 153.77956025106397 + }, + { + "date": "2020-08-12T00:40:00", + "unit": "mg/dL", + "amount": 154.8971400926358 + }, + { + "date": "2020-08-12T00:45:00", + "unit": "mg/dL", + "amount": 156.04000476640795 + }, + { + "date": "2020-08-12T00:50:00", + "unit": "mg/dL", + "amount": 157.2072828010016 + }, + { + "date": "2020-08-12T00:55:00", + "unit": "mg/dL", + "amount": 158.39809893033697 + }, + { + "date": "2020-08-12T01:00:00", + "unit": "mg/dL", + "amount": 159.61157786175207 + }, + { + "date": "2020-08-12T01:05:00", + "unit": "mg/dL", + "amount": 160.8468476243884 + }, + { + "date": "2020-08-12T01:10:00", + "unit": "mg/dL", + "amount": 162.10304253264678 + }, + { + "date": "2020-08-12T01:15:00", + "unit": "mg/dL", + "amount": 163.37930579699 + }, + { + "date": "2020-08-12T01:20:00", + "unit": "mg/dL", + "amount": 164.67479181201156 + }, + { + "date": "2020-08-12T01:25:00", + "unit": "mg/dL", + "amount": 165.98866814949244 + }, + { + "date": "2020-08-12T01:30:00", + "unit": "mg/dL", + "amount": 167.3201172821177 + }, + { + "date": "2020-08-12T01:35:00", + "unit": "mg/dL", + "amount": 168.6683380616153 + }, + { + "date": "2020-08-12T01:40:00", + "unit": "mg/dL", + "amount": 170.03254697329865 + }, + { + "date": "2020-08-12T01:45:00", + "unit": "mg/dL", + "amount": 171.41197918733738 + }, + { + "date": "2020-08-12T01:50:00", + "unit": "mg/dL", + "amount": 172.80588942553536 + }, + { + "date": "2020-08-12T01:55:00", + "unit": "mg/dL", + "amount": 174.2135526609585 + }, + { + "date": "2020-08-12T02:00:00", + "unit": "mg/dL", + "amount": 175.6342646664163 + }, + { + "date": "2020-08-12T02:05:00", + "unit": "mg/dL", + "amount": 177.0673424265569 + }, + { + "date": "2020-08-12T02:10:00", + "unit": "mg/dL", + "amount": 178.51212442717696 + }, + { + "date": "2020-08-12T02:15:00", + "unit": "mg/dL", + "amount": 179.96797083427222 + }, + { + "date": "2020-08-12T02:20:00", + "unit": "mg/dL", + "amount": 181.4342635743541 + }, + { + "date": "2020-08-12T02:25:00", + "unit": "mg/dL", + "amount": 182.91040632662805 + }, + { + "date": "2020-08-12T02:30:00", + "unit": "mg/dL", + "amount": 183.8903555177219 + }, + { + "date": "2020-08-12T02:35:00", + "unit": "mg/dL", + "amount": 183.85671806439052 + }, + { + "date": "2020-08-12T02:40:00", + "unit": "mg/dL", + "amount": 183.83068301021234 + }, + { + "date": "2020-08-12T02:45:00", + "unit": "mg/dL", + "amount": 183.8108075283024 + }, + { + "date": "2020-08-12T02:50:00", + "unit": "mg/dL", + "amount": 183.7961954921451 + }, + { + "date": "2020-08-12T02:55:00", + "unit": "mg/dL", + "amount": 183.78583659799057 + }, + { + "date": "2020-08-12T03:00:00", + "unit": "mg/dL", + "amount": 183.77890265516493 + }, + { + "date": "2020-08-12T03:05:00", + "unit": "mg/dL", + "amount": 183.77462708837004 + }, + { + "date": "2020-08-12T03:10:00", + "unit": "mg/dL", + "amount": 183.7723511364921 + }, + { + "date": "2020-08-12T03:15:00", + "unit": "mg/dL", + "amount": 183.7714572763011 + }, + { + "date": "2020-08-12T03:20:00", + "unit": "mg/dL", + "amount": 183.77136754050568 + }, + { + "date": "2020-08-12T03:25:00", + "unit": "mg/dL", + "amount": 183.77154186597141 + }, + { + "date": "2020-08-12T03:30:00", + "unit": "mg/dL", + "amount": 183.7716879707646 + }, + { + "date": "2020-08-12T03:35:00", + "unit": "mg/dL", + "amount": 183.7718062637161 + }, + { + "date": "2020-08-12T03:40:00", + "unit": "mg/dL", + "amount": 183.77189842246418 + }, + { + "date": "2020-08-12T03:45:00", + "unit": "mg/dL", + "amount": 183.7719660590186 + }, + { + "date": "2020-08-12T03:50:00", + "unit": "mg/dL", + "amount": 183.7720107206971 + }, + { + "date": "2020-08-12T03:55:00", + "unit": "mg/dL", + "amount": 183.77203389119472 + }, + { + "date": "2020-08-12T04:00:00", + "unit": "mg/dL", + "amount": 183.7720381037331 + }, + { + "date": "2020-08-12T04:05:00", + "unit": "mg/dL", + "amount": 183.7720381037331 + }, + { + "date": "2020-08-12T04:10:00", + "unit": "mg/dL", + "amount": 183.7720381037331 + } +] \ No newline at end of file diff --git a/LoopTests/Fixtures/high_and_stable/high_and_stable_carb_effect.json b/LoopTests/Fixtures/high_and_stable/high_and_stable_carb_effect.json new file mode 100644 index 0000000000..64848ef5a2 --- /dev/null +++ b/LoopTests/Fixtures/high_and_stable/high_and_stable_carb_effect.json @@ -0,0 +1,322 @@ +[ + { + "date": "2020-08-12T12:05:00", + "unit": "mg/dL", + "amount": 0.0 + }, + { + "date": "2020-08-12T12:10:00", + "unit": "mg/dL", + "amount": 0.0 + }, + { + "date": "2020-08-12T12:15:00", + "unit": "mg/dL", + "amount": 0.0 + }, + { + "date": "2020-08-12T12:20:00", + "unit": "mg/dL", + "amount": 0.0 + }, + { + "date": "2020-08-12T12:25:00", + "unit": "mg/dL", + "amount": 0.0 + }, + { + "date": "2020-08-12T12:30:00", + "unit": "mg/dL", + "amount": 0.0 + }, + { + "date": "2020-08-12T12:35:00", + "unit": "mg/dL", + "amount": 0.0 + }, + { + "date": "2020-08-12T12:40:00", + "unit": "mg/dL", + "amount": 0.0 + }, + { + "date": "2020-08-12T12:45:00", + "unit": "mg/dL", + "amount": 0.0 + }, + { + "date": "2020-08-12T12:50:00", + "unit": "mg/dL", + "amount": 0.03198444727394316 + }, + { + "date": "2020-08-12T12:55:00", + "unit": "mg/dL", + "amount": 0.4486511139406098 + }, + { + "date": "2020-08-12T13:00:00", + "unit": "mg/dL", + "amount": 0.8653177806072766 + }, + { + "date": "2020-08-12T13:05:00", + "unit": "mg/dL", + "amount": 1.281984447273943 + }, + { + "date": "2020-08-12T13:10:00", + "unit": "mg/dL", + "amount": 1.6986511139406095 + }, + { + "date": "2020-08-12T13:15:00", + "unit": "mg/dL", + "amount": 2.1153177806072767 + }, + { + "date": "2020-08-12T13:20:00", + "unit": "mg/dL", + "amount": 2.5319844472739432 + }, + { + "date": "2020-08-12T13:25:00", + "unit": "mg/dL", + "amount": 2.9486511139406097 + }, + { + "date": "2020-08-12T13:30:00", + "unit": "mg/dL", + "amount": 3.3653177806072763 + }, + { + "date": "2020-08-12T13:35:00", + "unit": "mg/dL", + "amount": 3.7819844472739437 + }, + { + "date": "2020-08-12T13:40:00", + "unit": "mg/dL", + "amount": 4.19865111394061 + }, + { + "date": "2020-08-12T13:45:00", + "unit": "mg/dL", + "amount": 4.615317780607277 + }, + { + "date": "2020-08-12T13:50:00", + "unit": "mg/dL", + "amount": 5.031984447273943 + }, + { + "date": "2020-08-12T13:55:00", + "unit": "mg/dL", + "amount": 5.44865111394061 + }, + { + "date": "2020-08-12T14:00:00", + "unit": "mg/dL", + "amount": 5.865317780607277 + }, + { + "date": "2020-08-12T14:05:00", + "unit": "mg/dL", + "amount": 6.281984447273943 + }, + { + "date": "2020-08-12T14:10:00", + "unit": "mg/dL", + "amount": 6.69865111394061 + }, + { + "date": "2020-08-12T14:15:00", + "unit": "mg/dL", + "amount": 7.115317780607277 + }, + { + "date": "2020-08-12T14:20:00", + "unit": "mg/dL", + "amount": 7.531984447273944 + }, + { + "date": "2020-08-12T14:25:00", + "unit": "mg/dL", + "amount": 7.94865111394061 + }, + { + "date": "2020-08-12T14:30:00", + "unit": "mg/dL", + "amount": 8.365317780607278 + }, + { + "date": "2020-08-12T14:35:00", + "unit": "mg/dL", + "amount": 8.781984447273942 + }, + { + "date": "2020-08-12T14:40:00", + "unit": "mg/dL", + "amount": 9.19865111394061 + }, + { + "date": "2020-08-12T14:45:00", + "unit": "mg/dL", + "amount": 9.615317780607278 + }, + { + "date": "2020-08-12T14:50:00", + "unit": "mg/dL", + "amount": 10.031984447273944 + }, + { + "date": "2020-08-12T14:55:00", + "unit": "mg/dL", + "amount": 10.44865111394061 + }, + { + "date": "2020-08-12T15:00:00", + "unit": "mg/dL", + "amount": 10.865317780607276 + }, + { + "date": "2020-08-12T15:05:00", + "unit": "mg/dL", + "amount": 11.281984447273942 + }, + { + "date": "2020-08-12T15:10:00", + "unit": "mg/dL", + "amount": 11.69865111394061 + }, + { + "date": "2020-08-12T15:15:00", + "unit": "mg/dL", + "amount": 12.115317780607278 + }, + { + "date": "2020-08-12T15:20:00", + "unit": "mg/dL", + "amount": 12.531984447273942 + }, + { + "date": "2020-08-12T15:25:00", + "unit": "mg/dL", + "amount": 12.94865111394061 + }, + { + "date": "2020-08-12T15:30:00", + "unit": "mg/dL", + "amount": 13.365317780607276 + }, + { + "date": "2020-08-12T15:35:00", + "unit": "mg/dL", + "amount": 13.781984447273944 + }, + { + "date": "2020-08-12T15:40:00", + "unit": "mg/dL", + "amount": 14.19865111394061 + }, + { + "date": "2020-08-12T15:45:00", + "unit": "mg/dL", + "amount": 14.615317780607274 + }, + { + "date": "2020-08-12T15:50:00", + "unit": "mg/dL", + "amount": 15.031984447273942 + }, + { + "date": "2020-08-12T15:55:00", + "unit": "mg/dL", + "amount": 15.44865111394061 + }, + { + "date": "2020-08-12T16:00:00", + "unit": "mg/dL", + "amount": 15.865317780607276 + }, + { + "date": "2020-08-12T16:05:00", + "unit": "mg/dL", + "amount": 16.281984447273942 + }, + { + "date": "2020-08-12T16:10:00", + "unit": "mg/dL", + "amount": 16.698651113940613 + }, + { + "date": "2020-08-12T16:15:00", + "unit": "mg/dL", + "amount": 17.115317780607278 + }, + { + "date": "2020-08-12T16:20:00", + "unit": "mg/dL", + "amount": 17.531984447273942 + }, + { + "date": "2020-08-12T16:25:00", + "unit": "mg/dL", + "amount": 17.94865111394061 + }, + { + "date": "2020-08-12T16:30:00", + "unit": "mg/dL", + "amount": 18.365317780607278 + }, + { + "date": "2020-08-12T16:35:00", + "unit": "mg/dL", + "amount": 18.781984447273945 + }, + { + "date": "2020-08-12T16:40:00", + "unit": "mg/dL", + "amount": 19.19865111394061 + }, + { + "date": "2020-08-12T16:45:00", + "unit": "mg/dL", + "amount": 19.615317780607278 + }, + { + "date": "2020-08-12T16:50:00", + "unit": "mg/dL", + "amount": 20.031984447273942 + }, + { + "date": "2020-08-12T16:55:00", + "unit": "mg/dL", + "amount": 20.44865111394061 + }, + { + "date": "2020-08-12T17:00:00", + "unit": "mg/dL", + "amount": 20.865317780607278 + }, + { + "date": "2020-08-12T17:05:00", + "unit": "mg/dL", + "amount": 21.281984447273942 + }, + { + "date": "2020-08-12T17:10:00", + "unit": "mg/dL", + "amount": 21.69865111394061 + }, + { + "date": "2020-08-12T17:15:00", + "unit": "mg/dL", + "amount": 22.115317780607278 + }, + { + "date": "2020-08-12T17:20:00", + "unit": "mg/dL", + "amount": 22.5 + } +] \ No newline at end of file diff --git a/LoopTests/Fixtures/high_and_stable/high_and_stable_counteraction_effect.json b/LoopTests/Fixtures/high_and_stable/high_and_stable_counteraction_effect.json new file mode 100644 index 0000000000..c7e1881c48 --- /dev/null +++ b/LoopTests/Fixtures/high_and_stable/high_and_stable_counteraction_effect.json @@ -0,0 +1,512 @@ +[ + { + "startDate": "2020-08-11T19:44:58", + "endDate": "2020-08-11T19:49:58", + "unit": "mg\/min·dL", + "value": 0.0 + }, + { + "startDate": "2020-08-11T19:49:58", + "endDate": "2020-08-11T19:54:58", + "unit": "mg\/min·dL", + "value": 0.0 + }, + { + "startDate": "2020-08-11T19:54:58", + "endDate": "2020-08-11T19:59:58", + "unit": "mg\/min·dL", + "value": 0.0 + }, + { + "startDate": "2020-08-11T19:59:58", + "endDate": "2020-08-11T20:04:58", + "unit": "mg\/min·dL", + "value": 0.0 + }, + { + "startDate": "2020-08-11T20:04:58", + "endDate": "2020-08-11T20:09:58", + "unit": "mg\/min·dL", + "value": 0.0 + }, + { + "startDate": "2020-08-11T20:09:58", + "endDate": "2020-08-11T20:14:58", + "unit": "mg\/min·dL", + "value": 0.0 + }, + { + "startDate": "2020-08-11T20:14:58", + "endDate": "2020-08-11T20:19:58", + "unit": "mg\/min·dL", + "value": 0.0 + }, + { + "startDate": "2020-08-11T20:19:58", + "endDate": "2020-08-11T20:24:58", + "unit": "mg\/min·dL", + "value": 0.0 + }, + { + "startDate": "2020-08-11T20:24:58", + "endDate": "2020-08-11T20:29:58", + "unit": "mg\/min·dL", + "value": 0.0 + }, + { + "startDate": "2020-08-11T20:29:58", + "endDate": "2020-08-11T20:34:58", + "unit": "mg\/min·dL", + "value": 0.0 + }, + { + "startDate": "2020-08-11T20:34:58", + "endDate": "2020-08-11T20:39:58", + "unit": "mg\/min·dL", + "value": 0.0 + }, + { + "startDate": "2020-08-11T20:39:58", + "endDate": "2020-08-11T20:44:58", + "unit": "mg\/min·dL", + "value": 0.0 + }, + { + "startDate": "2020-08-11T20:44:58", + "endDate": "2020-08-11T20:49:58", + "unit": "mg\/min·dL", + "value": 0.0 + }, + { + "startDate": "2020-08-11T20:49:58", + "endDate": "2020-08-11T20:54:58", + "unit": "mg\/min·dL", + "value": 0.0 + }, + { + "startDate": "2020-08-11T20:54:58", + "endDate": "2020-08-11T20:59:58", + "unit": "mg\/min·dL", + "value": 0.0 + }, + { + "startDate": "2020-08-11T20:59:58", + "endDate": "2020-08-11T21:04:58", + "unit": "mg\/min·dL", + "value": 0.0 + }, + { + "startDate": "2020-08-11T21:04:58", + "endDate": "2020-08-11T21:09:58", + "unit": "mg\/min·dL", + "value": 0.0 + }, + { + "startDate": "2020-08-11T21:09:58", + "endDate": "2020-08-11T21:14:58", + "unit": "mg\/min·dL", + "value": 0.0 + }, + { + "startDate": "2020-08-11T21:14:58", + "endDate": "2020-08-11T21:19:58", + "unit": "mg\/min·dL", + "value": 0.0 + }, + { + "startDate": "2020-08-11T21:19:58", + "endDate": "2020-08-11T21:24:58", + "unit": "mg\/min·dL", + "value": 0.0 + }, + { + "startDate": "2020-08-11T21:24:58", + "endDate": "2020-08-11T21:29:58", + "unit": "mg\/min·dL", + "value": 0.0 + }, + { + "startDate": "2020-08-11T21:29:58", + "endDate": "2020-08-11T21:34:58", + "unit": "mg\/min·dL", + "value": 0.0 + }, + { + "startDate": "2020-08-11T21:34:58", + "endDate": "2020-08-11T21:39:58", + "unit": "mg\/min·dL", + "value": 0.0 + }, + { + "startDate": "2020-08-11T21:39:58", + "endDate": "2020-08-11T21:44:58", + "unit": "mg\/min·dL", + "value": 0.0 + }, + { + "startDate": "2020-08-11T21:44:58", + "endDate": "2020-08-11T21:49:58", + "unit": "mg\/min·dL", + "value": 0.0 + }, + { + "startDate": "2020-08-11T21:49:58", + "endDate": "2020-08-11T21:54:58", + "unit": "mg\/min·dL", + "value": 0.0 + }, + { + "startDate": "2020-08-11T21:54:58", + "endDate": "2020-08-11T21:59:58", + "unit": "mg\/min·dL", + "value": 0.0 + }, + { + "startDate": "2020-08-11T21:59:58", + "endDate": "2020-08-11T22:04:58", + "unit": "mg\/min·dL", + "value": 0.0 + }, + { + "startDate": "2020-08-11T22:04:58", + "endDate": "2020-08-11T22:09:58", + "unit": "mg\/min·dL", + "value": 0.0 + }, + { + "startDate": "2020-08-11T22:09:58", + "endDate": "2020-08-11T22:14:58", + "unit": "mg\/min·dL", + "value": 0.0 + }, + { + "startDate": "2020-08-11T22:14:58", + "endDate": "2020-08-11T22:19:58", + "unit": "mg\/min·dL", + "value": 0.0 + }, + { + "startDate": "2020-08-11T22:19:58", + "endDate": "2020-08-11T22:24:58", + "unit": "mg\/min·dL", + "value": 0.0 + }, + { + "startDate": "2020-08-11T22:24:58", + "endDate": "2020-08-11T22:29:58", + "unit": "mg\/min·dL", + "value": 0.0 + }, + { + "startDate": "2020-08-11T22:29:58", + "endDate": "2020-08-11T22:34:58", + "unit": "mg\/min·dL", + "value": 0.0 + }, + { + "startDate": "2020-08-11T22:34:58", + "endDate": "2020-08-11T22:39:58", + "unit": "mg\/min·dL", + "value": 0.0 + }, + { + "startDate": "2020-08-11T22:39:58", + "endDate": "2020-08-11T22:44:58", + "unit": "mg\/min·dL", + "value": 0.0 + }, + { + "startDate": "2020-08-11T22:44:58", + "endDate": "2020-08-11T22:49:44", + "unit": "mg\/min·dL", + "value": 0.0 + }, + { + "startDate": "2020-08-11T22:49:44", + "endDate": "2020-08-11T22:54:44", + "unit": "mg\/min·dL", + "value": 0.0 + }, + { + "startDate": "2020-08-11T22:54:44", + "endDate": "2020-08-11T22:59:45", + "unit": "mg\/min·dL", + "value": 0.060537504513367056 + }, + { + "startDate": "2020-08-11T22:59:45", + "endDate": "2020-08-11T23:07:01", + "unit": "mg\/min·dL", + "value": 0.318789967635506 + }, + { + "startDate": "2020-08-11T23:07:01", + "endDate": "2020-08-11T23:20:52", + "unit": "mg\/min·dL", + "value": 0.4770283365992919 + }, + { + "startDate": "2020-08-11T23:20:52", + "endDate": "2020-08-11T23:48:53", + "unit": "mg\/min·dL", + "value": 0.560721533302221 + }, + { + "startDate": "2020-08-11T23:48:53", + "endDate": "2020-08-11T23:59:30", + "unit": "mg\/min·dL", + "value": 0.6389946260986602 + }, + { + "startDate": "2020-08-11T23:59:30", + "endDate": "2020-08-12T00:04:20", + "unit": "mg\/min·dL", + "value": 0.6935601631312946 + }, + { + "startDate": "2020-08-12T00:04:20", + "endDate": "2020-08-12T01:00:27", + "unit": "mg\/min·dL", + "value": 0.688973517799663 + }, + { + "startDate": "2020-08-12T01:00:27", + "endDate": "2020-08-12T02:58:40", + "unit": "mg\/min·dL", + "value": 0.5439342789219825 + }, + { + "startDate": "2020-08-12T02:58:40", + "endDate": "2020-08-12T03:04:10", + "unit": "mg\/min·dL", + "value": 0.3751525560480912 + }, + { + "startDate": "2020-08-12T03:04:10", + "endDate": "2020-08-12T03:16:07", + "unit": "mg\/min·dL", + "value": 0.48551004284584887 + }, + { + "startDate": "2020-08-12T03:16:07", + "endDate": "2020-08-12T09:39:22", + "unit": "mg\/min·dL", + "value": 0.0 + }, + { + "startDate": "2020-08-12T09:39:22", + "endDate": "2020-08-12T09:44:22", + "unit": "mg\/min·dL", + "value": 3.6693499969069935e-07 + }, + { + "startDate": "2020-08-12T09:44:22", + "endDate": "2020-08-12T09:49:22", + "unit": "mg\/min·dL", + "value": 1.23039439366464e-05 + }, + { + "startDate": "2020-08-12T09:49:22", + "endDate": "2020-08-12T09:54:22", + "unit": "mg\/min·dL", + "value": 2.8175153427568468e-05 + }, + { + "startDate": "2020-08-12T09:54:22", + "endDate": "2020-08-12T09:59:22", + "unit": "mg\/min·dL", + "value": 4.2046202615375436e-05 + }, + { + "startDate": "2020-08-12T09:59:22", + "endDate": "2020-08-12T10:04:22", + "unit": "mg\/min·dL", + "value": 5.409396554054199e-05 + }, + { + "startDate": "2020-08-12T10:04:22", + "endDate": "2020-08-12T10:09:22", + "unit": "mg\/min·dL", + "value": 6.448192040302968e-05 + }, + { + "startDate": "2020-08-12T10:09:22", + "endDate": "2020-08-12T10:14:22", + "unit": "mg\/min·dL", + "value": 7.336107701339417e-05 + }, + { + "startDate": "2020-08-12T10:14:22", + "endDate": "2020-08-12T10:19:22", + "unit": "mg\/min·dL", + "value": 8.08708437316198e-05 + }, + { + "startDate": "2020-08-12T10:19:22", + "endDate": "2020-08-12T10:24:22", + "unit": "mg\/min·dL", + "value": 8.713983767792378e-05 + }, + { + "startDate": "2020-08-12T10:24:22", + "endDate": "2020-08-12T10:29:22", + "unit": "mg\/min·dL", + "value": 9.228664177056543e-05 + }, + { + "startDate": "2020-08-12T10:29:22", + "endDate": "2020-08-12T10:34:22", + "unit": "mg\/min·dL", + "value": 9.642051192999891e-05 + }, + { + "startDate": "2020-08-12T10:34:22", + "endDate": "2020-08-12T10:39:22", + "unit": "mg\/min·dL", + "value": 9.964203758581272e-05 + }, + { + "startDate": "2020-08-12T10:39:22", + "endDate": "2020-08-12T10:44:22", + "unit": "mg\/min·dL", + "value": 0.0001020437584319726 + }, + { + "startDate": "2020-08-12T10:44:22", + "endDate": "2020-08-12T10:49:22", + "unit": "mg\/min·dL", + "value": 0.00010371074019636158 + }, + { + "startDate": "2020-08-12T10:49:22", + "endDate": "2020-08-12T10:54:22", + "unit": "mg\/min·dL", + "value": 0.00010472111202159181 + }, + { + "startDate": "2020-08-12T10:54:22", + "endDate": "2020-08-12T10:59:22", + "unit": "mg\/min·dL", + "value": 0.00010514656789532351 + }, + { + "startDate": "2020-08-12T10:59:22", + "endDate": "2020-08-12T11:04:22", + "unit": "mg\/min·dL", + "value": 0.00010505283441879423 + }, + { + "startDate": "2020-08-12T11:04:22", + "endDate": "2020-08-12T11:09:22", + "unit": "mg\/min·dL", + "value": 0.00010450010706183134 + }, + { + "startDate": "2020-08-12T11:09:22", + "endDate": "2020-08-12T11:14:22", + "unit": "mg\/min·dL", + "value": 0.00010354345692046938 + }, + { + "startDate": "2020-08-12T11:14:22", + "endDate": "2020-08-12T11:19:22", + "unit": "mg\/min·dL", + "value": 0.0001022332098690782 + }, + { + "startDate": "2020-08-12T11:19:22", + "endDate": "2020-08-12T11:24:22", + "unit": "mg\/min·dL", + "value": 0.00010061529988214819 + }, + { + "startDate": "2020-08-12T11:24:22", + "endDate": "2020-08-12T11:29:22", + "unit": "mg\/min·dL", + "value": 9.873159819104443e-05 + }, + { + "startDate": "2020-08-12T11:29:22", + "endDate": "2020-08-12T11:34:22", + "unit": "mg\/min·dL", + "value": 9.662021983793364e-05 + }, + { + "startDate": "2020-08-12T11:34:22", + "endDate": "2020-08-12T11:39:22", + "unit": "mg\/min·dL", + "value": 9.431580909200209e-05 + }, + { + "startDate": "2020-08-12T11:39:22", + "endDate": "2020-08-12T11:44:22", + "unit": "mg\/min·dL", + "value": 9.184980510203684e-05 + }, + { + "startDate": "2020-08-12T11:44:22", + "endDate": "2020-08-12T11:49:22", + "unit": "mg\/min·dL", + "value": 8.925068907371241e-05 + }, + { + "startDate": "2020-08-12T11:49:22", + "endDate": "2020-08-12T11:54:22", + "unit": "mg\/min·dL", + "value": 8.654421417950385e-05 + }, + { + "startDate": "2020-08-12T11:54:22", + "endDate": "2020-08-12T11:59:22", + "unit": "mg\/min·dL", + "value": 8.375361933351428e-05 + }, + { + "startDate": "2020-08-12T11:59:22", + "endDate": "2020-08-12T12:04:22", + "unit": "mg\/min·dL", + "value": 8.089982789249161e-05 + }, + { + "startDate": "2020-08-12T12:04:22", + "endDate": "2020-08-12T12:09:22", + "unit": "mg\/min·dL", + "value": 7.800163227757589e-05 + }, + { + "startDate": "2020-08-12T12:09:22", + "endDate": "2020-08-12T12:14:22", + "unit": "mg\/min·dL", + "value": 7.507586544868751e-05 + }, + { + "startDate": "2020-08-12T12:14:22", + "endDate": "2020-08-12T12:19:22", + "unit": "mg\/min·dL", + "value": 7.213756010459904e-05 + }, + { + "startDate": "2020-08-12T12:19:22", + "endDate": "2020-08-12T12:24:22", + "unit": "mg\/min·dL", + "value": 6.920009642648118e-05 + }, + { + "startDate": "2020-08-12T12:24:22", + "endDate": "2020-08-12T12:29:22", + "unit": "mg\/min·dL", + "value": 6.627533913084806e-05 + }, + { + "startDate": "2020-08-12T12:29:22", + "endDate": "2020-08-12T12:34:22", + "unit": "mg\/min·dL", + "value": 6.337376454910829e-05 + }, + { + "startDate": "2020-08-12T12:34:22", + "endDate": "2020-08-12T12:38:59", + "unit": "mg\/min·dL", + "value": 6.563204470819873e-05 + } +] diff --git a/LoopTests/Fixtures/high_and_stable/high_and_stable_insulin_effect.json b/LoopTests/Fixtures/high_and_stable/high_and_stable_insulin_effect.json new file mode 100644 index 0000000000..e27206385c --- /dev/null +++ b/LoopTests/Fixtures/high_and_stable/high_and_stable_insulin_effect.json @@ -0,0 +1,382 @@ +[ + { + "date": "2020-08-12T12:40:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T12:45:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T12:50:00", + "amount": -0.00010857088891486093, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T12:55:00", + "amount": -0.11764496465132551, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T13:00:00", + "amount": -0.43873902047529706, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T13:05:00", + "amount": -0.9379108424564665, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T13:10:00", + "amount": -1.5919285563573975, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T13:15:00", + "amount": -2.379638252059979, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T13:20:00", + "amount": -3.281805691343955, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T13:25:00", + "amount": -4.280969013729399, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T13:30:00", + "amount": -5.361301721085654, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T13:35:00", + "amount": -6.508485266770114, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T13:40:00", + "amount": -7.709590617387781, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T13:45:00", + "amount": -8.952968195018745, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T13:50:00", + "amount": -10.228145645097738, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T13:55:00", + "amount": -11.525732910191868, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T14:00:00", + "amount": -12.837334122842806, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T14:05:00", + "amount": -14.15546586154826, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T14:10:00", + "amount": -15.473481342970688, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T14:15:00", + "amount": -16.785500150695594, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T14:20:00", + "amount": -18.08634312642022, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T14:25:00", + "amount": -19.3714720734388, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T14:30:00", + "amount": -20.636933944795025, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T14:35:00", + "amount": -21.8793092095861, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T14:40:00", + "amount": -23.095664110708345, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T14:45:00", + "amount": -24.28350654591071, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T14:50:00", + "amount": -25.440745321443842, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T14:55:00", + "amount": -26.565652543928085, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T15:00:00", + "amount": -27.65682893137946, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T15:05:00", + "amount": -28.71317183868988, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T15:10:00", + "amount": -29.733845806315355, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T15:15:00", + "amount": -30.71825545353683, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T15:20:00", + "amount": -31.666020549476087, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T15:25:00", + "amount": -32.576953106120015, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T15:30:00", + "amount": -33.45103634797771, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T15:35:00", + "amount": -34.2884054227078, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T15:40:00", + "amount": -35.08932972614962, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T15:45:00", + "amount": -35.854196723707794, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T15:50:00", + "amount": -36.58349715801203, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T15:55:00", + "amount": -37.27781154023619, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T16:00:00", + "amount": -37.93779782944274, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T16:05:00", + "amount": -38.564180210852335, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T16:10:00", + "amount": -39.15773889005006, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T16:15:00", + "amount": -39.7193008258551, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T16:20:00", + "amount": -40.24973132992669, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T16:25:00", + "amount": -40.749926466175516, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T16:30:00", + "amount": -41.220806187721365, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T16:35:00", + "amount": -41.66330815350251, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T16:40:00", + "amount": -42.078382170721305, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T16:45:00", + "amount": -42.46698521311987, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T16:50:00", + "amount": -42.8300769686383, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T16:55:00", + "amount": -43.16861587332966, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T17:00:00", + "amount": -43.483555591507375, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T17:05:00", + "amount": -43.77584190499485, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T17:10:00", + "amount": -44.04640997704762, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T17:15:00", + "amount": -44.296181959036595, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T17:20:00", + "amount": -44.52606491033073, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T17:25:00", + "amount": -44.736949004006426, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T17:30:00", + "amount": -44.92970599305237, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T17:35:00", + "amount": -45.10518791363997, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T17:40:00", + "amount": -45.264226003800765, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T17:45:00", + "amount": -45.40762981750194, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T17:50:00", + "amount": -45.53618651564582, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T17:55:00", + "amount": -45.650660316948574, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T18:00:00", + "amount": -45.75179209298248, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T18:05:00", + "amount": -45.8402990929014, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T18:10:00", + "amount": -45.9168747845193, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T18:15:00", + "amount": -45.98218879947835, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T18:20:00", + "amount": -46.036886971235035, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T18:25:00", + "amount": -46.08159145551451, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T18:30:00", + "amount": -46.116900923736516, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T18:35:00", + "amount": -46.14339082071065, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T18:40:00", + "amount": -46.16161367863287, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T18:45:00", + "amount": -46.17209948009722, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T18:50:00", + "amount": -46.175359225273134, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T18:55:00", + "amount": -46.175359225273134, + "unit": "mg/dL" + } +] \ No newline at end of file diff --git a/LoopTests/Fixtures/high_and_stable/high_and_stable_momentum_effect.json b/LoopTests/Fixtures/high_and_stable/high_and_stable_momentum_effect.json new file mode 100644 index 0000000000..4d59e70865 --- /dev/null +++ b/LoopTests/Fixtures/high_and_stable/high_and_stable_momentum_effect.json @@ -0,0 +1,27 @@ +[ + { + "date": "2020-08-12T12:35:00", + "unit": "mg/dL", + "amount": 0.0 + }, + { + "date": "2020-08-12T12:40:00", + "unit": "mg/dL", + "amount": 0.0 + }, + { + "date": "2020-08-12T12:45:00", + "unit": "mg/dL", + "amount": 0.0 + }, + { + "date": "2020-08-12T12:50:00", + "unit": "mg/dL", + "amount": 0.0 + }, + { + "date": "2020-08-12T12:55:00", + "unit": "mg/dL", + "amount": 0.0 + } +] \ No newline at end of file diff --git a/LoopTests/Fixtures/high_and_stable/high_and_stable_predicted_glucose.json b/LoopTests/Fixtures/high_and_stable/high_and_stable_predicted_glucose.json new file mode 100644 index 0000000000..5f757341ae --- /dev/null +++ b/LoopTests/Fixtures/high_and_stable/high_and_stable_predicted_glucose.json @@ -0,0 +1,387 @@ +[ + { + "date": "2020-08-12T12:39:22", + "unit": "mg/dL", + "amount": 200.0 + }, + { + "date": "2020-08-12T12:40:00", + "unit": "mg/dL", + "amount": 200.0 + }, + { + "date": "2020-08-12T12:45:00", + "unit": "mg/dL", + "amount": 200.00001542044052 + }, + { + "date": "2020-08-12T12:50:00", + "unit": "mg/dL", + "amount": 200.0120908555042 + }, + { + "date": "2020-08-12T12:55:00", + "unit": "mg/dL", + "amount": 200.22415504165645 + }, + { + "date": "2020-08-12T13:00:00", + "unit": "mg/dL", + "amount": 200.31998733993237 + }, + { + "date": "2020-08-12T13:05:00", + "unit": "mg/dL", + "amount": 200.23770477384636 + }, + { + "date": "2020-08-12T13:10:00", + "unit": "mg/dL", + "amount": 200.00053921763583 + }, + { + "date": "2020-08-12T13:15:00", + "unit": "mg/dL", + "amount": 199.6296445814189 + }, + { + "date": "2020-08-12T13:20:00", + "unit": "mg/dL", + "amount": 199.14425510341582 + }, + { + "date": "2020-08-12T13:25:00", + "unit": "mg/dL", + "amount": 198.56183264410652 + }, + { + "date": "2020-08-12T13:30:00", + "unit": "mg/dL", + "amount": 197.8982037016217 + }, + { + "date": "2020-08-12T13:35:00", + "unit": "mg/dL", + "amount": 197.1676868226039 + }, + { + "date": "2020-08-12T13:40:00", + "unit": "mg/dL", + "amount": 196.3832481386529 + }, + { + "date": "2020-08-12T13:45:00", + "unit": "mg/dL", + "amount": 195.5565372276886 + }, + { + "date": "2020-08-12T13:50:00", + "unit": "mg/dL", + "amount": 194.69802644427628 + }, + { + "date": "2020-08-12T13:55:00", + "unit": "mg/dL", + "amount": 193.81710584584883 + }, + { + "date": "2020-08-12T14:00:00", + "unit": "mg/dL", + "amount": 192.92217129986454 + }, + { + "date": "2020-08-12T14:05:00", + "unit": "mg/dL", + "amount": 192.02070622782577 + }, + { + "date": "2020-08-12T14:10:00", + "unit": "mg/dL", + "amount": 191.11935741307002 + }, + { + "date": "2020-08-12T14:15:00", + "unit": "mg/dL", + "amount": 190.2240052720118 + }, + { + "date": "2020-08-12T14:20:00", + "unit": "mg/dL", + "amount": 189.33982896295385 + }, + { + "date": "2020-08-12T14:25:00", + "unit": "mg/dL", + "amount": 188.47136668260194 + }, + { + "date": "2020-08-12T14:30:00", + "unit": "mg/dL", + "amount": 187.62257147791237 + }, + { + "date": "2020-08-12T14:35:00", + "unit": "mg/dL", + "amount": 186.79686287978797 + }, + { + "date": "2020-08-12T14:40:00", + "unit": "mg/dL", + "amount": 185.9971746453324 + }, + { + "date": "2020-08-12T14:45:00", + "unit": "mg/dL", + "amount": 185.2259988767967 + }, + { + "date": "2020-08-12T14:50:00", + "unit": "mg/dL", + "amount": 184.48542676793022 + }, + { + "date": "2020-08-12T14:55:00", + "unit": "mg/dL", + "amount": 183.77718621211264 + }, + { + "date": "2020-08-12T15:00:00", + "unit": "mg/dL", + "amount": 183.10267649132794 + }, + { + "date": "2020-08-12T15:05:00", + "unit": "mg/dL", + "amount": 182.4630002506842 + }, + { + "date": "2020-08-12T15:10:00", + "unit": "mg/dL", + "amount": 181.85899294972538 + }, + { + "date": "2020-08-12T15:15:00", + "unit": "mg/dL", + "amount": 181.29124996917056 + }, + { + "date": "2020-08-12T15:20:00", + "unit": "mg/dL", + "amount": 180.76015153989798 + }, + { + "date": "2020-08-12T15:25:00", + "unit": "mg/dL", + "amount": 180.26588564992073 + }, + { + "date": "2020-08-12T15:30:00", + "unit": "mg/dL", + "amount": 179.80846907472971 + }, + { + "date": "2020-08-12T15:35:00", + "unit": "mg/dL", + "amount": 179.3877666666663 + }, + { + "date": "2020-08-12T15:40:00", + "unit": "mg/dL", + "amount": 179.00350902989112 + }, + { + "date": "2020-08-12T15:45:00", + "unit": "mg/dL", + "amount": 178.65530869899962 + }, + { + "date": "2020-08-12T15:50:00", + "unit": "mg/dL", + "amount": 178.34267493136204 + }, + { + "date": "2020-08-12T15:55:00", + "unit": "mg/dL", + "amount": 178.06502721580455 + }, + { + "date": "2020-08-12T16:00:00", + "unit": "mg/dL", + "amount": 177.82170759326468 + }, + { + "date": "2020-08-12T16:05:00", + "unit": "mg/dL", + "amount": 177.61199187852174 + }, + { + "date": "2020-08-12T16:10:00", + "unit": "mg/dL", + "amount": 177.43509986599068 + }, + { + "date": "2020-08-12T16:15:00", + "unit": "mg/dL", + "amount": 177.2902045968523 + }, + { + "date": "2020-08-12T16:20:00", + "unit": "mg/dL", + "amount": 177.1764407594474 + }, + { + "date": "2020-08-12T16:25:00", + "unit": "mg/dL", + "amount": 177.09291228986524 + }, + { + "date": "2020-08-12T16:30:00", + "unit": "mg/dL", + "amount": 177.03869923498607 + }, + { + "date": "2020-08-12T16:35:00", + "unit": "mg/dL", + "amount": 177.0128639358716 + }, + { + "date": "2020-08-12T16:40:00", + "unit": "mg/dL", + "amount": 177.01445658531946 + }, + { + "date": "2020-08-12T16:45:00", + "unit": "mg/dL", + "amount": 177.04252020958756 + }, + { + "date": "2020-08-12T16:50:00", + "unit": "mg/dL", + "amount": 177.0960951207358 + }, + { + "date": "2020-08-12T16:55:00", + "unit": "mg/dL", + "amount": 177.17422288271112 + }, + { + "date": "2020-08-12T17:00:00", + "unit": "mg/dL", + "amount": 177.27594983120008 + }, + { + "date": "2020-08-12T17:05:00", + "unit": "mg/dL", + "amount": 177.40033018437927 + }, + { + "date": "2020-08-12T17:10:00", + "unit": "mg/dL", + "amount": 177.54642877899317 + }, + { + "date": "2020-08-12T17:15:00", + "unit": "mg/dL", + "amount": 177.71332346367086 + }, + { + "date": "2020-08-12T17:20:00", + "unit": "mg/dL", + "amount": 177.86812273176946 + }, + { + "date": "2020-08-12T17:25:00", + "unit": "mg/dL", + "amount": 177.65723863809376 + }, + { + "date": "2020-08-12T17:30:00", + "unit": "mg/dL", + "amount": 177.4644816490478 + }, + { + "date": "2020-08-12T17:35:00", + "unit": "mg/dL", + "amount": 177.2889997284602 + }, + { + "date": "2020-08-12T17:40:00", + "unit": "mg/dL", + "amount": 177.1299616382994 + }, + { + "date": "2020-08-12T17:45:00", + "unit": "mg/dL", + "amount": 176.9865578245982 + }, + { + "date": "2020-08-12T17:50:00", + "unit": "mg/dL", + "amount": 176.85800112645433 + }, + { + "date": "2020-08-12T17:55:00", + "unit": "mg/dL", + "amount": 176.74352732515158 + }, + { + "date": "2020-08-12T18:00:00", + "unit": "mg/dL", + "amount": 176.64239554911768 + }, + { + "date": "2020-08-12T18:05:00", + "unit": "mg/dL", + "amount": 176.55388854919875 + }, + { + "date": "2020-08-12T18:10:00", + "unit": "mg/dL", + "amount": 176.47731285758084 + }, + { + "date": "2020-08-12T18:15:00", + "unit": "mg/dL", + "amount": 176.41199884262178 + }, + { + "date": "2020-08-12T18:20:00", + "unit": "mg/dL", + "amount": 176.3573006708651 + }, + { + "date": "2020-08-12T18:25:00", + "unit": "mg/dL", + "amount": 176.3125961865856 + }, + { + "date": "2020-08-12T18:30:00", + "unit": "mg/dL", + "amount": 176.2772867183636 + }, + { + "date": "2020-08-12T18:35:00", + "unit": "mg/dL", + "amount": 176.25079682138946 + }, + { + "date": "2020-08-12T18:40:00", + "unit": "mg/dL", + "amount": 176.23257396346725 + }, + { + "date": "2020-08-12T18:45:00", + "unit": "mg/dL", + "amount": 176.22208816200288 + }, + { + "date": "2020-08-12T18:50:00", + "unit": "mg/dL", + "amount": 176.21882841682697 + }, + { + "date": "2020-08-12T18:55:00", + "unit": "mg/dL", + "amount": 176.21882841682697 + } +] \ No newline at end of file diff --git a/LoopTests/Fixtures/insulin_effect.json b/LoopTests/Fixtures/insulin_effect.json new file mode 100644 index 0000000000..36cb2309d6 --- /dev/null +++ b/LoopTests/Fixtures/insulin_effect.json @@ -0,0 +1,106 @@ +[ + {"date": "2015-10-25T19:05:00", "amount": 0.0, "unit": "mg/dL"}, + {"date": "2015-10-25T19:10:00", "amount": 0.0, "unit": "mg/dL"}, + {"date": "2015-10-25T19:15:00", "amount": 0.0, "unit": "mg/dL"}, + {"date": "2015-10-25T19:20:00", "amount": 0.0006660982605206369, "unit": "mg/dL"}, + {"date": "2015-10-25T19:25:00", "amount": 0.4319784823610822, "unit": "mg/dL"}, + {"date": "2015-10-25T19:30:00", "amount": 0.44733221227527764, "unit": "mg/dL"}, + {"date": "2015-10-25T19:35:00", "amount": 0.48375082617688026, "unit": "mg/dL"}, + {"date": "2015-10-25T19:40:00", "amount": 0.5479479941286376, "unit": "mg/dL"}, + {"date": "2015-10-25T19:45:00", "amount": 0.6454842620882801, "unit": "mg/dL"}, + {"date": "2015-10-25T19:50:00", "amount": 0.7820579409770838, "unit": "mg/dL"}, + {"date": "2015-10-25T19:55:00", "amount": 0.9623103893258668, "unit": "mg/dL"}, + {"date": "2015-10-25T20:00:00", "amount": 1.1802588001439995, "unit": "mg/dL"}, + {"date": "2015-10-25T20:05:00", "amount": 1.4308032930809993, "unit": "mg/dL"}, + {"date": "2015-10-25T20:10:00", "amount": 1.7112340724483681, "unit": "mg/dL"}, + {"date": "2015-10-25T20:15:00", "amount": 2.2972885498746414, "unit": "mg/dL"}, + {"date": "2015-10-25T20:20:00", "amount": 2.657016843602961, "unit": "mg/dL"}, + {"date": "2015-10-25T20:25:00", "amount": 3.064535111779773, "unit": "mg/dL"}, + {"date": "2015-10-25T20:30:00", "amount": 3.524362622697622, "unit": "mg/dL"}, + {"date": "2015-10-25T20:35:00", "amount": 2.645152642588718, "unit": "mg/dL"}, + {"date": "2015-10-25T20:40:00", "amount": 2.1433293952834864, "unit": "mg/dL"}, + {"date": "2015-10-25T20:45:00", "amount": 1.0383154699059773, "unit": "mg/dL"}, + {"date": "2015-10-25T20:50:00", "amount": -0.6037289472355739, "unit": "mg/dL"}, + {"date": "2015-10-25T20:55:00", "amount": -2.7717544797822367, "unit": "mg/dL"}, + {"date": "2015-10-25T21:00:00", "amount": -5.455035986284498, "unit": "mg/dL"}, + {"date": "2015-10-25T21:05:00", "amount": -7.79751242933939, "unit": "mg/dL"}, + {"date": "2015-10-25T21:10:00", "amount": -11.517522500886, "unit": "mg/dL"}, + {"date": "2015-10-25T21:15:00", "amount": -15.716273635751, "unit": "mg/dL"}, + {"date": "2015-10-25T21:20:00", "amount": -21.1870685982325, "unit": "mg/dL"}, + {"date": "2015-10-25T21:25:00", "amount": -27.3631817597881, "unit": "mg/dL"}, + {"date": "2015-10-25T21:30:00", "amount": -34.2256777352351, "unit": "mg/dL"}, + {"date": "2015-10-25T21:35:00", "amount": -41.7099106346659, "unit": "mg/dL"}, + {"date": "2015-10-25T21:40:00", "amount": -49.7561873037511, "unit": "mg/dL"}, + {"date": "2015-10-25T21:45:00", "amount": -59.27102637839595, "unit": "mg/dL"}, + {"date": "2015-10-25T21:50:00", "amount": -67.90057488365268, "unit": "mg/dL"}, + {"date": "2015-10-25T21:55:00", "amount": -77.19519450218243, "unit": "mg/dL"}, + {"date": "2015-10-25T22:00:00", "amount": -86.8085049447589, "unit": "mg/dL"}, + {"date": "2015-10-25T22:05:00", "amount": -96.68477972174647, "unit": "mg/dL"}, + {"date": "2015-10-25T22:10:00", "amount": -106.77048448794005, "unit": "mg/dL"}, + {"date": "2015-10-25T22:15:00", "amount": -117.24440610028151, "unit": "mg/dL"}, + {"date": "2015-10-25T22:20:00", "amount": -127.67910744385503, "unit": "mg/dL"}, + {"date": "2015-10-25T22:25:00", "amount": -138.2396776587623, "unit": "mg/dL"}, + {"date": "2015-10-25T22:30:00", "amount": -148.88226145443326, "unit": "mg/dL"}, + {"date": "2015-10-25T22:35:00", "amount": -159.56499033505642, "unit": "mg/dL"}, + {"date": "2015-10-25T22:40:00", "amount": -170.2513561674504, "unit": "mg/dL"}, + {"date": "2015-10-25T22:45:00", "amount": -180.90675495330677, "unit": "mg/dL"}, + {"date": "2015-10-25T22:50:00", "amount": -192.4751418656235, "unit": "mg/dL"}, + {"date": "2015-10-25T22:55:00", "amount": -204.31129319425713, "unit": "mg/dL"}, + {"date": "2015-10-25T23:00:00", "amount": -216.40765980729208, "unit": "mg/dL"}, + {"date": "2015-10-25T23:05:00", "amount": -228.70879990446016, "unit": "mg/dL"}, + {"date": "2015-10-25T23:10:00", "amount": -241.112574438284, "unit": "mg/dL"}, + {"date": "2015-10-25T23:15:00", "amount": -253.7479400469, "unit": "mg/dL"}, + {"date": "2015-10-25T23:20:00", "amount": -266.494351926145, "unit": "mg/dL"}, + {"date": "2015-10-25T23:25:00", "amount": -279.323001345175, "unit": "mg/dL"}, + {"date": "2015-10-25T23:30:00", "amount": -293.2161250905183, "unit": "mg/dL"}, + {"date": "2015-10-25T23:35:00", "amount": -306.10434323611, "unit": "mg/dL"}, + {"date": "2015-10-25T23:40:00", "amount": -319.0224174958953, "unit": "mg/dL"}, + {"date": "2015-10-25T23:45:00", "amount": -331.92442295925844, "unit": "mg/dL"}, + {"date": "2015-10-25T23:50:00", "amount": -344.7614413986003, "unit": "mg/dL"}, + {"date": "2015-10-25T23:55:00", "amount": -357.4985644921268, "unit": "mg/dL"}, + {"date": "2015-10-26T00:00:00", "amount": -370.0997621103307, "unit": "mg/dL"}, + {"date": "2015-10-26T00:05:00", "amount": -382.5116285629616, "unit": "mg/dL"}, + {"date": "2015-10-26T00:10:00", "amount": -394.7084163066953, "unit": "mg/dL"}, + {"date": "2015-10-26T00:15:00", "amount": -406.66202868680824, "unit": "mg/dL"}, + {"date": "2015-10-26T00:20:00", "amount": -418.380026718382, "unit": "mg/dL"}, + {"date": "2015-10-26T00:25:00", "amount": -429.8474254518422, "unit": "mg/dL"}, + {"date": "2015-10-26T00:30:00", "amount": -441.04071612573085, "unit": "mg/dL"}, + {"date": "2015-10-26T00:35:00", "amount": -451.9832999579977, "unit": "mg/dL"}, + {"date": "2015-10-26T00:40:00", "amount": -462.46842592415237, "unit": "mg/dL"}, + {"date": "2015-10-26T00:45:00", "amount": -470.9378219290769, "unit": "mg/dL"}, + {"date": "2015-10-26T00:50:00", "amount": -479.11572935989676, "unit": "mg/dL"}, + {"date": "2015-10-26T00:55:00", "amount": -486.99657300588615, "unit": "mg/dL"}, + {"date": "2015-10-26T01:00:00", "amount": -494.5908193412654, "unit": "mg/dL"}, + {"date": "2015-10-26T01:05:00", "amount": -501.8628086209436, "unit": "mg/dL"}, + {"date": "2015-10-26T01:10:00", "amount": -508.88507182844774, "unit": "mg/dL"}, + {"date": "2015-10-26T01:15:00", "amount": -515.6007765994074, "unit": "mg/dL"}, + {"date": "2015-10-26T01:20:00", "amount": -521.7524801441155, "unit": "mg/dL"}, + {"date": "2015-10-26T01:25:00", "amount": -526.678274488565, "unit": "mg/dL"}, + {"date": "2015-10-26T01:30:00", "amount": -531.4447495748509, "unit": "mg/dL"}, + {"date": "2015-10-26T01:35:00", "amount": -536.0644055267877, "unit": "mg/dL"}, + {"date": "2015-10-26T01:40:00", "amount": -540.5295961381503, "unit": "mg/dL"}, + {"date": "2015-10-26T01:45:00", "amount": -544.8222739849136, "unit": "mg/dL"}, + {"date": "2015-10-26T01:50:00", "amount": -548.9890101619071, "unit": "mg/dL"}, + {"date": "2015-10-26T01:55:00", "amount": -552.9823593323354, "unit": "mg/dL"}, + {"date": "2015-10-26T02:00:00", "amount": -556.8260617933233, "unit": "mg/dL"}, + {"date": "2015-10-26T02:05:00", "amount": -560.5209949579603, "unit": "mg/dL"}, + {"date": "2015-10-26T02:10:00", "amount": -564.0644737271039, "unit": "mg/dL"}, + {"date": "2015-10-26T02:15:00", "amount": -567.4470540344793, "unit": "mg/dL"}, + {"date": "2015-10-26T02:20:00", "amount": -570.6264032893645, "unit": "mg/dL"}, + {"date": "2015-10-26T02:25:00", "amount": -573.5980315609713, "unit": "mg/dL"}, + {"date": "2015-10-26T02:30:00", "amount": -576.3722239003739, "unit": "mg/dL"}, + {"date": "2015-10-26T02:35:00", "amount": -578.9877073683267, "unit": "mg/dL"}, + {"date": "2015-10-26T02:40:00", "amount": -581.4501136238224, "unit": "mg/dL"}, + {"date": "2015-10-26T02:45:00", "amount": -583.7660851884525, "unit": "mg/dL"}, + {"date": "2015-10-26T02:50:00", "amount": -585.6148458007469, "unit": "mg/dL"}, + {"date": "2015-10-26T02:55:00", "amount": -586.2453549649722, "unit": "mg/dL"}, + {"date": "2015-10-26T03:00:00", "amount": -586.8400843798366, "unit": "mg/dL"}, + {"date": "2015-10-26T03:05:00", "amount": -587.3999796067811, "unit": "mg/dL"}, + {"date": "2015-10-26T03:10:00", "amount": -587.9299744107797, "unit": "mg/dL"}, + {"date": "2015-10-26T03:15:00", "amount": -588.4355419490084, "unit": "mg/dL"}, + {"date": "2015-10-26T03:20:00", "amount": -588.8431930574274, "unit": "mg/dL"}, + {"date": "2015-10-26T03:25:00", "amount": -589.1668633185876, "unit": "mg/dL"}, + {"date": "2015-10-26T03:30:00", "amount": -589.395649400709, "unit": "mg/dL"}, + {"date": "2015-10-26T03:35:00", "amount": -589.5435294582638, "unit": "mg/dL"}, + {"date": "2015-10-26T03:40:00", "amount": -589.6111111111112, "unit": "mg/dL"} +] diff --git a/LoopTests/Fixtures/live_capture/live_capture_input.json b/LoopTests/Fixtures/live_capture/live_capture_input.json new file mode 100644 index 0000000000..f010194a63 --- /dev/null +++ b/LoopTests/Fixtures/live_capture/live_capture_input.json @@ -0,0 +1,1009 @@ +{ + "carbEntries" : [ + { + "absorptionTime" : 10800, + "quantity" : 22, + "startDate" : "2023-06-22T19:20:53Z" + }, + { + "absorptionTime" : 10800, + "quantity" : 75, + "startDate" : "2023-06-22T21:04:45Z" + }, + { + "absorptionTime" : 10800, + "quantity" : 47, + "startDate" : "2023-06-23T02:10:13Z" + } + ], + "doses" : [ + { + "endDate" : "2023-06-22T16:22:40Z", + "startDate" : "2023-06-22T16:12:40Z", + "type" : "basal", + "unit" : "U", + "value" : 0.050000000000000003 + }, + { + "endDate" : "2023-06-22T16:17:54Z", + "startDate" : "2023-06-22T16:17:46Z", + "type" : "bolus", + "unit" : "U", + "value" : 0.20000000000000001 + }, + { + "endDate" : "2023-06-22T16:32:40Z", + "startDate" : "2023-06-22T16:22:40Z", + "type" : "tempBasal", + "unit" : "U\/hour", + "value" : 0 + }, + { + "endDate" : "2023-06-22T16:47:39Z", + "startDate" : "2023-06-22T16:32:40Z", + "type" : "basal", + "unit" : "U", + "value" : 0.10000000000000001 + }, + { + "endDate" : "2023-06-22T16:57:41Z", + "startDate" : "2023-06-22T16:47:39Z", + "type" : "tempBasal", + "unit" : "U\/hour", + "value" : 0 + }, + { + "endDate" : "2023-06-22T17:02:38Z", + "startDate" : "2023-06-22T16:57:41Z", + "type" : "basal", + "unit" : "U", + "value" : 0.050000000000000003 + }, + { + "endDate" : "2023-06-22T17:07:38Z", + "startDate" : "2023-06-22T17:02:38Z", + "type" : "tempBasal", + "unit" : "U\/hour", + "value" : 0.050000000000000003 + }, + { + "endDate" : "2023-06-22T17:22:45Z", + "startDate" : "2023-06-22T17:07:38Z", + "type" : "basal", + "unit" : "U", + "value" : 0.10000000000000001 + }, + { + "endDate" : "2023-06-22T17:12:46Z", + "startDate" : "2023-06-22T17:12:42Z", + "type" : "bolus", + "unit" : "U", + "value" : 0.10000000000000001 + }, + { + "endDate" : "2023-06-22T17:27:39Z", + "startDate" : "2023-06-22T17:22:45Z", + "type" : "tempBasal", + "unit" : "U\/hour", + "value" : 0 + }, + { + "endDate" : "2023-06-22T17:27:39Z", + "startDate" : "2023-06-22T17:27:39Z", + "type" : "basal", + "unit" : "U", + "value" : 0 + }, + { + "endDate" : "2023-06-22T17:32:39Z", + "startDate" : "2023-06-22T17:27:39Z", + "type" : "tempBasal", + "unit" : "U\/hour", + "value" : 0.050000000000000003 + }, + { + "endDate" : "2023-06-22T18:07:38Z", + "startDate" : "2023-06-22T17:32:39Z", + "type" : "basal", + "unit" : "U", + "value" : 0.25 + }, + { + "endDate" : "2023-06-22T17:32:45Z", + "startDate" : "2023-06-22T17:32:41Z", + "type" : "bolus", + "unit" : "U", + "value" : 0.10000000000000001 + }, + { + "endDate" : "2023-06-22T17:42:40Z", + "startDate" : "2023-06-22T17:42:38Z", + "type" : "bolus", + "unit" : "U", + "value" : 0.050000000000000003 + }, + { + "endDate" : "2023-06-22T17:47:43Z", + "startDate" : "2023-06-22T17:47:39Z", + "type" : "bolus", + "unit" : "U", + "value" : 0.10000000000000001 + }, + { + "endDate" : "2023-06-22T18:12:38Z", + "startDate" : "2023-06-22T18:07:38Z", + "type" : "tempBasal", + "unit" : "U\/hour", + "value" : 0 + }, + { + "endDate" : "2023-06-22T19:17:40Z", + "startDate" : "2023-06-22T18:12:38Z", + "type" : "basal", + "unit" : "U", + "value" : 0.45000000000000001 + }, + { + "endDate" : "2023-06-22T19:02:43Z", + "startDate" : "2023-06-22T19:02:39Z", + "type" : "bolus", + "unit" : "U", + "value" : 0.10000000000000001 + }, + { + "endDate" : "2023-06-22T19:22:43Z", + "startDate" : "2023-06-22T19:17:40Z", + "type" : "tempBasal", + "unit" : "U\/hour", + "value" : 0 + }, + { + "endDate" : "2023-06-22T19:21:49Z", + "startDate" : "2023-06-22T19:21:01Z", + "type" : "bolus", + "unit" : "U", + "value" : 1.2 + }, + { + "endDate" : "2023-06-22T19:37:37Z", + "startDate" : "2023-06-22T19:22:43Z", + "type" : "basal", + "unit" : "U", + "value" : 0.10000000000000001 + }, + { + "endDate" : "2023-06-22T19:27:43Z", + "startDate" : "2023-06-22T19:27:39Z", + "type" : "bolus", + "unit" : "U", + "value" : 0.10000000000000001 + }, + { + "endDate" : "2023-06-22T19:57:48Z", + "startDate" : "2023-06-22T19:37:37Z", + "type" : "tempBasal", + "unit" : "U\/hour", + "value" : 0 + }, + { + "endDate" : "2023-06-22T19:57:48Z", + "startDate" : "2023-06-22T19:57:48Z", + "type" : "basal", + "unit" : "U", + "value" : 0 + }, + { + "endDate" : "2023-06-22T20:02:39Z", + "startDate" : "2023-06-22T19:57:48Z", + "type" : "tempBasal", + "unit" : "U\/hour", + "value" : 0 + }, + { + "endDate" : "2023-06-22T20:07:40Z", + "startDate" : "2023-06-22T20:02:39Z", + "type" : "basal", + "unit" : "U", + "value" : 0.050000000000000003 + }, + { + "endDate" : "2023-06-22T20:12:40Z", + "startDate" : "2023-06-22T20:07:40Z", + "type" : "tempBasal", + "unit" : "U\/hour", + "value" : 0.10000000000000001 + }, + { + "endDate" : "2023-06-22T20:52:45Z", + "startDate" : "2023-06-22T20:12:40Z", + "type" : "basal", + "unit" : "U", + "value" : 0.25 + }, + { + "endDate" : "2023-06-22T21:07:43Z", + "startDate" : "2023-06-22T20:52:45Z", + "type" : "tempBasal", + "unit" : "U\/hour", + "value" : 0 + }, + { + "endDate" : "2023-06-22T21:07:49Z", + "startDate" : "2023-06-22T21:04:51Z", + "type" : "bolus", + "unit" : "U", + "value" : 4.4500000000000002 + }, + { + "endDate" : "2023-06-22T21:47:38Z", + "startDate" : "2023-06-22T21:07:43Z", + "type" : "basal", + "unit" : "U", + "value" : 0.25 + }, + { + "endDate" : "2023-06-22T21:12:42Z", + "startDate" : "2023-06-22T21:12:40Z", + "type" : "bolus", + "unit" : "U", + "value" : 0.050000000000000003 + }, + { + "endDate" : "2023-06-22T22:07:39Z", + "startDate" : "2023-06-22T21:47:38Z", + "type" : "tempBasal", + "unit" : "U\/hour", + "value" : 0 + }, + { + "endDate" : "2023-06-22T23:42:40Z", + "startDate" : "2023-06-22T22:07:39Z", + "type" : "basal", + "unit" : "U", + "value" : 0.65000000000000002 + }, + { + "endDate" : "2023-06-22T22:27:46Z", + "startDate" : "2023-06-22T22:27:38Z", + "type" : "bolus", + "unit" : "U", + "value" : 0.20000000000000001 + }, + { + "endDate" : "2023-06-22T22:37:44Z", + "startDate" : "2023-06-22T22:37:40Z", + "type" : "bolus", + "unit" : "U", + "value" : 0.10000000000000001 + }, + { + "endDate" : "2023-06-22T22:42:42Z", + "startDate" : "2023-06-22T22:42:40Z", + "type" : "bolus", + "unit" : "U", + "value" : 0.050000000000000003 + }, + { + "endDate" : "2023-06-22T23:52:44Z", + "startDate" : "2023-06-22T23:42:40Z", + "type" : "tempBasal", + "unit" : "U\/hour", + "value" : 0 + }, + { + "endDate" : "2023-06-22T23:57:46Z", + "startDate" : "2023-06-22T23:52:44Z", + "type" : "basal", + "unit" : "U", + "value" : 0.050000000000000003 + }, + { + "endDate" : "2023-06-23T00:02:37Z", + "startDate" : "2023-06-22T23:57:46Z", + "type" : "tempBasal", + "unit" : "U\/hour", + "value" : 0.050000000000000003 + }, + { + "endDate" : "2023-06-23T01:02:52Z", + "startDate" : "2023-06-23T00:02:37Z", + "type" : "basal", + "unit" : "U", + "value" : 0.40000000000000002 + }, + { + "endDate" : "2023-06-23T00:07:42Z", + "startDate" : "2023-06-23T00:07:40Z", + "type" : "bolus", + "unit" : "U", + "value" : 0.050000000000000003 + }, + { + "endDate" : "2023-06-23T00:12:44Z", + "startDate" : "2023-06-23T00:12:38Z", + "type" : "bolus", + "unit" : "U", + "value" : 0.14999999999999999 + }, + { + "endDate" : "2023-06-23T00:22:43Z", + "startDate" : "2023-06-23T00:22:39Z", + "type" : "bolus", + "unit" : "U", + "value" : 0.10000000000000001 + }, + { + "endDate" : "2023-06-23T00:27:49Z", + "startDate" : "2023-06-23T00:27:41Z", + "type" : "bolus", + "unit" : "U", + "value" : 0.20000000000000001 + }, + { + "endDate" : "2023-06-23T00:32:43Z", + "startDate" : "2023-06-23T00:32:39Z", + "type" : "bolus", + "unit" : "U", + "value" : 0.10000000000000001 + }, + { + "endDate" : "2023-06-23T00:37:58Z", + "startDate" : "2023-06-23T00:37:48Z", + "type" : "bolus", + "unit" : "U", + "value" : 0.25 + }, + { + "endDate" : "2023-06-23T00:42:47Z", + "startDate" : "2023-06-23T00:42:39Z", + "type" : "bolus", + "unit" : "U", + "value" : 0.20000000000000001 + }, + { + "endDate" : "2023-06-23T00:47:44Z", + "startDate" : "2023-06-23T00:47:40Z", + "type" : "bolus", + "unit" : "U", + "value" : 0.10000000000000001 + }, + { + "endDate" : "2023-06-23T00:52:51Z", + "startDate" : "2023-06-23T00:52:45Z", + "type" : "bolus", + "unit" : "U", + "value" : 0.14999999999999999 + }, + { + "endDate" : "2023-06-23T01:12:49Z", + "startDate" : "2023-06-23T01:02:52Z", + "type" : "tempBasal", + "unit" : "U\/hour", + "value" : 0 + }, + { + "endDate" : "2023-06-23T01:17:41Z", + "startDate" : "2023-06-23T01:12:49Z", + "type" : "basal", + "unit" : "U", + "value" : 0.050000000000000003 + }, + { + "endDate" : "2023-06-23T01:12:54Z", + "startDate" : "2023-06-23T01:12:50Z", + "type" : "bolus", + "unit" : "U", + "value" : 0.10000000000000001 + }, + { + "endDate" : "2023-06-23T01:37:39Z", + "startDate" : "2023-06-23T01:17:41Z", + "type" : "tempBasal", + "unit" : "U\/hour", + "value" : 0 + }, + { + "endDate" : "2023-06-23T01:37:39Z", + "startDate" : "2023-06-23T01:37:39Z", + "type" : "basal", + "unit" : "U", + "value" : 0 + }, + { + "endDate" : "2023-06-23T01:42:38Z", + "startDate" : "2023-06-23T01:37:39Z", + "type" : "tempBasal", + "unit" : "U\/hour", + "value" : 0 + }, + { + "endDate" : "2023-06-23T02:07:42Z", + "startDate" : "2023-06-23T01:42:38Z", + "type" : "basal", + "unit" : "U", + "value" : 0.14999999999999999 + }, + { + "endDate" : "2023-06-23T01:47:46Z", + "startDate" : "2023-06-23T01:47:38Z", + "type" : "bolus", + "unit" : "U", + "value" : 0.20000000000000001 + }, + { + "endDate" : "2023-06-23T01:52:47Z", + "startDate" : "2023-06-23T01:52:39Z", + "type" : "bolus", + "unit" : "U", + "value" : 0.20000000000000001 + }, + { + "endDate" : "2023-06-23T01:57:50Z", + "startDate" : "2023-06-23T01:57:40Z", + "type" : "bolus", + "unit" : "U", + "value" : 0.25 + }, + { + "endDate" : "2023-06-23T02:02:49Z", + "startDate" : "2023-06-23T02:02:39Z", + "type" : "bolus", + "unit" : "U", + "value" : 0.25 + }, + { + "endDate" : "2023-06-23T02:07:36Z", + "startDate" : "2023-06-23T02:04:30Z", + "type" : "bolus", + "unit" : "U", + "value" : 4.6500000000000004 + }, + { + "endDate" : "2023-06-23T02:27:44Z", + "startDate" : "2023-06-23T02:07:42Z", + "type" : "tempBasal", + "unit" : "U\/hour", + "value" : 0 + }, + { + "endDate" : "2023-06-23T02:27:44Z", + "startDate" : "2023-06-23T02:27:44Z", + "type" : "basal", + "unit" : "U", + "value" : 0 + }, + { + "endDate" : "2023-06-23T02:47:39Z", + "startDate" : "2023-06-23T02:27:44Z", + "type" : "tempBasal", + "unit" : "U\/hour", + "value" : 0 + } + ], + "glucoseHistory" : [ + { + "quantity" : 120, + "startDate" : "2023-06-22T16:42:33Z" + }, + { + "quantity" : 119, + "startDate" : "2023-06-22T16:47:33Z" + }, + { + "quantity" : 120, + "startDate" : "2023-06-22T16:52:34Z" + }, + { + "quantity" : 118, + "startDate" : "2023-06-22T16:57:34Z" + }, + { + "quantity" : 115, + "startDate" : "2023-06-22T17:02:34Z" + }, + { + "quantity" : 120, + "startDate" : "2023-06-22T17:07:34Z" + }, + { + "quantity" : 121, + "startDate" : "2023-06-22T17:12:34Z" + }, + { + "quantity" : 119, + "startDate" : "2023-06-22T17:17:34Z" + }, + { + "quantity" : 116, + "startDate" : "2023-06-22T17:22:34Z" + }, + { + "quantity" : 115, + "startDate" : "2023-06-22T17:27:34Z" + }, + { + "quantity" : 124, + "startDate" : "2023-06-22T17:32:34Z" + }, + { + "quantity" : 114, + "startDate" : "2023-06-22T17:37:34Z" + }, + { + "quantity" : 124, + "startDate" : "2023-06-22T17:42:34Z" + }, + { + "quantity" : 124, + "startDate" : "2023-06-22T17:47:33Z" + }, + { + "quantity" : 124, + "startDate" : "2023-06-22T17:52:34Z" + }, + { + "quantity" : 126, + "startDate" : "2023-06-22T17:57:33Z" + }, + { + "quantity" : 125, + "startDate" : "2023-06-22T18:02:34Z" + }, + { + "quantity" : 118, + "startDate" : "2023-06-22T18:07:34Z" + }, + { + "quantity" : 122, + "startDate" : "2023-06-22T18:12:33Z" + }, + { + "quantity" : 123, + "startDate" : "2023-06-22T18:17:34Z" + }, + { + "quantity" : 123, + "startDate" : "2023-06-22T18:22:34Z" + }, + { + "quantity" : 121, + "startDate" : "2023-06-22T18:27:34Z" + }, + { + "quantity" : 118, + "startDate" : "2023-06-22T18:32:34Z" + }, + { + "quantity" : 116, + "startDate" : "2023-06-22T18:37:34Z" + }, + { + "quantity" : 118, + "startDate" : "2023-06-22T18:42:34Z" + }, + { + "quantity" : 115, + "startDate" : "2023-06-22T18:47:34Z" + }, + { + "quantity" : 117, + "startDate" : "2023-06-22T18:52:34Z" + }, + { + "quantity" : 125, + "startDate" : "2023-06-22T18:57:34Z" + }, + { + "quantity" : 122, + "startDate" : "2023-06-22T19:02:34Z" + }, + { + "quantity" : 119, + "startDate" : "2023-06-22T19:07:34Z" + }, + { + "quantity" : 120, + "startDate" : "2023-06-22T19:12:34Z" + }, + { + "quantity" : 112, + "startDate" : "2023-06-22T19:17:34Z" + }, + { + "quantity" : 111, + "startDate" : "2023-06-22T19:22:34Z" + }, + { + "quantity" : 114, + "startDate" : "2023-06-22T19:27:34Z" + }, + { + "quantity" : 117, + "startDate" : "2023-06-22T19:32:34Z" + }, + { + "quantity" : 107, + "startDate" : "2023-06-22T19:37:34Z" + }, + { + "quantity" : 113, + "startDate" : "2023-06-22T19:42:34Z" + }, + { + "quantity" : 117, + "startDate" : "2023-06-22T19:47:34Z" + }, + { + "quantity" : 109, + "startDate" : "2023-06-22T19:52:34Z" + }, + { + "quantity" : 117, + "startDate" : "2023-06-22T19:57:34Z" + }, + { + "quantity" : 121, + "startDate" : "2023-06-22T20:02:34Z" + }, + { + "quantity" : 121, + "startDate" : "2023-06-22T20:07:34Z" + }, + { + "quantity" : 127, + "startDate" : "2023-06-22T20:12:34Z" + }, + { + "quantity" : 133, + "startDate" : "2023-06-22T20:17:34Z" + }, + { + "quantity" : 131, + "startDate" : "2023-06-22T20:22:34Z" + }, + { + "quantity" : 132, + "startDate" : "2023-06-22T20:27:34Z" + }, + { + "quantity" : 134, + "startDate" : "2023-06-22T20:32:34Z" + }, + { + "quantity" : 134, + "startDate" : "2023-06-22T20:37:34Z" + }, + { + "quantity" : 139, + "startDate" : "2023-06-22T20:42:34Z" + }, + { + "quantity" : 139, + "startDate" : "2023-06-22T20:47:34Z" + }, + { + "quantity" : 132, + "startDate" : "2023-06-22T20:52:34Z" + }, + { + "quantity" : 118, + "startDate" : "2023-06-22T20:57:34Z" + }, + { + "quantity" : 123, + "startDate" : "2023-06-22T21:02:34Z" + }, + { + "quantity" : 122, + "startDate" : "2023-06-22T21:07:34Z" + }, + { + "quantity" : 119, + "startDate" : "2023-06-22T21:12:34Z" + }, + { + "quantity" : 116, + "startDate" : "2023-06-22T21:17:34Z" + }, + { + "quantity" : 113, + "startDate" : "2023-06-22T21:22:34Z" + }, + { + "quantity" : 111, + "startDate" : "2023-06-22T21:27:34Z" + }, + { + "quantity" : 112, + "startDate" : "2023-06-22T21:32:34Z" + }, + { + "quantity" : 107, + "startDate" : "2023-06-22T21:37:34Z" + }, + { + "quantity" : 102, + "startDate" : "2023-06-22T21:42:34Z" + }, + { + "quantity" : 95, + "startDate" : "2023-06-22T21:47:34Z" + }, + { + "quantity" : 96, + "startDate" : "2023-06-22T21:52:34Z" + }, + { + "quantity" : 89, + "startDate" : "2023-06-22T21:57:34Z" + }, + { + "quantity" : 95, + "startDate" : "2023-06-22T22:02:34Z" + }, + { + "quantity" : 95, + "startDate" : "2023-06-22T22:07:34Z" + }, + { + "quantity" : 93, + "startDate" : "2023-06-22T22:12:34Z" + }, + { + "quantity" : 98, + "startDate" : "2023-06-22T22:17:35Z" + }, + { + "quantity" : 95, + "startDate" : "2023-06-22T22:22:35Z" + }, + { + "quantity" : 101, + "startDate" : "2023-06-22T22:27:34Z" + }, + { + "quantity" : 97, + "startDate" : "2023-06-22T22:32:34Z" + }, + { + "quantity" : 108, + "startDate" : "2023-06-22T22:37:35Z" + }, + { + "quantity" : 109, + "startDate" : "2023-06-22T22:42:34Z" + }, + { + "quantity" : 109, + "startDate" : "2023-06-22T22:47:34Z" + }, + { + "quantity" : 114, + "startDate" : "2023-06-22T22:52:34Z" + }, + { + "quantity" : 115, + "startDate" : "2023-06-22T22:57:34Z" + }, + { + "quantity" : 114, + "startDate" : "2023-06-22T23:02:34Z" + }, + { + "quantity" : 121, + "startDate" : "2023-06-22T23:07:34Z" + }, + { + "quantity" : 119, + "startDate" : "2023-06-22T23:12:34Z" + }, + { + "quantity" : 117, + "startDate" : "2023-06-22T23:17:34Z" + }, + { + "quantity" : 120, + "startDate" : "2023-06-22T23:22:35Z" + }, + { + "quantity" : 122, + "startDate" : "2023-06-22T23:27:34Z" + }, + { + "quantity" : 123, + "startDate" : "2023-06-22T23:32:34Z" + }, + { + "quantity" : 127, + "startDate" : "2023-06-22T23:37:34Z" + }, + { + "quantity" : 118, + "startDate" : "2023-06-22T23:42:35Z" + }, + { + "quantity" : 120, + "startDate" : "2023-06-22T23:47:34Z" + }, + { + "quantity" : 119, + "startDate" : "2023-06-22T23:52:35Z" + }, + { + "quantity" : 115, + "startDate" : "2023-06-22T23:57:34Z" + }, + { + "quantity" : 116, + "startDate" : "2023-06-23T00:02:34Z" + }, + { + "quantity" : 133, + "startDate" : "2023-06-23T00:07:34Z" + }, + { + "quantity" : 145, + "startDate" : "2023-06-23T00:12:34Z" + }, + { + "quantity" : 140, + "startDate" : "2023-06-23T00:17:34Z" + }, + { + "quantity" : 161, + "startDate" : "2023-06-23T00:22:35Z" + }, + { + "quantity" : 166, + "startDate" : "2023-06-23T00:27:34Z" + }, + { + "quantity" : 172, + "startDate" : "2023-06-23T00:32:35Z" + }, + { + "quantity" : 182, + "startDate" : "2023-06-23T00:37:35Z" + }, + { + "quantity" : 184, + "startDate" : "2023-06-23T00:42:35Z" + }, + { + "quantity" : 185, + "startDate" : "2023-06-23T00:47:34Z" + }, + { + "quantity" : 190, + "startDate" : "2023-06-23T00:52:35Z" + }, + { + "quantity" : 182, + "startDate" : "2023-06-23T00:57:34Z" + }, + { + "quantity" : 166, + "startDate" : "2023-06-23T01:02:35Z" + }, + { + "quantity" : 174, + "startDate" : "2023-06-23T01:07:34Z" + }, + { + "quantity" : 179, + "startDate" : "2023-06-23T01:12:34Z" + }, + { + "quantity" : 166, + "startDate" : "2023-06-23T01:17:35Z" + }, + { + "quantity" : 134, + "startDate" : "2023-06-23T01:22:34Z" + }, + { + "quantity" : 131, + "startDate" : "2023-06-23T01:27:35Z" + }, + { + "quantity" : 129, + "startDate" : "2023-06-23T01:32:34Z" + }, + { + "quantity" : 136, + "startDate" : "2023-06-23T01:37:34Z" + }, + { + "quantity" : 152, + "startDate" : "2023-06-23T01:42:34Z" + }, + { + "quantity" : 162, + "startDate" : "2023-06-23T01:47:35Z" + }, + { + "quantity" : 165, + "startDate" : "2023-06-23T01:52:34Z" + }, + { + "quantity" : 172, + "startDate" : "2023-06-23T01:57:34Z" + }, + { + "quantity" : 176, + "startDate" : "2023-06-23T02:02:35Z" + }, + { + "quantity" : 165, + "startDate" : "2023-06-23T02:07:35Z" + }, + { + "quantity" : 172, + "startDate" : "2023-06-23T02:12:34Z" + }, + { + "quantity" : 170, + "startDate" : "2023-06-23T02:17:35Z" + }, + { + "quantity" : 177, + "startDate" : "2023-06-23T02:22:35Z" + }, + { + "quantity" : 176, + "startDate" : "2023-06-23T02:27:35Z" + }, + { + "quantity" : 173, + "startDate" : "2023-06-23T02:32:34Z" + }, + { + "quantity" : 180, + "startDate" : "2023-06-23T02:37:35Z" + } + ], + "settings" : { + "basal" : [ + { + "endDate" : "2023-06-23T05:00:00Z", + "startDate" : "2023-06-22T10:00:00Z", + "value" : 0.45000000000000001 + } + ], + "carbRatio" : [ + { + "endDate" : "2023-06-23T07:00:00Z", + "startDate" : "2023-06-22T07:00:00Z", + "value" : 11 + } + ], + "maximumBasalRatePerHour" : null, + "maximumBolus" : null, + "sensitivity" : [ + { + "endDate" : "2023-06-23T05:00:00Z", + "startDate" : "2023-06-22T10:00:00Z", + "value" : 60 + } + ], + "suspendThreshold" : null, + "target" : [ + { + "endDate" : "2023-06-23T07:00:00Z", + "startDate" : "2023-06-22T20:25:00Z", + "value" : { + "maxValue" : 115, + "minValue" : 100 + } + }, + { + "endDate" : "2023-06-23T08:50:00Z", + "startDate" : "2023-06-23T07:00:00Z", + "value" : { + "maxValue" : 115, + "minValue" : 100 + } + } + ] + } +} diff --git a/LoopTests/Fixtures/live_capture/live_capture_predicted_glucose.json b/LoopTests/Fixtures/live_capture/live_capture_predicted_glucose.json new file mode 100644 index 0000000000..a98fbaccb7 --- /dev/null +++ b/LoopTests/Fixtures/live_capture/live_capture_predicted_glucose.json @@ -0,0 +1,392 @@ +[ + { + "quantity" : 180, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T02:37:35Z" + }, + { + "quantity" : 180.29132150657966, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T02:40:00Z" + }, + { + "quantity" : 180.51458820506667, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T02:45:00Z" + }, + { + "quantity" : 179.7158986124237, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T02:50:00Z" + }, + { + "quantity" : 177.66868460973922, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T02:55:00Z" + }, + { + "quantity" : 174.80252509117634, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T03:00:00Z" + }, + { + "quantity" : 171.74984493231631, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T03:05:00Z" + }, + { + "quantity" : 168.58187755437024, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T03:10:00Z" + }, + { + "quantity" : 165.36216340804185, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T03:15:00Z" + }, + { + "quantity" : 162.12697210734922, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T03:20:00Z" + }, + { + "quantity" : 158.90986429144345, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T03:25:00Z" + }, + { + "quantity" : 155.75684851046043, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T03:30:00Z" + }, + { + "quantity" : 152.70869296700107, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T03:35:00Z" + }, + { + "quantity" : 149.78068888956841, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T03:40:00Z" + }, + { + "quantity" : 147.00401242102828, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T03:45:00Z" + }, + { + "quantity" : 144.40563853768242, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T03:50:00Z" + }, + { + "quantity" : 142.0087170601098, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T03:55:00Z" + }, + { + "quantity" : 139.83295658233396, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T04:00:00Z" + }, + { + "quantity" : 137.89511837124121, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T04:05:00Z" + }, + { + "quantity" : 136.07526338088792, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T04:10:00Z" + }, + { + "quantity" : 134.25815754225141, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T04:15:00Z" + }, + { + "quantity" : 132.45275084533137, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T04:20:00Z" + }, + { + "quantity" : 130.66563522056958, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T04:25:00Z" + }, + { + "quantity" : 128.90146920949769, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T04:30:00Z" + }, + { + "quantity" : 127.16322092092855, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T04:35:00Z" + }, + { + "quantity" : 125.45215396105368, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T04:40:00Z" + }, + { + "quantity" : 123.76712483433676, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T04:45:00Z" + }, + { + "quantity" : 122.10683165409341, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T04:50:00Z" + }, + { + "quantity" : 120.46857875163471, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T04:55:00Z" + }, + { + "quantity" : 118.84903308222181, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T05:00:00Z" + }, + { + "quantity" : 117.24445077397047, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T05:05:00Z" + }, + { + "quantity" : 115.65043839655846, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T05:10:00Z" + }, + { + "quantity" : 114.06198688414838, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T05:15:00Z" + }, + { + "quantity" : 112.47356001340279, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T05:20:00Z" + }, + { + "quantity" : 110.87917488553444, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T05:25:00Z" + }, + { + "quantity" : 109.27247502015473, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T05:30:00Z" + }, + { + "quantity" : 107.64679662666447, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T05:35:00Z" + }, + { + "quantity" : 105.99522857963143, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T05:40:00Z" + }, + { + "quantity" : 104.31066658787131, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T05:45:00Z" + }, + { + "quantity" : 102.58586201263279, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T05:50:00Z" + }, + { + "quantity" : 100.81350120847731, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T05:55:00Z" + }, + { + "quantity" : 98.986445102805988, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T06:00:00Z" + }, + { + "quantity" : 97.097518927124952, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T06:05:00Z" + }, + { + "quantity" : 95.139330662672023, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T06:10:00Z" + }, + { + "quantity" : 93.104670202578632, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T06:15:00Z" + }, + { + "quantity" : 90.986165185301502, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T06:20:00Z" + }, + { + "quantity" : 88.909927040807588, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T06:25:00Z" + }, + { + "quantity" : 86.994338611676767, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T06:30:00Z" + }, + { + "quantity" : 85.232136877351081, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T06:35:00Z" + }, + { + "quantity" : 83.615651290380811, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T06:40:00Z" + }, + { + "quantity" : 82.136746744082188, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T06:45:00Z" + }, + { + "quantity" : 80.787935960558002, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T06:50:00Z" + }, + { + "quantity" : 79.561150334091622, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T06:55:00Z" + }, + { + "quantity" : 78.448809315519384, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T07:00:00Z" + }, + { + "quantity" : 77.444295000376087, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T07:05:00Z" + }, + { + "quantity" : 76.541144021775267, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T07:10:00Z" + }, + { + "quantity" : 75.734033247701291, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T07:15:00Z" + }, + { + "quantity" : 75.018229944400559, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T07:20:00Z" + }, + { + "quantity" : 74.389076912965834, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T07:25:00Z" + }, + { + "quantity" : 73.841309919727451, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T07:30:00Z" + }, + { + "quantity" : 73.370549918316215, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T07:35:00Z" + }, + { + "quantity" : 72.972744055408953, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T07:40:00Z" + }, + { + "quantity" : 72.643975082565134, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T07:45:00Z" + }, + { + "quantity" : 72.380461060355856, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T07:50:00Z" + }, + { + "quantity" : 72.178520063294286, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T07:55:00Z" + }, + { + "quantity" : 72.034174053629386, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T08:00:00Z" + }, + { + "quantity" : 71.942299096190823, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T08:05:00Z" + }, + { + "quantity" : 71.897751011456421, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T08:10:00Z" + }, + { + "quantity" : 71.895123880236383, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T08:15:00Z" + }, + { + "quantity" : 71.906254842464136, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T08:20:00Z" + }, + { + "quantity" : 71.914434937142801, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T08:25:00Z" + }, + { + "quantity" : 71.920167940771535, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T08:30:00Z" + }, + { + "quantity" : 71.923927819981145, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T08:35:00Z" + }, + { + "quantity" : 71.926159114246957, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T08:40:00Z" + }, + { + "quantity" : 71.927280081079402, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T08:45:00Z" + }, + { + "quantity" : 71.927682355083221, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T08:50:00Z" + }, + { + "quantity" : 71.927731342958282, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T08:55:00Z" + }, + { + "quantity" : 71.927731342958282, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T09:00:00Z" + } +] diff --git a/LoopTests/Fixtures/low_and_falling/low_and_falling_carb_effect.json b/LoopTests/Fixtures/low_and_falling/low_and_falling_carb_effect.json new file mode 100644 index 0000000000..3c22d51132 --- /dev/null +++ b/LoopTests/Fixtures/low_and_falling/low_and_falling_carb_effect.json @@ -0,0 +1,322 @@ +[ + { + "date": "2020-08-11T21:35:00", + "unit": "mg/dL", + "amount": 0.0 + }, + { + "date": "2020-08-11T21:40:00", + "unit": "mg/dL", + "amount": 0.0 + }, + { + "date": "2020-08-11T21:45:00", + "unit": "mg/dL", + "amount": 0.0 + }, + { + "date": "2020-08-11T21:50:00", + "unit": "mg/dL", + "amount": 0.0 + }, + { + "date": "2020-08-11T21:55:00", + "unit": "mg/dL", + "amount": 0.0 + }, + { + "date": "2020-08-11T22:00:00", + "unit": "mg/dL", + "amount": 0.0 + }, + { + "date": "2020-08-11T22:05:00", + "unit": "mg/dL", + "amount": 0.0 + }, + { + "date": "2020-08-11T22:10:00", + "unit": "mg/dL", + "amount": 0.0 + }, + { + "date": "2020-08-11T22:15:00", + "unit": "mg/dL", + "amount": 0.0 + }, + { + "date": "2020-08-11T22:20:00", + "unit": "mg/dL", + "amount": 1.113814925485187 + }, + { + "date": "2020-08-11T22:25:00", + "unit": "mg/dL", + "amount": 2.641592703262965 + }, + { + "date": "2020-08-11T22:30:00", + "unit": "mg/dL", + "amount": 4.169370481040743 + }, + { + "date": "2020-08-11T22:35:00", + "unit": "mg/dL", + "amount": 5.697148258818521 + }, + { + "date": "2020-08-11T22:40:00", + "unit": "mg/dL", + "amount": 7.224926036596299 + }, + { + "date": "2020-08-11T22:45:00", + "unit": "mg/dL", + "amount": 8.752703814374076 + }, + { + "date": "2020-08-11T22:50:00", + "unit": "mg/dL", + "amount": 10.280481592151855 + }, + { + "date": "2020-08-11T22:55:00", + "unit": "mg/dL", + "amount": 11.808259369929631 + }, + { + "date": "2020-08-11T23:00:00", + "unit": "mg/dL", + "amount": 13.336037147707408 + }, + { + "date": "2020-08-11T23:05:00", + "unit": "mg/dL", + "amount": 14.863814925485187 + }, + { + "date": "2020-08-11T23:10:00", + "unit": "mg/dL", + "amount": 16.391592703262965 + }, + { + "date": "2020-08-11T23:15:00", + "unit": "mg/dL", + "amount": 17.919370481040744 + }, + { + "date": "2020-08-11T23:20:00", + "unit": "mg/dL", + "amount": 19.44714825881852 + }, + { + "date": "2020-08-11T23:25:00", + "unit": "mg/dL", + "amount": 20.974926036596298 + }, + { + "date": "2020-08-11T23:30:00", + "unit": "mg/dL", + "amount": 22.502703814374076 + }, + { + "date": "2020-08-11T23:35:00", + "unit": "mg/dL", + "amount": 24.030481592151855 + }, + { + "date": "2020-08-11T23:40:00", + "unit": "mg/dL", + "amount": 25.558259369929633 + }, + { + "date": "2020-08-11T23:45:00", + "unit": "mg/dL", + "amount": 27.086037147707408 + }, + { + "date": "2020-08-11T23:50:00", + "unit": "mg/dL", + "amount": 28.613814925485187 + }, + { + "date": "2020-08-11T23:55:00", + "unit": "mg/dL", + "amount": 30.141592703262965 + }, + { + "date": "2020-08-12T00:00:00", + "unit": "mg/dL", + "amount": 31.66937048104074 + }, + { + "date": "2020-08-12T00:05:00", + "unit": "mg/dL", + "amount": 33.197148258818515 + }, + { + "date": "2020-08-12T00:10:00", + "unit": "mg/dL", + "amount": 34.7249260365963 + }, + { + "date": "2020-08-12T00:15:00", + "unit": "mg/dL", + "amount": 36.25270381437407 + }, + { + "date": "2020-08-12T00:20:00", + "unit": "mg/dL", + "amount": 37.78048159215186 + }, + { + "date": "2020-08-12T00:25:00", + "unit": "mg/dL", + "amount": 39.30825936992963 + }, + { + "date": "2020-08-12T00:30:00", + "unit": "mg/dL", + "amount": 40.83603714770741 + }, + { + "date": "2020-08-12T00:35:00", + "unit": "mg/dL", + "amount": 42.36381492548519 + }, + { + "date": "2020-08-12T00:40:00", + "unit": "mg/dL", + "amount": 43.891592703262965 + }, + { + "date": "2020-08-12T00:45:00", + "unit": "mg/dL", + "amount": 45.419370481040744 + }, + { + "date": "2020-08-12T00:50:00", + "unit": "mg/dL", + "amount": 46.947148258818515 + }, + { + "date": "2020-08-12T00:55:00", + "unit": "mg/dL", + "amount": 48.47492603659629 + }, + { + "date": "2020-08-12T01:00:00", + "unit": "mg/dL", + "amount": 50.00270381437408 + }, + { + "date": "2020-08-12T01:05:00", + "unit": "mg/dL", + "amount": 51.53048159215186 + }, + { + "date": "2020-08-12T01:10:00", + "unit": "mg/dL", + "amount": 53.05825936992963 + }, + { + "date": "2020-08-12T01:15:00", + "unit": "mg/dL", + "amount": 54.58603714770741 + }, + { + "date": "2020-08-12T01:20:00", + "unit": "mg/dL", + "amount": 56.113814925485194 + }, + { + "date": "2020-08-12T01:25:00", + "unit": "mg/dL", + "amount": 57.641592703262965 + }, + { + "date": "2020-08-12T01:30:00", + "unit": "mg/dL", + "amount": 59.169370481040744 + }, + { + "date": "2020-08-12T01:35:00", + "unit": "mg/dL", + "amount": 60.697148258818515 + }, + { + "date": "2020-08-12T01:40:00", + "unit": "mg/dL", + "amount": 62.2249260365963 + }, + { + "date": "2020-08-12T01:45:00", + "unit": "mg/dL", + "amount": 63.75270381437407 + }, + { + "date": "2020-08-12T01:50:00", + "unit": "mg/dL", + "amount": 65.28048159215186 + }, + { + "date": "2020-08-12T01:55:00", + "unit": "mg/dL", + "amount": 66.80825936992963 + }, + { + "date": "2020-08-12T02:00:00", + "unit": "mg/dL", + "amount": 68.33603714770742 + }, + { + "date": "2020-08-12T02:05:00", + "unit": "mg/dL", + "amount": 69.86381492548519 + }, + { + "date": "2020-08-12T02:10:00", + "unit": "mg/dL", + "amount": 71.39159270326296 + }, + { + "date": "2020-08-12T02:15:00", + "unit": "mg/dL", + "amount": 72.91937048104073 + }, + { + "date": "2020-08-12T02:20:00", + "unit": "mg/dL", + "amount": 74.44714825881853 + }, + { + "date": "2020-08-12T02:25:00", + "unit": "mg/dL", + "amount": 75.9749260365963 + }, + { + "date": "2020-08-12T02:30:00", + "unit": "mg/dL", + "amount": 77.50270381437407 + }, + { + "date": "2020-08-12T02:35:00", + "unit": "mg/dL", + "amount": 79.03048159215186 + }, + { + "date": "2020-08-12T02:40:00", + "unit": "mg/dL", + "amount": 80.55825936992963 + }, + { + "date": "2020-08-12T02:45:00", + "unit": "mg/dL", + "amount": 82.08603714770742 + }, + { + "date": "2020-08-12T02:50:00", + "unit": "mg/dL", + "amount": 82.5 + } +] \ No newline at end of file diff --git a/LoopTests/Fixtures/low_and_falling/low_and_falling_counteraction_effect.json b/LoopTests/Fixtures/low_and_falling/low_and_falling_counteraction_effect.json new file mode 100644 index 0000000000..5e9442a191 --- /dev/null +++ b/LoopTests/Fixtures/low_and_falling/low_and_falling_counteraction_effect.json @@ -0,0 +1,218 @@ +[ + { + "startDate": "2020-08-11T19:06:06", + "endDate": "2020-08-11T19:11:06", + "unit": "mg\/min·dL", + "value": -0.3485359639226971 + }, + { + "startDate": "2020-08-11T19:11:06", + "endDate": "2020-08-11T19:16:06", + "unit": "mg\/min·dL", + "value": -0.34571948711910916 + }, + { + "startDate": "2020-08-11T19:16:06", + "endDate": "2020-08-11T19:21:06", + "unit": "mg\/min·dL", + "value": -0.3110996208816001 + }, + { + "startDate": "2020-08-11T19:21:06", + "endDate": "2020-08-11T19:26:06", + "unit": "mg\/min·dL", + "value": -0.17115290442012446 + }, + { + "startDate": "2020-08-11T19:26:06", + "endDate": "2020-08-11T19:31:06", + "unit": "mg\/min·dL", + "value": -0.035078937546724906 + }, + { + "startDate": "2020-08-11T19:31:06", + "endDate": "2020-08-11T19:36:06", + "unit": "mg\/min·dL", + "value": 0.08735109214809061 + }, + { + "startDate": "2020-08-11T19:36:06", + "endDate": "2020-08-11T19:41:06", + "unit": "mg\/min·dL", + "value": 0.19746935782304254 + }, + { + "startDate": "2020-08-11T19:41:06", + "endDate": "2020-08-11T19:46:06", + "unit": "mg\/min·dL", + "value": 0.2964814415989495 + }, + { + "startDate": "2020-08-11T19:46:06", + "endDate": "2020-08-11T19:51:06", + "unit": "mg\/min·dL", + "value": 0.3854747645772338 + }, + { + "startDate": "2020-08-11T19:51:06", + "endDate": "2020-08-11T19:56:06", + "unit": "mg\/min·dL", + "value": 0.4654266535840445 + }, + { + "startDate": "2020-08-11T19:56:06", + "endDate": "2020-08-11T20:01:06", + "unit": "mg\/min·dL", + "value": 0.5372120677140927 + }, + { + "startDate": "2020-08-11T20:01:06", + "endDate": "2020-08-11T20:06:06", + "unit": "mg\/min·dL", + "value": 0.6016110049547307 + }, + { + "startDate": "2020-08-11T20:06:06", + "endDate": "2020-08-11T20:11:06", + "unit": "mg\/min·dL", + "value": 0.6593156065538323 + }, + { + "startDate": "2020-08-11T20:11:06", + "endDate": "2020-08-11T20:16:06", + "unit": "mg\/min·dL", + "value": 0.7109369743543738 + }, + { + "startDate": "2020-08-11T20:16:06", + "endDate": "2020-08-11T20:21:06", + "unit": "mg\/min·dL", + "value": 0.7570117140551543 + }, + { + "startDate": "2020-08-11T20:21:06", + "endDate": "2020-08-11T20:26:06", + "unit": "mg\/min·dL", + "value": 0.7980082152690883 + }, + { + "startDate": "2020-08-11T20:26:06", + "endDate": "2020-08-11T20:31:06", + "unit": "mg\/min·dL", + "value": 0.8343326773392674 + }, + { + "startDate": "2020-08-11T20:31:06", + "endDate": "2020-08-11T20:36:06", + "unit": "mg\/min·dL", + "value": 0.8663348881353556 + }, + { + "startDate": "2020-08-11T20:36:06", + "endDate": "2020-08-11T20:41:06", + "unit": "mg\/min·dL", + "value": 0.894313761489434 + }, + { + "startDate": "2020-08-11T20:41:06", + "endDate": "2020-08-11T20:46:06", + "unit": "mg\/min·dL", + "value": 0.9185226375377566 + }, + { + "startDate": "2020-08-11T20:46:06", + "endDate": "2020-08-11T20:51:06", + "unit": "mg\/min·dL", + "value": 0.9391743490118711 + }, + { + "startDate": "2020-08-11T20:51:06", + "endDate": "2020-08-11T20:56:06", + "unit": "mg\/min·dL", + "value": 0.9564460554651047 + }, + { + "startDate": "2020-08-11T20:56:06", + "endDate": "2020-08-11T21:01:06", + "unit": "mg\/min·dL", + "value": 0.9704838465264228 + }, + { + "startDate": "2020-08-11T21:01:06", + "endDate": "2020-08-11T21:06:06", + "unit": "mg\/min·dL", + "value": 0.9814071145378676 + }, + { + "startDate": "2020-08-11T21:06:06", + "endDate": "2020-08-11T21:11:06", + "unit": "mg\/min·dL", + "value": 0.9893126963505664 + }, + { + "startDate": "2020-08-11T21:11:06", + "endDate": "2020-08-11T21:16:06", + "unit": "mg\/min·dL", + "value": 0.9942787836220887 + }, + { + "startDate": "2020-08-11T21:16:06", + "endDate": "2020-08-11T21:21:06", + "unit": "mg\/min·dL", + "value": 0.9963686006690381 + }, + { + "startDate": "2020-08-11T21:21:06", + "endDate": "2020-08-11T21:26:06", + "unit": "mg\/min·dL", + "value": 0.9956338487771189 + }, + { + "startDate": "2020-08-11T21:26:06", + "endDate": "2020-08-11T21:31:06", + "unit": "mg\/min·dL", + "value": 0.9921179158496414 + }, + { + "startDate": "2020-08-11T21:31:06", + "endDate": "2020-08-11T21:36:06", + "unit": "mg\/min·dL", + "value": 0.9858588503769765 + }, + { + "startDate": "2020-08-11T21:36:06", + "endDate": "2020-08-11T21:41:06", + "unit": "mg\/min·dL", + "value": 0.9768920989266177 + }, + { + "startDate": "2020-08-11T21:41:06", + "endDate": "2020-08-11T21:46:06", + "unit": "mg\/min·dL", + "value": 0.965253006677156 + }, + { + "startDate": "2020-08-11T21:46:06", + "endDate": "2020-08-11T21:51:06", + "unit": "mg\/min·dL", + "value": 0.9509790809419911 + }, + { + "startDate": "2020-08-11T21:51:06", + "endDate": "2020-08-11T21:56:06", + "unit": "mg\/min·dL", + "value": 0.9341120181395608 + }, + { + "startDate": "2020-08-11T21:56:06", + "endDate": "2020-08-11T22:01:06", + "unit": "mg\/min·dL", + "value": 0.9146994952586709 + }, + { + "startDate": "2020-08-11T22:01:06", + "endDate": "2020-08-11T22:06:06", + "unit": "mg\/min·dL", + "value": 0.8927967275284316 + } +] diff --git a/LoopTests/Fixtures/low_and_falling/low_and_falling_insulin_effect.json b/LoopTests/Fixtures/low_and_falling/low_and_falling_insulin_effect.json new file mode 100644 index 0000000000..fadbdb4765 --- /dev/null +++ b/LoopTests/Fixtures/low_and_falling/low_and_falling_insulin_effect.json @@ -0,0 +1,382 @@ +[ + { + "date": "2020-08-11T22:05:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T22:10:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T22:15:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T22:20:00", + "amount": -0.1458612769290415, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T22:25:00", + "amount": -0.9512697097060898, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T22:30:00", + "amount": -2.3842190211605305, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T22:35:00", + "amount": -4.364249056420911, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T22:40:00", + "amount": -6.818055744021179, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T22:45:00", + "amount": -9.678947529165939, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T22:50:00", + "amount": -12.88633950788323, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T22:55:00", + "amount": -16.385282799253694, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:00:00", + "amount": -20.126026847056842, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:05:00", + "amount": -24.06361248698291, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:10:00", + "amount": -28.157493751577, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:15:00", + "amount": -32.37118651282725, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:20:00", + "amount": -36.67194218227609, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:25:00", + "amount": -41.03044480117815, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:30:00", + "amount": -45.420529958992645, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:35:00", + "amount": -49.81892407778424, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:40:00", + "amount": -54.205002693305985, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:45:00", + "amount": -58.56056645101005, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:50:00", + "amount": -62.869633617321924, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:55:00", + "amount": -67.11824798354047, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:00:00", + "amount": -71.29430111199574, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:05:00", + "amount": -75.38736794189215, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:10:00", + "amount": -79.38855483585641, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:15:00", + "amount": -83.29035920784969, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:20:00", + "amount": -87.0865399290309, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:25:00", + "amount": -90.77199776059544, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:30:00", + "amount": -94.34266511177375, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:35:00", + "amount": -97.79540446725352, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:40:00", + "amount": -101.12791487147612, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:45:00", + "amount": -104.33864589772718, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:50:00", + "amount": -107.42671856785853, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:55:00", + "amount": -110.39185272399912, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:00:00", + "amount": -113.23430038688294, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:05:00", + "amount": -115.95478466657976, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:10:00", + "amount": -118.55444382058852, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:15:00", + "amount": -121.03478008156584, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:20:00", + "amount": -123.39761290252783, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:25:00", + "amount": -125.64503629128907, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:30:00", + "amount": -127.7793799282899, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:35:00", + "amount": -129.8031737829074, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:40:00", + "amount": -131.71911596293566, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:45:00", + "amount": -133.53004355024044, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:50:00", + "amount": -135.23890619272336, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:55:00", + "amount": -136.84874223874277, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:00:00", + "amount": -138.3626572151035, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:05:00", + "amount": -139.78380446371185, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:10:00", + "amount": -141.1153677650564, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:15:00", + "amount": -142.3605457888761, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:20:00", + "amount": -143.5225382237712, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:25:00", + "amount": -144.6045334481494, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:30:00", + "amount": -145.60969761482852, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:35:00", + "amount": -146.54116503087826, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:40:00", + "amount": -147.40202972292892, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:45:00", + "amount": -148.19533808623217, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:50:00", + "amount": -148.9240825232751, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:55:00", + "amount": -149.5911959847532, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T03:00:00", + "amount": -150.1995473322352, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T03:05:00", + "amount": -150.7519374479337, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T03:10:00", + "amount": -151.251096022658, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T03:15:00", + "amount": -151.69967895829902, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T03:20:00", + "amount": -152.1002663261011, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T03:25:00", + "amount": -152.45536082654326, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T03:30:00", + "amount": -152.76738670089628, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T03:35:00", + "amount": -153.03868904847147, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T03:40:00", + "amount": -153.2715335072446, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T03:45:00", + "amount": -153.4681062589482, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T03:50:00", + "amount": -153.63051432289012, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T03:55:00", + "amount": -153.76078610569454, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T04:00:00", + "amount": -153.86087217688896, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T04:05:00", + "amount": -153.9326462427885, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T04:10:00", + "amount": -153.97790629347384, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T04:15:00", + "amount": -153.99837589983005, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T04:20:00", + "amount": -154.0, + "unit": "mg/dL" + } +] \ No newline at end of file diff --git a/LoopTests/Fixtures/low_and_falling/low_and_falling_momentum_effect.json b/LoopTests/Fixtures/low_and_falling/low_and_falling_momentum_effect.json new file mode 100644 index 0000000000..984694a465 --- /dev/null +++ b/LoopTests/Fixtures/low_and_falling/low_and_falling_momentum_effect.json @@ -0,0 +1,27 @@ +[ + { + "date": "2020-08-11T22:05:00", + "unit": "mg/dL", + "amount": 0.0 + }, + { + "date": "2020-08-11T22:10:00", + "unit": "mg/dL", + "amount": 1.35325 + }, + { + "date": "2020-08-11T22:15:00", + "unit": "mg/dL", + "amount": 3.09052 + }, + { + "date": "2020-08-11T22:20:00", + "unit": "mg/dL", + "amount": 4.8278 + }, + { + "date": "2020-08-11T22:25:00", + "unit": "mg/dL", + "amount": 6.56507 + } +] \ No newline at end of file diff --git a/LoopTests/Fixtures/low_and_falling/low_and_falling_predicted_glucose.json b/LoopTests/Fixtures/low_and_falling/low_and_falling_predicted_glucose.json new file mode 100644 index 0000000000..06e2b7a85e --- /dev/null +++ b/LoopTests/Fixtures/low_and_falling/low_and_falling_predicted_glucose.json @@ -0,0 +1,382 @@ +[ + { + "date": "2020-08-11T22:06:06", + "unit": "mg/dL", + "amount": 75.10768374646841 + }, + { + "date": "2020-08-11T22:10:00", + "unit": "mg/dL", + "amount": 76.46093289895596 + }, + { + "date": "2020-08-11T22:15:00", + "unit": "mg/dL", + "amount": 79.04942397908675 + }, + { + "date": "2020-08-11T22:20:00", + "unit": "mg/dL", + "amount": 83.00725362848293 + }, + { + "date": "2020-08-11T22:25:00", + "unit": "mg/dL", + "amount": 87.52123075828584 + }, + { + "date": "2020-08-11T22:30:00", + "unit": "mg/dL", + "amount": 91.12697884165053 + }, + { + "date": "2020-08-11T22:35:00", + "unit": "mg/dL", + "amount": 93.68408625591766 + }, + { + "date": "2020-08-11T22:40:00", + "unit": "mg/dL", + "amount": 95.26585707255327 + }, + { + "date": "2020-08-11T22:45:00", + "unit": "mg/dL", + "amount": 95.93898284635277 + }, + { + "date": "2020-08-11T22:50:00", + "unit": "mg/dL", + "amount": 95.76404848128813 + }, + { + "date": "2020-08-11T22:55:00", + "unit": "mg/dL", + "amount": 94.7960028582787 + }, + { + "date": "2020-08-11T23:00:00", + "unit": "mg/dL", + "amount": 93.08459653354495 + }, + { + "date": "2020-08-11T23:05:00", + "unit": "mg/dL", + "amount": 90.67478867139667 + }, + { + "date": "2020-08-11T23:10:00", + "unit": "mg/dL", + "amount": 88.10868518458037 + }, + { + "date": "2020-08-11T23:15:00", + "unit": "mg/dL", + "amount": 85.4227702011079 + }, + { + "date": "2020-08-11T23:20:00", + "unit": "mg/dL", + "amount": 82.64979230943683 + }, + { + "date": "2020-08-11T23:25:00", + "unit": "mg/dL", + "amount": 79.81906746831255 + }, + { + "date": "2020-08-11T23:30:00", + "unit": "mg/dL", + "amount": 76.95676008827584 + }, + { + "date": "2020-08-11T23:35:00", + "unit": "mg/dL", + "amount": 74.08614374726203 + }, + { + "date": "2020-08-11T23:40:00", + "unit": "mg/dL", + "amount": 71.22784290951806 + }, + { + "date": "2020-08-11T23:45:00", + "unit": "mg/dL", + "amount": 68.40005692959177 + }, + { + "date": "2020-08-11T23:50:00", + "unit": "mg/dL", + "amount": 65.61876754105768 + }, + { + "date": "2020-08-11T23:55:00", + "unit": "mg/dL", + "amount": 62.8979309526169 + }, + { + "date": "2020-08-12T00:00:00", + "unit": "mg/dL", + "amount": 60.24965560193941 + }, + { + "date": "2020-08-12T00:05:00", + "unit": "mg/dL", + "amount": 57.684366549820766 + }, + { + "date": "2020-08-12T00:10:00", + "unit": "mg/dL", + "amount": 55.21095743363429 + }, + { + "date": "2020-08-12T00:15:00", + "unit": "mg/dL", + "amount": 52.836930839418784 + }, + { + "date": "2020-08-12T00:20:00", + "unit": "mg/dL", + "amount": 50.568527896015354 + }, + { + "date": "2020-08-12T00:25:00", + "unit": "mg/dL", + "amount": 48.41084784222859 + }, + { + "date": "2020-08-12T00:30:00", + "unit": "mg/dL", + "amount": 46.36795826882805 + }, + { + "date": "2020-08-12T00:35:00", + "unit": "mg/dL", + "amount": 44.442996691126055 + }, + { + "date": "2020-08-12T00:40:00", + "unit": "mg/dL", + "amount": 42.63826406468122 + }, + { + "date": "2020-08-12T00:45:00", + "unit": "mg/dL", + "amount": 40.955310816207955 + }, + { + "date": "2020-08-12T00:50:00", + "unit": "mg/dL", + "amount": 39.39501592385437 + }, + { + "date": "2020-08-12T00:55:00", + "unit": "mg/dL", + "amount": 37.95765954549155 + }, + { + "date": "2020-08-12T01:00:00", + "unit": "mg/dL", + "amount": 36.642989660385524 + }, + { + "date": "2020-08-12T01:05:00", + "unit": "mg/dL", + "amount": 35.45028315846649 + }, + { + "date": "2020-08-12T01:10:00", + "unit": "mg/dL", + "amount": 34.3784017822355 + }, + { + "date": "2020-08-12T01:15:00", + "unit": "mg/dL", + "amount": 33.42584329903596 + }, + { + "date": "2020-08-12T01:20:00", + "unit": "mg/dL", + "amount": 32.59078825585176 + }, + { + "date": "2020-08-12T01:25:00", + "unit": "mg/dL", + "amount": 31.871142644868286 + }, + { + "date": "2020-08-12T01:30:00", + "unit": "mg/dL", + "amount": 31.264576785645232 + }, + { + "date": "2020-08-12T01:35:00", + "unit": "mg/dL", + "amount": 30.768560708805495 + }, + { + "date": "2020-08-12T01:40:00", + "unit": "mg/dL", + "amount": 30.380396306555042 + }, + { + "date": "2020-08-12T01:45:00", + "unit": "mg/dL", + "amount": 30.09724649702804 + }, + { + "date": "2020-08-12T01:50:00", + "unit": "mg/dL", + "amount": 29.91616163232291 + }, + { + "date": "2020-08-12T01:55:00", + "unit": "mg/dL", + "amount": 29.834103364081273 + }, + { + "date": "2020-08-12T02:00:00", + "unit": "mg/dL", + "amount": 29.84796616549835 + }, + { + "date": "2020-08-12T02:05:00", + "unit": "mg/dL", + "amount": 29.95459669466777 + }, + { + "date": "2020-08-12T02:10:00", + "unit": "mg/dL", + "amount": 30.150811171100997 + }, + { + "date": "2020-08-12T02:15:00", + "unit": "mg/dL", + "amount": 30.433410925059064 + }, + { + "date": "2020-08-12T02:20:00", + "unit": "mg/dL", + "amount": 30.799196267941767 + }, + { + "date": "2020-08-12T02:25:00", + "unit": "mg/dL", + "amount": 31.244978821341334 + }, + { + "date": "2020-08-12T02:30:00", + "unit": "mg/dL", + "amount": 31.767592432439983 + }, + { + "date": "2020-08-12T02:35:00", + "unit": "mg/dL", + "amount": 32.36390279416804 + }, + { + "date": "2020-08-12T02:40:00", + "unit": "mg/dL", + "amount": 33.03081587989516 + }, + { + "date": "2020-08-12T02:45:00", + "unit": "mg/dL", + "amount": 33.765285294369704 + }, + { + "date": "2020-08-12T02:50:00", + "unit": "mg/dL", + "amount": 33.45050370961934 + }, + { + "date": "2020-08-12T02:55:00", + "unit": "mg/dL", + "amount": 32.783390248141245 + }, + { + "date": "2020-08-12T03:00:00", + "unit": "mg/dL", + "amount": 32.175038900659246 + }, + { + "date": "2020-08-12T03:05:00", + "unit": "mg/dL", + "amount": 31.622648784960745 + }, + { + "date": "2020-08-12T03:10:00", + "unit": "mg/dL", + "amount": 31.12349021023644 + }, + { + "date": "2020-08-12T03:15:00", + "unit": "mg/dL", + "amount": 30.674907274595427 + }, + { + "date": "2020-08-12T03:20:00", + "unit": "mg/dL", + "amount": 30.27431990679335 + }, + { + "date": "2020-08-12T03:25:00", + "unit": "mg/dL", + "amount": 29.919225406351188 + }, + { + "date": "2020-08-12T03:30:00", + "unit": "mg/dL", + "amount": 29.607199531998162 + }, + { + "date": "2020-08-12T03:35:00", + "unit": "mg/dL", + "amount": 29.335897184422976 + }, + { + "date": "2020-08-12T03:40:00", + "unit": "mg/dL", + "amount": 29.10305272564983 + }, + { + "date": "2020-08-12T03:45:00", + "unit": "mg/dL", + "amount": 28.906479973946233 + }, + { + "date": "2020-08-12T03:50:00", + "unit": "mg/dL", + "amount": 28.744071910004322 + }, + { + "date": "2020-08-12T03:55:00", + "unit": "mg/dL", + "amount": 28.61380012719991 + }, + { + "date": "2020-08-12T04:00:00", + "unit": "mg/dL", + "amount": 28.513714056005483 + }, + { + "date": "2020-08-12T04:05:00", + "unit": "mg/dL", + "amount": 28.441939990105936 + }, + { + "date": "2020-08-12T04:10:00", + "unit": "mg/dL", + "amount": 28.396679939420608 + }, + { + "date": "2020-08-12T04:15:00", + "unit": "mg/dL", + "amount": 28.376210333064392 + }, + { + "date": "2020-08-12T04:20:00", + "unit": "mg/dL", + "amount": 28.374586232894444 + } +] \ No newline at end of file diff --git a/LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_carb_effect.json b/LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_carb_effect.json new file mode 100644 index 0000000000..c72f05d1b8 --- /dev/null +++ b/LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_carb_effect.json @@ -0,0 +1,312 @@ +[ + { + "date": "2020-08-11T21:50:00", + "unit": "mg/dL", + "amount": 0.0 + }, + { + "date": "2020-08-11T21:55:00", + "unit": "mg/dL", + "amount": 0.0 + }, + { + "date": "2020-08-11T22:00:00", + "unit": "mg/dL", + "amount": 0.0 + }, + { + "date": "2020-08-11T22:05:00", + "unit": "mg/dL", + "amount": 0.0 + }, + { + "date": "2020-08-11T22:10:00", + "unit": "mg/dL", + "amount": 0.0 + }, + { + "date": "2020-08-11T22:15:00", + "unit": "mg/dL", + "amount": 0.0 + }, + { + "date": "2020-08-11T22:20:00", + "unit": "mg/dL", + "amount": 0.0 + }, + { + "date": "2020-08-11T22:25:00", + "unit": "mg/dL", + "amount": 3.3782119779158717 + }, + { + "date": "2020-08-11T22:30:00", + "unit": "mg/dL", + "amount": 4.90598975569365 + }, + { + "date": "2020-08-11T22:35:00", + "unit": "mg/dL", + "amount": 6.4337675334714275 + }, + { + "date": "2020-08-11T22:40:00", + "unit": "mg/dL", + "amount": 13.234180198944518 + }, + { + "date": "2020-08-11T22:45:00", + "unit": "mg/dL", + "amount": 20.873069087833407 + }, + { + "date": "2020-08-11T22:50:00", + "unit": "mg/dL", + "amount": 28.511957976722293 + }, + { + "date": "2020-08-11T22:55:00", + "unit": "mg/dL", + "amount": 36.150846865611186 + }, + { + "date": "2020-08-11T23:00:00", + "unit": "mg/dL", + "amount": 43.78973575450007 + }, + { + "date": "2020-08-11T23:05:00", + "unit": "mg/dL", + "amount": 51.428624643388964 + }, + { + "date": "2020-08-11T23:10:00", + "unit": "mg/dL", + "amount": 59.06751353227786 + }, + { + "date": "2020-08-11T23:15:00", + "unit": "mg/dL", + "amount": 66.70640242116674 + }, + { + "date": "2020-08-11T23:20:00", + "unit": "mg/dL", + "amount": 74.34529131005563 + }, + { + "date": "2020-08-11T23:25:00", + "unit": "mg/dL", + "amount": 76.71154531124921 + }, + { + "date": "2020-08-11T23:30:00", + "unit": "mg/dL", + "amount": 78.23932308902698 + }, + { + "date": "2020-08-11T23:35:00", + "unit": "mg/dL", + "amount": 79.76710086680475 + }, + { + "date": "2020-08-11T23:40:00", + "unit": "mg/dL", + "amount": 81.29487864458254 + }, + { + "date": "2020-08-11T23:45:00", + "unit": "mg/dL", + "amount": 82.82265642236032 + }, + { + "date": "2020-08-11T23:50:00", + "unit": "mg/dL", + "amount": 84.3504342001381 + }, + { + "date": "2020-08-11T23:55:00", + "unit": "mg/dL", + "amount": 85.87821197791587 + }, + { + "date": "2020-08-12T00:00:00", + "unit": "mg/dL", + "amount": 87.40598975569364 + }, + { + "date": "2020-08-12T00:05:00", + "unit": "mg/dL", + "amount": 88.93376753347144 + }, + { + "date": "2020-08-12T00:10:00", + "unit": "mg/dL", + "amount": 90.46154531124921 + }, + { + "date": "2020-08-12T00:15:00", + "unit": "mg/dL", + "amount": 91.98932308902698 + }, + { + "date": "2020-08-12T00:20:00", + "unit": "mg/dL", + "amount": 93.51710086680475 + }, + { + "date": "2020-08-12T00:25:00", + "unit": "mg/dL", + "amount": 95.04487864458254 + }, + { + "date": "2020-08-12T00:30:00", + "unit": "mg/dL", + "amount": 96.57265642236032 + }, + { + "date": "2020-08-12T00:35:00", + "unit": "mg/dL", + "amount": 98.1004342001381 + }, + { + "date": "2020-08-12T00:40:00", + "unit": "mg/dL", + "amount": 99.62821197791587 + }, + { + "date": "2020-08-12T00:45:00", + "unit": "mg/dL", + "amount": 101.15598975569364 + }, + { + "date": "2020-08-12T00:50:00", + "unit": "mg/dL", + "amount": 102.68376753347144 + }, + { + "date": "2020-08-12T00:55:00", + "unit": "mg/dL", + "amount": 104.21154531124921 + }, + { + "date": "2020-08-12T01:00:00", + "unit": "mg/dL", + "amount": 105.73932308902698 + }, + { + "date": "2020-08-12T01:05:00", + "unit": "mg/dL", + "amount": 107.26710086680475 + }, + { + "date": "2020-08-12T01:10:00", + "unit": "mg/dL", + "amount": 108.79487864458254 + }, + { + "date": "2020-08-12T01:15:00", + "unit": "mg/dL", + "amount": 110.32265642236032 + }, + { + "date": "2020-08-12T01:20:00", + "unit": "mg/dL", + "amount": 111.8504342001381 + }, + { + "date": "2020-08-12T01:25:00", + "unit": "mg/dL", + "amount": 113.37821197791587 + }, + { + "date": "2020-08-12T01:30:00", + "unit": "mg/dL", + "amount": 114.90598975569367 + }, + { + "date": "2020-08-12T01:35:00", + "unit": "mg/dL", + "amount": 116.43376753347144 + }, + { + "date": "2020-08-12T01:40:00", + "unit": "mg/dL", + "amount": 117.96154531124921 + }, + { + "date": "2020-08-12T01:45:00", + "unit": "mg/dL", + "amount": 119.48932308902698 + }, + { + "date": "2020-08-12T01:50:00", + "unit": "mg/dL", + "amount": 121.01710086680477 + }, + { + "date": "2020-08-12T01:55:00", + "unit": "mg/dL", + "amount": 122.54487864458254 + }, + { + "date": "2020-08-12T02:00:00", + "unit": "mg/dL", + "amount": 124.07265642236031 + }, + { + "date": "2020-08-12T02:05:00", + "unit": "mg/dL", + "amount": 125.6004342001381 + }, + { + "date": "2020-08-12T02:10:00", + "unit": "mg/dL", + "amount": 127.12821197791588 + }, + { + "date": "2020-08-12T02:15:00", + "unit": "mg/dL", + "amount": 128.65598975569367 + }, + { + "date": "2020-08-12T02:20:00", + "unit": "mg/dL", + "amount": 130.18376753347144 + }, + { + "date": "2020-08-12T02:25:00", + "unit": "mg/dL", + "amount": 131.7115453112492 + }, + { + "date": "2020-08-12T02:30:00", + "unit": "mg/dL", + "amount": 133.23932308902698 + }, + { + "date": "2020-08-12T02:35:00", + "unit": "mg/dL", + "amount": 134.76710086680475 + }, + { + "date": "2020-08-12T02:40:00", + "unit": "mg/dL", + "amount": 136.29487864458252 + }, + { + "date": "2020-08-12T02:45:00", + "unit": "mg/dL", + "amount": 137.5 + }, + { + "date": "2020-08-12T02:50:00", + "unit": "mg/dL", + "amount": 137.5 + }, + { + "date": "2020-08-12T02:55:00", + "unit": "mg/dL", + "amount": 137.5 + } +] \ No newline at end of file diff --git a/LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_counteraction_effect.json b/LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_counteraction_effect.json new file mode 100644 index 0000000000..04a954b411 --- /dev/null +++ b/LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_counteraction_effect.json @@ -0,0 +1,230 @@ +[ + { + "startDate": "2020-08-11T19:06:06", + "endDate": "2020-08-11T19:11:06", + "unit": "mg\/min·dL", + "value": -0.3485359639226971 + }, + { + "startDate": "2020-08-11T19:11:06", + "endDate": "2020-08-11T19:16:06", + "unit": "mg\/min·dL", + "value": -0.34571948711910916 + }, + { + "startDate": "2020-08-11T19:16:06", + "endDate": "2020-08-11T19:21:06", + "unit": "mg\/min·dL", + "value": -0.3110996208816001 + }, + { + "startDate": "2020-08-11T19:21:06", + "endDate": "2020-08-11T19:26:06", + "unit": "mg\/min·dL", + "value": -0.17115290442012446 + }, + { + "startDate": "2020-08-11T19:26:06", + "endDate": "2020-08-11T19:31:06", + "unit": "mg\/min·dL", + "value": -0.035078937546724906 + }, + { + "startDate": "2020-08-11T19:31:06", + "endDate": "2020-08-11T19:36:06", + "unit": "mg\/min·dL", + "value": 0.08735109214809061 + }, + { + "startDate": "2020-08-11T19:36:06", + "endDate": "2020-08-11T19:41:06", + "unit": "mg\/min·dL", + "value": 0.19746935782304254 + }, + { + "startDate": "2020-08-11T19:41:06", + "endDate": "2020-08-11T19:46:06", + "unit": "mg\/min·dL", + "value": 0.2964814415989495 + }, + { + "startDate": "2020-08-11T19:46:06", + "endDate": "2020-08-11T19:51:06", + "unit": "mg\/min·dL", + "value": 0.3854747645772338 + }, + { + "startDate": "2020-08-11T19:51:06", + "endDate": "2020-08-11T19:56:06", + "unit": "mg\/min·dL", + "value": 0.4654266535840445 + }, + { + "startDate": "2020-08-11T19:56:06", + "endDate": "2020-08-11T20:01:06", + "unit": "mg\/min·dL", + "value": 0.5372120677140927 + }, + { + "startDate": "2020-08-11T20:01:06", + "endDate": "2020-08-11T20:06:06", + "unit": "mg\/min·dL", + "value": 0.6016110049547307 + }, + { + "startDate": "2020-08-11T20:06:06", + "endDate": "2020-08-11T20:11:06", + "unit": "mg\/min·dL", + "value": 0.6593156065538323 + }, + { + "startDate": "2020-08-11T20:11:06", + "endDate": "2020-08-11T20:16:06", + "unit": "mg\/min·dL", + "value": 0.7109369743543738 + }, + { + "startDate": "2020-08-11T20:16:06", + "endDate": "2020-08-11T20:21:06", + "unit": "mg\/min·dL", + "value": 0.7570117140551543 + }, + { + "startDate": "2020-08-11T20:21:06", + "endDate": "2020-08-11T20:26:06", + "unit": "mg\/min·dL", + "value": 0.7980082152690883 + }, + { + "startDate": "2020-08-11T20:26:06", + "endDate": "2020-08-11T20:31:06", + "unit": "mg\/min·dL", + "value": 0.8343326773392674 + }, + { + "startDate": "2020-08-11T20:31:06", + "endDate": "2020-08-11T20:36:06", + "unit": "mg\/min·dL", + "value": 0.8663348881353556 + }, + { + "startDate": "2020-08-11T20:36:06", + "endDate": "2020-08-11T20:41:06", + "unit": "mg\/min·dL", + "value": 0.894313761489434 + }, + { + "startDate": "2020-08-11T20:41:06", + "endDate": "2020-08-11T20:46:06", + "unit": "mg\/min·dL", + "value": 0.9185226375377566 + }, + { + "startDate": "2020-08-11T20:46:06", + "endDate": "2020-08-11T20:51:06", + "unit": "mg\/min·dL", + "value": 0.9391743490118711 + }, + { + "startDate": "2020-08-11T20:51:06", + "endDate": "2020-08-11T20:56:06", + "unit": "mg\/min·dL", + "value": 0.9564460554651047 + }, + { + "startDate": "2020-08-11T20:56:06", + "endDate": "2020-08-11T21:01:06", + "unit": "mg\/min·dL", + "value": 0.9704838465264228 + }, + { + "startDate": "2020-08-11T21:01:06", + "endDate": "2020-08-11T21:06:06", + "unit": "mg\/min·dL", + "value": 0.9814071145378676 + }, + { + "startDate": "2020-08-11T21:06:06", + "endDate": "2020-08-11T21:11:06", + "unit": "mg\/min·dL", + "value": 0.9893126963505664 + }, + { + "startDate": "2020-08-11T21:11:06", + "endDate": "2020-08-11T21:16:06", + "unit": "mg\/min·dL", + "value": 0.9942787836220887 + }, + { + "startDate": "2020-08-11T21:16:06", + "endDate": "2020-08-11T21:21:06", + "unit": "mg\/min·dL", + "value": 0.9963686006690381 + }, + { + "startDate": "2020-08-11T21:21:06", + "endDate": "2020-08-11T21:26:06", + "unit": "mg\/min·dL", + "value": 0.9956338487771189 + }, + { + "startDate": "2020-08-11T21:26:06", + "endDate": "2020-08-11T21:31:06", + "unit": "mg\/min·dL", + "value": 0.9921179158496414 + }, + { + "startDate": "2020-08-11T21:31:06", + "endDate": "2020-08-11T21:36:06", + "unit": "mg\/min·dL", + "value": 0.9858588503769765 + }, + { + "startDate": "2020-08-11T21:36:06", + "endDate": "2020-08-11T21:41:06", + "unit": "mg\/min·dL", + "value": 0.9768920989266177 + }, + { + "startDate": "2020-08-11T21:41:06", + "endDate": "2020-08-11T21:46:06", + "unit": "mg\/min·dL", + "value": 0.965253006677156 + }, + { + "startDate": "2020-08-11T21:46:06", + "endDate": "2020-08-11T21:51:06", + "unit": "mg\/min·dL", + "value": 0.9509790809419911 + }, + { + "startDate": "2020-08-11T21:51:06", + "endDate": "2020-08-11T21:56:06", + "unit": "mg\/min·dL", + "value": 0.9341120181395608 + }, + { + "startDate": "2020-08-11T21:56:06", + "endDate": "2020-08-11T22:01:06", + "unit": "mg\/min·dL", + "value": 0.9146994952586709 + }, + { + "startDate": "2020-08-11T22:01:06", + "endDate": "2020-08-11T22:06:06", + "unit": "mg\/min·dL", + "value": 0.8927967275284316 + }, + { + "startDate": "2020-08-11T22:06:06", + "endDate": "2020-08-11T22:17:16", + "unit": "mg\/min·dL", + "value": 0.3597357885896396 + }, + { + "startDate": "2020-08-11T22:17:16", + "endDate": "2020-08-11T22:23:55", + "unit": "mg\/min·dL", + "value": 0.45827708950324664 + } +] diff --git a/LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_insulin_effect.json b/LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_insulin_effect.json new file mode 100644 index 0000000000..c4576feeae --- /dev/null +++ b/LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_insulin_effect.json @@ -0,0 +1,377 @@ +[ + { + "date": "2020-08-11T22:25:00", + "amount": -0.9512697097060898, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T22:30:00", + "amount": -2.3813732447934624, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T22:35:00", + "amount": -4.341390140188103, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T22:40:00", + "amount": -6.753751683906663, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T22:45:00", + "amount": -9.55387357996081, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T22:50:00", + "amount": -12.683720606187977, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T22:55:00", + "amount": -16.090664681076284, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:00:00", + "amount": -19.72706463270244, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:05:00", + "amount": -23.549875518271012, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:10:00", + "amount": -27.520285545942553, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:15:00", + "amount": -31.60337877339881, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:20:00", + "amount": -35.76782187287874, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:25:00", + "amount": -39.98557336066623, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:30:00", + "amount": -44.23161379064487, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:35:00", + "amount": -48.48369550694377, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:40:00", + "amount": -52.72211064025665, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:45:00", + "amount": -56.92947611647066, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:50:00", + "amount": -61.090534525122315, + "unit": "mg/dL" + }, + { + "date": "2020-08-11T23:55:00", + "amount": -65.19196976921322, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:00:00", + "amount": -69.22223648736112, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:05:00", + "amount": -73.17140230440528, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:10:00", + "amount": -77.03100202768849, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:15:00", + "amount": -80.79390296354336, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:20:00", + "amount": -84.45418058224833, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:25:00", + "amount": -88.00700381010158, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:30:00", + "amount": -91.44852927449627, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:35:00", + "amount": -94.77580387215212, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:40:00", + "amount": -97.98667507215376, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:45:00", + "amount": -101.07970840432662, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:50:00", + "amount": -104.05411161991226, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T00:55:00", + "amount": -106.9096650456303, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:00:00", + "amount": -109.64665768417882, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:05:00", + "amount": -112.26582864415883, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:10:00", + "amount": -114.7683135104363, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:15:00", + "amount": -117.15559529219412, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:20:00", + "amount": -119.42945961048726, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:25:00", + "amount": -121.59195381009803, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:30:00", + "amount": -123.64534970199563, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:35:00", + "amount": -125.592109662823, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:40:00", + "amount": -127.43485583665301, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:45:00", + "amount": -129.17634220185357, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:50:00", + "amount": -130.81942928235597, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T01:55:00", + "amount": -132.36706129800115, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:00:00", + "amount": -133.8222455630132, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:05:00", + "amount": -135.18803395508212, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:10:00", + "amount": -136.46750629008383, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:15:00", + "amount": -137.66375544918807, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:20:00", + "amount": -138.779874116044, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:25:00", + "amount": -139.81894299195267, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:30:00", + "amount": -140.78402036646978, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:35:00", + "amount": -141.67813292977746, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:40:00", + "amount": -142.50426772146503, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:45:00", + "amount": -143.2653651180992, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:50:00", + "amount": -143.9643127691786, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T02:55:00", + "amount": -144.60394039779732, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T03:00:00", + "amount": -145.18701538860697, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T03:05:00", + "amount": -145.71623909150875, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T03:10:00", + "amount": -146.19424377494204, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T03:15:00", + "amount": -146.62359016770122, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T03:20:00", + "amount": -147.00676553292078, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T03:25:00", + "amount": -147.3461822222548, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T03:30:00", + "amount": -147.64417666235195, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T03:35:00", + "amount": -147.90300872951764, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T03:40:00", + "amount": -148.12486147197743, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T03:45:00", + "amount": -148.3118411424277, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T03:50:00", + "amount": -148.46597750659902, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T03:55:00", + "amount": -148.58922439637678, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T04:00:00", + "amount": -148.68346047864273, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T04:05:00", + "amount": -148.75049021342636, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T04:10:00", + "amount": -148.79204497720696, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T04:15:00", + "amount": -148.8097843292894, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T04:20:00", + "amount": -148.80959176148318, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T04:25:00", + "amount": -148.80862975663214, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T04:30:00", + "amount": -148.8083823028405, + "unit": "mg/dL" + }, + { + "date": "2020-08-12T04:35:00", + "amount": -148.80836238795683, + "unit": "mg/dL" + } +] \ No newline at end of file diff --git a/LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_momentum_effect.json b/LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_momentum_effect.json new file mode 100644 index 0000000000..0637a088a0 --- /dev/null +++ b/LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_momentum_effect.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_predicted_glucose.json b/LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_predicted_glucose.json new file mode 100644 index 0000000000..4ac4d64f44 --- /dev/null +++ b/LoopTests/Fixtures/low_with_low_treatment/low_with_low_treatment_predicted_glucose.json @@ -0,0 +1,382 @@ +[ + { + "date": "2020-08-11T22:23:55", + "unit": "mg/dL", + "amount": 81.22399763523448 + }, + { + "date": "2020-08-11T22:25:00", + "unit": "mg/dL", + "amount": 87.005525216014 + }, + { + "date": "2020-08-11T22:30:00", + "unit": "mg/dL", + "amount": 89.28803182494407 + }, + { + "date": "2020-08-11T22:35:00", + "unit": "mg/dL", + "amount": 90.82214183694292 + }, + { + "date": "2020-08-11T22:40:00", + "unit": "mg/dL", + "amount": 96.95805885168919 + }, + { + "date": "2020-08-11T22:45:00", + "unit": "mg/dL", + "amount": 103.32620850089171 + }, + { + "date": "2020-08-11T22:50:00", + "unit": "mg/dL", + "amount": 109.14614978329723 + }, + { + "date": "2020-08-11T22:55:00", + "unit": "mg/dL", + "amount": 114.47051078041765 + }, + { + "date": "2020-08-11T23:00:00", + "unit": "mg/dL", + "amount": 119.34693266417625 + }, + { + "date": "2020-08-11T23:05:00", + "unit": "mg/dL", + "amount": 123.81846037736848 + }, + { + "date": "2020-08-11T23:10:00", + "unit": "mg/dL", + "amount": 127.92390571183377 + }, + { + "date": "2020-08-11T23:15:00", + "unit": "mg/dL", + "amount": 131.69818460989035 + }, + { + "date": "2020-08-11T23:20:00", + "unit": "mg/dL", + "amount": 135.1726303992993 + }, + { + "date": "2020-08-11T23:25:00", + "unit": "mg/dL", + "amount": 133.3211329127054 + }, + { + "date": "2020-08-11T23:30:00", + "unit": "mg/dL", + "amount": 130.60287026050452 + }, + { + "date": "2020-08-11T23:35:00", + "unit": "mg/dL", + "amount": 127.87856632198339 + }, + { + "date": "2020-08-11T23:40:00", + "unit": "mg/dL", + "amount": 125.16792896644829 + }, + { + "date": "2020-08-11T23:45:00", + "unit": "mg/dL", + "amount": 122.48834126801206 + }, + { + "date": "2020-08-11T23:50:00", + "unit": "mg/dL", + "amount": 119.85506063713818 + }, + { + "date": "2020-08-11T23:55:00", + "unit": "mg/dL", + "amount": 117.28140317082506 + }, + { + "date": "2020-08-12T00:00:00", + "unit": "mg/dL", + "amount": 114.77891423045493 + }, + { + "date": "2020-08-12T00:05:00", + "unit": "mg/dL", + "amount": 112.35752619118857 + }, + { + "date": "2020-08-12T00:10:00", + "unit": "mg/dL", + "amount": 110.02570424568313 + }, + { + "date": "2020-08-12T00:15:00", + "unit": "mg/dL", + "amount": 107.79058108760603 + }, + { + "date": "2020-08-12T00:20:00", + "unit": "mg/dL", + "amount": 105.65808124667883 + }, + { + "date": "2020-08-12T00:25:00", + "unit": "mg/dL", + "amount": 103.63303579660337 + }, + { + "date": "2020-08-12T00:30:00", + "unit": "mg/dL", + "amount": 101.71928810998646 + }, + { + "date": "2020-08-12T00:35:00", + "unit": "mg/dL", + "amount": 99.91979129010838 + }, + { + "date": "2020-08-12T00:40:00", + "unit": "mg/dL", + "amount": 98.23669786788452 + }, + { + "date": "2020-08-12T00:45:00", + "unit": "mg/dL", + "amount": 96.67144231348942 + }, + { + "date": "2020-08-12T00:50:00", + "unit": "mg/dL", + "amount": 95.22481687568158 + }, + { + "date": "2020-08-12T00:55:00", + "unit": "mg/dL", + "amount": 93.89704122774131 + }, + { + "date": "2020-08-12T01:00:00", + "unit": "mg/dL", + "amount": 92.68782636697057 + }, + { + "date": "2020-08-12T01:05:00", + "unit": "mg/dL", + "amount": 91.59643318476833 + }, + { + "date": "2020-08-12T01:10:00", + "unit": "mg/dL", + "amount": 90.62172609626865 + }, + { + "date": "2020-08-12T01:15:00", + "unit": "mg/dL", + "amount": 89.76222209228861 + }, + { + "date": "2020-08-12T01:20:00", + "unit": "mg/dL", + "amount": 89.01613555177325 + }, + { + "date": "2020-08-12T01:25:00", + "unit": "mg/dL", + "amount": 88.38141912994024 + }, + { + "date": "2020-08-12T01:30:00", + "unit": "mg/dL", + "amount": 87.85580101582045 + }, + { + "date": "2020-08-12T01:35:00", + "unit": "mg/dL", + "amount": 87.43681883277084 + }, + { + "date": "2020-08-12T01:40:00", + "unit": "mg/dL", + "amount": 87.1218504367186 + }, + { + "date": "2020-08-12T01:45:00", + "unit": "mg/dL", + "amount": 86.90814184929582 + }, + { + "date": "2020-08-12T01:50:00", + "unit": "mg/dL", + "amount": 86.79283254657122 + }, + { + "date": "2020-08-12T01:55:00", + "unit": "mg/dL", + "amount": 86.77297830870381 + }, + { + "date": "2020-08-12T02:00:00", + "unit": "mg/dL", + "amount": 86.84557182146952 + }, + { + "date": "2020-08-12T02:05:00", + "unit": "mg/dL", + "amount": 87.00756120717838 + }, + { + "date": "2020-08-12T02:10:00", + "unit": "mg/dL", + "amount": 87.25586664995447 + }, + { + "date": "2020-08-12T02:15:00", + "unit": "mg/dL", + "amount": 87.58739526862803 + }, + { + "date": "2020-08-12T02:20:00", + "unit": "mg/dL", + "amount": 87.99905437954988 + }, + { + "date": "2020-08-12T02:25:00", + "unit": "mg/dL", + "amount": 88.48776328141898 + }, + { + "date": "2020-08-12T02:30:00", + "unit": "mg/dL", + "amount": 89.05046368467964 + }, + { + "date": "2020-08-12T02:35:00", + "unit": "mg/dL", + "amount": 89.68412889914973 + }, + { + "date": "2020-08-12T02:40:00", + "unit": "mg/dL", + "amount": 90.38577188523993 + }, + { + "date": "2020-08-12T02:45:00", + "unit": "mg/dL", + "amount": 90.82979584402324 + }, + { + "date": "2020-08-12T02:50:00", + "unit": "mg/dL", + "amount": 90.13084819294383 + }, + { + "date": "2020-08-12T02:55:00", + "unit": "mg/dL", + "amount": 89.49122056432512 + }, + { + "date": "2020-08-12T03:00:00", + "unit": "mg/dL", + "amount": 88.90814557351547 + }, + { + "date": "2020-08-12T03:05:00", + "unit": "mg/dL", + "amount": 88.37892187061368 + }, + { + "date": "2020-08-12T03:10:00", + "unit": "mg/dL", + "amount": 87.9009171871804 + }, + { + "date": "2020-08-12T03:15:00", + "unit": "mg/dL", + "amount": 87.47157079442121 + }, + { + "date": "2020-08-12T03:20:00", + "unit": "mg/dL", + "amount": 87.08839542920165 + }, + { + "date": "2020-08-12T03:25:00", + "unit": "mg/dL", + "amount": 86.74897873986762 + }, + { + "date": "2020-08-12T03:30:00", + "unit": "mg/dL", + "amount": 86.45098429977048 + }, + { + "date": "2020-08-12T03:35:00", + "unit": "mg/dL", + "amount": 86.1921522326048 + }, + { + "date": "2020-08-12T03:40:00", + "unit": "mg/dL", + "amount": 85.97029949014501 + }, + { + "date": "2020-08-12T03:45:00", + "unit": "mg/dL", + "amount": 85.78331981969473 + }, + { + "date": "2020-08-12T03:50:00", + "unit": "mg/dL", + "amount": 85.62918345552342 + }, + { + "date": "2020-08-12T03:55:00", + "unit": "mg/dL", + "amount": 85.50593656574566 + }, + { + "date": "2020-08-12T04:00:00", + "unit": "mg/dL", + "amount": 85.4117004834797 + }, + { + "date": "2020-08-12T04:05:00", + "unit": "mg/dL", + "amount": 85.34467074869607 + }, + { + "date": "2020-08-12T04:10:00", + "unit": "mg/dL", + "amount": 85.30311598491548 + }, + { + "date": "2020-08-12T04:15:00", + "unit": "mg/dL", + "amount": 85.28537663283302 + }, + { + "date": "2020-08-12T04:20:00", + "unit": "mg/dL", + "amount": 85.28556920063926 + }, + { + "date": "2020-08-12T04:25:00", + "unit": "mg/dL", + "amount": 85.28653120549029 + }, + { + "date": "2020-08-12T04:30:00", + "unit": "mg/dL", + "amount": 85.28677865928194 + }, + { + "date": "2020-08-12T04:35:00", + "unit": "mg/dL", + "amount": 85.2867985741656 + } +] \ No newline at end of file diff --git a/LoopTests/Fixtures/meal_detection/dynamic_autofill_counteraction_effect.json b/LoopTests/Fixtures/meal_detection/dynamic_autofill_counteraction_effect.json new file mode 100644 index 0000000000..6f979e79cc --- /dev/null +++ b/LoopTests/Fixtures/meal_detection/dynamic_autofill_counteraction_effect.json @@ -0,0 +1,1730 @@ +[ + { + "startDate": "2022-10-16T20:30:55", + "endDate": "2022-10-16T20:35:55", + "unit": "mg\/min·dL", + "value": -0.2845291412399927 + }, + { + "startDate": "2022-10-16T20:35:55", + "endDate": "2022-10-16T20:40:55", + "unit": "mg\/min·dL", + "value": -0.720332507317587 + }, + { + "startDate": "2022-10-16T20:40:55", + "endDate": "2022-10-16T20:45:55", + "unit": "mg\/min·dL", + "value": 0.0420715382145751 + }, + { + "startDate": "2022-10-16T20:45:55", + "endDate": "2022-10-16T20:50:55", + "unit": "mg\/min·dL", + "value": 0.0038033636146233146 + }, + { + "startDate": "2022-10-16T20:50:55", + "endDate": "2022-10-16T20:55:55", + "unit": "mg\/min·dL", + "value": 0.36479459077661286 + }, + { + "startDate": "2022-10-16T20:55:55", + "endDate": "2022-10-16T21:00:55", + "unit": "mg\/min·dL", + "value": 0.12560308127768485 + }, + { + "startDate": "2022-10-16T21:00:55", + "endDate": "2022-10-16T21:05:55", + "unit": "mg\/min·dL", + "value": 0.2871269278384104 + }, + { + "startDate": "2022-10-16T21:05:55", + "endDate": "2022-10-16T21:10:55", + "unit": "mg\/min·dL", + "value": 0.24698121433301015 + }, + { + "startDate": "2022-10-16T21:10:55", + "endDate": "2022-10-16T21:15:55", + "unit": "mg\/min·dL", + "value": 0.4090910045267783 + }, + { + "startDate": "2022-10-16T21:15:55", + "endDate": "2022-10-16T21:20:56", + "unit": "mg\/min·dL", + "value": 0.36970428418744017 + }, + { + "startDate": "2022-10-16T21:20:56", + "endDate": "2022-10-16T21:25:55", + "unit": "mg\/min·dL", + "value": 0.9356144093998734 + }, + { + "startDate": "2022-10-16T21:25:55", + "endDate": "2022-10-16T21:30:55", + "unit": "mg\/min·dL", + "value": 0.3045237460699238 + }, + { + "startDate": "2022-10-16T21:30:55", + "endDate": "2022-10-16T21:35:55", + "unit": "mg\/min·dL", + "value": -0.12222077929078642 + }, + { + "startDate": "2022-10-16T21:35:55", + "endDate": "2022-10-16T21:40:55", + "unit": "mg\/min·dL", + "value": 1.8610345585630323 + }, + { + "startDate": "2022-10-16T21:40:55", + "endDate": "2022-10-16T21:45:56", + "unit": "mg\/min·dL", + "value": 1.0401503514922685 + }, + { + "startDate": "2022-10-16T21:45:56", + "endDate": "2022-10-16T21:50:55", + "unit": "mg\/min·dL", + "value": 1.2329955169598097 + }, + { + "startDate": "2022-10-16T21:50:55", + "endDate": "2022-10-16T21:55:55", + "unit": "mg\/min·dL", + "value": -0.372691168979093 + }, + { + "startDate": "2022-10-16T21:55:55", + "endDate": "2022-10-16T22:00:56", + "unit": "mg\/min·dL", + "value": 1.0320426981004522 + }, + { + "startDate": "2022-10-16T22:00:56", + "endDate": "2022-10-16T22:05:55", + "unit": "mg\/min·dL", + "value": 1.2446535840823372 + }, + { + "startDate": "2022-10-16T22:05:55", + "endDate": "2022-10-16T22:10:55", + "unit": "mg\/min·dL", + "value": 1.4513990428468961 + }, + { + "startDate": "2022-10-16T22:10:55", + "endDate": "2022-10-16T22:15:55", + "unit": "mg\/min·dL", + "value": 1.662878682899968 + }, + { + "startDate": "2022-10-16T22:15:55", + "endDate": "2022-10-16T22:20:55", + "unit": "mg\/min·dL", + "value": 1.6776600731029174 + }, + { + "startDate": "2022-10-16T22:20:55", + "endDate": "2022-10-16T22:25:55", + "unit": "mg\/min·dL", + "value": 2.0945857017085485 + }, + { + "startDate": "2022-10-16T22:25:55", + "endDate": "2022-10-16T22:30:56", + "unit": "mg\/min·dL", + "value": 2.3253439979429293 + }, + { + "startDate": "2022-10-16T22:30:56", + "endDate": "2022-10-16T22:35:55", + "unit": "mg\/min·dL", + "value": 2.762596869393967 + }, + { + "startDate": "2022-10-16T22:35:55", + "endDate": "2022-10-16T22:40:55", + "unit": "mg\/min·dL", + "value": 2.8086729434998707 + }, + { + "startDate": "2022-10-16T22:40:55", + "endDate": "2022-10-16T22:45:56", + "unit": "mg\/min·dL", + "value": 2.252477346032686 + }, + { + "startDate": "2022-10-16T22:45:56", + "endDate": "2022-10-16T22:50:55", + "unit": "mg\/min·dL", + "value": 1.5612654702448618 + }, + { + "startDate": "2022-10-16T22:50:55", + "endDate": "2022-10-16T22:55:55", + "unit": "mg\/min·dL", + "value": 1.4645801980357367 + }, + { + "startDate": "2022-10-16T22:55:55", + "endDate": "2022-10-16T23:00:55", + "unit": "mg\/min·dL", + "value": 0.754887677850649 + }, + { + "startDate": "2022-10-16T23:00:55", + "endDate": "2022-10-16T23:05:55", + "unit": "mg\/min·dL", + "value": 0.6300240922287167 + }, + { + "startDate": "2022-10-16T23:05:55", + "endDate": "2022-10-16T23:10:55", + "unit": "mg\/min·dL", + "value": 0.687012158407236 + }, + { + "startDate": "2022-10-16T23:10:55", + "endDate": "2022-10-16T23:15:55", + "unit": "mg\/min·dL", + "value": 0.9327353913928134 + }, + { + "startDate": "2022-10-16T23:15:55", + "endDate": "2022-10-16T23:20:55", + "unit": "mg\/min·dL", + "value": 1.567929156289032 + }, + { + "startDate": "2022-10-16T23:20:55", + "endDate": "2022-10-16T23:25:55", + "unit": "mg\/min·dL", + "value": 0.5902090457631002 + }, + { + "startDate": "2022-10-16T23:25:55", + "endDate": "2022-10-16T23:30:56", + "unit": "mg\/min·dL", + "value": 0.0041194999268927 + }, + { + "startDate": "2022-10-16T23:30:56", + "endDate": "2022-10-16T23:35:55", + "unit": "mg\/min·dL", + "value": -0.5903810118195975 + }, + { + "startDate": "2022-10-16T23:35:55", + "endDate": "2022-10-16T23:40:56", + "unit": "mg\/min·dL", + "value": -0.7888201851294322 + }, + { + "startDate": "2022-10-16T23:40:56", + "endDate": "2022-10-16T23:45:55", + "unit": "mg\/min·dL", + "value": -0.19719045325679185 + }, + { + "startDate": "2022-10-16T23:45:55", + "endDate": "2022-10-16T23:50:55", + "unit": "mg\/min·dL", + "value": 1.391481925699727 + }, + { + "startDate": "2022-10-16T23:50:55", + "endDate": "2022-10-16T23:55:55", + "unit": "mg\/min·dL", + "value": 0.7714860788525394 + }, + { + "startDate": "2022-10-16T23:55:55", + "endDate": "2022-10-17T00:00:55", + "unit": "mg\/min·dL", + "value": 1.148931071940478 + }, + { + "startDate": "2022-10-17T00:00:55", + "endDate": "2022-10-17T00:05:55", + "unit": "mg\/min·dL", + "value": 0.7236798330387322 + }, + { + "startDate": "2022-10-17T00:05:55", + "endDate": "2022-10-17T00:10:55", + "unit": "mg\/min·dL", + "value": 0.8960938670797415 + }, + { + "startDate": "2022-10-17T00:10:55", + "endDate": "2022-10-17T00:15:55", + "unit": "mg\/min·dL", + "value": 0.8719274942897706 + }, + { + "startDate": "2022-10-17T00:15:55", + "endDate": "2022-10-17T00:20:55", + "unit": "mg\/min·dL", + "value": 1.4509978874832035 + }, + { + "startDate": "2022-10-17T00:20:55", + "endDate": "2022-10-17T00:25:55", + "unit": "mg\/min·dL", + "value": 3.633991893395394 + }, + { + "startDate": "2022-10-17T00:25:55", + "endDate": "2022-10-17T00:30:55", + "unit": "mg\/min·dL", + "value": 5.219533814878678 + }, + { + "startDate": "2022-10-17T00:30:55", + "endDate": "2022-10-17T00:35:56", + "unit": "mg\/min·dL", + "value": -0.7755871939106258 + }, + { + "startDate": "2022-10-17T00:35:56", + "endDate": "2022-10-17T00:40:56", + "unit": "mg\/min·dL", + "value": 1.6450492189675925 + }, + { + "startDate": "2022-10-17T00:40:56", + "endDate": "2022-10-17T00:45:55", + "unit": "mg\/min·dL", + "value": 2.2932552815173004 + }, + { + "startDate": "2022-10-17T00:45:55", + "endDate": "2022-10-17T00:50:56", + "unit": "mg\/min·dL", + "value": 1.5190954507889283 + }, + { + "startDate": "2022-10-17T00:50:56", + "endDate": "2022-10-17T00:55:56", + "unit": "mg\/min·dL", + "value": 2.9516354434782834 + }, + { + "startDate": "2022-10-17T00:55:56", + "endDate": "2022-10-17T01:00:56", + "unit": "mg\/min·dL", + "value": 0.5732859864418134 + }, + { + "startDate": "2022-10-17T01:00:56", + "endDate": "2022-10-17T01:05:55", + "unit": "mg\/min·dL", + "value": -1.20756468395372 + }, + { + "startDate": "2022-10-17T01:05:55", + "endDate": "2022-10-17T01:10:55", + "unit": "mg\/min·dL", + "value": -0.7867304872657194 + }, + { + "startDate": "2022-10-17T01:10:55", + "endDate": "2022-10-17T01:15:56", + "unit": "mg\/min·dL", + "value": -1.5694673963202832 + }, + { + "startDate": "2022-10-17T01:15:56", + "endDate": "2022-10-17T01:20:55", + "unit": "mg\/min·dL", + "value": -0.1643639957386131 + }, + { + "startDate": "2022-10-17T01:20:55", + "endDate": "2022-10-17T01:25:56", + "unit": "mg\/min·dL", + "value": -2.3577500064743524 + }, + { + "startDate": "2022-10-17T01:25:56", + "endDate": "2022-10-17T01:30:56", + "unit": "mg\/min·dL", + "value": 0.8323428726747981 + }, + { + "startDate": "2022-10-17T01:30:56", + "endDate": "2022-10-17T01:35:56", + "unit": "mg\/min·dL", + "value": 1.2228081822030008 + }, + { + "startDate": "2022-10-17T01:35:56", + "endDate": "2022-10-17T01:40:55", + "unit": "mg\/min·dL", + "value": 0.8048056766676048 + }, + { + "startDate": "2022-10-17T01:40:55", + "endDate": "2022-10-17T01:45:55", + "unit": "mg\/min·dL", + "value": 0.38224475602652913 + }, + { + "startDate": "2022-10-17T01:45:55", + "endDate": "2022-10-17T01:50:55", + "unit": "mg\/min·dL", + "value": -0.4440735677784057 + }, + { + "startDate": "2022-10-17T01:50:55", + "endDate": "2022-10-17T01:55:56", + "unit": "mg\/min·dL", + "value": 0.5243983931271272 + }, + { + "startDate": "2022-10-17T01:55:56", + "endDate": "2022-10-17T02:00:55", + "unit": "mg\/min·dL", + "value": 0.2931345766919252 + }, + { + "startDate": "2022-10-17T02:00:55", + "endDate": "2022-10-17T02:05:56", + "unit": "mg\/min·dL", + "value": 0.4561831643005171 + }, + { + "startDate": "2022-10-17T02:05:56", + "endDate": "2022-10-17T02:10:55", + "unit": "mg\/min·dL", + "value": 4.632973330858247 + }, + { + "startDate": "2022-10-17T02:10:55", + "endDate": "2022-10-17T02:15:55", + "unit": "mg\/min·dL", + "value": 3.3923410537792185 + }, + { + "startDate": "2022-10-17T02:15:55", + "endDate": "2022-10-17T02:20:55", + "unit": "mg\/min·dL", + "value": 2.7692455985225015 + }, + { + "startDate": "2022-10-17T02:20:55", + "endDate": "2022-10-17T02:25:56", + "unit": "mg\/min·dL", + "value": 0.7675601718208241 + }, + { + "startDate": "2022-10-17T02:25:56", + "endDate": "2022-10-17T02:30:55", + "unit": "mg\/min·dL", + "value": 1.0000691322446786 + }, + { + "startDate": "2022-10-17T02:30:55", + "endDate": "2022-10-17T02:35:55", + "unit": "mg\/min·dL", + "value": 0.6611668014658957 + }, + { + "startDate": "2022-10-17T02:35:55", + "endDate": "2022-10-17T02:40:56", + "unit": "mg\/min·dL", + "value": -0.08596328117482305 + }, + { + "startDate": "2022-10-17T02:40:56", + "endDate": "2022-10-17T02:45:56", + "unit": "mg\/min·dL", + "value": 0.7540608960491663 + }, + { + "startDate": "2022-10-17T02:45:56", + "endDate": "2022-10-17T02:50:55", + "unit": "mg\/min·dL", + "value": -0.818006344776246 + }, + { + "startDate": "2022-10-17T02:50:55", + "endDate": "2022-10-17T02:55:56", + "unit": "mg\/min·dL", + "value": 2.39887981869481 + }, + { + "startDate": "2022-10-17T02:55:56", + "endDate": "2022-10-17T03:00:56", + "unit": "mg\/min·dL", + "value": 0.20930108912543072 + }, + { + "startDate": "2022-10-17T03:00:56", + "endDate": "2022-10-17T03:05:56", + "unit": "mg\/min·dL", + "value": 0.8097746905008025 + }, + { + "startDate": "2022-10-17T03:05:56", + "endDate": "2022-10-17T03:10:56", + "unit": "mg\/min·dL", + "value": 0.6033947886970124 + }, + { + "startDate": "2022-10-17T03:10:56", + "endDate": "2022-10-17T03:15:56", + "unit": "mg\/min·dL", + "value": 0.9898220114011889 + }, + { + "startDate": "2022-10-17T03:15:56", + "endDate": "2022-10-17T03:20:56", + "unit": "mg\/min·dL", + "value": -0.22832265899703647 + }, + { + "startDate": "2022-10-17T03:20:56", + "endDate": "2022-10-17T03:25:56", + "unit": "mg\/min·dL", + "value": -0.4511116066129434 + }, + { + "startDate": "2022-10-17T03:25:56", + "endDate": "2022-10-17T03:30:55", + "unit": "mg\/min·dL", + "value": -2.477086162741612 + }, + { + "startDate": "2022-10-17T03:30:55", + "endDate": "2022-10-17T03:35:56", + "unit": "mg\/min·dL", + "value": -1.3028794753131472 + }, + { + "startDate": "2022-10-17T03:35:56", + "endDate": "2022-10-17T03:40:56", + "unit": "mg\/min·dL", + "value": 0.6617447275509728 + }, + { + "startDate": "2022-10-17T03:40:56", + "endDate": "2022-10-17T03:45:56", + "unit": "mg\/min·dL", + "value": 1.0257874848496134 + }, + { + "startDate": "2022-10-17T03:45:56", + "endDate": "2022-10-17T03:50:56", + "unit": "mg\/min·dL", + "value": 1.5904120793516692 + }, + { + "startDate": "2022-10-17T03:50:56", + "endDate": "2022-10-17T03:55:56", + "unit": "mg\/min·dL", + "value": 1.350173285305319 + }, + { + "startDate": "2022-10-17T03:55:56", + "endDate": "2022-10-17T04:00:56", + "unit": "mg\/min·dL", + "value": 2.313304809362078 + }, + { + "startDate": "2022-10-17T04:00:56", + "endDate": "2022-10-17T04:05:56", + "unit": "mg\/min·dL", + "value": 1.472727094038384 + }, + { + "startDate": "2022-10-17T04:05:56", + "endDate": "2022-10-17T04:10:56", + "unit": "mg\/min·dL", + "value": 1.2459875080276552 + }, + { + "startDate": "2022-10-17T04:10:56", + "endDate": "2022-10-17T04:15:56", + "unit": "mg\/min·dL", + "value": 0.4290145455866161 + }, + { + "startDate": "2022-10-17T04:15:56", + "endDate": "2022-10-17T04:20:56", + "unit": "mg\/min·dL", + "value": 0.6263357321101345 + }, + { + "startDate": "2022-10-17T04:20:56", + "endDate": "2022-10-17T04:25:56", + "unit": "mg\/min·dL", + "value": -0.36841711164706026 + }, + { + "startDate": "2022-10-17T04:25:56", + "endDate": "2022-10-17T04:30:56", + "unit": "mg\/min·dL", + "value": 1.0381399131596578 + }, + { + "startDate": "2022-10-17T04:30:56", + "endDate": "2022-10-17T04:35:56", + "unit": "mg\/min·dL", + "value": 2.449160211321216 + }, + { + "startDate": "2022-10-17T04:35:56", + "endDate": "2022-10-17T04:40:56", + "unit": "mg\/min·dL", + "value": 0.8519932553148932 + }, + { + "startDate": "2022-10-17T04:40:56", + "endDate": "2022-10-17T04:45:56", + "unit": "mg\/min·dL", + "value": 0.25545807211354093 + }, + { + "startDate": "2022-10-17T04:45:56", + "endDate": "2022-10-17T04:50:56", + "unit": "mg\/min·dL", + "value": 0.2672233629275522 + }, + { + "startDate": "2022-10-17T04:50:56", + "endDate": "2022-10-17T04:55:56", + "unit": "mg\/min·dL", + "value": -0.3192664353132631 + }, + { + "startDate": "2022-10-17T04:55:56", + "endDate": "2022-10-17T05:00:56", + "unit": "mg\/min·dL", + "value": -1.513791537638646 + }, + { + "startDate": "2022-10-17T05:00:56", + "endDate": "2022-10-17T05:05:56", + "unit": "mg\/min·dL", + "value": -1.7044380808946273 + }, + { + "startDate": "2022-10-17T05:05:56", + "endDate": "2022-10-17T05:10:56", + "unit": "mg\/min·dL", + "value": -1.712644832803029 + }, + { + "startDate": "2022-10-17T05:10:56", + "endDate": "2022-10-17T05:15:56", + "unit": "mg\/min·dL", + "value": -0.9190525555156 + }, + { + "startDate": "2022-10-17T05:15:56", + "endDate": "2022-10-17T05:20:56", + "unit": "mg\/min·dL", + "value": -0.3311164563982576 + }, + { + "startDate": "2022-10-17T05:20:56", + "endDate": "2022-10-17T05:25:56", + "unit": "mg\/min·dL", + "value": 0.6520001664415103 + }, + { + "startDate": "2022-10-17T05:25:56", + "endDate": "2022-10-17T05:30:56", + "unit": "mg\/min·dL", + "value": 1.233062198641944 + }, + { + "startDate": "2022-10-17T05:30:56", + "endDate": "2022-10-17T05:35:56", + "unit": "mg\/min·dL", + "value": 1.2076048576203615 + }, + { + "startDate": "2022-10-17T05:35:56", + "endDate": "2022-10-17T05:40:56", + "unit": "mg\/min·dL", + "value": 1.18294662356514 + }, + { + "startDate": "2022-10-17T05:40:56", + "endDate": "2022-10-17T05:45:56", + "unit": "mg\/min·dL", + "value": 0.9530329092590353 + }, + { + "startDate": "2022-10-17T05:45:56", + "endDate": "2022-10-17T05:50:56", + "unit": "mg\/min·dL", + "value": 0.32623717759217646 + }, + { + "startDate": "2022-10-17T05:50:56", + "endDate": "2022-10-17T05:55:56", + "unit": "mg\/min·dL", + "value": 0.8983368506804443 + }, + { + "startDate": "2022-10-17T05:55:56", + "endDate": "2022-10-17T06:00:56", + "unit": "mg\/min·dL", + "value": 0.47971971019911824 + }, + { + "startDate": "2022-10-17T06:00:56", + "endDate": "2022-10-17T06:05:56", + "unit": "mg\/min·dL", + "value": 0.26323063620005377 + }, + { + "startDate": "2022-10-17T06:05:56", + "endDate": "2022-10-17T06:10:56", + "unit": "mg\/min·dL", + "value": 0.2527988070184891 + }, + { + "startDate": "2022-10-17T06:10:56", + "endDate": "2022-10-17T06:15:56", + "unit": "mg\/min·dL", + "value": 0.44391960463529634 + }, + { + "startDate": "2022-10-17T06:15:56", + "endDate": "2022-10-17T06:20:56", + "unit": "mg\/min·dL", + "value": 0.0338159426958477 + }, + { + "startDate": "2022-10-17T06:20:56", + "endDate": "2022-10-17T06:25:56", + "unit": "mg\/min·dL", + "value": 0.42080301326849073 + }, + { + "startDate": "2022-10-17T06:25:56", + "endDate": "2022-10-17T06:30:56", + "unit": "mg\/min·dL", + "value": 0.009200834959802624 + }, + { + "startDate": "2022-10-17T06:30:56", + "endDate": "2022-10-17T06:35:56", + "unit": "mg\/min·dL", + "value": -0.0034976034396666947 + }, + { + "startDate": "2022-10-17T06:35:56", + "endDate": "2022-10-17T06:40:56", + "unit": "mg\/min·dL", + "value": -0.01574338703246741 + }, + { + "startDate": "2022-10-17T06:40:56", + "endDate": "2022-10-17T06:45:56", + "unit": "mg\/min·dL", + "value": -0.028328548453654148 + }, + { + "startDate": "2022-10-17T06:45:56", + "endDate": "2022-10-17T06:50:56", + "unit": "mg\/min·dL", + "value": -0.042803942449195886 + }, + { + "startDate": "2022-10-17T06:50:56", + "endDate": "2022-10-17T06:55:56", + "unit": "mg\/min·dL", + "value": -0.05933842985149843 + }, + { + "startDate": "2022-10-17T06:55:56", + "endDate": "2022-10-17T07:00:56", + "unit": "mg\/min·dL", + "value": -0.07734206261505443 + }, + { + "startDate": "2022-10-17T07:00:56", + "endDate": "2022-10-17T07:05:57", + "unit": "mg\/min·dL", + "value": 0.10354020565314719 + }, + { + "startDate": "2022-10-17T07:05:57", + "endDate": "2022-10-17T07:10:56", + "unit": "mg\/min·dL", + "value": 0.2848604801395987 + }, + { + "startDate": "2022-10-17T07:10:56", + "endDate": "2022-10-17T07:15:56", + "unit": "mg\/min·dL", + "value": -0.5351997683038484 + }, + { + "startDate": "2022-10-17T07:15:56", + "endDate": "2022-10-17T07:20:56", + "unit": "mg\/min·dL", + "value": -0.5561162558548638 + }, + { + "startDate": "2022-10-17T07:20:56", + "endDate": "2022-10-17T07:25:56", + "unit": "mg\/min·dL", + "value": 1.026446015181327 + }, + { + "startDate": "2022-10-17T07:25:56", + "endDate": "2022-10-17T07:30:56", + "unit": "mg\/min·dL", + "value": -1.3889750203453213 + }, + { + "startDate": "2022-10-17T07:30:56", + "endDate": "2022-10-17T07:35:56", + "unit": "mg\/min·dL", + "value": 3.5926136152071413 + }, + { + "startDate": "2022-10-17T07:35:56", + "endDate": "2022-10-17T07:40:56", + "unit": "mg\/min·dL", + "value": 2.7749093320580926 + }, + { + "startDate": "2022-10-17T07:40:56", + "endDate": "2022-10-17T07:45:57", + "unit": "mg\/min·dL", + "value": 3.746086706283121 + }, + { + "startDate": "2022-10-17T07:45:57", + "endDate": "2022-10-17T07:50:57", + "unit": "mg\/min·dL", + "value": 4.955723649353499 + }, + { + "startDate": "2022-10-17T07:50:57", + "endDate": "2022-10-17T07:55:56", + "unit": "mg\/min·dL", + "value": 4.996640987612763 + }, + { + "startDate": "2022-10-17T07:55:56", + "endDate": "2022-10-17T08:00:57", + "unit": "mg\/min·dL", + "value": 3.0304810395539663 + }, + { + "startDate": "2022-10-17T08:00:57", + "endDate": "2022-10-17T08:05:56", + "unit": "mg\/min·dL", + "value": 4.332986252787241 + }, + { + "startDate": "2022-10-17T08:05:56", + "endDate": "2022-10-17T08:10:57", + "unit": "mg\/min·dL", + "value": 3.7965717213147436 + }, + { + "startDate": "2022-10-17T08:10:57", + "endDate": "2022-10-17T08:15:56", + "unit": "mg\/min·dL", + "value": 0.6920745985592388 + }, + { + "startDate": "2022-10-17T08:15:56", + "endDate": "2022-10-17T08:20:57", + "unit": "mg\/min·dL", + "value": 1.380230886893775 + }, + { + "startDate": "2022-10-17T08:20:57", + "endDate": "2022-10-17T08:25:57", + "unit": "mg\/min·dL", + "value": -0.31528831809019864 + }, + { + "startDate": "2022-10-17T08:25:57", + "endDate": "2022-10-17T08:30:56", + "unit": "mg\/min·dL", + "value": 0.18164411041348966 + }, + { + "startDate": "2022-10-17T08:30:56", + "endDate": "2022-10-17T08:35:56", + "unit": "mg\/min·dL", + "value": 0.06938282997424218 + }, + { + "startDate": "2022-10-17T08:35:56", + "endDate": "2022-10-17T08:40:57", + "unit": "mg\/min·dL", + "value": -0.6569780599920586 + }, + { + "startDate": "2022-10-17T08:40:57", + "endDate": "2022-10-17T08:45:56", + "unit": "mg\/min·dL", + "value": -2.002245300154777 + }, + { + "startDate": "2022-10-17T08:45:56", + "endDate": "2022-10-17T08:50:57", + "unit": "mg\/min·dL", + "value": -1.549451452214076 + }, + { + "startDate": "2022-10-17T08:50:57", + "endDate": "2022-10-17T08:55:56", + "unit": "mg\/min·dL", + "value": -1.322155355994713 + }, + { + "startDate": "2022-10-17T08:55:56", + "endDate": "2022-10-17T09:00:56", + "unit": "mg\/min·dL", + "value": -0.6943400893385767 + }, + { + "startDate": "2022-10-17T09:00:56", + "endDate": "2022-10-17T09:05:56", + "unit": "mg\/min·dL", + "value": -0.48121967301796426 + }, + { + "startDate": "2022-10-17T09:05:56", + "endDate": "2022-10-17T09:10:56", + "unit": "mg\/min·dL", + "value": -0.27611861679495037 + }, + { + "startDate": "2022-10-17T09:10:56", + "endDate": "2022-10-17T09:15:57", + "unit": "mg\/min·dL", + "value": 0.12006679946502295 + }, + { + "startDate": "2022-10-17T09:15:57", + "endDate": "2022-10-17T09:20:57", + "unit": "mg\/min·dL", + "value": 0.9113005453156893 + }, + { + "startDate": "2022-10-17T09:20:57", + "endDate": "2022-10-17T09:25:56", + "unit": "mg\/min·dL", + "value": 0.09388318724059296 + }, + { + "startDate": "2022-10-17T09:25:56", + "endDate": "2022-10-17T09:30:56", + "unit": "mg\/min·dL", + "value": 0.871487757074061 + }, + { + "startDate": "2022-10-17T09:30:56", + "endDate": "2022-10-17T09:35:57", + "unit": "mg\/min·dL", + "value": 1.2446560973111296 + }, + { + "startDate": "2022-10-17T09:35:57", + "endDate": "2022-10-17T09:40:56", + "unit": "mg\/min·dL", + "value": 1.0166924505981447 + }, + { + "startDate": "2022-10-17T09:40:56", + "endDate": "2022-10-17T09:45:57", + "unit": "mg\/min·dL", + "value": 0.5813199722905042 + }, + { + "startDate": "2022-10-17T09:45:57", + "endDate": "2022-10-17T09:50:56", + "unit": "mg\/min·dL", + "value": 0.9470217340786014 + }, + { + "startDate": "2022-10-17T09:50:56", + "endDate": "2022-10-17T09:55:57", + "unit": "mg\/min·dL", + "value": 1.1063805177477521 + }, + { + "startDate": "2022-10-17T09:55:57", + "endDate": "2022-10-17T10:00:57", + "unit": "mg\/min·dL", + "value": 1.0678400294991015 + }, + { + "startDate": "2022-10-17T10:00:57", + "endDate": "2022-10-17T10:05:56", + "unit": "mg\/min·dL", + "value": 0.6343386087427577 + }, + { + "startDate": "2022-10-17T10:05:56", + "endDate": "2022-10-17T10:10:56", + "unit": "mg\/min·dL", + "value": 0.8006679951794808 + }, + { + "startDate": "2022-10-17T10:10:56", + "endDate": "2022-10-17T10:15:57", + "unit": "mg\/min·dL", + "value": 1.1757271031846188 + }, + { + "startDate": "2022-10-17T10:15:57", + "endDate": "2022-10-17T10:20:57", + "unit": "mg\/min·dL", + "value": 0.9549561105429651 + }, + { + "startDate": "2022-10-17T10:20:57", + "endDate": "2022-10-17T10:25:56", + "unit": "mg\/min·dL", + "value": 0.9395422101128353 + }, + { + "startDate": "2022-10-17T10:25:56", + "endDate": "2022-10-17T10:30:57", + "unit": "mg\/min·dL", + "value": 0.7213247424132531 + }, + { + "startDate": "2022-10-17T10:30:57", + "endDate": "2022-10-17T10:35:57", + "unit": "mg\/min·dL", + "value": 0.7122028044445942 + }, + { + "startDate": "2022-10-17T10:35:57", + "endDate": "2022-10-17T10:40:57", + "unit": "mg\/min·dL", + "value": 0.9052517663340723 + }, + { + "startDate": "2022-10-17T10:40:57", + "endDate": "2022-10-17T10:45:56", + "unit": "mg\/min·dL", + "value": 0.699122847987222 + }, + { + "startDate": "2022-10-17T10:45:56", + "endDate": "2022-10-17T10:50:56", + "unit": "mg\/min·dL", + "value": 0.6913335747487636 + }, + { + "startDate": "2022-10-17T10:50:56", + "endDate": "2022-10-17T10:55:56", + "unit": "mg\/min·dL", + "value": 0.8872653413930243 + }, + { + "startDate": "2022-10-17T10:55:56", + "endDate": "2022-10-17T11:00:56", + "unit": "mg\/min·dL", + "value": 0.8821124122832216 + }, + { + "startDate": "2022-10-17T11:00:56", + "endDate": "2022-10-17T11:05:57", + "unit": "mg\/min·dL", + "value": 1.0762823125902266 + }, + { + "startDate": "2022-10-17T11:05:57", + "endDate": "2022-10-17T11:10:57", + "unit": "mg\/min·dL", + "value": 0.8705356061523262 + }, + { + "startDate": "2022-10-17T11:10:57", + "endDate": "2022-10-17T11:15:57", + "unit": "mg\/min·dL", + "value": 2.270378271296252 + }, + { + "startDate": "2022-10-17T11:15:57", + "endDate": "2022-10-17T11:20:57", + "unit": "mg\/min·dL", + "value": 2.0675398966404512 + }, + { + "startDate": "2022-10-17T11:20:57", + "endDate": "2022-10-17T11:25:57", + "unit": "mg\/min·dL", + "value": 1.672835991153337 + }, + { + "startDate": "2022-10-17T11:25:57", + "endDate": "2022-10-17T11:30:57", + "unit": "mg\/min·dL", + "value": 1.282134118615466 + }, + { + "startDate": "2022-10-17T11:30:57", + "endDate": "2022-10-17T11:35:57", + "unit": "mg\/min·dL", + "value": 1.1042760922545358 + }, + { + "startDate": "2022-10-17T11:35:57", + "endDate": "2022-10-17T11:40:57", + "unit": "mg\/min·dL", + "value": -0.0715803708831969 + }, + { + "startDate": "2022-10-17T11:40:57", + "endDate": "2022-10-17T11:45:57", + "unit": "mg\/min·dL", + "value": 0.5562508834174531 + }, + { + "startDate": "2022-10-17T11:45:57", + "endDate": "2022-10-17T11:50:57", + "unit": "mg\/min·dL", + "value": 1.3791757347276055 + }, + { + "startDate": "2022-10-17T11:50:57", + "endDate": "2022-10-17T11:55:57", + "unit": "mg\/min·dL", + "value": 0.7975595824531685 + }, + { + "startDate": "2022-10-17T11:55:57", + "endDate": "2022-10-17T12:00:56", + "unit": "mg\/min·dL", + "value": 1.0107065963916195 + }, + { + "startDate": "2022-10-17T12:00:56", + "endDate": "2022-10-17T12:05:57", + "unit": "mg\/min·dL", + "value": 1.619041638687975 + }, + { + "startDate": "2022-10-17T12:05:57", + "endDate": "2022-10-17T12:10:56", + "unit": "mg\/min·dL", + "value": 0.2289011705408734 + }, + { + "startDate": "2022-10-17T12:10:56", + "endDate": "2022-10-17T12:15:57", + "unit": "mg\/min·dL", + "value": 0.8341745845319312 + }, + { + "startDate": "2022-10-17T12:15:57", + "endDate": "2022-10-17T12:20:57", + "unit": "mg\/min·dL", + "value": 1.043486724815214 + }, + { + "startDate": "2022-10-17T12:20:57", + "endDate": "2022-10-17T12:25:57", + "unit": "mg\/min·dL", + "value": 0.6465113919254944 + }, + { + "startDate": "2022-10-17T12:25:57", + "endDate": "2022-10-17T12:30:57", + "unit": "mg\/min·dL", + "value": 0.8497079852663679 + }, + { + "startDate": "2022-10-17T12:30:57", + "endDate": "2022-10-17T12:35:57", + "unit": "mg\/min·dL", + "value": 0.8516497402932928 + }, + { + "startDate": "2022-10-17T12:35:57", + "endDate": "2022-10-17T12:40:57", + "unit": "mg\/min·dL", + "value": 0.4544982669966845 + }, + { + "startDate": "2022-10-17T12:40:57", + "endDate": "2022-10-17T12:45:57", + "unit": "mg\/min·dL", + "value": 0.45502813749310494 + }, + { + "startDate": "2022-10-17T12:45:57", + "endDate": "2022-10-17T12:50:57", + "unit": "mg\/min·dL", + "value": 0.053607228109501304 + }, + { + "startDate": "2022-10-17T12:50:57", + "endDate": "2022-10-17T12:55:57", + "unit": "mg\/min·dL", + "value": 3.850030747446343 + }, + { + "startDate": "2022-10-17T12:55:57", + "endDate": "2022-10-17T13:00:57", + "unit": "mg\/min·dL", + "value": -2.5605776759549843 + }, + { + "startDate": "2022-10-17T13:00:57", + "endDate": "2022-10-17T13:05:58", + "unit": "mg\/min·dL", + "value": -0.16929980570474054 + }, + { + "startDate": "2022-10-17T13:05:58", + "endDate": "2022-10-17T13:10:57", + "unit": "mg\/min·dL", + "value": -0.17024253181035856 + }, + { + "startDate": "2022-10-17T13:10:57", + "endDate": "2022-10-17T13:15:57", + "unit": "mg\/min·dL", + "value": 0.22396575670759422 + }, + { + "startDate": "2022-10-17T13:15:57", + "endDate": "2022-10-17T13:20:57", + "unit": "mg\/min·dL", + "value": 0.2131383127213337 + }, + { + "startDate": "2022-10-17T13:20:57", + "endDate": "2022-10-17T13:25:57", + "unit": "mg\/min·dL", + "value": -0.0036422184008190975 + }, + { + "startDate": "2022-10-17T13:25:57", + "endDate": "2022-10-17T13:30:57", + "unit": "mg\/min·dL", + "value": -0.024307494393551208 + }, + { + "startDate": "2022-10-17T13:30:57", + "endDate": "2022-10-17T13:35:57", + "unit": "mg\/min·dL", + "value": 0.7504265623640302 + }, + { + "startDate": "2022-10-17T13:35:57", + "endDate": "2022-10-17T13:40:57", + "unit": "mg\/min·dL", + "value": 0.12395887249486975 + }, + { + "startDate": "2022-10-17T13:40:57", + "endDate": "2022-10-17T13:45:57", + "unit": "mg\/min·dL", + "value": -0.5054765171613136 + }, + { + "startDate": "2022-10-17T13:45:57", + "endDate": "2022-10-17T13:50:57", + "unit": "mg\/min·dL", + "value": 0.2650315439906281 + }, + { + "startDate": "2022-10-17T13:50:57", + "endDate": "2022-10-17T13:55:57", + "unit": "mg\/min·dL", + "value": 0.235027270256946 + }, + { + "startDate": "2022-10-17T13:55:57", + "endDate": "2022-10-17T14:00:57", + "unit": "mg\/min·dL", + "value": 0.004825228772659867 + }, + { + "startDate": "2022-10-17T14:00:57", + "endDate": "2022-10-17T14:05:57", + "unit": "mg\/min·dL", + "value": 0.17631247206302156 + }, + { + "startDate": "2022-10-17T14:05:57", + "endDate": "2022-10-17T14:10:57", + "unit": "mg\/min·dL", + "value": 0.3494460554759167 + }, + { + "startDate": "2022-10-17T14:10:57", + "endDate": "2022-10-17T14:15:57", + "unit": "mg\/min·dL", + "value": -0.07881975737358604 + }, + { + "startDate": "2022-10-17T14:15:57", + "endDate": "2022-10-17T14:20:57", + "unit": "mg\/min·dL", + "value": 0.09501914368510557 + }, + { + "startDate": "2022-10-17T14:20:57", + "endDate": "2022-10-17T14:25:57", + "unit": "mg\/min·dL", + "value": 0.26962224455049527 + }, + { + "startDate": "2022-10-17T14:25:57", + "endDate": "2022-10-17T14:30:58", + "unit": "mg\/min·dL", + "value": 1.0461918213992094 + }, + { + "startDate": "2022-10-17T14:30:58", + "endDate": "2022-10-17T14:35:58", + "unit": "mg\/min·dL", + "value": 0.2233686770059514 + }, + { + "startDate": "2022-10-17T14:35:58", + "endDate": "2022-10-17T14:40:57", + "unit": "mg\/min·dL", + "value": -0.7968131225503261 + }, + { + "startDate": "2022-10-17T14:40:57", + "endDate": "2022-10-17T14:45:57", + "unit": "mg\/min·dL", + "value": -0.00755583436261384 + }, + { + "startDate": "2022-10-17T14:45:57", + "endDate": "2022-10-17T14:50:58", + "unit": "mg\/min·dL", + "value": -0.017988670741366602 + }, + { + "startDate": "2022-10-17T14:50:58", + "endDate": "2022-10-17T14:55:57", + "unit": "mg\/min·dL", + "value": 0.16860756482113998 + }, + { + "startDate": "2022-10-17T14:55:57", + "endDate": "2022-10-17T15:00:57", + "unit": "mg\/min·dL", + "value": 0.15258695408591513 + }, + { + "startDate": "2022-10-17T15:00:57", + "endDate": "2022-10-17T15:05:57", + "unit": "mg\/min·dL", + "value": 0.3366705688171287 + }, + { + "startDate": "2022-10-17T15:05:57", + "endDate": "2022-10-17T15:10:57", + "unit": "mg\/min·dL", + "value": 0.12225745161360409 + }, + { + "startDate": "2022-10-17T15:10:57", + "endDate": "2022-10-17T15:15:57", + "unit": "mg\/min·dL", + "value": 0.3057494415391212 + }, + { + "startDate": "2022-10-17T15:15:57", + "endDate": "2022-10-17T15:20:57", + "unit": "mg\/min·dL", + "value": 1.6878262062492664 + }, + { + "startDate": "2022-10-17T15:20:57", + "endDate": "2022-10-17T15:25:57", + "unit": "mg\/min·dL", + "value": -0.12540003293832777 + }, + { + "startDate": "2022-10-17T15:25:57", + "endDate": "2022-10-17T15:30:58", + "unit": "mg\/min·dL", + "value": 3.2579639609054944 + }, + { + "startDate": "2022-10-17T15:30:58", + "endDate": "2022-10-17T15:35:57", + "unit": "mg\/min·dL", + "value": -2.33882331116317 + }, + { + "startDate": "2022-10-17T15:35:57", + "endDate": "2022-10-17T15:40:57", + "unit": "mg\/min·dL", + "value": -1.3337321869891747 + }, + { + "startDate": "2022-10-17T15:40:57", + "endDate": "2022-10-17T15:45:57", + "unit": "mg\/min·dL", + "value": -0.717298126861785 + }, + { + "startDate": "2022-10-17T15:45:57", + "endDate": "2022-10-17T15:50:57", + "unit": "mg\/min·dL", + "value": -1.3071661542366684 + }, + { + "startDate": "2022-10-17T15:50:57", + "endDate": "2022-10-17T15:55:57", + "unit": "mg\/min·dL", + "value": -1.4985287049008267 + }, + { + "startDate": "2022-10-17T15:55:57", + "endDate": "2022-10-17T16:00:57", + "unit": "mg\/min·dL", + "value": -0.29630881245414115 + }, + { + "startDate": "2022-10-17T16:00:57", + "endDate": "2022-10-17T16:05:57", + "unit": "mg\/min·dL", + "value": -0.2973617987728701 + }, + { + "startDate": "2022-10-17T16:05:57", + "endDate": "2022-10-17T16:10:58", + "unit": "mg\/min·dL", + "value": -0.10233458926668869 + }, + { + "startDate": "2022-10-17T16:10:58", + "endDate": "2022-10-17T16:15:58", + "unit": "mg\/min·dL", + "value": 0.08971198167566777 + }, + { + "startDate": "2022-10-17T16:15:58", + "endDate": "2022-10-17T16:20:57", + "unit": "mg\/min·dL", + "value": -0.32128769588079303 + }, + { + "startDate": "2022-10-17T16:20:57", + "endDate": "2022-10-17T16:25:58", + "unit": "mg\/min·dL", + "value": -0.1333543684979505 + }, + { + "startDate": "2022-10-17T16:25:58", + "endDate": "2022-10-17T16:30:57", + "unit": "mg\/min·dL", + "value": 0.05179539206684855 + }, + { + "startDate": "2022-10-17T16:30:57", + "endDate": "2022-10-17T16:35:57", + "unit": "mg\/min·dL", + "value": 0.03525249389131893 + }, + { + "startDate": "2022-10-17T16:35:57", + "endDate": "2022-10-17T16:40:57", + "unit": "mg\/min·dL", + "value": 0.21730726337856762 + }, + { + "startDate": "2022-10-17T16:40:57", + "endDate": "2022-10-17T16:45:58", + "unit": "mg\/min·dL", + "value": 1.3965937622299813 + }, + { + "startDate": "2022-10-17T16:45:58", + "endDate": "2022-10-17T16:50:57", + "unit": "mg\/min·dL", + "value": 1.580064623999398 + }, + { + "startDate": "2022-10-17T16:50:57", + "endDate": "2022-10-17T16:55:58", + "unit": "mg\/min·dL", + "value": 0.7568514241319295 + }, + { + "startDate": "2022-10-17T16:55:58", + "endDate": "2022-10-17T17:00:58", + "unit": "mg\/min·dL", + "value": 1.1430376620243732 + }, + { + "startDate": "2022-10-17T17:00:58", + "endDate": "2022-10-17T17:05:57", + "unit": "mg\/min·dL", + "value": 0.7341003445506363 + }, + { + "startDate": "2022-10-17T17:05:57", + "endDate": "2022-10-17T17:10:58", + "unit": "mg\/min·dL", + "value": 1.1268541295706538 + }, + { + "startDate": "2022-10-17T17:10:58", + "endDate": "2022-10-17T17:15:57", + "unit": "mg\/min·dL", + "value": 0.9347439999943791 + }, + { + "startDate": "2022-10-17T17:15:57", + "endDate": "2022-10-17T17:20:57", + "unit": "mg\/min·dL", + "value": -0.05849178159961313 + }, + { + "startDate": "2022-10-17T17:20:57", + "endDate": "2022-10-17T17:25:58", + "unit": "mg\/min·dL", + "value": -0.043980150922760856 + }, + { + "startDate": "2022-10-17T17:25:58", + "endDate": "2022-10-17T17:30:58", + "unit": "mg\/min·dL", + "value": 1.172267021770458 + }, + { + "startDate": "2022-10-17T17:30:58", + "endDate": "2022-10-17T17:35:58", + "unit": "mg\/min·dL", + "value": 0.9867370663288156 + }, + { + "startDate": "2022-10-17T17:35:58", + "endDate": "2022-10-17T17:40:58", + "unit": "mg\/min·dL", + "value": 0.5971550847739795 + }, + { + "startDate": "2022-10-17T17:40:58", + "endDate": "2022-10-17T17:45:57", + "unit": "mg\/min·dL", + "value": 0.007334399026725797 + }, + { + "startDate": "2022-10-17T17:45:57", + "endDate": "2022-10-17T17:50:57", + "unit": "mg\/min·dL", + "value": 0.4211816970126617 + }, + { + "startDate": "2022-10-17T17:50:57", + "endDate": "2022-10-17T17:55:58", + "unit": "mg\/min·dL", + "value": 1.0317341149283863 + }, + { + "startDate": "2022-10-17T17:55:58", + "endDate": "2022-10-17T18:00:58", + "unit": "mg\/min·dL", + "value": 1.4451839309800605 + }, + { + "startDate": "2022-10-17T18:00:58", + "endDate": "2022-10-17T18:05:58", + "unit": "mg\/min·dL", + "value": 1.6521747464530774 + }, + { + "startDate": "2022-10-17T18:05:58", + "endDate": "2022-10-17T18:10:57", + "unit": "mg\/min·dL", + "value": 1.6670375773385095 + }, + { + "startDate": "2022-10-17T18:10:57", + "endDate": "2022-10-17T18:15:58", + "unit": "mg\/min·dL", + "value": 1.6815570963307955 + }, + { + "startDate": "2022-10-17T18:15:58", + "endDate": "2022-10-17T18:20:58", + "unit": "mg\/min·dL", + "value": 1.9055447248731132 + }, + { + "startDate": "2022-10-17T18:20:58", + "endDate": "2022-10-17T18:25:57", + "unit": "mg\/min·dL", + "value": 1.7396687349251063 + }, + { + "startDate": "2022-10-17T18:25:57", + "endDate": "2022-10-17T18:30:57", + "unit": "mg\/min·dL", + "value": 1.176224616460157 + }, + { + "startDate": "2022-10-17T18:30:57", + "endDate": "2022-10-17T18:35:58", + "unit": "mg\/min·dL", + "value": 0.8161964326183754 + }, + { + "startDate": "2022-10-17T18:35:58", + "endDate": "2022-10-17T18:40:57", + "unit": "mg\/min·dL", + "value": 1.069405878974623 + }, + { + "startDate": "2022-10-17T18:40:57", + "endDate": "2022-10-17T18:45:57", + "unit": "mg\/min·dL", + "value": 0.7149637327769314 + }, + { + "startDate": "2022-10-17T18:45:57", + "endDate": "2022-10-17T18:50:58", + "unit": "mg\/min·dL", + "value": 0.7580055677633227 + }, + { + "startDate": "2022-10-17T18:50:58", + "endDate": "2022-10-17T18:55:57", + "unit": "mg\/min·dL", + "value": 0.39954773081158734 + }, + { + "startDate": "2022-10-17T18:55:57", + "endDate": "2022-10-17T19:00:57", + "unit": "mg\/min·dL", + "value": 0.6322656375672243 + }, + { + "startDate": "2022-10-17T19:00:57", + "endDate": "2022-10-17T19:05:58", + "unit": "mg\/min·dL", + "value": 0.6601567890512666 + }, + { + "startDate": "2022-10-17T19:05:58", + "endDate": "2022-10-17T19:10:58", + "unit": "mg\/min·dL", + "value": 0.28254788027999844 + }, + { + "startDate": "2022-10-17T19:10:58", + "endDate": "2022-10-17T19:15:57", + "unit": "mg\/min·dL", + "value": 0.29810083959368033 + }, + { + "startDate": "2022-10-17T19:15:57", + "endDate": "2022-10-17T19:20:58", + "unit": "mg\/min·dL", + "value": 0.50675343533354 + }, + { + "startDate": "2022-10-17T19:20:58", + "endDate": "2022-10-17T19:25:58", + "unit": "mg\/min·dL", + "value": 0.5137649628645453 + }, + { + "startDate": "2022-10-17T19:25:58", + "endDate": "2022-10-17T19:30:58", + "unit": "mg\/min·dL", + "value": 0.5169966515232866 + }, + { + "startDate": "2022-10-17T19:30:58", + "endDate": "2022-10-17T19:35:58", + "unit": "mg\/min·dL", + "value": 0.7146717742568007 + }, + { + "startDate": "2022-10-17T19:35:58", + "endDate": "2022-10-17T19:40:57", + "unit": "mg\/min·dL", + "value": 0.9088903732995701 + }, + { + "startDate": "2022-10-17T19:40:57", + "endDate": "2022-10-17T19:45:58", + "unit": "mg\/min·dL", + "value": 1.100742556000779 + }, + { + "startDate": "2022-10-17T19:45:58", + "endDate": "2022-10-17T19:50:58", + "unit": "mg\/min·dL", + "value": 1.292926565538361 + }, + { + "startDate": "2022-10-17T19:50:58", + "endDate": "2022-10-17T19:55:58", + "unit": "mg\/min·dL", + "value": 1.0930916176029168 + }, + { + "startDate": "2022-10-17T19:55:58", + "endDate": "2022-10-17T20:00:58", + "unit": "mg\/min·dL", + "value": 1.0936289317791132 + }, + { + "startDate": "2022-10-17T20:00:58", + "endDate": "2022-10-17T20:05:57", + "unit": "mg\/min·dL", + "value": 0.8987065716926492 + }, + { + "startDate": "2022-10-17T20:05:57", + "endDate": "2022-10-17T20:10:58", + "unit": "mg\/min·dL", + "value": 0.7075247469377013 + }, + { + "startDate": "2022-10-17T20:10:58", + "endDate": "2022-10-17T20:15:58", + "unit": "mg\/min·dL", + "value": 0.5191499097513893 + }, + { + "startDate": "2022-10-17T20:15:58", + "endDate": "2022-10-17T20:20:58", + "unit": "mg\/min·dL", + "value": 0.3324377735152427 + }, + { + "startDate": "2022-10-17T20:20:58", + "endDate": "2022-10-17T20:25:58", + "unit": "mg\/min·dL", + "value": 0.3452106927143431 + }, + { + "startDate": "2022-10-17T20:25:58", + "endDate": "2022-10-17T20:30:58", + "unit": "mg\/min·dL", + "value": 0.5526530110663183 + } +] diff --git a/LoopTests/Fixtures/meal_detection/long_interval_counteraction_effect.json b/LoopTests/Fixtures/meal_detection/long_interval_counteraction_effect.json new file mode 100644 index 0000000000..fd52dc5698 --- /dev/null +++ b/LoopTests/Fixtures/meal_detection/long_interval_counteraction_effect.json @@ -0,0 +1,14 @@ +[ + { + "startDate": "2022-10-17T01:06:47", + "endDate": "2022-10-17T02:49:16", + "unit": "mg\/min·dL", + "value": 1.0556978820387204 + }, + { + "startDate": "2022-10-17T02:49:16", + "endDate": "2022-10-17T02:57:50", + "unit": "mg\/min·dL", + "value": 2.0566560442527893 + } +] diff --git a/LoopTests/Fixtures/meal_detection/missed_meal_counteraction_effect.json b/LoopTests/Fixtures/meal_detection/missed_meal_counteraction_effect.json new file mode 100644 index 0000000000..64b5f498a4 --- /dev/null +++ b/LoopTests/Fixtures/meal_detection/missed_meal_counteraction_effect.json @@ -0,0 +1,122 @@ +[ + { + "startDate": "2022-10-17T21:08:45", + "endDate": "2022-10-17T21:13:45", + "unit": "mg\/min·dL", + "value": 0.8019261605973419 + }, + { + "startDate": "2022-10-17T21:13:45", + "endDate": "2022-10-17T21:18:45", + "unit": "mg\/min·dL", + "value": -0.5784692917036025 + }, + { + "startDate": "2022-10-17T21:18:45", + "endDate": "2022-10-17T21:23:45", + "unit": "mg\/min·dL", + "value": 0.1655312713905142 + }, + { + "startDate": "2022-10-17T21:23:45", + "endDate": "2022-10-17T21:28:45", + "unit": "mg\/min·dL", + "value": 1.7504524257718737 + }, + { + "startDate": "2022-10-17T21:28:45", + "endDate": "2022-10-17T21:33:45", + "unit": "mg\/min·dL", + "value": -0.0922608525680516 + }, + { + "startDate": "2022-10-17T21:33:45", + "endDate": "2022-10-17T21:38:45", + "unit": "mg\/min·dL", + "value": -0.3598634421205699 + }, + { + "startDate": "2022-10-17T21:38:45", + "endDate": "2022-10-17T21:54:19", + "unit": "mg\/min·dL", + "value": 0.8027570693463704 + }, + { + "startDate": "2022-10-17T21:54:19", + "endDate": "2022-10-17T22:12:04", + "unit": "mg\/min·dL", + "value": 1.4477572210086778 + }, + { + "startDate": "2022-10-17T22:12:04", + "endDate": "2022-10-17T22:18:45", + "unit": "mg\/min·dL", + "value": 1.4975396644708778 + }, + { + "startDate": "2022-10-17T22:18:45", + "endDate": "2022-10-17T22:28:45", + "unit": "mg\/min·dL", + "value": 1.5245986218389043 + }, + { + "startDate": "2022-10-17T22:28:45", + "endDate": "2022-10-17T22:33:45", + "unit": "mg\/min·dL", + "value": 2.3929007506455973 + }, + { + "startDate": "2022-10-17T22:33:45", + "endDate": "2022-10-17T22:38:45", + "unit": "mg\/min·dL", + "value": 2.2706182546903664 + }, + { + "startDate": "2022-10-17T22:38:45", + "endDate": "2022-10-17T22:43:45", + "unit": "mg\/min·dL", + "value": 1.7258552314883575 + }, + { + "startDate": "2022-10-17T22:43:45", + "endDate": "2022-10-17T22:48:45", + "unit": "mg\/min·dL", + "value": 2.760986190856003 + }, + { + "startDate": "2022-10-17T22:48:45", + "endDate": "2022-10-17T22:53:45", + "unit": "mg\/min·dL", + "value": 2.578233617155381 + }, + { + "startDate": "2022-10-17T22:53:45", + "endDate": "2022-10-17T22:58:45", + "unit": "mg\/min·dL", + "value": 0.7795720392241906 + }, + { + "startDate": "2022-10-17T22:58:45", + "endDate": "2022-10-17T23:03:45", + "unit": "mg\/min·dL", + "value": 2.766911269858242 + }, + { + "startDate": "2022-10-17T23:03:45", + "endDate": "2022-10-17T23:18:42", + "unit": "mg\/min·dL", + "value": 1.9079807396410984 + }, + { + "startDate": "2022-10-17T23:18:42", + "endDate": "2022-10-17T23:23:45", + "unit": "mg\/min·dL", + "value": 2.5862132855399116 + }, + { + "startDate": "2022-10-17T23:23:45", + "endDate": "2022-10-17T23:28:45", + "unit": "mg\/min·dL", + "value": 1.346722448222869 + } +] diff --git a/LoopTests/Fixtures/meal_detection/needs_clamping_counteraction_effect.json b/LoopTests/Fixtures/meal_detection/needs_clamping_counteraction_effect.json new file mode 100644 index 0000000000..44ab9719b6 --- /dev/null +++ b/LoopTests/Fixtures/meal_detection/needs_clamping_counteraction_effect.json @@ -0,0 +1,14 @@ +[ + { + "startDate": "2022-10-16T01:06:47", + "endDate": "2022-10-17T02:49:16", + "unit": "mg\/min·dL", + "value": 0.5556978820387204 + }, + { + "startDate": "2022-10-17T02:49:16", + "endDate": "2022-10-20T02:57:50", + "unit": "mg\/min·dL", + "value": 0.0566560442527893 + } +] diff --git a/LoopTests/Fixtures/meal_detection/noisy_cgm_counteraction_effect.json b/LoopTests/Fixtures/meal_detection/noisy_cgm_counteraction_effect.json new file mode 100644 index 0000000000..63cb6285f0 --- /dev/null +++ b/LoopTests/Fixtures/meal_detection/noisy_cgm_counteraction_effect.json @@ -0,0 +1,386 @@ +[ + { + "startDate": "2022-10-18T20:43:08", + "endDate": "2022-10-18T20:50:42", + "unit": "mg\/min·dL", + "value": 2.1413880378038854 + }, + { + "startDate": "2022-10-18T20:50:42", + "endDate": "2022-10-18T20:58:27", + "unit": "mg\/min·dL", + "value": 2.4011098101277946 + }, + { + "startDate": "2022-10-18T20:58:27", + "endDate": "2022-10-18T21:14:25", + "unit": "mg\/min·dL", + "value": 2.057214771814263 + }, + { + "startDate": "2022-10-18T21:14:25", + "endDate": "2022-10-18T21:22:05", + "unit": "mg\/min·dL", + "value": 1.991425926715544 + }, + { + "startDate": "2022-10-18T21:22:05", + "endDate": "2022-10-18T21:42:38", + "unit": "mg\/min·dL", + "value": 2.0051308533580032 + }, + { + "startDate": "2022-10-18T21:42:38", + "endDate": "2022-10-18T22:43:11", + "unit": "mg\/min·dL", + "value": 2.180566858455272 + }, + { + "startDate": "2022-10-18T22:43:11", + "endDate": "2022-10-18T23:13:29", + "unit": "mg\/min·dL", + "value": 1.9197677207835058 + }, + { + "startDate": "2022-10-18T23:13:29", + "endDate": "2022-10-18T23:23:44", + "unit": "mg\/min·dL", + "value": 1.6582944320917057 + }, + { + "startDate": "2022-10-18T23:23:44", + "endDate": "2022-10-18T23:31:29", + "unit": "mg\/min·dL", + "value": 2.9251517652457917 + }, + { + "startDate": "2022-10-18T23:31:29", + "endDate": "2022-10-18T23:39:02", + "unit": "mg\/min·dL", + "value": 0.16546279064227673 + }, + { + "startDate": "2022-10-18T23:39:02", + "endDate": "2022-10-18T23:46:51", + "unit": "mg\/min·dL", + "value": 3.5246556156903477 + }, + { + "startDate": "2022-10-18T23:46:51", + "endDate": "2022-10-19T00:01:55", + "unit": "mg\/min·dL", + "value": 1.6837264254514583 + }, + { + "startDate": "2022-10-19T00:01:55", + "endDate": "2022-10-19T00:09:24", + "unit": "mg\/min·dL", + "value": 0.6255424707941111 + }, + { + "startDate": "2022-10-19T00:09:24", + "endDate": "2022-10-19T00:17:11", + "unit": "mg\/min·dL", + "value": 3.5624118067540476 + }, + { + "startDate": "2022-10-19T00:17:11", + "endDate": "2022-10-19T00:26:34", + "unit": "mg\/min·dL", + "value": 3.4181695387179754 + }, + { + "startDate": "2022-10-19T00:26:34", + "endDate": "2022-10-19T00:34:20", + "unit": "mg\/min·dL", + "value": -0.3706991177681455 + }, + { + "startDate": "2022-10-19T00:34:20", + "endDate": "2022-10-19T00:42:23", + "unit": "mg\/min·dL", + "value": 2.408865149934943 + }, + { + "startDate": "2022-10-19T00:42:23", + "endDate": "2022-10-19T00:50:58", + "unit": "mg\/min·dL", + "value": 1.5845764542225527 + }, + { + "startDate": "2022-10-19T00:50:58", + "endDate": "2022-10-19T01:07:52", + "unit": "mg\/min·dL", + "value": 2.0292268456298372 + }, + { + "startDate": "2022-10-19T01:07:52", + "endDate": "2022-10-19T01:15:47", + "unit": "mg\/min·dL", + "value": 2.402478504248885 + }, + { + "startDate": "2022-10-19T01:15:47", + "endDate": "2022-10-19T01:23:21", + "unit": "mg\/min·dL", + "value": 1.9234931834453135 + }, + { + "startDate": "2022-10-19T01:23:21", + "endDate": "2022-10-19T01:31:11", + "unit": "mg\/min·dL", + "value": 2.557351249850377 + }, + { + "startDate": "2022-10-19T01:31:11", + "endDate": "2022-10-19T01:38:53", + "unit": "mg\/min·dL", + "value": 1.8865201976400663 + }, + { + "startDate": "2022-10-19T01:38:53", + "endDate": "2022-10-19T01:46:38", + "unit": "mg\/min·dL", + "value": 2.188515989682273 + }, + { + "startDate": "2022-10-19T01:46:38", + "endDate": "2022-10-19T01:55:13", + "unit": "mg\/min·dL", + "value": 2.446288202801156 + }, + { + "startDate": "2022-10-19T01:55:13", + "endDate": "2022-10-19T02:02:55", + "unit": "mg\/min·dL", + "value": 1.8874025096295566 + }, + { + "startDate": "2022-10-19T02:02:55", + "endDate": "2022-10-19T03:03:30", + "unit": "mg\/min·dL", + "value": 1.9901048084934858 + }, + { + "startDate": "2022-10-19T03:03:30", + "endDate": "2022-10-19T03:44:04", + "unit": "mg\/min·dL", + "value": 1.7104947217909385 + }, + { + "startDate": "2022-10-19T03:44:04", + "endDate": "2022-10-19T04:24:37", + "unit": "mg\/min·dL", + "value": 2.0313009513772373 + }, + { + "startDate": "2022-10-19T04:24:37", + "endDate": "2022-10-19T14:58:20", + "unit": "mg\/min·dL", + "value": 0.6779229200420821 + }, + { + "startDate": "2022-10-19T17:47:43", + "endDate": "2022-10-19T17:52:43", + "unit": "mg\/min·dL", + "value": -1.764725420239631 + }, + { + "startDate": "2022-10-19T17:52:43", + "endDate": "2022-10-19T18:02:43", + "unit": "mg\/min·dL", + "value": -0.2854672410290561 + }, + { + "startDate": "2022-10-19T18:02:43", + "endDate": "2022-10-19T18:07:43", + "unit": "mg\/min·dL", + "value": 1.423225171907336 + }, + { + "startDate": "2022-10-19T18:07:43", + "endDate": "2022-10-19T18:12:43", + "unit": "mg\/min·dL", + "value": -3.18226150417708 + }, + { + "startDate": "2022-10-19T18:12:43", + "endDate": "2022-10-19T18:17:43", + "unit": "mg\/min·dL", + "value": -4.787369366471273 + }, + { + "startDate": "2022-10-19T18:17:43", + "endDate": "2022-10-19T18:22:43", + "unit": "mg\/min·dL", + "value": 3.6083669362007353 + }, + { + "startDate": "2022-10-19T18:22:43", + "endDate": "2022-10-19T18:27:43", + "unit": "mg\/min·dL", + "value": -0.3949565747393592 + }, + { + "startDate": "2022-10-19T18:27:43", + "endDate": "2022-10-19T18:32:43", + "unit": "mg\/min·dL", + "value": -0.3973978843060308 + }, + { + "startDate": "2022-10-19T18:32:43", + "endDate": "2022-10-19T18:37:43", + "unit": "mg\/min·dL", + "value": 2.6009873496056284 + }, + { + "startDate": "2022-10-19T18:37:43", + "endDate": "2022-10-19T18:42:43", + "unit": "mg\/min·dL", + "value": -4.199854242276523 + }, + { + "startDate": "2022-10-19T18:42:43", + "endDate": "2022-10-19T18:47:43", + "unit": "mg\/min·dL", + "value": 3.199988677140936 + }, + { + "startDate": "2022-10-19T18:47:43", + "endDate": "2022-10-19T18:52:43", + "unit": "mg\/min·dL", + "value": 1.7999880977313012 + }, + { + "startDate": "2022-10-19T18:52:43", + "endDate": "2022-10-19T18:57:43", + "unit": "mg\/min·dL", + "value": -0.8000123609487502 + }, + { + "startDate": "2022-10-19T18:57:43", + "endDate": "2022-10-19T19:02:43", + "unit": "mg\/min·dL", + "value": -2.400012711019661 + }, + { + "startDate": "2022-10-19T19:02:43", + "endDate": "2022-10-19T19:07:43", + "unit": "mg\/min·dL", + "value": 5.199987036372721 + }, + { + "startDate": "2022-10-19T19:07:43", + "endDate": "2022-10-19T19:12:43", + "unit": "mg\/min·dL", + "value": -4.60001312901278 + }, + { + "startDate": "2022-10-19T19:12:43", + "endDate": "2022-10-19T19:22:43", + "unit": "mg\/min·dL", + "value": 2.299993391711052 + }, + { + "startDate": "2022-10-19T19:22:43", + "endDate": "2022-10-19T19:27:43", + "unit": "mg\/min·dL", + "value": -1.000013234945743 + }, + { + "startDate": "2022-10-19T19:27:43", + "endDate": "2022-10-19T19:32:43", + "unit": "mg\/min·dL", + "value": -2.600013192017644 + }, + { + "startDate": "2022-10-19T19:32:43", + "endDate": "2022-10-19T19:37:43", + "unit": "mg\/min·dL", + "value": 2.3999869049740195 + }, + { + "startDate": "2022-10-19T19:37:43", + "endDate": "2022-10-19T19:42:43", + "unit": "mg\/min·dL", + "value": -2.2000129505836123 + }, + { + "startDate": "2022-10-19T19:42:43", + "endDate": "2022-10-19T19:47:43", + "unit": "mg\/min·dL", + "value": 2.9999872352701686 + }, + { + "startDate": "2022-10-19T19:47:43", + "endDate": "2022-10-19T19:52:43", + "unit": "mg\/min·dL", + "value": -1.8000125429732121 + }, + { + "startDate": "2022-10-19T19:52:43", + "endDate": "2022-10-19T19:57:43", + "unit": "mg\/min·dL", + "value": -1.000012290331557 + }, + { + "startDate": "2022-10-19T19:57:43", + "endDate": "2022-10-19T20:02:43", + "unit": "mg\/min·dL", + "value": 0.1999879886309827 + }, + { + "startDate": "2022-10-19T20:02:43", + "endDate": "2022-10-19T20:07:43", + "unit": "mg\/min·dL", + "value": -0.8000117102307285 + }, + { + "startDate": "2022-10-19T20:07:43", + "endDate": "2022-10-19T20:12:43", + "unit": "mg\/min·dL", + "value": 2.3999886093249985 + }, + { + "startDate": "2022-10-19T20:12:43", + "endDate": "2022-10-19T20:17:43", + "unit": "mg\/min·dL", + "value": -2.2000110561032553 + }, + { + "startDate": "2022-10-19T20:17:43", + "endDate": "2022-10-19T20:22:43", + "unit": "mg\/min·dL", + "value": 0.39998929041208836 + }, + { + "startDate": "2022-10-19T20:22:43", + "endDate": "2022-10-19T20:27:43", + "unit": "mg\/min·dL", + "value": 2.19998964610175 + }, + { + "startDate": "2022-10-19T20:27:43", + "endDate": "2022-10-19T20:32:43", + "unit": "mg\/min·dL", + "value": -1.2000099915245013 + }, + { + "startDate": "2022-10-19T20:32:43", + "endDate": "2022-10-19T20:37:43", + "unit": "mg\/min·dL", + "value": 1.399990375299808 + }, + { + "startDate": "2022-10-19T20:37:43", + "endDate": "2022-10-19T20:42:43", + "unit": "mg\/min·dL", + "value": -0.8000092554229123 + }, + { + "startDate": "2022-10-19T20:42:43", + "endDate": "2022-10-19T20:47:43", + "unit": "mg\/min·dL", + "value": 3.799991114526437 + } +] diff --git a/LoopTests/Fixtures/meal_detection/realistic_report_counteraction_effect.json b/LoopTests/Fixtures/meal_detection/realistic_report_counteraction_effect.json new file mode 100644 index 0000000000..c8e82f11f5 --- /dev/null +++ b/LoopTests/Fixtures/meal_detection/realistic_report_counteraction_effect.json @@ -0,0 +1,1724 @@ +[ + { + "startDate": "2022-10-18T21:40:00", + "endDate": "2022-10-18T21:45:00", + "unit": "mg\/min·dL", + "value": 0.9043847043689791 + }, + { + "startDate": "2022-10-18T21:45:00", + "endDate": "2022-10-18T21:50:01", + "unit": "mg\/min·dL", + "value": 0.8701737681583791 + }, + { + "startDate": "2022-10-18T21:50:01", + "endDate": "2022-10-18T21:55:01", + "unit": "mg\/min·dL", + "value": 0.44559430294201047 + }, + { + "startDate": "2022-10-18T21:55:01", + "endDate": "2022-10-18T22:00:00", + "unit": "mg\/min·dL", + "value": 0.41686359006411966 + }, + { + "startDate": "2022-10-18T22:00:00", + "endDate": "2022-10-18T22:05:01", + "unit": "mg\/min·dL", + "value": 0.38648385789866685 + }, + { + "startDate": "2022-10-18T22:05:01", + "endDate": "2022-10-18T22:10:01", + "unit": "mg\/min·dL", + "value": 0.3578544271635854 + }, + { + "startDate": "2022-10-18T22:10:01", + "endDate": "2022-10-18T22:15:01", + "unit": "mg\/min·dL", + "value": 0.32931115774406733 + }, + { + "startDate": "2022-10-18T22:15:01", + "endDate": "2022-10-18T22:20:01", + "unit": "mg\/min·dL", + "value": 0.10057626183015964 + }, + { + "startDate": "2022-10-18T22:20:01", + "endDate": "2022-10-18T22:25:00", + "unit": "mg\/min·dL", + "value": 0.4729780858945708 + }, + { + "startDate": "2022-10-18T22:25:00", + "endDate": "2022-10-18T22:30:00", + "unit": "mg\/min·dL", + "value": -0.3546781456753047 + }, + { + "startDate": "2022-10-18T22:30:00", + "endDate": "2022-10-18T22:35:01", + "unit": "mg\/min·dL", + "value": -0.18131076193035978 + }, + { + "startDate": "2022-10-18T22:35:01", + "endDate": "2022-10-18T22:40:00", + "unit": "mg\/min·dL", + "value": -1.010247144263618 + }, + { + "startDate": "2022-10-18T22:40:00", + "endDate": "2022-10-18T22:45:00", + "unit": "mg\/min·dL", + "value": -0.3668240075057755 + }, + { + "startDate": "2022-10-18T22:45:00", + "endDate": "2022-10-18T22:50:01", + "unit": "mg\/min·dL", + "value": 1.559394854618121 + }, + { + "startDate": "2022-10-18T22:50:01", + "endDate": "2022-10-18T22:55:00", + "unit": "mg\/min·dL", + "value": 2.071374628866257 + }, + { + "startDate": "2022-10-18T22:55:00", + "endDate": "2022-10-18T23:00:01", + "unit": "mg\/min·dL", + "value": 1.5603433296934301 + }, + { + "startDate": "2022-10-18T23:00:01", + "endDate": "2022-10-18T23:05:00", + "unit": "mg\/min·dL", + "value": 1.4440744498686167 + }, + { + "startDate": "2022-10-18T23:05:00", + "endDate": "2022-10-18T23:10:00", + "unit": "mg\/min·dL", + "value": 1.5156883860813217 + }, + { + "startDate": "2022-10-18T23:10:00", + "endDate": "2022-10-18T23:15:00", + "unit": "mg\/min·dL", + "value": 1.7727836419735754 + }, + { + "startDate": "2022-10-18T23:15:00", + "endDate": "2022-10-18T23:20:01", + "unit": "mg\/min·dL", + "value": 1.6191783680941592 + }, + { + "startDate": "2022-10-18T23:20:01", + "endDate": "2022-10-18T23:25:00", + "unit": "mg\/min·dL", + "value": 1.2614262583288534 + }, + { + "startDate": "2022-10-18T23:25:00", + "endDate": "2022-10-18T23:30:00", + "unit": "mg\/min·dL", + "value": 0.28656922575226085 + }, + { + "startDate": "2022-10-18T23:30:00", + "endDate": "2022-10-18T23:35:01", + "unit": "mg\/min·dL", + "value": 0.5040272887218451 + }, + { + "startDate": "2022-10-18T23:35:01", + "endDate": "2022-10-18T23:40:01", + "unit": "mg\/min·dL", + "value": -0.88797938985592 + }, + { + "startDate": "2022-10-18T23:40:01", + "endDate": "2022-10-18T23:45:00", + "unit": "mg\/min·dL", + "value": 0.3102981695282373 + }, + { + "startDate": "2022-10-18T23:45:00", + "endDate": "2022-10-18T23:50:00", + "unit": "mg\/min·dL", + "value": 0.2990145023827218 + }, + { + "startDate": "2022-10-18T23:50:00", + "endDate": "2022-10-18T23:55:00", + "unit": "mg\/min·dL", + "value": 0.2813549886870839 + }, + { + "startDate": "2022-10-18T23:55:00", + "endDate": "2022-10-19T00:00:00", + "unit": "mg\/min·dL", + "value": 0.25803373961516457 + }, + { + "startDate": "2022-10-19T00:00:00", + "endDate": "2022-10-19T00:05:00", + "unit": "mg\/min·dL", + "value": 0.029882256677400014 + }, + { + "startDate": "2022-10-19T00:05:00", + "endDate": "2022-10-19T00:10:00", + "unit": "mg\/min·dL", + "value": -0.0018539363122007967 + }, + { + "startDate": "2022-10-19T00:10:00", + "endDate": "2022-10-19T00:15:01", + "unit": "mg\/min·dL", + "value": -0.4362035671247494 + }, + { + "startDate": "2022-10-19T00:15:01", + "endDate": "2022-10-19T00:20:00", + "unit": "mg\/min·dL", + "value": 0.12697827245171217 + }, + { + "startDate": "2022-10-19T00:20:00", + "endDate": "2022-10-19T00:25:01", + "unit": "mg\/min·dL", + "value": -0.1137114895201975 + }, + { + "startDate": "2022-10-19T00:25:01", + "endDate": "2022-10-19T00:30:00", + "unit": "mg\/min·dL", + "value": 0.8404650492430689 + }, + { + "startDate": "2022-10-19T00:30:00", + "endDate": "2022-10-19T00:35:01", + "unit": "mg\/min·dL", + "value": -0.011742501835059939 + }, + { + "startDate": "2022-10-19T00:35:01", + "endDate": "2022-10-19T00:40:00", + "unit": "mg\/min·dL", + "value": 0.5345114838195776 + }, + { + "startDate": "2022-10-19T00:40:00", + "endDate": "2022-10-19T00:45:00", + "unit": "mg\/min·dL", + "value": 0.47586430649907935 + }, + { + "startDate": "2022-10-19T00:45:00", + "endDate": "2022-10-19T00:50:00", + "unit": "mg\/min·dL", + "value": 0.2178093373305446 + }, + { + "startDate": "2022-10-19T00:50:00", + "endDate": "2022-10-19T00:55:00", + "unit": "mg\/min·dL", + "value": 0.5578754706878531 + }, + { + "startDate": "2022-10-19T00:55:00", + "endDate": "2022-10-19T01:00:00", + "unit": "mg\/min·dL", + "value": -0.9011291440087709 + }, + { + "startDate": "2022-10-19T01:00:00", + "endDate": "2022-10-19T01:05:00", + "unit": "mg\/min·dL", + "value": 0.03906347621778584 + }, + { + "startDate": "2022-10-19T01:05:00", + "endDate": "2022-10-19T01:10:00", + "unit": "mg\/min·dL", + "value": -0.02020473697510492 + }, + { + "startDate": "2022-10-19T01:10:00", + "endDate": "2022-10-19T01:15:00", + "unit": "mg\/min·dL", + "value": -0.07837275714453532 + }, + { + "startDate": "2022-10-19T01:15:00", + "endDate": "2022-10-19T01:20:00", + "unit": "mg\/min·dL", + "value": 1.4649496777138962 + }, + { + "startDate": "2022-10-19T01:20:00", + "endDate": "2022-10-19T01:25:01", + "unit": "mg\/min·dL", + "value": 3.401845866546212 + }, + { + "startDate": "2022-10-19T01:25:01", + "endDate": "2022-10-19T01:30:01", + "unit": "mg\/min·dL", + "value": 3.3542079394357533 + }, + { + "startDate": "2022-10-19T01:30:01", + "endDate": "2022-10-19T01:35:01", + "unit": "mg\/min·dL", + "value": 2.107098415709067 + }, + { + "startDate": "2022-10-19T01:35:01", + "endDate": "2022-10-19T01:40:00", + "unit": "mg\/min·dL", + "value": 0.4658451108604793 + }, + { + "startDate": "2022-10-19T01:40:00", + "endDate": "2022-10-19T01:45:01", + "unit": "mg\/min·dL", + "value": -0.1632488253614695 + }, + { + "startDate": "2022-10-19T01:45:01", + "endDate": "2022-10-19T01:50:01", + "unit": "mg\/min·dL", + "value": -0.5885867519773967 + }, + { + "startDate": "2022-10-19T01:50:01", + "endDate": "2022-10-19T01:55:00", + "unit": "mg\/min·dL", + "value": -1.0062735825086297 + }, + { + "startDate": "2022-10-19T01:55:00", + "endDate": "2022-10-19T02:00:00", + "unit": "mg\/min·dL", + "value": -1.2185933300337954 + }, + { + "startDate": "2022-10-19T02:00:00", + "endDate": "2022-10-19T02:05:01", + "unit": "mg\/min·dL", + "value": -0.8326700677766216 + }, + { + "startDate": "2022-10-19T02:05:01", + "endDate": "2022-10-19T02:10:01", + "unit": "mg\/min·dL", + "value": -2.84257051980203 + }, + { + "startDate": "2022-10-19T02:10:01", + "endDate": "2022-10-19T02:15:00", + "unit": "mg\/min·dL", + "value": -0.8562035248873597 + }, + { + "startDate": "2022-10-19T02:15:00", + "endDate": "2022-10-19T02:20:01", + "unit": "mg\/min·dL", + "value": -0.26526876046429276 + }, + { + "startDate": "2022-10-19T02:20:01", + "endDate": "2022-10-19T02:25:01", + "unit": "mg\/min·dL", + "value": -0.27929377419252777 + }, + { + "startDate": "2022-10-19T02:25:01", + "endDate": "2022-10-19T02:30:00", + "unit": "mg\/min·dL", + "value": -0.09615878507465565 + }, + { + "startDate": "2022-10-19T02:30:00", + "endDate": "2022-10-19T02:35:00", + "unit": "mg\/min·dL", + "value": 0.2853641733897771 + }, + { + "startDate": "2022-10-19T02:35:00", + "endDate": "2022-10-19T02:40:01", + "unit": "mg\/min·dL", + "value": 2.2630294082591282 + }, + { + "startDate": "2022-10-19T02:40:01", + "endDate": "2022-10-19T02:45:00", + "unit": "mg\/min·dL", + "value": 2.935184435842075 + }, + { + "startDate": "2022-10-19T02:45:00", + "endDate": "2022-10-19T02:50:01", + "unit": "mg\/min·dL", + "value": 3.4054208562036465 + }, + { + "startDate": "2022-10-19T02:50:01", + "endDate": "2022-10-19T02:55:01", + "unit": "mg\/min·dL", + "value": 2.7032681066820055 + }, + { + "startDate": "2022-10-19T02:55:01", + "endDate": "2022-10-19T03:00:00", + "unit": "mg\/min·dL", + "value": 2.80018879273112 + }, + { + "startDate": "2022-10-19T03:00:00", + "endDate": "2022-10-19T03:05:01", + "unit": "mg\/min·dL", + "value": 2.4965292339587837 + }, + { + "startDate": "2022-10-19T03:05:01", + "endDate": "2022-10-19T03:10:01", + "unit": "mg\/min·dL", + "value": 1.6113117856204644 + }, + { + "startDate": "2022-10-19T03:10:01", + "endDate": "2022-10-19T03:15:01", + "unit": "mg\/min·dL", + "value": 1.1148901778931035 + }, + { + "startDate": "2022-10-19T03:15:01", + "endDate": "2022-10-19T03:25:01", + "unit": "mg\/min·dL", + "value": 1.2465705652221148 + }, + { + "startDate": "2022-10-19T03:25:01", + "endDate": "2022-10-19T03:30:00", + "unit": "mg\/min·dL", + "value": 0.5552565109650426 + }, + { + "startDate": "2022-10-19T03:30:00", + "endDate": "2022-10-19T03:35:01", + "unit": "mg\/min·dL", + "value": 0.4093372449598604 + }, + { + "startDate": "2022-10-19T03:35:01", + "endDate": "2022-10-19T03:40:01", + "unit": "mg\/min·dL", + "value": 0.6526956800529764 + }, + { + "startDate": "2022-10-19T03:40:01", + "endDate": "2022-10-19T03:45:00", + "unit": "mg\/min·dL", + "value": 0.2837328512839709 + }, + { + "startDate": "2022-10-19T03:45:00", + "endDate": "2022-10-19T03:50:01", + "unit": "mg\/min·dL", + "value": -0.2965329523104659 + }, + { + "startDate": "2022-10-19T03:50:01", + "endDate": "2022-10-19T03:55:01", + "unit": "mg\/min·dL", + "value": 0.11264296048927881 + }, + { + "startDate": "2022-10-19T03:55:01", + "endDate": "2022-10-19T04:00:01", + "unit": "mg\/min·dL", + "value": 0.11365480733176563 + }, + { + "startDate": "2022-10-19T04:00:01", + "endDate": "2022-10-19T04:05:01", + "unit": "mg\/min·dL", + "value": 0.7079504519365847 + }, + { + "startDate": "2022-10-19T04:05:01", + "endDate": "2022-10-19T04:10:00", + "unit": "mg\/min·dL", + "value": 0.29539031027196594 + }, + { + "startDate": "2022-10-19T04:10:00", + "endDate": "2022-10-19T04:15:00", + "unit": "mg\/min·dL", + "value": 0.4750368523506627 + }, + { + "startDate": "2022-10-19T04:15:00", + "endDate": "2022-10-19T04:20:01", + "unit": "mg\/min·dL", + "value": 0.2516481493765345 + }, + { + "startDate": "2022-10-19T04:20:01", + "endDate": "2022-10-19T04:25:00", + "unit": "mg\/min·dL", + "value": 0.42687884270523624 + }, + { + "startDate": "2022-10-19T04:25:00", + "endDate": "2022-10-19T04:30:01", + "unit": "mg\/min·dL", + "value": 0.1970294938484129 + }, + { + "startDate": "2022-10-19T04:30:01", + "endDate": "2022-10-19T04:35:01", + "unit": "mg\/min·dL", + "value": -0.6325663853295713 + }, + { + "startDate": "2022-10-19T04:35:01", + "endDate": "2022-10-19T04:40:00", + "unit": "mg\/min·dL", + "value": -1.6671946453873905 + }, + { + "startDate": "2022-10-19T04:40:00", + "endDate": "2022-10-19T04:45:01", + "unit": "mg\/min·dL", + "value": -1.095982136517308 + }, + { + "startDate": "2022-10-19T04:45:01", + "endDate": "2022-10-19T04:50:00", + "unit": "mg\/min·dL", + "value": -0.5411661721419859 + }, + { + "startDate": "2022-10-19T04:50:00", + "endDate": "2022-10-19T04:55:00", + "unit": "mg\/min·dL", + "value": 0.028358336047663885 + }, + { + "startDate": "2022-10-19T04:55:00", + "endDate": "2022-10-19T05:00:01", + "unit": "mg\/min·dL", + "value": -0.20955099234535207 + }, + { + "startDate": "2022-10-19T05:00:01", + "endDate": "2022-10-19T05:05:01", + "unit": "mg\/min·dL", + "value": 0.30207612513942395 + }, + { + "startDate": "2022-10-19T05:05:01", + "endDate": "2022-10-19T05:10:00", + "unit": "mg\/min·dL", + "value": 0.04103782125221336 + }, + { + "startDate": "2022-10-19T05:10:00", + "endDate": "2022-10-19T05:15:01", + "unit": "mg\/min·dL", + "value": 0.3809787987795194 + }, + { + "startDate": "2022-10-19T05:15:01", + "endDate": "2022-10-19T05:20:01", + "unit": "mg\/min·dL", + "value": -0.0772329850138329 + }, + { + "startDate": "2022-10-19T05:20:01", + "endDate": "2022-10-19T05:25:01", + "unit": "mg\/min·dL", + "value": 0.2837794247411642 + }, + { + "startDate": "2022-10-19T05:25:01", + "endDate": "2022-10-19T05:30:00", + "unit": "mg\/min·dL", + "value": 0.4091244566696956 + }, + { + "startDate": "2022-10-19T05:30:00", + "endDate": "2022-10-19T05:35:00", + "unit": "mg\/min·dL", + "value": -0.040777547570781135 + }, + { + "startDate": "2022-10-19T05:35:00", + "endDate": "2022-10-19T05:40:01", + "unit": "mg\/min·dL", + "value": -0.28963383865548703 + }, + { + "startDate": "2022-10-19T05:40:01", + "endDate": "2022-10-19T05:45:00", + "unit": "mg\/min·dL", + "value": 0.8815079785477252 + }, + { + "startDate": "2022-10-19T05:45:00", + "endDate": "2022-10-19T05:50:00", + "unit": "mg\/min·dL", + "value": 0.4431363092446005 + }, + { + "startDate": "2022-10-19T05:50:00", + "endDate": "2022-10-19T05:55:00", + "unit": "mg\/min·dL", + "value": 0.21446766128625064 + }, + { + "startDate": "2022-10-19T05:55:00", + "endDate": "2022-10-19T06:00:01", + "unit": "mg\/min·dL", + "value": 0.3745668250547948 + }, + { + "startDate": "2022-10-19T06:00:01", + "endDate": "2022-10-19T06:05:01", + "unit": "mg\/min·dL", + "value": 0.34009354076149223 + }, + { + "startDate": "2022-10-19T06:05:01", + "endDate": "2022-10-19T06:10:01", + "unit": "mg\/min·dL", + "value": 0.909838642145608 + }, + { + "startDate": "2022-10-19T06:10:01", + "endDate": "2022-10-19T06:15:01", + "unit": "mg\/min·dL", + "value": 0.6925767053834189 + }, + { + "startDate": "2022-10-19T06:15:01", + "endDate": "2022-10-19T06:20:01", + "unit": "mg\/min·dL", + "value": 0.48208611056098555 + }, + { + "startDate": "2022-10-19T06:20:01", + "endDate": "2022-10-19T06:25:00", + "unit": "mg\/min·dL", + "value": 0.2831925640399337 + }, + { + "startDate": "2022-10-19T06:25:00", + "endDate": "2022-10-19T06:30:01", + "unit": "mg\/min·dL", + "value": 0.29029277640990603 + }, + { + "startDate": "2022-10-19T06:30:01", + "endDate": "2022-10-19T06:35:01", + "unit": "mg\/min·dL", + "value": -0.2977661275897406 + }, + { + "startDate": "2022-10-19T06:35:01", + "endDate": "2022-10-19T06:40:01", + "unit": "mg\/min·dL", + "value": -1.4835473972248248 + }, + { + "startDate": "2022-10-19T06:40:01", + "endDate": "2022-10-19T06:45:01", + "unit": "mg\/min·dL", + "value": -0.06684185693201271 + }, + { + "startDate": "2022-10-19T06:45:01", + "endDate": "2022-10-19T06:50:01", + "unit": "mg\/min·dL", + "value": -0.05240734057580225 + }, + { + "startDate": "2022-10-19T06:50:01", + "endDate": "2022-10-19T06:55:00", + "unit": "mg\/min·dL", + "value": -0.24556517930204355 + }, + { + "startDate": "2022-10-19T06:55:00", + "endDate": "2022-10-19T07:00:00", + "unit": "mg\/min·dL", + "value": -0.44211478120218145 + }, + { + "startDate": "2022-10-19T07:00:00", + "endDate": "2022-10-19T07:05:01", + "unit": "mg\/min·dL", + "value": -1.03857881100502 + }, + { + "startDate": "2022-10-19T07:05:01", + "endDate": "2022-10-19T07:10:01", + "unit": "mg\/min·dL", + "value": -0.43890261619044063 + }, + { + "startDate": "2022-10-19T07:10:01", + "endDate": "2022-10-19T07:15:01", + "unit": "mg\/min·dL", + "value": -0.6439573640191639 + }, + { + "startDate": "2022-10-19T07:15:01", + "endDate": "2022-10-19T07:20:01", + "unit": "mg\/min·dL", + "value": -0.4532385550898397 + }, + { + "startDate": "2022-10-19T07:20:01", + "endDate": "2022-10-19T07:25:01", + "unit": "mg\/min·dL", + "value": -0.8660471979684273 + }, + { + "startDate": "2022-10-19T07:25:01", + "endDate": "2022-10-19T07:30:01", + "unit": "mg\/min·dL", + "value": -0.281574959738387 + }, + { + "startDate": "2022-10-19T07:30:01", + "endDate": "2022-10-19T07:35:01", + "unit": "mg\/min·dL", + "value": 0.30063941285933987 + }, + { + "startDate": "2022-10-19T07:35:01", + "endDate": "2022-10-19T07:40:01", + "unit": "mg\/min·dL", + "value": -0.3188112289597015 + }, + { + "startDate": "2022-10-19T07:40:01", + "endDate": "2022-10-19T07:45:00", + "unit": "mg\/min·dL", + "value": 0.062436338615094615 + }, + { + "startDate": "2022-10-19T07:45:00", + "endDate": "2022-10-19T07:50:00", + "unit": "mg\/min·dL", + "value": 0.4478691259722767 + }, + { + "startDate": "2022-10-19T07:50:00", + "endDate": "2022-10-19T07:55:01", + "unit": "mg\/min·dL", + "value": 0.8352780727089159 + }, + { + "startDate": "2022-10-19T07:55:01", + "endDate": "2022-10-19T08:00:01", + "unit": "mg\/min·dL", + "value": 0.22900532179424257 + }, + { + "startDate": "2022-10-19T08:00:01", + "endDate": "2022-10-19T08:05:01", + "unit": "mg\/min·dL", + "value": 0.02347733142306274 + }, + { + "startDate": "2022-10-19T08:05:01", + "endDate": "2022-10-19T08:10:01", + "unit": "mg\/min·dL", + "value": -0.7783748292358011 + }, + { + "startDate": "2022-10-19T08:10:01", + "endDate": "2022-10-19T08:15:01", + "unit": "mg\/min·dL", + "value": 0.6272885060509404 + }, + { + "startDate": "2022-10-19T08:15:01", + "endDate": "2022-10-19T08:20:01", + "unit": "mg\/min·dL", + "value": 0.23419734350722396 + }, + { + "startDate": "2022-10-19T08:20:01", + "endDate": "2022-10-19T08:25:00", + "unit": "mg\/min·dL", + "value": 0.2428650510584241 + }, + { + "startDate": "2022-10-19T08:25:00", + "endDate": "2022-10-19T08:30:01", + "unit": "mg\/min·dL", + "value": 0.2524272001329743 + }, + { + "startDate": "2022-10-19T08:30:01", + "endDate": "2022-10-19T08:35:01", + "unit": "mg\/min·dL", + "value": 0.06685058482744398 + }, + { + "startDate": "2022-10-19T08:35:01", + "endDate": "2022-10-19T08:40:01", + "unit": "mg\/min·dL", + "value": -0.11765785383167682 + }, + { + "startDate": "2022-10-19T08:40:01", + "endDate": "2022-10-19T08:45:01", + "unit": "mg\/min·dL", + "value": 0.2974449346156582 + }, + { + "startDate": "2022-10-19T08:45:01", + "endDate": "2022-10-19T08:50:01", + "unit": "mg\/min·dL", + "value": 0.11291581770004194 + }, + { + "startDate": "2022-10-19T08:50:01", + "endDate": "2022-10-19T08:55:01", + "unit": "mg\/min·dL", + "value": -0.07123242642898692 + }, + { + "startDate": "2022-10-19T08:55:01", + "endDate": "2022-10-19T09:00:01", + "unit": "mg\/min·dL", + "value": -0.05448916692972083 + }, + { + "startDate": "2022-10-19T09:00:01", + "endDate": "2022-10-19T09:05:01", + "unit": "mg\/min·dL", + "value": 0.16099146820008903 + }, + { + "startDate": "2022-10-19T09:05:01", + "endDate": "2022-10-19T09:10:01", + "unit": "mg\/min·dL", + "value": 0.17549047688591932 + }, + { + "startDate": "2022-10-19T09:10:01", + "endDate": "2022-10-19T09:15:01", + "unit": "mg\/min·dL", + "value": -0.21178831469440673 + }, + { + "startDate": "2022-10-19T09:15:01", + "endDate": "2022-10-19T09:20:00", + "unit": "mg\/min·dL", + "value": -0.4010107502180188 + }, + { + "startDate": "2022-10-19T09:20:00", + "endDate": "2022-10-19T09:25:01", + "unit": "mg\/min·dL", + "value": -0.38816196596823593 + }, + { + "startDate": "2022-10-19T09:25:01", + "endDate": "2022-10-19T09:30:01", + "unit": "mg\/min·dL", + "value": -0.3803371601566195 + }, + { + "startDate": "2022-10-19T09:30:01", + "endDate": "2022-10-19T09:35:01", + "unit": "mg\/min·dL", + "value": 0.027318177225786312 + }, + { + "startDate": "2022-10-19T09:35:01", + "endDate": "2022-10-19T09:40:01", + "unit": "mg\/min·dL", + "value": 0.23321816158317168 + }, + { + "startDate": "2022-10-19T09:40:01", + "endDate": "2022-10-19T09:45:01", + "unit": "mg\/min·dL", + "value": 0.03823915818460353 + }, + { + "startDate": "2022-10-19T09:45:01", + "endDate": "2022-10-19T09:50:01", + "unit": "mg\/min·dL", + "value": 0.04214613731129099 + }, + { + "startDate": "2022-10-19T09:50:01", + "endDate": "2022-10-19T09:55:01", + "unit": "mg\/min·dL", + "value": -0.3547462209861638 + }, + { + "startDate": "2022-10-19T09:55:01", + "endDate": "2022-10-19T10:00:01", + "unit": "mg\/min·dL", + "value": -0.1528813572718399 + }, + { + "startDate": "2022-10-19T10:00:01", + "endDate": "2022-10-19T10:05:01", + "unit": "mg\/min·dL", + "value": 0.0487369632008895 + }, + { + "startDate": "2022-10-19T10:05:01", + "endDate": "2022-10-19T10:10:01", + "unit": "mg\/min·dL", + "value": -0.15044460943490148 + }, + { + "startDate": "2022-10-19T10:10:01", + "endDate": "2022-10-19T10:15:01", + "unit": "mg\/min·dL", + "value": -0.34944869885912044 + }, + { + "startDate": "2022-10-19T10:15:01", + "endDate": "2022-10-19T10:20:01", + "unit": "mg\/min·dL", + "value": 0.6507861066920333 + }, + { + "startDate": "2022-10-19T10:20:01", + "endDate": "2022-10-19T10:25:01", + "unit": "mg\/min·dL", + "value": 0.24969999327567519 + }, + { + "startDate": "2022-10-19T10:25:01", + "endDate": "2022-10-19T10:30:01", + "unit": "mg\/min·dL", + "value": 0.048794343384953386 + }, + { + "startDate": "2022-10-19T10:30:01", + "endDate": "2022-10-19T10:35:01", + "unit": "mg\/min·dL", + "value": -1.7526382442657054 + }, + { + "startDate": "2022-10-19T10:35:01", + "endDate": "2022-10-19T10:40:01", + "unit": "mg\/min·dL", + "value": -0.3534383576597841 + }, + { + "startDate": "2022-10-19T10:40:01", + "endDate": "2022-10-19T10:45:01", + "unit": "mg\/min·dL", + "value": 0.4455757100573625 + }, + { + "startDate": "2022-10-19T10:45:01", + "endDate": "2022-10-19T10:50:01", + "unit": "mg\/min·dL", + "value": -0.7586169858263023 + }, + { + "startDate": "2022-10-19T10:50:01", + "endDate": "2022-10-19T10:55:01", + "unit": "mg\/min·dL", + "value": -0.3648598606818268 + }, + { + "startDate": "2022-10-19T10:55:01", + "endDate": "2022-10-19T11:00:01", + "unit": "mg\/min·dL", + "value": 0.42939657423429517 + }, + { + "startDate": "2022-10-19T11:00:01", + "endDate": "2022-10-19T11:05:00", + "unit": "mg\/min·dL", + "value": 0.42225019302974154 + }, + { + "startDate": "2022-10-19T11:05:00", + "endDate": "2022-10-19T11:10:01", + "unit": "mg\/min·dL", + "value": 0.21186447917983653 + }, + { + "startDate": "2022-10-19T11:10:01", + "endDate": "2022-10-19T11:15:01", + "unit": "mg\/min·dL", + "value": -0.19565062644910516 + }, + { + "startDate": "2022-10-19T11:15:01", + "endDate": "2022-10-19T11:20:01", + "unit": "mg\/min·dL", + "value": 0.19783593524528145 + }, + { + "startDate": "2022-10-19T11:20:01", + "endDate": "2022-10-19T11:25:01", + "unit": "mg\/min·dL", + "value": -0.008068324764244858 + }, + { + "startDate": "2022-10-19T11:25:01", + "endDate": "2022-10-19T11:30:01", + "unit": "mg\/min·dL", + "value": -0.21297518811277394 + }, + { + "startDate": "2022-10-19T11:30:01", + "endDate": "2022-10-19T11:35:01", + "unit": "mg\/min·dL", + "value": -1.4154392414184709 + }, + { + "startDate": "2022-10-19T11:35:01", + "endDate": "2022-10-19T11:40:01", + "unit": "mg\/min·dL", + "value": 0.18063367404695194 + }, + { + "startDate": "2022-10-19T11:40:01", + "endDate": "2022-10-19T11:45:01", + "unit": "mg\/min·dL", + "value": -0.024732221844547427 + }, + { + "startDate": "2022-10-19T11:45:01", + "endDate": "2022-10-19T11:50:01", + "unit": "mg\/min·dL", + "value": 0.36890313428117716 + }, + { + "startDate": "2022-10-19T11:50:01", + "endDate": "2022-10-19T11:55:01", + "unit": "mg\/min·dL", + "value": -0.037138331257601985 + }, + { + "startDate": "2022-10-19T11:55:01", + "endDate": "2022-10-19T12:00:01", + "unit": "mg\/min·dL", + "value": 0.15833000430373778 + }, + { + "startDate": "2022-10-19T12:00:01", + "endDate": "2022-10-19T12:05:01", + "unit": "mg\/min·dL", + "value": 0.5559567576354827 + }, + { + "startDate": "2022-10-19T12:05:01", + "endDate": "2022-10-19T12:10:01", + "unit": "mg\/min·dL", + "value": 0.5530172956564566 + }, + { + "startDate": "2022-10-19T12:10:01", + "endDate": "2022-10-19T12:15:01", + "unit": "mg\/min·dL", + "value": -0.44852664362951983 + }, + { + "startDate": "2022-10-19T12:15:01", + "endDate": "2022-10-19T12:20:01", + "unit": "mg\/min·dL", + "value": -2.6477415901684105 + }, + { + "startDate": "2022-10-19T12:20:01", + "endDate": "2022-10-19T12:25:01", + "unit": "mg\/min·dL", + "value": 0.1552977678286194 + }, + { + "startDate": "2022-10-19T12:25:01", + "endDate": "2022-10-19T12:30:01", + "unit": "mg\/min·dL", + "value": 0.35583861287686863 + }, + { + "startDate": "2022-10-19T12:30:01", + "endDate": "2022-10-19T12:35:01", + "unit": "mg\/min·dL", + "value": 0.15178321128801062 + }, + { + "startDate": "2022-10-19T12:35:01", + "endDate": "2022-10-19T12:40:01", + "unit": "mg\/min·dL", + "value": -0.054382641516428916 + }, + { + "startDate": "2022-10-19T12:40:01", + "endDate": "2022-10-19T12:45:01", + "unit": "mg\/min·dL", + "value": 0.34013478086255156 + }, + { + "startDate": "2022-10-19T12:45:01", + "endDate": "2022-10-19T12:50:01", + "unit": "mg\/min·dL", + "value": 0.3382845937498871 + }, + { + "startDate": "2022-10-19T12:50:01", + "endDate": "2022-10-19T12:55:01", + "unit": "mg\/min·dL", + "value": 0.3348726530312953 + }, + { + "startDate": "2022-10-19T12:55:01", + "endDate": "2022-10-19T13:00:01", + "unit": "mg\/min·dL", + "value": 0.13375947107729694 + }, + { + "startDate": "2022-10-19T13:00:01", + "endDate": "2022-10-19T13:05:01", + "unit": "mg\/min·dL", + "value": 0.33363760645792245 + }, + { + "startDate": "2022-10-19T13:05:01", + "endDate": "2022-10-19T13:10:01", + "unit": "mg\/min·dL", + "value": 0.13416151687489303 + }, + { + "startDate": "2022-10-19T13:10:01", + "endDate": "2022-10-19T13:15:01", + "unit": "mg\/min·dL", + "value": 0.13637905971035752 + }, + { + "startDate": "2022-10-19T13:15:01", + "endDate": "2022-10-19T13:20:01", + "unit": "mg\/min·dL", + "value": -0.05841515325947949 + }, + { + "startDate": "2022-10-19T13:20:01", + "endDate": "2022-10-19T13:25:01", + "unit": "mg\/min·dL", + "value": 0.34745831602294186 + }, + { + "startDate": "2022-10-19T13:25:01", + "endDate": "2022-10-19T13:30:01", + "unit": "mg\/min·dL", + "value": 0.15356560530275998 + }, + { + "startDate": "2022-10-19T13:30:01", + "endDate": "2022-10-19T13:35:01", + "unit": "mg\/min·dL", + "value": 0.15814886769556477 + }, + { + "startDate": "2022-10-19T13:35:01", + "endDate": "2022-10-19T13:40:01", + "unit": "mg\/min·dL", + "value": 0.1643211476796707 + }, + { + "startDate": "2022-10-19T13:40:01", + "endDate": "2022-10-19T13:45:01", + "unit": "mg\/min·dL", + "value": -0.42819858626465096 + }, + { + "startDate": "2022-10-19T13:45:01", + "endDate": "2022-10-19T13:50:01", + "unit": "mg\/min·dL", + "value": -0.4246398182972079 + }, + { + "startDate": "2022-10-19T13:50:01", + "endDate": "2022-10-19T13:55:01", + "unit": "mg\/min·dL", + "value": -1.6170221190514171 + }, + { + "startDate": "2022-10-19T13:55:01", + "endDate": "2022-10-19T14:00:01", + "unit": "mg\/min·dL", + "value": -0.01458102994261643 + }, + { + "startDate": "2022-10-19T14:00:01", + "endDate": "2022-10-19T14:05:01", + "unit": "mg\/min·dL", + "value": -0.013310698763393285 + }, + { + "startDate": "2022-10-19T14:05:01", + "endDate": "2022-10-19T14:10:01", + "unit": "mg\/min·dL", + "value": -3.0229583310838355 + }, + { + "startDate": "2022-10-19T14:10:01", + "endDate": "2022-10-19T14:15:01", + "unit": "mg\/min·dL", + "value": -0.6231747533325243 + }, + { + "startDate": "2022-10-19T14:15:01", + "endDate": "2022-10-19T14:20:01", + "unit": "mg\/min·dL", + "value": -0.031222581685987863 + }, + { + "startDate": "2022-10-19T14:20:01", + "endDate": "2022-10-19T14:25:01", + "unit": "mg\/min·dL", + "value": -0.24248441048452055 + }, + { + "startDate": "2022-10-19T14:25:01", + "endDate": "2022-10-19T14:30:01", + "unit": "mg\/min·dL", + "value": -0.2576185319073888 + }, + { + "startDate": "2022-10-19T14:30:01", + "endDate": "2022-10-19T14:35:01", + "unit": "mg\/min·dL", + "value": -0.07416048984164367 + }, + { + "startDate": "2022-10-19T14:35:01", + "endDate": "2022-10-19T14:40:02", + "unit": "mg\/min·dL", + "value": 0.10654218048278917 + }, + { + "startDate": "2022-10-19T14:40:02", + "endDate": "2022-10-19T14:45:01", + "unit": "mg\/min·dL", + "value": 0.2868467771919189 + }, + { + "startDate": "2022-10-19T14:45:01", + "endDate": "2022-10-19T14:50:01", + "unit": "mg\/min·dL", + "value": 3.859662943460218 + }, + { + "startDate": "2022-10-19T14:50:01", + "endDate": "2022-10-19T14:55:01", + "unit": "mg\/min·dL", + "value": 2.0426250789551563 + }, + { + "startDate": "2022-10-19T14:55:01", + "endDate": "2022-10-19T15:00:02", + "unit": "mg\/min·dL", + "value": 0.8274726282751605 + }, + { + "startDate": "2022-10-19T15:00:02", + "endDate": "2022-10-19T15:05:01", + "unit": "mg\/min·dL", + "value": -0.1647373172917607 + }, + { + "startDate": "2022-10-19T15:05:01", + "endDate": "2022-10-19T15:10:01", + "unit": "mg\/min·dL", + "value": -0.3383393318533003 + }, + { + "startDate": "2022-10-19T15:10:01", + "endDate": "2022-10-19T15:15:01", + "unit": "mg\/min·dL", + "value": -1.3010751993501548 + }, + { + "startDate": "2022-10-19T15:15:01", + "endDate": "2022-10-19T15:20:01", + "unit": "mg\/min·dL", + "value": -1.0578507825421526 + }, + { + "startDate": "2022-10-19T15:20:01", + "endDate": "2022-10-19T15:25:01", + "unit": "mg\/min·dL", + "value": -1.2236208075979995 + }, + { + "startDate": "2022-10-19T15:25:01", + "endDate": "2022-10-19T15:30:01", + "unit": "mg\/min·dL", + "value": -0.1898106156676509 + }, + { + "startDate": "2022-10-19T15:30:01", + "endDate": "2022-10-19T15:35:01", + "unit": "mg\/min·dL", + "value": -0.16517201004765142 + }, + { + "startDate": "2022-10-19T15:35:01", + "endDate": "2022-10-19T15:40:01", + "unit": "mg\/min·dL", + "value": -0.9484454909505492 + }, + { + "startDate": "2022-10-19T15:40:01", + "endDate": "2022-10-19T15:45:01", + "unit": "mg\/min·dL", + "value": 0.056840842438597564 + }, + { + "startDate": "2022-10-19T15:45:01", + "endDate": "2022-10-19T15:50:01", + "unit": "mg\/min·dL", + "value": 1.6894159072949382 + }, + { + "startDate": "2022-10-19T15:50:01", + "endDate": "2022-10-19T15:55:01", + "unit": "mg\/min·dL", + "value": 3.4370337711808054 + }, + { + "startDate": "2022-10-19T15:55:01", + "endDate": "2022-10-19T16:00:01", + "unit": "mg\/min·dL", + "value": 4.154314244332858 + }, + { + "startDate": "2022-10-19T16:00:01", + "endDate": "2022-10-19T16:05:01", + "unit": "mg\/min·dL", + "value": 4.291317248669294 + }, + { + "startDate": "2022-10-19T16:05:01", + "endDate": "2022-10-19T16:10:01", + "unit": "mg\/min·dL", + "value": 3.5995338405154502 + }, + { + "startDate": "2022-10-19T16:10:01", + "endDate": "2022-10-19T16:15:01", + "unit": "mg\/min·dL", + "value": 2.503912693304304 + }, + { + "startDate": "2022-10-19T16:15:01", + "endDate": "2022-10-19T16:20:01", + "unit": "mg\/min·dL", + "value": 1.6066437038993249 + }, + { + "startDate": "2022-10-19T16:20:01", + "endDate": "2022-10-19T16:25:02", + "unit": "mg\/min·dL", + "value": 0.6934545720681625 + }, + { + "startDate": "2022-10-19T16:25:02", + "endDate": "2022-10-19T16:30:01", + "unit": "mg\/min·dL", + "value": -0.03221607567498431 + }, + { + "startDate": "2022-10-19T16:30:01", + "endDate": "2022-10-19T16:35:01", + "unit": "mg\/min·dL", + "value": -0.37750668609914667 + }, + { + "startDate": "2022-10-19T16:35:01", + "endDate": "2022-10-19T16:40:02", + "unit": "mg\/min·dL", + "value": -0.5366513932315495 + }, + { + "startDate": "2022-10-19T16:40:02", + "endDate": "2022-10-19T16:45:01", + "unit": "mg\/min·dL", + "value": -0.7149824772770897 + }, + { + "startDate": "2022-10-19T16:45:01", + "endDate": "2022-10-19T16:50:01", + "unit": "mg\/min·dL", + "value": -0.5044701021773266 + }, + { + "startDate": "2022-10-19T16:50:01", + "endDate": "2022-10-19T16:55:01", + "unit": "mg\/min·dL", + "value": -2.109505339654445 + }, + { + "startDate": "2022-10-19T16:55:01", + "endDate": "2022-10-19T17:00:01", + "unit": "mg\/min·dL", + "value": 0.4716914633278851 + }, + { + "startDate": "2022-10-19T17:00:01", + "endDate": "2022-10-19T17:05:01", + "unit": "mg\/min·dL", + "value": -2.5569783763841247 + }, + { + "startDate": "2022-10-19T17:05:01", + "endDate": "2022-10-19T17:10:01", + "unit": "mg\/min·dL", + "value": -0.5931422423549346 + }, + { + "startDate": "2022-10-19T17:10:01", + "endDate": "2022-10-19T17:15:01", + "unit": "mg\/min·dL", + "value": 0.9651429840342454 + }, + { + "startDate": "2022-10-19T17:15:01", + "endDate": "2022-10-19T17:20:01", + "unit": "mg\/min·dL", + "value": 1.7185111311450811 + }, + { + "startDate": "2022-10-19T17:20:01", + "endDate": "2022-10-19T17:25:01", + "unit": "mg\/min·dL", + "value": 3.261097657377394 + }, + { + "startDate": "2022-10-19T17:25:01", + "endDate": "2022-10-19T17:30:01", + "unit": "mg\/min·dL", + "value": 2.218111960607878 + }, + { + "startDate": "2022-10-19T17:30:01", + "endDate": "2022-10-19T17:35:01", + "unit": "mg\/min·dL", + "value": 1.9614989874606985 + }, + { + "startDate": "2022-10-19T17:35:01", + "endDate": "2022-10-19T17:40:01", + "unit": "mg\/min·dL", + "value": 2.310030719935431 + }, + { + "startDate": "2022-10-19T17:40:01", + "endDate": "2022-10-19T17:45:01", + "unit": "mg\/min·dL", + "value": 2.2796929226444966 + }, + { + "startDate": "2022-10-19T17:45:01", + "endDate": "2022-10-19T17:50:01", + "unit": "mg\/min·dL", + "value": 1.3070330004516437 + }, + { + "startDate": "2022-10-19T17:50:01", + "endDate": "2022-10-19T17:55:01", + "unit": "mg\/min·dL", + "value": 1.3369831256626694 + }, + { + "startDate": "2022-10-19T17:55:01", + "endDate": "2022-10-19T18:00:01", + "unit": "mg\/min·dL", + "value": 0.7637465872357964 + }, + { + "startDate": "2022-10-19T18:00:01", + "endDate": "2022-10-19T18:05:01", + "unit": "mg\/min·dL", + "value": 2.982136459448906 + }, + { + "startDate": "2022-10-19T18:05:01", + "endDate": "2022-10-19T18:10:02", + "unit": "mg\/min·dL", + "value": 1.7816453872682723 + }, + { + "startDate": "2022-10-19T18:10:02", + "endDate": "2022-10-19T18:15:01", + "unit": "mg\/min·dL", + "value": 1.3895013557358005 + }, + { + "startDate": "2022-10-19T18:15:01", + "endDate": "2022-10-19T18:20:02", + "unit": "mg\/min·dL", + "value": 1.7750958006672506 + }, + { + "startDate": "2022-10-19T18:20:02", + "endDate": "2022-10-19T18:25:01", + "unit": "mg\/min·dL", + "value": -0.0325518583724865 + }, + { + "startDate": "2022-10-19T18:25:01", + "endDate": "2022-10-19T18:30:01", + "unit": "mg\/min·dL", + "value": 2.5531676738470956 + }, + { + "startDate": "2022-10-19T18:30:01", + "endDate": "2022-10-19T18:35:01", + "unit": "mg\/min·dL", + "value": 1.541259298963333 + }, + { + "startDate": "2022-10-19T18:35:01", + "endDate": "2022-10-19T18:40:01", + "unit": "mg\/min·dL", + "value": 2.7233457312657436 + }, + { + "startDate": "2022-10-19T18:40:01", + "endDate": "2022-10-19T18:45:02", + "unit": "mg\/min·dL", + "value": 1.3138886583696006 + }, + { + "startDate": "2022-10-19T18:45:02", + "endDate": "2022-10-19T18:50:02", + "unit": "mg\/min·dL", + "value": -0.8787840983987345 + }, + { + "startDate": "2022-10-19T18:50:02", + "endDate": "2022-10-19T18:55:01", + "unit": "mg\/min·dL", + "value": 4.349193045733724 + }, + { + "startDate": "2022-10-19T18:55:01", + "endDate": "2022-10-19T19:00:01", + "unit": "mg\/min·dL", + "value": 0.957056782908769 + }, + { + "startDate": "2022-10-19T19:00:01", + "endDate": "2022-10-19T19:05:01", + "unit": "mg\/min·dL", + "value": 1.1801653071785538 + }, + { + "startDate": "2022-10-19T19:05:01", + "endDate": "2022-10-19T19:10:01", + "unit": "mg\/min·dL", + "value": -0.38564618768177417 + }, + { + "startDate": "2022-10-19T19:10:01", + "endDate": "2022-10-19T19:15:02", + "unit": "mg\/min·dL", + "value": 0.4529340515641141 + }, + { + "startDate": "2022-10-19T19:15:02", + "endDate": "2022-10-19T19:20:01", + "unit": "mg\/min·dL", + "value": 0.3289978439895736 + }, + { + "startDate": "2022-10-19T19:20:01", + "endDate": "2022-10-19T19:25:01", + "unit": "mg\/min·dL", + "value": 1.1342381510013801 + }, + { + "startDate": "2022-10-19T19:25:01", + "endDate": "2022-10-19T19:30:01", + "unit": "mg\/min·dL", + "value": 2.1251594326202325 + }, + { + "startDate": "2022-10-19T19:30:01", + "endDate": "2022-10-19T19:35:01", + "unit": "mg\/min·dL", + "value": 2.6903281741958516 + }, + { + "startDate": "2022-10-19T19:35:01", + "endDate": "2022-10-19T19:40:01", + "unit": "mg\/min·dL", + "value": 2.4470085889667157 + }, + { + "startDate": "2022-10-19T19:40:01", + "endDate": "2022-10-19T19:45:01", + "unit": "mg\/min·dL", + "value": 4.7634008664345195 + }, + { + "startDate": "2022-10-19T19:45:01", + "endDate": "2022-10-19T19:50:02", + "unit": "mg\/min·dL", + "value": 1.8652478945159814 + }, + { + "startDate": "2022-10-19T19:50:02", + "endDate": "2022-10-19T19:55:01", + "unit": "mg\/min·dL", + "value": 0.34772059751605466 + }, + { + "startDate": "2022-10-19T19:55:01", + "endDate": "2022-10-19T20:00:02", + "unit": "mg\/min·dL", + "value": 1.806746991288012 + }, + { + "startDate": "2022-10-19T20:00:02", + "endDate": "2022-10-19T20:05:01", + "unit": "mg\/min·dL", + "value": 0.2464776812075047 + }, + { + "startDate": "2022-10-19T20:05:01", + "endDate": "2022-10-19T20:10:01", + "unit": "mg\/min·dL", + "value": -0.9376351386189138 + }, + { + "startDate": "2022-10-19T20:10:01", + "endDate": "2022-10-19T20:15:01", + "unit": "mg\/min·dL", + "value": 0.05860685806376006 + }, + { + "startDate": "2022-10-19T20:15:01", + "endDate": "2022-10-19T20:20:02", + "unit": "mg\/min·dL", + "value": 2.0363173703859756 + }, + { + "startDate": "2022-10-19T20:20:02", + "endDate": "2022-10-19T20:25:01", + "unit": "mg\/min·dL", + "value": 3.4046523515645477 + }, + { + "startDate": "2022-10-19T20:25:01", + "endDate": "2022-10-19T20:30:01", + "unit": "mg\/min·dL", + "value": 3.345839857833783 + }, + { + "startDate": "2022-10-19T20:30:01", + "endDate": "2022-10-19T20:35:01", + "unit": "mg\/min·dL", + "value": 3.2841929027812276 + }, + { + "startDate": "2022-10-19T20:35:01", + "endDate": "2022-10-19T20:40:02", + "unit": "mg\/min·dL", + "value": 3.2232532666739027 + }, + { + "startDate": "2022-10-19T20:40:02", + "endDate": "2022-10-19T20:45:01", + "unit": "mg\/min·dL", + "value": 1.9676584040493557 + }, + { + "startDate": "2022-10-19T20:45:01", + "endDate": "2022-10-19T20:50:01", + "unit": "mg\/min·dL", + "value": 2.299368111515345 + }, + { + "startDate": "2022-10-19T20:50:01", + "endDate": "2022-10-19T20:55:02", + "unit": "mg\/min·dL", + "value": 4.034026725887583 + }, + { + "startDate": "2022-10-19T20:55:02", + "endDate": "2022-10-19T21:00:01", + "unit": "mg\/min·dL", + "value": 2.3799054220542706 + }, + { + "startDate": "2022-10-19T21:00:01", + "endDate": "2022-10-19T21:05:01", + "unit": "mg\/min·dL", + "value": 2.3167018765165173 + }, + { + "startDate": "2022-10-19T21:05:01", + "endDate": "2022-10-19T21:10:01", + "unit": "mg\/min·dL", + "value": 1.8556929650115728 + }, + { + "startDate": "2022-10-19T21:10:01", + "endDate": "2022-10-19T21:15:01", + "unit": "mg\/min·dL", + "value": 3.616009538834508 + }, + { + "startDate": "2022-10-19T21:15:01", + "endDate": "2022-10-19T21:20:01", + "unit": "mg\/min·dL", + "value": 2.1720784828835176 + }, + { + "startDate": "2022-10-19T21:20:01", + "endDate": "2022-10-19T21:25:01", + "unit": "mg\/min·dL", + "value": 1.3496070250759875 + }, + { + "startDate": "2022-10-19T21:25:01", + "endDate": "2022-10-19T21:30:01", + "unit": "mg\/min·dL", + "value": 0.9670380126484306 + }, + { + "startDate": "2022-10-19T21:30:01", + "endDate": "2022-10-19T21:35:01", + "unit": "mg\/min·dL", + "value": -0.16076892735619985 + }, + { + "startDate": "2022-10-19T21:35:01", + "endDate": "2022-10-19T21:40:01", + "unit": "mg\/min·dL", + "value": -0.9050254515029905 + } +] diff --git a/LoopTests/Fixtures/momentum_effect_bouncing.json b/LoopTests/Fixtures/momentum_effect_bouncing.json new file mode 100644 index 0000000000..8214796b03 --- /dev/null +++ b/LoopTests/Fixtures/momentum_effect_bouncing.json @@ -0,0 +1,42 @@ +[ + { + "date": "2015-10-25T19:25:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-25T19:30:00", + "amount": 0.23051025736941719, + "unit": "mg/dL" + }, + { + "date": "2015-10-25T19:35:00", + "amount": 3.2371657882748588, + "unit": "mg/dL" + }, + { + "date": "2015-10-25T19:40:00", + "amount": 6.2438213191803005, + "unit": "mg/dL" + }, + { + "date": "2015-10-25T19:45:00", + "amount": 9.2504768500857413, + "unit": "mg/dL" + }, + { + "date": "2015-10-25T19:50:00", + "amount": 12.257132380991184, + "unit": "mg/dL" + }, + { + "date": "2015-10-25T19:55:00", + "amount": 15.263787911896625, + "unit": "mg/dL" + }, + { + "date": "2015-10-25T20:00:00", + "amount": 18.270443442802062, + "unit": "mg/dL" + } +] diff --git a/LoopTests/Fixtures/predicted_glucose_very_negative.json b/LoopTests/Fixtures/predicted_glucose_very_negative.json new file mode 100644 index 0000000000..7a38e57fec --- /dev/null +++ b/LoopTests/Fixtures/predicted_glucose_very_negative.json @@ -0,0 +1,76 @@ +[ + {"date": "2015-10-25T19:30:00", "unit": "mg/dL", "amount": 80.0}, + {"date": "2015-10-25T19:35:00", "unit": "mg/dL", "amount": -810.6979368479385}, + {"date": "2015-10-25T19:40:00", "unit": "mg/dL", "amount": -1620.2346956733388}, + {"date": "2015-10-25T19:45:00", "unit": "mg/dL", "amount": -2323.6169189977927}, + {"date": "2015-10-25T19:50:00", "unit": "mg/dL", "amount": -2955.52383055983}, + {"date": "2015-10-25T19:55:00", "unit": "mg/dL", "amount": -3510.2043898637166}, + {"date": "2015-10-25T20:00:00", "unit": "mg/dL", "amount": -3978.841068608162}, + {"date": "2015-10-25T20:05:00", "unit": "mg/dL", "amount": -4359.441467885512}, + {"date": "2015-10-25T20:10:00", "unit": "mg/dL", "amount": -4679.603865929835}, + {"date": "2015-10-25T20:15:00", "unit": "mg/dL", "amount": -4918.5504025649725}, + {"date": "2015-10-25T20:20:00", "unit": "mg/dL", "amount": -5076.39092196022}, + {"date": "2015-10-25T20:25:00", "unit": "mg/dL", "amount": -5153.139359698128}, + {"date": "2015-10-25T20:30:00", "unit": "mg/dL", "amount": -5148.784812446916}, + {"date": "2015-10-25T20:35:00", "unit": "mg/dL", "amount": -5144.349359171137}, + {"date": "2015-10-25T20:40:00", "unit": "mg/dL", "amount": -5139.833607490651}, + {"date": "2015-10-25T20:45:00", "unit": "mg/dL", "amount": -5135.250274157318}, + {"date": "2015-10-25T20:50:00", "unit": "mg/dL", "amount": -5130.666940823985}, + {"date": "2015-10-25T20:55:00", "unit": "mg/dL", "amount": -5126.083607490652}, + {"date": "2015-10-25T21:00:00", "unit": "mg/dL", "amount": -5121.500274157319}, + {"date": "2015-10-25T21:05:00", "unit": "mg/dL", "amount": -5116.916940823986}, + {"date": "2015-10-25T21:10:00", "unit": "mg/dL", "amount": -5112.333607490653}, + {"date": "2015-10-25T21:15:00", "unit": "mg/dL", "amount": -5107.75027415732}, + {"date": "2015-10-25T21:20:00", "unit": "mg/dL", "amount": -5103.166940823987}, + {"date": "2015-10-25T21:25:00", "unit": "mg/dL", "amount": -5098.583607490654}, + {"date": "2015-10-25T21:30:00", "unit": "mg/dL", "amount": -5094.000274157321}, + {"date": "2015-10-25T21:35:00", "unit": "mg/dL", "amount": -5089.416940823988}, + {"date": "2015-10-25T21:40:00", "unit": "mg/dL", "amount": -5084.833607490655}, + {"date": "2015-10-25T21:45:00", "unit": "mg/dL", "amount": -5080.250274157322}, + {"date": "2015-10-25T21:50:00", "unit": "mg/dL", "amount": -5075.666940823989}, + {"date": "2015-10-25T21:55:00", "unit": "mg/dL", "amount": -5071.083607490656}, + {"date": "2015-10-25T22:00:00", "unit": "mg/dL", "amount": -5066.500274157323}, + {"date": "2015-10-25T22:05:00", "unit": "mg/dL", "amount": -5061.9169408239895}, + {"date": "2015-10-25T22:10:00", "unit": "mg/dL", "amount": -5057.3336074906565}, + {"date": "2015-10-25T22:15:00", "unit": "mg/dL", "amount": -5052.7502741573235}, + {"date": "2015-10-25T22:20:00", "unit": "mg/dL", "amount": -5048.16694082399}, + {"date": "2015-10-25T22:25:00", "unit": "mg/dL", "amount": -5043.583607490657}, + {"date": "2015-10-25T22:30:00", "unit": "mg/dL", "amount": -5041.733607490657}, + {"date": "2015-10-25T22:35:00", "unit": "mg/dL", "amount": -5041.733607490657}, + {"date": "2015-10-25T22:40:00", "unit": "mg/dL", "amount": -5041.733607490657}, + {"date": "2015-10-25T22:45:00", "unit": "mg/dL", "amount": -5041.733607490657}, + {"date": "2015-10-25T22:50:00", "unit": "mg/dL", "amount": -5041.733607490657}, + {"date": "2015-10-25T22:55:00", "unit": "mg/dL", "amount": -5041.733607490657}, + {"date": "2015-10-25T23:00:00", "unit": "mg/dL", "amount": -5041.733607490657}, + {"date": "2015-10-25T23:05:00", "unit": "mg/dL", "amount": -5041.733607490657}, + {"date": "2015-10-25T23:10:00", "unit": "mg/dL", "amount": -5041.733607490657}, + {"date": "2015-10-25T23:15:00", "unit": "mg/dL", "amount": -5041.733607490657}, + {"date": "2015-10-25T23:20:00", "unit": "mg/dL", "amount": -5041.733607490657}, + {"date": "2015-10-25T23:25:00", "unit": "mg/dL", "amount": -5041.733607490657}, + {"date": "2015-10-25T23:30:00", "unit": "mg/dL", "amount": -5041.733607490657}, + {"date": "2015-10-25T23:35:00", "unit": "mg/dL", "amount": -5041.733607490657}, + {"date": "2015-10-25T23:40:00", "unit": "mg/dL", "amount": -5041.733607490657}, + {"date": "2015-10-25T23:45:00", "unit": "mg/dL", "amount": -5041.733607490657}, + {"date": "2015-10-25T23:50:00", "unit": "mg/dL", "amount": -5041.733607490657}, + {"date": "2015-10-25T23:55:00", "unit": "mg/dL", "amount": -5041.733607490657}, + {"date": "2015-10-26T00:00:00", "unit": "mg/dL", "amount": -5041.733607490657}, + {"date": "2015-10-26T00:05:00", "unit": "mg/dL", "amount": -5041.733607490657}, + {"date": "2015-10-26T00:10:00", "unit": "mg/dL", "amount": -5041.733607490657}, + {"date": "2015-10-26T00:15:00", "unit": "mg/dL", "amount": -5041.733607490657}, + {"date": "2015-10-26T00:20:00", "unit": "mg/dL", "amount": -5041.733607490657}, + {"date": "2015-10-26T00:25:00", "unit": "mg/dL", "amount": -5041.733607490657}, + {"date": "2015-10-26T00:30:00", "unit": "mg/dL", "amount": -5041.733607490657}, + {"date": "2015-10-26T00:35:00", "unit": "mg/dL", "amount": -5041.733607490657}, + {"date": "2015-10-26T00:40:00", "unit": "mg/dL", "amount": -5041.733607490657}, + {"date": "2015-10-26T00:45:00", "unit": "mg/dL", "amount": -5041.733607490657}, + {"date": "2015-10-26T00:50:00", "unit": "mg/dL", "amount": -5041.733607490657}, + {"date": "2015-10-26T00:55:00", "unit": "mg/dL", "amount": -5041.733607490657}, + {"date": "2015-10-26T01:00:00", "unit": "mg/dL", "amount": -5041.733607490657}, + {"date": "2015-10-26T01:05:00", "unit": "mg/dL", "amount": -5041.733607490657}, + {"date": "2015-10-26T01:10:00", "unit": "mg/dL", "amount": -5041.733607490657}, + {"date": "2015-10-26T01:15:00", "unit": "mg/dL", "amount": -5041.733607490657}, + {"date": "2015-10-26T01:20:00", "unit": "mg/dL", "amount": -5041.733607490657}, + {"date": "2015-10-26T01:25:00", "unit": "mg/dL", "amount": -5041.733607490657}, + {"date": "2015-10-26T01:30:00", "unit": "mg/dL", "amount": -5041.733607490657}, + {"date": "2015-10-26T01:35:00", "unit": "mg/dL", "amount": -5041.733607490657} +] diff --git a/LoopTests/Fixtures/predicted_glucose_without_retrospective.json b/LoopTests/Fixtures/predicted_glucose_without_retrospective.json new file mode 100644 index 0000000000..efa7675f89 --- /dev/null +++ b/LoopTests/Fixtures/predicted_glucose_without_retrospective.json @@ -0,0 +1,76 @@ +[ + {"date": "2015-10-25T19:30:00", "unit": "mg/dL", "amount": 80.0}, + {"date": "2015-10-25T19:35:00", "unit": "mg/dL", "amount": 80.39117206295265}, + {"date": "2015-10-25T19:40:00", "unit": "mg/dL", "amount": 80.9354213383624}, + {"date": "2015-10-25T19:45:00", "unit": "mg/dL", "amount": 106.62610530463775}, + {"date": "2015-10-25T19:50:00", "unit": "mg/dL", "amount": 122.78400022324877}, + {"date": "2015-10-25T19:55:00", "unit": "mg/dL", "amount": 135.16014658992896}, + {"date": "2015-10-25T20:00:00", "unit": "mg/dL", "amount": 152.57207270596996}, + {"date": "2015-10-25T20:05:00", "unit": "mg/dL", "amount": 177.01217747902547}, + {"date": "2015-10-25T20:10:00", "unit": "mg/dL", "amount": 180.8821826750269}, + {"date": "2015-10-25T20:15:00", "unit": "mg/dL", "amount": 184.9599484701315}, + {"date": "2015-10-25T20:20:00", "unit": "mg/dL", "amount": 189.13563069504596}, + {"date": "2015-10-25T20:25:00", "unit": "mg/dL", "amount": 193.39529376721907}, + {"date": "2015-10-25T20:30:00", "unit": "mg/dL", "amount": 197.74984101843108}, + {"date": "2015-10-25T20:35:00", "unit": "mg/dL", "amount": 202.1852942942096}, + {"date": "2015-10-25T20:40:00", "unit": "mg/dL", "amount": 206.7010459746956}, + {"date": "2015-10-25T20:45:00", "unit": "mg/dL", "amount": 211.28437930802892}, + {"date": "2015-10-25T20:50:00", "unit": "mg/dL", "amount": 215.86771264136223}, + {"date": "2015-10-25T20:55:00", "unit": "mg/dL", "amount": 220.45104597469557}, + {"date": "2015-10-25T21:00:00", "unit": "mg/dL", "amount": 225.03437930802892}, + {"date": "2015-10-25T21:05:00", "unit": "mg/dL", "amount": 229.61771264136223}, + {"date": "2015-10-25T21:10:00", "unit": "mg/dL", "amount": 234.20104597469557}, + {"date": "2015-10-25T21:15:00", "unit": "mg/dL", "amount": 238.78437930802892}, + {"date": "2015-10-25T21:20:00", "unit": "mg/dL", "amount": 243.36771264136223}, + {"date": "2015-10-25T21:25:00", "unit": "mg/dL", "amount": 247.95104597469557}, + {"date": "2015-10-25T21:30:00", "unit": "mg/dL", "amount": 252.53437930802892}, + {"date": "2015-10-25T21:35:00", "unit": "mg/dL", "amount": 257.11771264136223}, + {"date": "2015-10-25T21:40:00", "unit": "mg/dL", "amount": 261.70104597469555}, + {"date": "2015-10-25T21:45:00", "unit": "mg/dL", "amount": 266.28437930802886}, + {"date": "2015-10-25T21:50:00", "unit": "mg/dL", "amount": 270.86771264136223}, + {"date": "2015-10-25T21:55:00", "unit": "mg/dL", "amount": 275.45104597469555}, + {"date": "2015-10-25T22:00:00", "unit": "mg/dL", "amount": 280.03437930802886}, + {"date": "2015-10-25T22:05:00", "unit": "mg/dL", "amount": 284.61771264136223}, + {"date": "2015-10-25T22:10:00", "unit": "mg/dL", "amount": 289.20104597469555}, + {"date": "2015-10-25T22:15:00", "unit": "mg/dL", "amount": 293.78437930802886}, + {"date": "2015-10-25T22:20:00", "unit": "mg/dL", "amount": 298.36771264136223}, + {"date": "2015-10-25T22:25:00", "unit": "mg/dL", "amount": 302.95104597469555}, + {"date": "2015-10-25T22:30:00", "unit": "mg/dL", "amount": 304.8010459746955}, + {"date": "2015-10-25T22:35:00", "unit": "mg/dL", "amount": 304.8010459746955}, + {"date": "2015-10-25T22:40:00", "unit": "mg/dL", "amount": 304.8010459746955}, + {"date": "2015-10-25T22:45:00", "unit": "mg/dL", "amount": 304.8010459746955}, + {"date": "2015-10-25T22:50:00", "unit": "mg/dL", "amount": 304.8010459746955}, + {"date": "2015-10-25T22:55:00", "unit": "mg/dL", "amount": 304.8010459746955}, + {"date": "2015-10-25T23:00:00", "unit": "mg/dL", "amount": 304.8010459746955}, + {"date": "2015-10-25T23:05:00", "unit": "mg/dL", "amount": 304.8010459746955}, + {"date": "2015-10-25T23:10:00", "unit": "mg/dL", "amount": 304.8010459746955}, + {"date": "2015-10-25T23:15:00", "unit": "mg/dL", "amount": 304.8010459746955}, + {"date": "2015-10-25T23:20:00", "unit": "mg/dL", "amount": 304.8010459746955}, + {"date": "2015-10-25T23:25:00", "unit": "mg/dL", "amount": 304.8010459746955}, + {"date": "2015-10-25T23:30:00", "unit": "mg/dL", "amount": 304.8010459746955}, + {"date": "2015-10-25T23:35:00", "unit": "mg/dL", "amount": 304.8010459746955}, + {"date": "2015-10-25T23:40:00", "unit": "mg/dL", "amount": 304.8010459746955}, + {"date": "2015-10-25T23:45:00", "unit": "mg/dL", "amount": 304.8010459746955}, + {"date": "2015-10-25T23:50:00", "unit": "mg/dL", "amount": 304.8010459746955}, + {"date": "2015-10-25T23:55:00", "unit": "mg/dL", "amount": 304.8010459746955}, + {"date": "2015-10-26T00:00:00", "unit": "mg/dL", "amount": 304.8010459746955}, + {"date": "2015-10-26T00:05:00", "unit": "mg/dL", "amount": 304.8010459746955}, + {"date": "2015-10-26T00:10:00", "unit": "mg/dL", "amount": 304.8010459746955}, + {"date": "2015-10-26T00:15:00", "unit": "mg/dL", "amount": 304.8010459746955}, + {"date": "2015-10-26T00:20:00", "unit": "mg/dL", "amount": 304.8010459746955}, + {"date": "2015-10-26T00:25:00", "unit": "mg/dL", "amount": 304.8010459746955}, + {"date": "2015-10-26T00:30:00", "unit": "mg/dL", "amount": 304.8010459746955}, + {"date": "2015-10-26T00:35:00", "unit": "mg/dL", "amount": 304.8010459746955}, + {"date": "2015-10-26T00:40:00", "unit": "mg/dL", "amount": 304.8010459746955}, + {"date": "2015-10-26T00:45:00", "unit": "mg/dL", "amount": 304.8010459746955}, + {"date": "2015-10-26T00:50:00", "unit": "mg/dL", "amount": 304.8010459746955}, + {"date": "2015-10-26T00:55:00", "unit": "mg/dL", "amount": 304.8010459746955}, + {"date": "2015-10-26T01:00:00", "unit": "mg/dL", "amount": 304.8010459746955}, + {"date": "2015-10-26T01:05:00", "unit": "mg/dL", "amount": 304.8010459746955}, + {"date": "2015-10-26T01:10:00", "unit": "mg/dL", "amount": 304.8010459746955}, + {"date": "2015-10-26T01:15:00", "unit": "mg/dL", "amount": 304.8010459746955}, + {"date": "2015-10-26T01:20:00", "unit": "mg/dL", "amount": 304.8010459746955}, + {"date": "2015-10-26T01:25:00", "unit": "mg/dL", "amount": 304.8010459746955}, + {"date": "2015-10-26T01:30:00", "unit": "mg/dL", "amount": 304.8010459746955}, + {"date": "2015-10-26T01:35:00", "unit": "mg/dL", "amount": 304.8010459746955} +] diff --git a/LoopTests/Fixtures/retrospective_output.json b/LoopTests/Fixtures/retrospective_output.json new file mode 100644 index 0000000000..9b1283b8ae --- /dev/null +++ b/LoopTests/Fixtures/retrospective_output.json @@ -0,0 +1,14 @@ +[ + { "date": "2015-10-25T19:30:00", "unit": "mg/dL", "amount": 80.0 }, + { "date": "2015-10-25T19:35:00", "unit": "mg/dL", "amount": -811.0891089108911 }, + { "date": "2015-10-25T19:40:00", "unit": "mg/dL", "amount": -1621.1701170117012 }, + { "date": "2015-10-25T19:45:00", "unit": "mg/dL", "amount": -2350.24302430243 }, + { "date": "2015-10-25T19:50:00", "unit": "mg/dL", "amount": -2998.3078307830783 }, + { "date": "2015-10-25T19:55:00", "unit": "mg/dL", "amount": -3565.364536453645 }, + { "date": "2015-10-25T20:00:00", "unit": "mg/dL", "amount": -4051.4131413141313 }, + { "date": "2015-10-25T20:05:00", "unit": "mg/dL", "amount": -4456.453645364536 }, + { "date": "2015-10-25T20:10:00", "unit": "mg/dL", "amount": -4780.48604860486 }, + { "date": "2015-10-25T20:15:00", "unit": "mg/dL", "amount": -5023.510351035103 }, + { "date": "2015-10-25T20:20:00", "unit": "mg/dL", "amount": -5185.526552655265 }, + { "date": "2015-10-25T20:25:00", "unit": "mg/dL", "amount": -5266.534653465345 } +] diff --git a/LoopTests/Info.plist b/LoopTests/Info.plist index 631d44c4bd..e28d249e32 100644 --- a/LoopTests/Info.plist +++ b/LoopTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.2.0 + $(LOOP_MARKETING_VERSION) CFBundleSignature ???? CFBundleVersion - 1 + $(CURRENT_PROJECT_VERSION) diff --git a/LoopTests/KeychainManagerTests.swift b/LoopTests/KeychainManagerTests.swift deleted file mode 100644 index 242849833e..0000000000 --- a/LoopTests/KeychainManagerTests.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// KeychainManagerTests.swift -// Loop -// -// Created by Nate Racklyeft on 6/26/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import XCTest -@testable import Loop - - -class KeychainManagerTests: XCTestCase { - - func testInvalidData() throws { - let manager = KeychainManager() - - try manager.setDexcomShareUsername(nil, password: "foo") - XCTAssertNil(manager.getDexcomShareCredentials()) - - try manager.setDexcomShareUsername("foo", password: nil) - XCTAssertNil(manager.getDexcomShareCredentials()) - - manager.setNightscoutURL(nil, secret: "foo") - XCTAssertNil(manager.getNightscoutCredentials()) - - manager.setNightscoutURL(URL(string: "foo"), secret: nil) - XCTAssertNil(manager.getNightscoutCredentials()) - } - - func testValidData() throws { - let manager = KeychainManager() - - try manager.setAmplitudeAPIKey("1234") - XCTAssertEqual("1234", manager.getAmplitudeAPIKey()) - - try manager.setAmplitudeAPIKey(nil) - XCTAssertNil(manager.getAmplitudeAPIKey()) - - try manager.setDexcomShareUsername("sugarman", password: "rodriguez") - let dexcomCredentials = manager.getDexcomShareCredentials()! - XCTAssertEqual("sugarman", dexcomCredentials.username) - XCTAssertEqual("rodriguez", dexcomCredentials.password) - - try manager.setDexcomShareUsername(nil, password: nil) - XCTAssertNil(manager.getDexcomShareCredentials()) - - manager.setNightscoutURL(URL(string: "http://mysite.azurewebsites.net")!, secret: "ABCDEFG") - let nightscoutCredentials = manager.getNightscoutCredentials()! - XCTAssertEqual(URL(string: "http://mysite.azurewebsites.net")!, nightscoutCredentials.url) - XCTAssertEqual("ABCDEFG", nightscoutCredentials.secret) - - manager.setNightscoutURL(nil, secret: nil) - XCTAssertNil(manager.getNightscoutCredentials()) - - try manager.setMLabDatabaseName("sugarmandb", APIKey: "rodriguez") - let mLabCredentials = manager.getMLabCredentials()! - XCTAssertEqual("sugarmandb", mLabCredentials.databaseName) - XCTAssertEqual("rodriguez", mLabCredentials.APIKey) - - } -} diff --git a/LoopTests/LoopCore/LoopCompletionFreshnessTests.swift b/LoopTests/LoopCore/LoopCompletionFreshnessTests.swift new file mode 100644 index 0000000000..7f5d7095b0 --- /dev/null +++ b/LoopTests/LoopCore/LoopCompletionFreshnessTests.swift @@ -0,0 +1,50 @@ +// +// LoopCompletionFreshnessTests.swift +// LoopTests +// +// Created by Nathaniel Hamming on 2020-10-28. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import XCTest +@testable import LoopCore + +class LoopCompletionFreshnessTests: XCTestCase { + + func testInitializationWithAge() { + let freshAge = TimeInterval(minutes: 5) + let agingAge = TimeInterval(minutes: 15) + let staleAge1 = TimeInterval(minutes: 20) + let staleAge2 = TimeInterval(hours: 20) + + XCTAssertEqual(LoopCompletionFreshness(age: nil), .stale) + XCTAssertEqual(LoopCompletionFreshness(age: freshAge), .fresh) + XCTAssertEqual(LoopCompletionFreshness(age: agingAge), .aging) + XCTAssertEqual(LoopCompletionFreshness(age: staleAge1), .stale) + XCTAssertEqual(LoopCompletionFreshness(age: staleAge2), .stale) + } + + func testInitializationWithLoopCompletion() { + let freshDate = Date().addingTimeInterval(-.minutes(1)) + let agingDate = Date().addingTimeInterval(-.minutes(7)) + let staleDate1 = Date().addingTimeInterval(-.minutes(17)) + let staleDate2 = Date().addingTimeInterval(-.hours(13)) + + XCTAssertEqual(LoopCompletionFreshness(lastCompletion: nil), .stale) + XCTAssertEqual(LoopCompletionFreshness(lastCompletion: freshDate), .fresh) + XCTAssertEqual(LoopCompletionFreshness(lastCompletion: agingDate), .aging) + XCTAssertEqual(LoopCompletionFreshness(lastCompletion: staleDate1), .stale) + XCTAssertEqual(LoopCompletionFreshness(lastCompletion: staleDate2), .stale) + } + + func testMaxAge() { + var loopCompletionFreshness: LoopCompletionFreshness = .fresh + XCTAssertEqual(loopCompletionFreshness.maxAge, TimeInterval.minutes(6)) + + loopCompletionFreshness = .aging + XCTAssertEqual(loopCompletionFreshness.maxAge, TimeInterval.minutes(16)) + + loopCompletionFreshness = .stale + XCTAssertNil(loopCompletionFreshness.maxAge) + } +} diff --git a/LoopTests/LoopSettingsTests.swift b/LoopTests/LoopSettingsTests.swift new file mode 100644 index 0000000000..a0ad8f4503 --- /dev/null +++ b/LoopTests/LoopSettingsTests.swift @@ -0,0 +1,121 @@ +// +// LoopSettingsTests.swift +// LoopTests +// +// Created by Michael Pangburn on 3/1/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import XCTest +import LoopCore +import LoopKit + + +class LoopSettingsTests: XCTestCase { + private let preMealRange = DoubleRange(minValue: 80, maxValue: 80).quantityRange(for: .milligramsPerDeciliter) + private let targetRange = DoubleRange(minValue: 95, maxValue: 105) + + private lazy var settings: LoopSettings = { + var settings = LoopSettings() + settings.preMealTargetRange = preMealRange + settings.glucoseTargetRangeSchedule = GlucoseRangeSchedule( + unit: .milligramsPerDeciliter, + dailyItems: [.init(startTime: 0, value: targetRange)] + ) + return settings + }() + + func testPreMealOverride() { + var settings = self.settings + let preMealStart = Date() + settings.enablePreMealOverride(at: preMealStart, for: 1 /* hour */ * 60 * 60) + let actualPreMealRange = settings.effectiveGlucoseTargetRangeSchedule()?.quantityRange(at: preMealStart.addingTimeInterval(30 /* minutes */ * 60)) + XCTAssertEqual(preMealRange, actualPreMealRange) + } + + func testPreMealOverrideWithPotentialCarbEntry() { + var settings = self.settings + let preMealStart = Date() + settings.enablePreMealOverride(at: preMealStart, for: 1 /* hour */ * 60 * 60) + let actualRange = settings.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: true)?.value(at: preMealStart.addingTimeInterval(30 /* minutes */ * 60)) + XCTAssertEqual(targetRange, actualRange) + } + + func testScheduleOverride() { + var settings = self.settings + let overrideStart = Date() + let overrideTargetRange = DoubleRange(minValue: 130, maxValue: 150) + let override = TemporaryScheduleOverride( + context: .custom, + settings: TemporaryScheduleOverrideSettings( + unit: .milligramsPerDeciliter, + targetRange: overrideTargetRange + ), + startDate: overrideStart, + duration: .finite(3 /* hours */ * 60 * 60), + enactTrigger: .local, + syncIdentifier: UUID() + ) + settings.scheduleOverride = override + let actualOverrideRange = settings.effectiveGlucoseTargetRangeSchedule()?.value(at: overrideStart.addingTimeInterval(30 /* minutes */ * 60)) + XCTAssertEqual(actualOverrideRange, overrideTargetRange) + } + + func testBothPreMealAndScheduleOverride() { + var settings = self.settings + let preMealStart = Date() + settings.enablePreMealOverride(at: preMealStart, for: 1 /* hour */ * 60 * 60) + + let overrideStart = Date() + let overrideTargetRange = DoubleRange(minValue: 130, maxValue: 150) + let override = TemporaryScheduleOverride( + context: .custom, + settings: TemporaryScheduleOverrideSettings( + unit: .milligramsPerDeciliter, + targetRange: overrideTargetRange + ), + startDate: overrideStart, + duration: .finite(3 /* hours */ * 60 * 60), + enactTrigger: .local, + syncIdentifier: UUID() + ) + settings.scheduleOverride = override + + let actualPreMealRange = settings.effectiveGlucoseTargetRangeSchedule()?.quantityRange(at: preMealStart.addingTimeInterval(30 /* minutes */ * 60)) + XCTAssertEqual(actualPreMealRange, preMealRange) + + // The pre-meal range should be projected into the future, despite the simultaneous schedule override + let preMealRangeDuringOverride = settings.effectiveGlucoseTargetRangeSchedule()?.quantityRange(at: preMealStart.addingTimeInterval(2 /* hours */ * 60 * 60)) + XCTAssertEqual(preMealRangeDuringOverride, preMealRange) + } + + func testScheduleOverrideWithExpiredPreMealOverride() { + var settings = self.settings + settings.preMealOverride = TemporaryScheduleOverride( + context: .preMeal, + settings: TemporaryScheduleOverrideSettings(targetRange: preMealRange), + startDate: Date(timeIntervalSinceNow: -2 /* hours */ * 60 * 60), + duration: .finite(1 /* hours */ * 60 * 60), + enactTrigger: .local, + syncIdentifier: UUID() + ) + + let overrideStart = Date() + let overrideTargetRange = DoubleRange(minValue: 130, maxValue: 150) + let override = TemporaryScheduleOverride( + context: .custom, + settings: TemporaryScheduleOverrideSettings( + unit: .milligramsPerDeciliter, + targetRange: overrideTargetRange + ), + startDate: overrideStart, + duration: .finite(3 /* hours */ * 60 * 60), + enactTrigger: .local, + syncIdentifier: UUID() + ) + settings.scheduleOverride = override + + let actualOverrideRange = settings.effectiveGlucoseTargetRangeSchedule()?.value(at: overrideStart.addingTimeInterval(2 /* hours */ * 60 * 60)) + XCTAssertEqual(actualOverrideRange, overrideTargetRange) + } +} diff --git a/LoopTests/LoopTests.swift b/LoopTests/LoopTests.swift new file mode 100644 index 0000000000..b29b1d5c03 --- /dev/null +++ b/LoopTests/LoopTests.swift @@ -0,0 +1,28 @@ +// +// LoopTests.swift +// LoopTests +// +// Created by Darin Krauss on 9/18/19. +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import XCTest + +@testable import Loop + +class LoopTests: XCTestCase {} + +extension XCTestCase { + + func waitOnMain(timeout: TimeInterval = 1.0, file: StaticString = #file, function: String = #function, line: UInt = #line) { + let exp = expectation(description: function) + var fulfilled = false + DispatchQueue.main.async { + fulfilled = true + exp.fulfill() + } + wait(for: [exp], timeout: timeout) + XCTAssertTrue(fulfilled, "Failed to wait on main in \(function)", file: file, line: line) + } + +} diff --git a/LoopTests/Managers/Alerts/AlertManagerTests.swift b/LoopTests/Managers/Alerts/AlertManagerTests.swift new file mode 100644 index 0000000000..5eeca9cebd --- /dev/null +++ b/LoopTests/Managers/Alerts/AlertManagerTests.swift @@ -0,0 +1,569 @@ +// +// AlertManagerTests.swift +// LoopTests +// +// Created by Rick Pasetto on 4/15/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import LoopKit +import UserNotifications +import XCTest +@testable import Loop + +class AlertManagerTests: XCTestCase { + + class MockBluetoothProvider: BluetoothProvider { + var bluetoothAuthorization: BluetoothAuthorization = .authorized + + var bluetoothState: BluetoothState = .poweredOn + + func authorizeBluetooth(_ completion: @escaping (BluetoothAuthorization) -> Void) { + completion(bluetoothAuthorization) + } + + func addBluetoothObserver(_ observer: BluetoothObserver, queue: DispatchQueue) { + } + + func removeBluetoothObserver(_ observer: BluetoothObserver) { + } + } + + class MockModalAlertScheduler: InAppModalAlertScheduler { + var scheduledAlert: Alert? + override func scheduleAlert(_ alert: Alert) { + scheduledAlert = alert + } + var unscheduledAlertIdentifier: Alert.Identifier? + override func unscheduleAlert(identifier: Alert.Identifier) { + unscheduledAlertIdentifier = identifier + } + } + + class MockUserNotificationAlertScheduler: UserNotificationAlertScheduler { + var scheduledAlert: Alert? + var muted: Bool? + + override func scheduleAlert(_ alert: Alert, muted: Bool) { + scheduledAlert = alert + self.muted = muted + } + var unscheduledAlertIdentifier: Alert.Identifier? + override func unscheduleAlert(identifier: Alert.Identifier) { + unscheduledAlertIdentifier = identifier + } + } + + class MockResponder: AlertResponder { + var acknowledged: [Alert.AlertIdentifier: Bool] = [:] + func acknowledgeAlert(alertIdentifier: Alert.AlertIdentifier, completion: @escaping (Error?) -> Void) { + completion(nil) + acknowledged[alertIdentifier] = true + } + } + + class MockFileManager: FileManager { + + var fileExists = true + let newer = Date() + let older = Date.distantPast + + var createdDirURL: URL? + override func createDirectory(at url: URL, withIntermediateDirectories createIntermediates: Bool, attributes: [FileAttributeKey : Any]? = nil) throws { + createdDirURL = url + } + override func fileExists(atPath path: String) -> Bool { + return !path.contains("doesntExist") + } + override func attributesOfItem(atPath path: String) throws -> [FileAttributeKey : Any] { + return path.contains("Sounds") ? path.contains("existsNewer") ? [.creationDate: newer] : [.creationDate: older] : + [.creationDate: newer] + } + var removedURLs = [URL]() + override func removeItem(at URL: URL) throws { + removedURLs.append(URL) + } + var copiedSrcURLs = [URL]() + var copiedDstURLs = [URL]() + override func copyItem(at srcURL: URL, to dstURL: URL) throws { + copiedSrcURLs.append(srcURL) + copiedDstURLs.append(dstURL) + } + override func urls(for directory: FileManager.SearchPathDirectory, in domainMask: FileManager.SearchPathDomainMask) -> [URL] { + return [] + } + } + + class MockPresenter: AlertPresenter { + func present(_ viewControllerToPresent: UIViewController, animated: Bool, completion: (() -> Void)?) { completion?() } + func dismissTopMost(animated: Bool, completion: (() -> Void)?) { completion?() } + func dismissAlert(_ alertToDismiss: UIAlertController, animated: Bool, completion: (() -> Void)?) { completion?() } + } + + class MockAlertManagerResponder: AlertManagerResponder { + func acknowledgeAlert(identifier: LoopKit.Alert.Identifier) { } + } + + class MockSoundVendor: AlertSoundVendor { + func getSoundBaseURL() -> URL? { + // Hm. It's not easy to make a "fake" URL, so we'll use this one: + return Bundle.main.resourceURL + } + + func getSounds() -> [Alert.Sound] { + return [.sound(name: "doesntExist"), .sound(name: "existsNewer"), .sound(name: "existsOlder")] + } + } + + class MockAlertStore: AlertStore { + + var issuedAlert: Alert? + override public func recordIssued(alert: Alert, at date: Date = Date(), completion: ((Result) -> Void)? = nil) { + issuedAlert = alert + completion?(.success) + } + + var retractedAlert: Alert? + var retractedAlertDate: Date? + override public func recordRetractedAlert(_ alert: Alert, at date: Date, completion: ((Result) -> Void)? = nil) { + retractedAlert = alert + retractedAlertDate = date + completion?(.success) + } + + var acknowledgedAlertIdentifier: Alert.Identifier? + var acknowledgedAlertDate: Date? + override public func recordAcknowledgement(of identifier: Alert.Identifier, at date: Date = Date(), + completion: ((Result) -> Void)? = nil) { + acknowledgedAlertIdentifier = identifier + acknowledgedAlertDate = date + completion?(.success) + } + + var retractededAlertIdentifier: Alert.Identifier? + override public func recordRetraction(of identifier: Alert.Identifier, at date: Date = Date(), + completion: ((Result) -> Void)? = nil) { + retractededAlertIdentifier = identifier + retractedAlertDate = date + completion?(.success) + } + + var storedAlerts = [StoredAlert]() + override public func lookupAllUnacknowledgedUnretracted(managerIdentifier: String? = nil, filteredByTriggers triggersStoredType: [AlertTriggerStoredType]? = nil, completion: @escaping (Result<[StoredAlert], Error>) -> Void) { + completion(.success(storedAlerts)) + } + + override public func lookupAllUnretracted(managerIdentifier: String?, completion: @escaping (Result<[StoredAlert], Error>) -> Void) { + completion(.success(storedAlerts)) + } + } + + static let mockManagerIdentifier = "mockManagerIdentifier" + static let mockTypeIdentifier = "mockTypeIdentifier" + static let mockIdentifier = Alert.Identifier(managerIdentifier: mockManagerIdentifier, alertIdentifier: mockTypeIdentifier) + static let backgroundContent = Alert.Content(title: "BACKGROUND", body: "background", acknowledgeActionButtonLabel: "") + let mockAlert = Alert(identifier: mockIdentifier, foregroundContent: nil, backgroundContent: backgroundContent, trigger: .immediate) + + var mockFileManager: MockFileManager! + var mockPresenter: MockPresenter! + var mockModalScheduler: MockModalAlertScheduler! + var mockUserNotificationScheduler: MockUserNotificationAlertScheduler! + var mockAlertStore: MockAlertStore! + var alertManager: AlertManager! + var isInBackground = true + + override func setUp() { + UNUserNotificationCenter.current().removeAllPendingNotificationRequests() + UNUserNotificationCenter.current().removeAllDeliveredNotifications() + mockFileManager = MockFileManager() + mockPresenter = MockPresenter() + mockModalScheduler = MockModalAlertScheduler(alertPresenter: mockPresenter, alertManagerResponder: MockAlertManagerResponder()) + mockUserNotificationScheduler = MockUserNotificationAlertScheduler(userNotificationCenter: MockUserNotificationCenter()) + mockAlertStore = MockAlertStore() + alertManager = AlertManager(alertPresenter: mockPresenter, + modalAlertScheduler: mockModalScheduler, + userNotificationAlertScheduler: mockUserNotificationScheduler, + fileManager: mockFileManager, + alertStore: mockAlertStore, + bluetoothProvider: MockBluetoothProvider(), + analyticsServicesManager: AnalyticsServicesManager(), + preventIssuanceBeforePlayback: false) + } + + override func tearDown() { + mockAlertStore = nil + } + + func testIssueAlertOnHandlerCalled() { + alertManager.issueAlert(mockAlert) + XCTAssertEqual(mockAlert.identifier, mockModalScheduler.scheduledAlert?.identifier) + XCTAssertEqual(mockAlert.identifier, mockUserNotificationScheduler.scheduledAlert?.identifier) + XCTAssertNil(mockModalScheduler.unscheduledAlertIdentifier) + XCTAssertNil(mockUserNotificationScheduler.unscheduledAlertIdentifier) + } + + func testRetractAlertOnHandlerCalled() { + alertManager.retractAlert(identifier: mockAlert.identifier) + XCTAssertNil(mockModalScheduler.scheduledAlert) + XCTAssertNil(mockUserNotificationScheduler.scheduledAlert) + XCTAssertEqual(mockAlert.identifier, mockModalScheduler.unscheduledAlertIdentifier) + XCTAssertEqual(mockAlert.identifier, mockUserNotificationScheduler.unscheduledAlertIdentifier) + } + + func testAlertResponderAcknowledged() { + let responder = MockResponder() + alertManager.addAlertResponder(managerIdentifier: Self.mockManagerIdentifier, alertResponder: responder) + XCTAssertTrue(responder.acknowledged.isEmpty) + alertManager.acknowledgeAlert(identifier: Self.mockIdentifier) + XCTAssert(responder.acknowledged[Self.mockTypeIdentifier] == true) + } + + func testAlertResponderNotAcknowledgedIfWrongManagerIdentifier() { + let responder = MockResponder() + alertManager.addAlertResponder(managerIdentifier: Self.mockManagerIdentifier, alertResponder: responder) + XCTAssertTrue(responder.acknowledged.isEmpty) + alertManager.acknowledgeAlert(identifier: Alert.Identifier(managerIdentifier: "foo", alertIdentifier: Self.mockTypeIdentifier)) + XCTAssertTrue(responder.acknowledged.isEmpty) + } + + func testRemovedAlertResponderDoesntAcknowledge() { + let responder = MockResponder() + alertManager.addAlertResponder(managerIdentifier: Self.mockManagerIdentifier, alertResponder: responder) + XCTAssertTrue(responder.acknowledged.isEmpty) + alertManager.acknowledgeAlert(identifier: Self.mockIdentifier) + XCTAssert(responder.acknowledged[Self.mockTypeIdentifier] == true) + + responder.acknowledged[AlertManagerTests.mockTypeIdentifier] = false + alertManager.removeAlertResponder(managerIdentifier: AlertManagerTests.mockManagerIdentifier) + alertManager.acknowledgeAlert(identifier: Self.mockIdentifier) + XCTAssert(responder.acknowledged[Self.mockTypeIdentifier] == false) + } + + func testAcknowledgedAlertsRemovedFromUserNotificationCenter() { + alertManager.acknowledgeAlert(identifier: Self.mockIdentifier) + } + + func testSoundVendorInitialization() { + let soundVendor = MockSoundVendor() + alertManager.addAlertSoundVendor(managerIdentifier: Self.mockManagerIdentifier, soundVendor: soundVendor) + XCTAssertEqual("Sounds", mockFileManager.createdDirURL?.lastPathComponent) + XCTAssertEqual(["\(Self.mockManagerIdentifier)-existsOlder"], mockFileManager.removedURLs.map { $0.lastPathComponent }) + XCTAssertEqual(["doesntExist", "existsOlder"], mockFileManager.copiedSrcURLs.map { $0.lastPathComponent }) + XCTAssertEqual(["\(Self.mockManagerIdentifier)-doesntExist", "\(Self.mockManagerIdentifier)-existsOlder"], mockFileManager.copiedDstURLs.map { $0.lastPathComponent }) + } + + func testPlaybackPendingImmediateAlert() { + mockAlertStore.managedObjectContext.performAndWait { + let content = Alert.Content(title: "title", body: "body", acknowledgeActionButtonLabel: "label") + let alert = Alert(identifier: Self.mockIdentifier, + foregroundContent: content, backgroundContent: content, trigger: .immediate) + mockAlertStore.storedAlerts = [StoredAlert(from: alert, context: mockAlertStore.managedObjectContext)] + + alertManager = AlertManager(alertPresenter: mockPresenter, + modalAlertScheduler: mockModalScheduler, + userNotificationAlertScheduler: mockUserNotificationScheduler, + fileManager: mockFileManager, + alertStore: mockAlertStore, + bluetoothProvider: MockBluetoothProvider(), + analyticsServicesManager: AnalyticsServicesManager()) + alertManager.playbackAlertsFromPersistence() + XCTAssertEqual(alert, mockModalScheduler.scheduledAlert) + XCTAssertNil(mockUserNotificationScheduler.scheduledAlert) + } + } + + func testPlaybackPendingExpiredDelayedNotification() { + mockAlertStore.managedObjectContext.performAndWait { + let date = Date.distantPast + let content = Alert.Content(title: "title", body: "body", acknowledgeActionButtonLabel: "label") + let alert = Alert(identifier: Self.mockIdentifier, + foregroundContent: content, backgroundContent: content, trigger: .delayed(interval: 30.0)) + let storedAlert = StoredAlert(from: alert, context: mockAlertStore.managedObjectContext) + storedAlert.issuedDate = date + mockAlertStore.storedAlerts = [storedAlert] + alertManager = AlertManager(alertPresenter: mockPresenter, + modalAlertScheduler: mockModalScheduler, + userNotificationAlertScheduler: mockUserNotificationScheduler, + fileManager: mockFileManager, + alertStore: mockAlertStore, + bluetoothProvider: MockBluetoothProvider(), + analyticsServicesManager: AnalyticsServicesManager()) + alertManager.playbackAlertsFromPersistence() + let expected = Alert(identifier: Self.mockIdentifier, foregroundContent: content, backgroundContent: content, trigger: .immediate) + XCTAssertEqual(expected, mockModalScheduler.scheduledAlert) + XCTAssertNil(mockUserNotificationScheduler.scheduledAlert) + } + } + + func testPlaybackPendingDelayedNotification() { + mockAlertStore.managedObjectContext.performAndWait { + let date = Date().addingTimeInterval(-15.0) // Pretend the 30-second-delayed alert was issued 15 seconds ago + let content = Alert.Content(title: "title", body: "body", acknowledgeActionButtonLabel: "label") + let alert = Alert(identifier: Self.mockIdentifier, + foregroundContent: content, backgroundContent: content, trigger: .delayed(interval: 30.0)) + let storedAlert = StoredAlert(from: alert, context: mockAlertStore.managedObjectContext) + storedAlert.issuedDate = date + mockAlertStore.storedAlerts = [storedAlert] + alertManager = AlertManager(alertPresenter: mockPresenter, + modalAlertScheduler: mockModalScheduler, + userNotificationAlertScheduler: mockUserNotificationScheduler, + fileManager: mockFileManager, + alertStore: mockAlertStore, + bluetoothProvider: MockBluetoothProvider(), + analyticsServicesManager: AnalyticsServicesManager()) + alertManager.playbackAlertsFromPersistence() + + // The trigger for this should be `.delayed` by "something less than 15 seconds", + // but the exact value depends on the speed of executing this test. + // As long as it is <= 15 seconds, we call it good. + XCTAssertNotNil(mockModalScheduler.scheduledAlert) + switch mockModalScheduler.scheduledAlert?.trigger { + case .some(.delayed(let interval)): + XCTAssertLessThanOrEqual(interval, 15.0) + default: + XCTFail("Wrong trigger \(String(describing: mockModalScheduler.scheduledAlert?.trigger))") + } + } + } + + func testPlaybackPendingRepeatingNotification() { + mockAlertStore.managedObjectContext.performAndWait { + let date = Date.distantPast + let content = Alert.Content(title: "title", body: "body", acknowledgeActionButtonLabel: "label") + let alert = Alert(identifier: Self.mockIdentifier, + foregroundContent: content, backgroundContent: content, trigger: .repeating(repeatInterval: 60.0)) + let storedAlert = StoredAlert(from: alert, context: mockAlertStore.managedObjectContext) + storedAlert.issuedDate = date + mockAlertStore.storedAlerts = [storedAlert] + alertManager = AlertManager(alertPresenter: mockPresenter, + modalAlertScheduler: mockModalScheduler, + userNotificationAlertScheduler: mockUserNotificationScheduler, + fileManager: mockFileManager, + alertStore: mockAlertStore, + bluetoothProvider: MockBluetoothProvider(), + analyticsServicesManager: AnalyticsServicesManager()) + alertManager.playbackAlertsFromPersistence() + + XCTAssertEqual(alert, mockModalScheduler.scheduledAlert) + XCTAssertNil(mockUserNotificationScheduler.scheduledAlert) + } + } + + func testPersistedAlertStoreLookupAllUnretracted() throws { + mockAlertStore.managedObjectContext.performAndWait { + let date = Date.distantPast + let content = Alert.Content(title: "title", body: "body", acknowledgeActionButtonLabel: "label") + let alert = Alert(identifier: Self.mockIdentifier, + foregroundContent: content, backgroundContent: content, trigger: .repeating(repeatInterval: 60.0)) + let storedAlert = StoredAlert(from: alert, context: mockAlertStore.managedObjectContext) + storedAlert.issuedDate = date + mockAlertStore.storedAlerts = [storedAlert] + alertManager = AlertManager(alertPresenter: mockPresenter, + modalAlertScheduler: mockModalScheduler, + userNotificationAlertScheduler: mockUserNotificationScheduler, + fileManager: mockFileManager, + alertStore: mockAlertStore, + bluetoothProvider: MockBluetoothProvider(), + analyticsServicesManager: AnalyticsServicesManager()) + alertManager.lookupAllUnretracted(managerIdentifier: Self.mockManagerIdentifier) { result in + try? XCTAssertEqual([PersistedAlert(alert: alert, issuedDate: date, retractedDate: nil, acknowledgedDate: nil)], + XCTUnwrap(result.successValue)) + } + } + } + + func testPersistedAlertStoreLookupAllUnacknowledgedUnretracted() throws { + mockAlertStore.managedObjectContext.performAndWait { + let date = Date.distantPast + let content = Alert.Content(title: "title", body: "body", acknowledgeActionButtonLabel: "label") + let alert = Alert(identifier: Self.mockIdentifier, + foregroundContent: content, backgroundContent: content, trigger: .repeating(repeatInterval: 60.0)) + let storedAlert = StoredAlert(from: alert, context: mockAlertStore.managedObjectContext) + storedAlert.issuedDate = date + mockAlertStore.storedAlerts = [storedAlert] + alertManager = AlertManager(alertPresenter: mockPresenter, + modalAlertScheduler: mockModalScheduler, + userNotificationAlertScheduler: mockUserNotificationScheduler, + fileManager: mockFileManager, + alertStore: mockAlertStore, + bluetoothProvider: MockBluetoothProvider(), + analyticsServicesManager: AnalyticsServicesManager()) + alertManager.lookupAllUnacknowledgedUnretracted(managerIdentifier: Self.mockManagerIdentifier) { result in + try? XCTAssertEqual([PersistedAlert(alert: alert, issuedDate: date, retractedDate: nil, acknowledgedDate: nil)], + XCTUnwrap(result.successValue)) + } + } + } + + func testPersistedAlertStoreDoesIssuedAlertExist() throws { + mockAlertStore.managedObjectContext.performAndWait { + let date = Date.distantPast + let content = Alert.Content(title: "title", body: "body", acknowledgeActionButtonLabel: "label") + let alert = Alert(identifier: Self.mockIdentifier, + foregroundContent: content, backgroundContent: content, trigger: .repeating(repeatInterval: 60.0)) + let storedAlert = StoredAlert(from: alert, context: mockAlertStore.managedObjectContext) + storedAlert.issuedDate = date + mockAlertStore.storedAlerts = [storedAlert] + alertManager = AlertManager(alertPresenter: mockPresenter, + modalAlertScheduler: mockModalScheduler, + userNotificationAlertScheduler: mockUserNotificationScheduler, + fileManager: mockFileManager, + alertStore: mockAlertStore, + bluetoothProvider: MockBluetoothProvider(), + analyticsServicesManager: AnalyticsServicesManager()) + let identifierExists = Self.mockIdentifier + let identifierDoesNotExist = Alert.Identifier(managerIdentifier: "TestManagerIdentifier", alertIdentifier: "TestAlertIdentifier") + alertManager.doesIssuedAlertExist(identifier: identifierExists) { result in + try? XCTAssertEqual(true, XCTUnwrap(result.successValue)) + } + alertManager.doesIssuedAlertExist(identifier: identifierDoesNotExist) { result in + try? XCTAssertEqual(false, XCTUnwrap(result.successValue)) + } + } + } + + func testReportRetractedAlert() throws { + mockAlertStore.managedObjectContext.performAndWait { + let content = Alert.Content(title: "title", body: "body", acknowledgeActionButtonLabel: "label") + let alert = Alert(identifier: Self.mockIdentifier, + foregroundContent: content, backgroundContent: content, trigger: .repeating(repeatInterval: 60.0)) + mockAlertStore.storedAlerts = [] + alertManager = AlertManager(alertPresenter: mockPresenter, + modalAlertScheduler: mockModalScheduler, + userNotificationAlertScheduler: mockUserNotificationScheduler, + fileManager: mockFileManager, + alertStore: mockAlertStore, + bluetoothProvider: MockBluetoothProvider(), + analyticsServicesManager: AnalyticsServicesManager()) + let now = Date() + alertManager.recordRetractedAlert(alert, at: now) + XCTAssertEqual(mockAlertStore.retractedAlert, alert) + XCTAssertEqual(mockAlertStore.retractedAlertDate, now) + } + } + + func testScheduleAlertForWorkoutReminder() { + alertManager.presetActivated(context: .legacyWorkout, duration: .indefinite) + XCTAssertEqual(AlertManager.workoutOverrideReminderAlertIdentifier, mockModalScheduler.scheduledAlert?.identifier) + XCTAssertEqual(AlertManager.workoutOverrideReminderAlertIdentifier, mockUserNotificationScheduler.scheduledAlert?.identifier) + XCTAssertEqual(AlertManager.workoutOverrideReminderAlertIdentifier, mockAlertStore.issuedAlert?.identifier) + + alertManager.presetDeactivated(context: .legacyWorkout) + XCTAssertEqual(AlertManager.workoutOverrideReminderAlertIdentifier, mockModalScheduler.unscheduledAlertIdentifier) + XCTAssertEqual(AlertManager.workoutOverrideReminderAlertIdentifier, mockUserNotificationScheduler.unscheduledAlertIdentifier) + XCTAssertEqual(AlertManager.workoutOverrideReminderAlertIdentifier, mockAlertStore.retractededAlertIdentifier) + } + + func testLoopDidCompleteRecordsNotifications() { + alertManager.loopDidComplete() + XCTAssertEqual(4, UserDefaults.appGroup?.loopNotRunningNotifications.count) + } + + func testLoopFailureFor10MinutesDoesNotRecordAlert() { + alertManager.loopDidComplete() + XCTAssertNil(mockAlertStore.issuedAlert) + alertManager.getCurrentDate = { return Date().addingTimeInterval(.minutes(10))} + alertManager.inferDeliveredLoopNotRunningNotifications() + XCTAssertNil(mockAlertStore.issuedAlert) + } + + func testLoopFailureFor30MinutesRecordsTimeSensitiveAlert() { + alertManager.loopDidComplete() + XCTAssertNil(mockAlertStore.issuedAlert) + alertManager.getCurrentDate = { return Date().addingTimeInterval(.minutes(30))} + alertManager.inferDeliveredLoopNotRunningNotifications() + XCTAssertEqual(3, UserDefaults.appGroup?.loopNotRunningNotifications.count) + XCTAssertNotNil(mockAlertStore.issuedAlert) + XCTAssertEqual(.timeSensitive, mockAlertStore.issuedAlert!.interruptionLevel) + } + + func testLoopFailureFor65MinutesRecordsCriticalAlert() { + alertManager.loopDidComplete() + alertManager.getCurrentDate = { return Date().addingTimeInterval(.minutes(65))} + alertManager.inferDeliveredLoopNotRunningNotifications() + XCTAssertEqual(1, UserDefaults.appGroup?.loopNotRunningNotifications.count) + XCTAssertNotNil(mockAlertStore.issuedAlert) + XCTAssertEqual(.critical, mockAlertStore.issuedAlert!.interruptionLevel) + } + + func testRescheduleMutedLoopNotLoopingAlerts() { + UNUserNotificationCenter.current().removeAllPendingNotificationRequests() + + let lastLoopDate = Date() + alertManager.loopDidComplete(lastLoopDate) + alertManager.alertMuter.configuration.startTime = Date() + alertManager.alertMuter.configuration.duration = .hours(4) + waitOnMain() + + let testExpectation = expectation(description: #function) + var loopNotRunningRequests: [UNNotificationRequest] = [] + UNUserNotificationCenter.current().getPendingNotificationRequests() { notificationRequests in + loopNotRunningRequests = notificationRequests.filter({ + $0.content.categoryIdentifier == LoopNotificationCategory.loopNotRunning.rawValue + }) + testExpectation.fulfill() + } + + wait(for: [testExpectation], timeout: 1) + if #available(iOS 15.0, *) { + XCTAssertNil(loopNotRunningRequests.first(where: { $0.content.interruptionLevel == .timeSensitive })?.content.sound) + if let request = loopNotRunningRequests.first(where: { $0.content.interruptionLevel == .critical }) { + XCTAssertEqual(request.content.sound, .defaultCriticalSound(withAudioVolume: 0)) + } + } else if FeatureFlags.criticalAlertsEnabled { + for request in loopNotRunningRequests { + let sound = request.content.sound + XCTAssertTrue(sound == nil || sound == .defaultCriticalSound(withAudioVolume: 0.0)) + } + } else { + for request in loopNotRunningRequests { + XCTAssertNil(request.content.sound) + } + } + } +} + +extension Swift.Result { + var successValue: Success? { + switch self { + case .failure: return nil + case .success(let s): return s + } + } +} + +class MockUserNotificationCenter: UserNotificationCenter { + + var pendingRequests = [UNNotificationRequest]() + var deliveredRequests = [UNNotificationRequest]() + + func add(_ request: UNNotificationRequest, withCompletionHandler completionHandler: ((Error?) -> Void)? = nil) { + pendingRequests.append(request) + } + + func removePendingNotificationRequests(withIdentifiers identifiers: [String]) { + identifiers.forEach { identifier in + pendingRequests.removeAll { $0.identifier == identifier } + } + } + + func removeDeliveredNotifications(withIdentifiers identifiers: [String]) { + identifiers.forEach { identifier in + deliveredRequests.removeAll { $0.identifier == identifier } + } + } + + func deliverAll() { + deliveredRequests = pendingRequests + pendingRequests = [] + } + + func getDeliveredNotifications(completionHandler: @escaping ([UNNotification]) -> Void) { + // Sadly, we can't create UNNotifications. + completionHandler([]) + } + + func getPendingNotificationRequests(completionHandler: @escaping ([UNNotificationRequest]) -> Void) { + completionHandler(pendingRequests) + } +} diff --git a/LoopTests/Managers/Alerts/AlertMuterTests.swift b/LoopTests/Managers/Alerts/AlertMuterTests.swift new file mode 100644 index 0000000000..bcabfb33da --- /dev/null +++ b/LoopTests/Managers/Alerts/AlertMuterTests.swift @@ -0,0 +1,176 @@ +// +// AlertMuterTests.swift +// LoopTests +// +// Created by Nathaniel Hamming on 2022-09-29. +// Copyright © 2022 LoopKit Authors. All rights reserved. +// + +import XCTest +import Combine +import LoopKit +@testable import Loop + +final class AlertMuterTests: XCTestCase { + + func testInitialization() { + var alertMuter = AlertMuter(duration: AlertMuter.allowedDurations[1]) + XCTAssertFalse(alertMuter.configuration.shouldMute) + XCTAssertEqual(alertMuter.configuration.duration, AlertMuter.allowedDurations[1]) + XCTAssertNil(alertMuter.configuration.startTime) + + let now = Date() + alertMuter = AlertMuter(startTime: now) + XCTAssertTrue(alertMuter.configuration.shouldMute) + XCTAssertEqual(alertMuter.configuration.duration, AlertMuter.allowedDurations[0]) + XCTAssertEqual(alertMuter.configuration.startTime, now) + } + + func testPublishingUpdateDuration() { + var cancellables: Set = [] + let alertMuter = AlertMuter() + var receivedConfiguration: AlertMuter.Configuration? + let testExpection = expectation(description: #function) + testExpection.assertForOverFulfill = false + alertMuter.$configuration + .sink { configuration in + receivedConfiguration = configuration + testExpection.fulfill() + } + .store(in: &cancellables) + + alertMuter.configuration.duration = .minutes(30) + wait(for: [testExpection], timeout: 1) + XCTAssertEqual(receivedConfiguration, alertMuter.configuration) + } + + func testPublishingUpdateStartTime() { + var cancellables: Set = [] + let alertMuter = AlertMuter() + var receivedConfiguration: AlertMuter.Configuration? + let testExpection = expectation(description: #function) + testExpection.assertForOverFulfill = false + alertMuter.$configuration + .sink { configuration in + receivedConfiguration = configuration + testExpection.fulfill() + } + .store(in: &cancellables) + + alertMuter.configuration.startTime = Date() + wait(for: [testExpection], timeout: 1) + XCTAssertEqual(receivedConfiguration, alertMuter.configuration) + } + + func testPublishingMutePeriodEnded() { + var cancellables: Set = [] + let alertMuter = AlertMuter() + var receivedConfiguration: AlertMuter.Configuration? + let testExpection = expectation(description: #function) + testExpection.assertForOverFulfill = false + alertMuter.configuration.startTime = Date() + alertMuter.configuration.duration = .seconds(0.5) + + alertMuter.$configuration + .sink { configuration in + receivedConfiguration = configuration + testExpection.fulfill() + } + .store(in: &cancellables) + + wait(for: [testExpection], timeout: 1) + XCTAssertEqual(receivedConfiguration, alertMuter.configuration) + } + + func testShouldMuteAlertIssuedFromNow() { + let alertMuter = AlertMuter() + XCTAssertFalse(alertMuter.shouldMuteAlert()) + XCTAssertFalse(alertMuter.shouldMuteAlert(scheduledAt: -1)) + + let duration = TimeInterval.minutes(45) + alertMuter.configuration.duration = duration + alertMuter.configuration.startTime = Date() + XCTAssertTrue(alertMuter.shouldMuteAlert()) + XCTAssertFalse(alertMuter.shouldMuteAlert(scheduledAt: duration)) + } + + func testShouldMuteAlert() { + let duration = TimeInterval.seconds(10) + let now = Date() + let durationExpired = now.addingTimeInterval(duration) + let alertMuter = AlertMuter(startTime: now, duration: duration) + let immediateAlert = LoopKit.Alert(identifier: Alert.Identifier(managerIdentifier: "test", alertIdentifier: "test"), foregroundContent: nil, backgroundContent: Alert.Content(title: "test", body: "test", acknowledgeActionButtonLabel: "OK"), trigger: .immediate) + XCTAssertTrue(alertMuter.shouldMuteAlert(immediateAlert)) + XCTAssertTrue(alertMuter.shouldMuteAlert(immediateAlert, issuedDate: now, now: now)) + XCTAssertFalse(alertMuter.shouldMuteAlert(immediateAlert, issuedDate: durationExpired, now: now)) + + let delayedAlert = LoopKit.Alert(identifier: Alert.Identifier(managerIdentifier: "test", alertIdentifier: "test"), foregroundContent: nil, backgroundContent: Alert.Content(title: "test", body: "test", acknowledgeActionButtonLabel: "OK"), trigger: .delayed(interval: duration/5)) + XCTAssertTrue(alertMuter.shouldMuteAlert(delayedAlert, issuedDate: now, now: now)) + XCTAssertFalse(alertMuter.shouldMuteAlert(delayedAlert, issuedDate: durationExpired, now: now)) + + let repeatedAlert = LoopKit.Alert(identifier: Alert.Identifier(managerIdentifier: "test", alertIdentifier: "test"), foregroundContent: nil, backgroundContent: Alert.Content(title: "test", body: "test", acknowledgeActionButtonLabel: "OK"), trigger: .repeating(repeatInterval: duration/2)) + XCTAssertTrue(alertMuter.shouldMuteAlert(repeatedAlert, issuedDate: now, now: now)) + XCTAssertFalse(alertMuter.shouldMuteAlert(repeatedAlert, issuedDate: durationExpired, now: now)) + } + + // MARK: Configuration Tests + + func testRawValue() { + let now = Date() + let alertMuter = AlertMuter(startTime: now) + let rawValue = alertMuter.configuration.rawValue + XCTAssertEqual(rawValue["duration"] as? TimeInterval, alertMuter.configuration.duration) + XCTAssertEqual(rawValue["startTime"] as? Date, alertMuter.configuration.startTime) + } + + func testInitFromRawValue() { + let duration = TimeInterval.minutes(30) + let now = Date() + let rawValue: [String: Any] = ["duration": duration, "startTime": now] + + let configuration = AlertMuter.Configuration(rawValue: rawValue) + XCTAssertEqual(duration, configuration?.duration) + XCTAssertEqual(now, configuration?.startTime) + } + + func testInitFromRawValueNil() { + let rawValue = ["startTime": Date()] + XCTAssertNil(AlertMuter.Configuration(rawValue: rawValue)) + } + + func testShouldMute() { + var configuration = AlertMuter.Configuration() + XCTAssertFalse(configuration.shouldMute) + + configuration.startTime = Date() + XCTAssertTrue(configuration.shouldMute) + + let duration = TimeInterval.minutes(45) + configuration.duration = duration + configuration.startTime = Date().addingTimeInterval(-(duration+1)) + XCTAssertFalse(configuration.shouldMute) + } + + func testMutingEndTime() { + var configuration = AlertMuter.Configuration() + XCTAssertNil(configuration.mutingEndTime) + + let duration = TimeInterval.minutes(45) + configuration.duration = duration + let now = Date() + configuration.startTime = now + XCTAssertEqual(configuration.mutingEndTime, now.addingTimeInterval(duration)) + } + + func testShouldMuteAlertScheduledAt() { + var configuration = AlertMuter.Configuration() + XCTAssertFalse(configuration.shouldMuteAlert()) + XCTAssertFalse(configuration.shouldMuteAlert(scheduledAt: -1)) + + let duration = TimeInterval.minutes(45) + configuration.duration = duration + configuration.startTime = Date() + XCTAssertTrue(configuration.shouldMuteAlert()) + XCTAssertFalse(configuration.shouldMuteAlert(scheduledAt: duration)) + } +} diff --git a/LoopTests/Managers/Alerts/AlertStoreTests.swift b/LoopTests/Managers/Alerts/AlertStoreTests.swift new file mode 100644 index 0000000000..bb9d109633 --- /dev/null +++ b/LoopTests/Managers/Alerts/AlertStoreTests.swift @@ -0,0 +1,902 @@ +// +// AlertStoreTests.swift +// LoopTests +// +// Created by Rick Pasetto on 5/19/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import CoreData +import LoopKit +import XCTest +@testable import Loop + +class AlertStoreTests: XCTestCase { + + var alertStore: AlertStore! + + static let defaultTimeout: TimeInterval = 1.5 + static let expiryInterval: TimeInterval = 24 /* hours */ * 60 /* minutes */ * 60 /* seconds */ + static let historicDate = Date(timeIntervalSinceNow: -expiryInterval + TimeInterval.hours(4)) // Within default 24 hour expiration + + static let identifier1 = Alert.Identifier(managerIdentifier: "managerIdentifier1", alertIdentifier: "alertIdentifier1") + static let backgroundContent = Alert.Content(title: "BACKGROUND", body: "background", acknowledgeActionButtonLabel: "OK") + let alert1 = Alert(identifier: identifier1, foregroundContent: nil, backgroundContent: backgroundContent, trigger: .immediate, sound: nil) + static let identifier2 = Alert.Identifier(managerIdentifier: "managerIdentifier2", alertIdentifier: "alertIdentifier2") + static let content = Alert.Content(title: "title", body: "body", acknowledgeActionButtonLabel: "label") + let alert2 = Alert(identifier: identifier2, foregroundContent: content, backgroundContent: content, trigger: .immediate, interruptionLevel: .critical, sound: .sound(name: "soundName")) + static let delayedAlertDelay = 30.0 // seconds + static let delayedAlertIdentifier = Alert.Identifier(managerIdentifier: "managerIdentifier3", alertIdentifier: "alertIdentifier3") + let delayedAlert = Alert(identifier: delayedAlertIdentifier, foregroundContent: nil, backgroundContent: backgroundContent, trigger: .delayed(interval: delayedAlertDelay), sound: nil) + static let repeatingAlertDelay = 30.0 // seconds + static let repeatingAlertIdentifier = Alert.Identifier(managerIdentifier: "managerIdentifier4", alertIdentifier: "alertIdentifier4") + let repeatingAlert = Alert(identifier: repeatingAlertIdentifier, foregroundContent: nil, backgroundContent: backgroundContent, trigger: .repeating(repeatInterval: repeatingAlertDelay), sound: nil) + + override func setUp() { + alertStore = AlertStore(expireAfter: Self.expiryInterval) + } + + override func tearDown() { + alertStore = nil + } + + func testTriggerTypeIntervalConversion() { + let immediate = Alert.Trigger.immediate + let delayed = Alert.Trigger.delayed(interval: 1.0) + let repeating = Alert.Trigger.repeating(repeatInterval: 2.0) + XCTAssertEqual(immediate, try? Alert.Trigger(storedType: immediate.storedType, storedInterval: immediate.storedInterval)) + XCTAssertEqual(delayed, try? Alert.Trigger(storedType: delayed.storedType, storedInterval: delayed.storedInterval)) + XCTAssertEqual(repeating, try? Alert.Trigger(storedType: repeating.storedType, storedInterval: repeating.storedInterval)) + XCTAssertNil(immediate.storedInterval) + } + + func testTriggerTypeIntervalConversionAdjustedForStorageTime() { + let immediate = Alert.Trigger.immediate + let delayed = Alert.Trigger.delayed(interval: 10.0) + let repeating = Alert.Trigger.repeating(repeatInterval: 20.0) + XCTAssertEqual(immediate, try? Alert.Trigger(storedType: immediate.storedType, storedInterval: immediate.storedInterval, storageDate: Self.historicDate)) + XCTAssertEqual(immediate, try? Alert.Trigger(storedType: delayed.storedType, storedInterval: delayed.storedInterval, storageDate: Self.historicDate)) + XCTAssertEqual(immediate, try? Alert.Trigger(storedType: delayed.storedType, storedInterval: delayed.storedInterval, storageDate: Date(timeIntervalSinceNow: -10.0.nextUp))) + XCTAssertEqual(Alert.Trigger.delayed(interval: 10.0), try? Alert.Trigger(storedType: delayed.storedType, storedInterval: delayed.storedInterval, storageDate: Date(timeIntervalSinceNow: 5.0))) + let adjustedTrigger = try? Alert.Trigger(storedType: delayed.storedType, storedInterval: delayed.storedInterval, storageDate: Date(timeIntervalSinceNow: -5.0)) + switch adjustedTrigger { + case .delayed(let interval): XCTAssertLessThanOrEqual(interval, 5.0) // The new delay interval value may be close to, but no more than 5, but not exact + default: XCTFail("Wrong trigger") + } + XCTAssertEqual(repeating, try? Alert.Trigger(storedType: repeating.storedType, storedInterval: repeating.storedInterval, storageDate: Self.historicDate)) + XCTAssertNil(immediate.storedInterval) + } + + func testStoredAlertSerialization() { + alertStore.managedObjectContext.performAndWait { + let object = StoredAlert(from: alert2, context: alertStore.managedObjectContext, issuedDate: Self.historicDate) + XCTAssertNil(object.acknowledgedDate) + XCTAssertNil(object.retractedDate) + XCTAssertEqual("{\"acknowledgeActionButtonLabel\":\"label\",\"body\":\"body\",\"title\":\"title\"}", object.backgroundContent) + XCTAssertEqual("{\"acknowledgeActionButtonLabel\":\"label\",\"body\":\"body\",\"title\":\"title\"}", object.foregroundContent) + XCTAssertEqual("managerIdentifier2.alertIdentifier2", object.identifier.value) + XCTAssertEqual(Self.historicDate, object.issuedDate) + XCTAssertEqual(1, object.modificationCounter) + XCTAssertEqual("{\"sound\":{\"name\":\"soundName\"}}", object.sound) + XCTAssertEqual(Alert.Trigger.immediate, object.trigger) + XCTAssertEqual(Alert.InterruptionLevel.critical, object.interruptionLevel) + } + } + + func testQueryAnchorSerialization() { + var anchor = AlertStore.QueryAnchor() + anchor.modificationCounter = 999 + let newAnchor = AlertStore.QueryAnchor(rawValue: anchor.rawValue) + XCTAssertEqual(anchor, newAnchor) + XCTAssertEqual(999, newAnchor?.modificationCounter) + } + + func testRecordIssued() { + let expect = self.expectation(description: #function) + alertStore.recordIssued(alert: alert1, at: Self.historicDate, completion: self.expectSuccess { + self.alertStore.fetch(identifier: Self.identifier1, completion: self.expectSuccess { storedAlerts in + XCTAssertEqual(1, storedAlerts.count) + XCTAssertEqual(Self.identifier1, storedAlerts.first?.identifier) + XCTAssertEqual(Self.historicDate, storedAlerts.first?.issuedDate) + XCTAssertNil(storedAlerts.first?.acknowledgedDate) + XCTAssertNil(storedAlerts.first?.retractedDate) + expect.fulfill() + }) + }) + wait(for: [expect], timeout: Self.defaultTimeout) + } + + func testRecordIssuedTwo() { + let expect = self.expectation(description: #function) + alertStore.recordIssued(alert: alert1, at: Self.historicDate, completion: self.expectSuccess { + self.alertStore.recordIssued(alert: self.alert1, at: Self.historicDate, completion: self.expectSuccess { + self.alertStore.fetch(identifier: Self.identifier1, completion: self.expectSuccess { storedAlerts in + self.assertEqual([self.alert1, self.alert1], storedAlerts) + expect.fulfill() + }) + }) + }) + wait(for: [expect], timeout: Self.defaultTimeout) + } + + func testRecordAcknowledged() { + let expect = self.expectation(description: #function) + let issuedDate = Self.historicDate + let acknowledgedDate = issuedDate.addingTimeInterval(1) + alertStore.recordIssued(alert: alert1, at: Self.historicDate, completion: self.expectSuccess { + self.alertStore.recordAcknowledgement(of: Self.identifier1, at: acknowledgedDate, completion: self.expectSuccess { + self.alertStore.fetch(identifier: Self.identifier1, completion: self.expectSuccess { storedAlerts in + XCTAssertEqual(1, storedAlerts.count) + XCTAssertEqual(Self.identifier1, storedAlerts.first?.identifier) + XCTAssertEqual(issuedDate, storedAlerts.first?.issuedDate) + XCTAssertEqual(acknowledgedDate, storedAlerts.first?.acknowledgedDate) + XCTAssertNil(storedAlerts.first?.retractedDate) + expect.fulfill() + }) + }) + }) + wait(for: [expect], timeout: Self.defaultTimeout) + } + + func testRecordAcknowledgedOfInvalid() { + let expect = self.expectation(description: #function) + self.alertStore.recordAcknowledgement(of: Self.identifier1, at: Self.historicDate) { + switch $0 { + case .failure: break + case .success: XCTFail("Unexpected success") + } + expect.fulfill() + } + wait(for: [expect], timeout: Self.defaultTimeout) + } + + func testRecordRetracted() { + let expect = self.expectation(description: #function) + let issuedDate = Self.historicDate + let retractedDate = issuedDate.addingTimeInterval(2) + alertStore.recordIssued(alert: alert1, at: Self.historicDate, completion: self.expectSuccess { + self.alertStore.recordRetraction(of: Self.identifier1, at: retractedDate, completion: self.expectSuccess { + self.alertStore.fetch(identifier: Self.identifier1, completion: self.expectSuccess { storedAlerts in + XCTAssertEqual(1, storedAlerts.count) + XCTAssertEqual(Self.identifier1, storedAlerts.first?.identifier) + XCTAssertEqual(issuedDate, storedAlerts.first?.issuedDate) + XCTAssertEqual(retractedDate, storedAlerts.first?.retractedDate) + XCTAssertNil(storedAlerts.first?.acknowledgedDate) + expect.fulfill() + }) + }) + }) + wait(for: [expect], timeout: Self.defaultTimeout) + } + + func testRecordIssuedExpiresOld() { + let expect = self.expectation(description: #function) + alertStore.recordIssued(alert: alert1, at: Date.distantPast, completion: self.expectSuccess { + self.alertStore.recordIssued(alert: self.alert1, at: Self.historicDate, completion: self.expectSuccess { + self.alertStore.fetch(identifier: Self.identifier1, completion: self.expectSuccess { storedAlerts in + XCTAssertEqual(1, storedAlerts.count) + XCTAssertEqual(Self.identifier1, storedAlerts.first?.identifier) + XCTAssertEqual(Self.historicDate, storedAlerts.first?.issuedDate) + XCTAssertNil(storedAlerts.first?.acknowledgedDate) + XCTAssertNil(storedAlerts.first?.retractedDate) + expect.fulfill() + }) + }) + }) + wait(for: [expect], timeout: Self.defaultTimeout) + } + + func testRecordAcknowledgedExpiresOld() { + // TODO: Not quite sure how to do this yet. + } + + func testRecordRetractedExpiresOld() { + // TODO: Not quite sure how to do this yet. + } + + func testRecordRetractedBeforeDelayShouldDelete() { + let expect = self.expectation(description: #function) + let issuedDate = Self.historicDate + let retractedDate = issuedDate + Self.delayedAlertDelay - 1.0 + alertStore.recordIssued(alert: delayedAlert, at: issuedDate, completion: self.expectSuccess { + self.alertStore.recordRetraction(of: Self.delayedAlertIdentifier, at: retractedDate, completion: self.expectSuccess { + self.alertStore.fetch(identifier: Self.delayedAlertIdentifier, completion: self.expectSuccess { storedAlerts in + XCTAssertEqual(0, storedAlerts.count) + expect.fulfill() + }) + }) + }) + wait(for: [expect], timeout: Self.defaultTimeout) + } + + func testRecordRetractedBeforeRepeatDelayShouldDelete() { + let expect = self.expectation(description: #function) + let issuedDate = Self.historicDate + let retractedDate = issuedDate + Self.repeatingAlertDelay - 1.0 + alertStore.recordIssued(alert: repeatingAlert, at: issuedDate, completion: self.expectSuccess { + self.alertStore.recordRetraction(of: Self.repeatingAlertIdentifier, at: retractedDate, completion: self.expectSuccess { + self.alertStore.fetch(identifier: Self.repeatingAlertIdentifier, completion: self.expectSuccess { storedAlerts in + XCTAssertEqual(0, storedAlerts.count) + expect.fulfill() + }) + }) + }) + wait(for: [expect], timeout: Self.defaultTimeout) + } + + func testRecordRetractedExactlyAtDelayShouldDelete() { + let expect = self.expectation(description: #function) + let issuedDate = Self.historicDate + let retractedDate = issuedDate + Self.delayedAlertDelay + alertStore.recordIssued(alert: delayedAlert, at: issuedDate, completion: self.expectSuccess { + self.alertStore.recordRetraction(of: Self.delayedAlertIdentifier, at: retractedDate, completion: self.expectSuccess { + self.alertStore.fetch(identifier: Self.delayedAlertIdentifier, completion: self.expectSuccess { storedAlerts in + XCTAssertEqual(0, storedAlerts.count) + expect.fulfill() + }) + }) + }) + wait(for: [expect], timeout: Self.defaultTimeout) + } + + func testRecordRetractedExactlyAtRepeatDelayShouldDelete() { + let expect = self.expectation(description: #function) + let issuedDate = Self.historicDate + let retractedDate = issuedDate + Self.repeatingAlertDelay + alertStore.recordIssued(alert: repeatingAlert, at: issuedDate, completion: self.expectSuccess { + self.alertStore.recordRetraction(of: Self.repeatingAlertIdentifier, at: retractedDate, completion: self.expectSuccess { + self.alertStore.fetch(identifier: Self.repeatingAlertIdentifier, completion: self.expectSuccess { storedAlerts in + XCTAssertEqual(0, storedAlerts.count) + expect.fulfill() + }) + }) + }) + wait(for: [expect], timeout: Self.defaultTimeout) + } + + + func testRecordRetractedAfterDelayShouldRetract() { + let expect = self.expectation(description: #function) + let issuedDate = Self.historicDate + let retractedDate = issuedDate + Self.delayedAlertDelay + 1.0 + alertStore.recordIssued(alert: delayedAlert, at: issuedDate, completion: self.expectSuccess { + self.alertStore.recordRetraction(of: Self.delayedAlertIdentifier, at: retractedDate, completion: self.expectSuccess { + self.alertStore.fetch(identifier: Self.delayedAlertIdentifier, completion: self.expectSuccess { storedAlerts in + XCTAssertEqual(1, storedAlerts.count) + XCTAssertEqual(Self.delayedAlertIdentifier, storedAlerts.first?.identifier) + XCTAssertEqual(issuedDate, storedAlerts.first?.issuedDate) + XCTAssertEqual(retractedDate, storedAlerts.first?.retractedDate) + XCTAssertNil(storedAlerts.first?.acknowledgedDate) + expect.fulfill() + }) + }) + }) + wait(for: [expect], timeout: Self.defaultTimeout) + } + + func testRecordRetractedAfterRepeatDelayShouldRetract() { + let expect = self.expectation(description: #function) + let issuedDate = Self.historicDate + let retractedDate = issuedDate + Self.repeatingAlertDelay + 1.0 + alertStore.recordIssued(alert: repeatingAlert, at: issuedDate, completion: self.expectSuccess { + self.alertStore.recordRetraction(of: Self.repeatingAlertIdentifier, at: retractedDate, completion: self.expectSuccess { + self.alertStore.fetch(identifier: Self.repeatingAlertIdentifier, completion: self.expectSuccess { storedAlerts in + XCTAssertEqual(1, storedAlerts.count) + XCTAssertEqual(Self.repeatingAlertIdentifier, storedAlerts.first?.identifier) + XCTAssertEqual(issuedDate, storedAlerts.first?.issuedDate) + XCTAssertEqual(retractedDate, storedAlerts.first?.retractedDate) + XCTAssertNil(storedAlerts.first?.acknowledgedDate) + expect.fulfill() + }) + }) + }) + wait(for: [expect], timeout: Self.defaultTimeout) + } + + // These next two tests are admittedly weird corner cases, but theoretically they might be race conditions, + // and so are allowed + func testRecordRetractedThenAcknowledged() { + let expect = self.expectation(description: #function) + let issuedDate = Self.historicDate + let retractedDate = issuedDate.addingTimeInterval(2) + let acknowledgedDate = issuedDate.addingTimeInterval(4) + alertStore.recordIssued(alert: alert1, at: Self.historicDate, completion: self.expectSuccess { + self.alertStore.recordRetraction(of: Self.identifier1, at: retractedDate, completion: self.expectSuccess { + self.alertStore.recordAcknowledgement(of: Self.identifier1, at: acknowledgedDate, completion: self.expectSuccess { + self.alertStore.fetch(identifier: Self.identifier1, completion: self.expectSuccess { storedAlerts in + XCTAssertEqual(1, storedAlerts.count) + XCTAssertEqual(Self.identifier1, storedAlerts.first?.identifier) + XCTAssertEqual(issuedDate, storedAlerts.first?.issuedDate) + XCTAssertEqual(acknowledgedDate, storedAlerts.first?.acknowledgedDate) + XCTAssertEqual(retractedDate, storedAlerts.first?.retractedDate) + expect.fulfill() + }) + }) + }) + }) + wait(for: [expect], timeout: Self.defaultTimeout) + } + + func testRecordAcknowledgedThenRetracted() { + let expect = self.expectation(description: #function) + let issuedDate = Self.historicDate + let retractedDate = issuedDate.addingTimeInterval(2) + let acknowledgedDate = issuedDate.addingTimeInterval(4) + alertStore.recordIssued(alert: alert1, at: Self.historicDate, completion: self.expectSuccess { + self.alertStore.recordAcknowledgement(of: Self.identifier1, at: acknowledgedDate, completion: self.expectSuccess { + self.alertStore.recordRetraction(of: Self.identifier1, at: retractedDate, completion: self.expectSuccess { + self.alertStore.fetch(identifier: Self.identifier1, completion: self.expectSuccess { storedAlerts in + XCTAssertEqual(1, storedAlerts.count) + XCTAssertEqual(Self.identifier1, storedAlerts.first?.identifier) + XCTAssertEqual(issuedDate, storedAlerts.first?.issuedDate) + XCTAssertEqual(acknowledgedDate, storedAlerts.first?.acknowledgedDate) + XCTAssertEqual(retractedDate, storedAlerts.first?.retractedDate) + expect.fulfill() + }) + }) + }) + }) + wait(for: [expect], timeout: Self.defaultTimeout) + } + + func testRecordRetractedAlert() { + let expect = self.expectation(description: #function) + let alertDate = Self.historicDate + alertStore.recordRetractedAlert(alert1, at: alertDate, completion: self.expectSuccess { + self.alertStore.fetch(identifier: Self.identifier1, completion: self.expectSuccess { storedAlerts in + XCTAssertEqual(1, storedAlerts.count) + XCTAssertEqual(Self.identifier1, storedAlerts.first?.identifier) + XCTAssertEqual(alertDate, storedAlerts.first?.issuedDate) + XCTAssertNil(storedAlerts.first?.acknowledgedDate) + XCTAssertEqual(alertDate, storedAlerts.first?.retractedDate) + expect.fulfill() + }) + }) + wait(for: [expect], timeout: Self.defaultTimeout) + } + + func testEmptyQuery() { + let expect = self.expectation(description: #function) + alertStore.recordIssued(alert: alert1, at: Self.historicDate, completion: self.expectSuccess { + self.alertStore.executeQuery(since: Date.distantPast, limit: 0, completion: self.expectSuccess { _, objects in + XCTAssertTrue(objects.isEmpty) + expect.fulfill() + }) + }) + wait(for: [expect], timeout: Self.defaultTimeout) + } + + func testSimpleQuery() { + let expect = self.expectation(description: #function) + alertStore.recordIssued(alert: alert1, at: Self.historicDate, completion: self.expectSuccess { + self.alertStore.executeQuery(since: Date.distantPast, limit: 100, completion: self.expectSuccess { anchor, objects in + XCTAssertEqual(1, anchor.modificationCounter) + XCTAssertEqual(1, objects.count) + XCTAssertEqual(Self.identifier1, objects.first?.identifier) + XCTAssertEqual(Self.historicDate, objects.first?.issuedDate) + XCTAssertNil(objects.first?.acknowledgedDate) + XCTAssertNil(objects.first?.retractedDate) + expect.fulfill() + }) + }) + wait(for: [expect], timeout: Self.defaultTimeout) + } + + func testSimpleQueryThenRetraction() { + let expect = self.expectation(description: #function) + let issuedDate = Self.historicDate + let retractedDate = issuedDate.addingTimeInterval(2) + alertStore.recordIssued(alert: alert1, at: Self.historicDate, completion: self.expectSuccess { + self.alertStore.executeQuery(since: Date.distantPast, limit: 100, completion: self.expectSuccess { anchor, objects in + XCTAssertEqual(1, anchor.modificationCounter) + XCTAssertEqual(1, objects.count) + XCTAssertEqual(Self.identifier1, objects.first?.identifier) + XCTAssertEqual(Self.historicDate, objects.first?.issuedDate) + XCTAssertNil(objects.first?.acknowledgedDate) + XCTAssertNil(objects.first?.retractedDate) + self.alertStore.recordRetraction(of: Self.identifier1, at: retractedDate, completion: self.expectSuccess { + self.alertStore.executeQuery(since: Date.distantPast, limit: 100, completion: self.expectSuccess { anchor, objects in + XCTAssertEqual(2, anchor.modificationCounter) + XCTAssertEqual(1, objects.count) + XCTAssertEqual(Self.identifier1, objects.first?.identifier) + XCTAssertEqual(issuedDate, objects.first?.issuedDate) + XCTAssertEqual(retractedDate, objects.first?.retractedDate) + XCTAssertNil(objects.first?.acknowledgedDate) + expect.fulfill() + }) + }) + }) + }) + wait(for: [expect], timeout: Self.defaultTimeout) + } + + func testQueryByDate() { + let expect = self.expectation(description: #function) + alertStore.recordIssued(alert: alert1, at: Self.historicDate, completion: self.expectSuccess { + let now = Date() + self.alertStore.recordIssued(alert: self.alert2, at: now, completion: self.expectSuccess { + self.alertStore.executeQuery(since: now, limit: 100, completion: self.expectSuccess { anchor, objects in + XCTAssertEqual(2, anchor.modificationCounter) + XCTAssertEqual(1, objects.count) + XCTAssertEqual(Self.identifier2, objects.first?.identifier) + XCTAssertEqual(now, objects.first?.issuedDate) + XCTAssertNil(objects.first?.acknowledgedDate) + XCTAssertNil(objects.first?.retractedDate) + expect.fulfill() + }) + }) + }) + wait(for: [expect], timeout: Self.defaultTimeout) + } + + func testQueryByDateExcludingFutureDelayed() { + let expect = self.expectation(description: #function) + let now = Date() + alertStore.recordIssued(alert: alert1, at: now, completion: self.expectSuccess { + self.alertStore.recordIssued(alert: self.delayedAlert, at: now, completion: self.expectSuccess { + self.alertStore.executeQuery(since: now, limit: 100, completion: self.expectSuccess { anchor, objects in + XCTAssertEqual(1, anchor.modificationCounter) + XCTAssertEqual(1, objects.count) + XCTAssertEqual(Self.identifier1, objects.first?.identifier) + XCTAssertEqual(now, objects.first?.issuedDate) + XCTAssertNil(objects.first?.acknowledgedDate) + XCTAssertNil(objects.first?.retractedDate) + expect.fulfill() + }) + }) + }) + wait(for: [expect], timeout: Self.defaultTimeout) + } + + func testQueryByDateExcludingFutureRepeating() { + let expect = self.expectation(description: #function) + let now = Date() + alertStore.recordIssued(alert: alert1, at: now, completion: self.expectSuccess { + self.alertStore.recordIssued(alert: self.repeatingAlert, at: now, completion: self.expectSuccess { + self.alertStore.executeQuery(since: now, limit: 100, completion: self.expectSuccess { anchor, objects in + XCTAssertEqual(1, anchor.modificationCounter) + XCTAssertEqual(1, objects.count) + XCTAssertEqual(Self.identifier1, objects.first?.identifier) + XCTAssertEqual(now, objects.first?.issuedDate) + XCTAssertNil(objects.first?.acknowledgedDate) + XCTAssertNil(objects.first?.retractedDate) + expect.fulfill() + }) + }) + }) + wait(for: [expect], timeout: Self.defaultTimeout) + } + + func testQueryByDateNotExcludingFutureDelayed() { + let expect = self.expectation(description: #function) + let now = Date() + alertStore.recordIssued(alert: alert1, at: now, completion: self.expectSuccess { + self.alertStore.recordIssued(alert: self.delayedAlert, at: now, completion: self.expectSuccess { + self.alertStore.executeQuery(since: now, excludingFutureAlerts: false, limit: 100, completion: self.expectSuccess { anchor, objects in + XCTAssertEqual(2, anchor.modificationCounter) + self.assertEqual([self.alert1, self.delayedAlert], objects) + expect.fulfill() + }) + }) + }) + wait(for: [expect], timeout: Self.defaultTimeout) + } + + func testQueryWithLimit() { + let expect = self.expectation(description: #function) + alertStore.recordIssued(alert: alert1, at: Self.historicDate, completion: self.expectSuccess { + self.alertStore.recordIssued(alert: self.alert2, at: Date(), completion: self.expectSuccess { + self.alertStore.executeQuery(since: Date.distantPast, limit: 1, completion: self.expectSuccess { anchor, objects in + XCTAssertEqual(1, anchor.modificationCounter) + XCTAssertEqual(1, objects.count) + XCTAssertEqual(Self.identifier1, objects.first?.identifier) + XCTAssertEqual(Self.historicDate, objects.first?.issuedDate) + XCTAssertNil(objects.first?.acknowledgedDate) + XCTAssertNil(objects.first?.retractedDate) + expect.fulfill() + }) + }) + }) + wait(for: [expect], timeout: Self.defaultTimeout) + } + + func testQueryThenContinue() { + let expect = self.expectation(description: #function) + alertStore.recordIssued(alert: alert1, at: Self.historicDate, completion: expectSuccess { + let now = Date() + self.alertStore.recordIssued(alert: self.alert2, at: now, completion: self.expectSuccess { + self.alertStore.executeQuery(since: Date.distantPast, limit: 1, completion: self.expectSuccess { anchor, _ in + self.alertStore.executeQuery(fromQueryAnchor: anchor, since: Date.distantPast, limit: 1, completion: self.expectSuccess { anchor, objects in + XCTAssertEqual(2, anchor.modificationCounter) + XCTAssertEqual(1, objects.count) + XCTAssertEqual(Self.identifier2, objects.first?.identifier) + XCTAssertEqual(now, objects.first?.issuedDate) + XCTAssertNil(objects.first?.acknowledgedDate) + XCTAssertNil(objects.first?.retractedDate) + expect.fulfill() + }) + }) + }) + }) + wait(for: [expect], timeout: Self.defaultTimeout) + } + + func testAcknowledgeFindsCorrectOne() { + let expect = self.expectation(description: #function) + let now = Date() + fillWith(startDate: Self.historicDate, data: [ + (alert1, true, false), + (alert2, false, false), + (alert1, false, false) + ]) { + self.alertStore.recordAcknowledgement(of: self.alert1.identifier, at: now, completion: self.expectSuccess { + self.alertStore.fetch(completion: self.expectSuccess { storedAlerts in + XCTAssertEqual(3, storedAlerts.count) + // Last one is last-modified + XCTAssertNotNil(storedAlerts.last) + if let last = storedAlerts.last { + XCTAssertEqual(Self.identifier1, last.identifier) + XCTAssertEqual(Self.historicDate + 4, last.issuedDate) + XCTAssertEqual(now, last.acknowledgedDate) + XCTAssertNil(last.retractedDate) + } + expect.fulfill() + }) + }) + } + wait(for: [expect], timeout: Self.defaultTimeout) + } + + func testAcknowledgeMultiple() { + let expect = self.expectation(description: #function) + let now = Date() + fillWith(startDate: Self.historicDate, data: [ + (alert1, false, false), + (alert2, false, false), + (alert1, false, false) + ]) { + self.alertStore.recordAcknowledgement(of: self.alert1.identifier, at: now, completion: self.expectSuccess { + self.alertStore.fetch(completion: self.expectSuccess { storedAlerts in + XCTAssertEqual(3, storedAlerts.count) + for alert in storedAlerts where alert.identifier == Self.identifier1 { + XCTAssertEqual(now, alert.acknowledgedDate) + XCTAssertNil(alert.retractedDate) + } + expect.fulfill() + }) + }) + } + wait(for: [expect], timeout: Self.defaultTimeout) + } + + func testLookupAllUnacknowledgedUnretractedEmpty() { + let expect = self.expectation(description: #function) + alertStore.lookupAllUnacknowledgedUnretracted(completion: expectSuccess { alerts in + XCTAssertTrue(alerts.isEmpty) + expect.fulfill() + }) + wait(for: [expect], timeout: Self.defaultTimeout) + } + + func testLookupAllUnacknowledgedUnretractedOne() { + let expect = self.expectation(description: #function) + fillWith(startDate: Self.historicDate, data: [(alert1, false, false)]) { + self.alertStore.lookupAllUnacknowledgedUnretracted(completion: self.expectSuccess { alerts in + self.assertEqual([self.alert1], alerts) + expect.fulfill() + }) + } + wait(for: [expect], timeout: Self.defaultTimeout) + } + + + func testLookupAllUnacknowledgedUnretractedOneAcknowledged() { + let expect = self.expectation(description: #function) + fillWith(startDate: Self.historicDate, data: [(alert1, true, false)]) { + self.alertStore.lookupAllUnacknowledgedUnretracted(completion: self.expectSuccess { alerts in + self.assertEqual([], alerts) + expect.fulfill() + }) + } + wait(for: [expect], timeout: Self.defaultTimeout) + } + + func testLookupAllUnacknowledgedUnretractedSomeNot() { + let expect = self.expectation(description: #function) + fillWith(startDate: Self.historicDate, data: [ + (alert1, true, false), + (alert2, false, false), + (alert1, false, false), + ]) { + self.alertStore.lookupAllUnacknowledgedUnretracted(completion: self.expectSuccess { alerts in + self.assertEqual([self.alert2, self.alert1], alerts) + expect.fulfill() + }) + } + wait(for: [expect], timeout: Self.defaultTimeout) + } + + func testLookupAllUnacknowledgedUnretractedSomeRetracted() { + let expect = self.expectation(description: #function) + fillWith(startDate: Self.historicDate, data: [ + (alert1, false, true), + (alert2, false, false), + (alert1, false, true) + ]) { + self.alertStore.lookupAllUnacknowledgedUnretracted(completion: self.expectSuccess { alerts in + self.assertEqual([self.alert2], alerts) + expect.fulfill() + }) + } + wait(for: [expect], timeout: Self.defaultTimeout) + } + + func testLookupAllUnretractedEmpty() { + let expect = self.expectation(description: #function) + alertStore.lookupAllUnretracted(completion: expectSuccess { alerts in + XCTAssertTrue(alerts.isEmpty) + expect.fulfill() + }) + wait(for: [expect], timeout: Self.defaultTimeout) + } + + func testLookupAllUnretractedOne() { + let expect = self.expectation(description: #function) + fillWith(startDate: Self.historicDate, data: [(alert1, false, false)]) { + self.alertStore.lookupAllUnretracted(completion: self.expectSuccess { alerts in + self.assertEqual([self.alert1], alerts) + expect.fulfill() + }) + } + wait(for: [expect], timeout: Self.defaultTimeout) + } + + + func testLookupAllUnretractedOneAcknowledged() { + let expect = self.expectation(description: #function) + fillWith(startDate: Self.historicDate, data: [(alert1, true, false)]) { + self.alertStore.lookupAllUnretracted(completion: self.expectSuccess { alerts in + self.assertEqual([self.alert1], alerts) + expect.fulfill() + }) + } + wait(for: [expect], timeout: Self.defaultTimeout) + } + + func testLookupAllUnretractedSomeAcknowledgedSomeNot() { + let expect = self.expectation(description: #function) + fillWith(startDate: Self.historicDate, data: [ + (alert1, true, false), + (alert2, false, false), + (alert1, false, false), + ]) { + self.alertStore.lookupAllUnretracted(completion: self.expectSuccess { alerts in + self.assertEqual([self.alert1, self.alert2, self.alert1], alerts) + expect.fulfill() + }) + } + wait(for: [expect], timeout: Self.defaultTimeout) + } + + func testLookupAllUnretractedSomeRetracted() { + let expect = self.expectation(description: #function) + fillWith(startDate: Self.historicDate, data: [ + (alert1, false, true), + (alert2, false, false), + (alert1, false, true) + ]) { + self.alertStore.lookupAllUnretracted(completion: self.expectSuccess { alerts in + self.assertEqual([self.alert2], alerts) + expect.fulfill() + }) + } + wait(for: [expect], timeout: Self.defaultTimeout) + } + + func testLookupAllAcknowledgedUnretractedRepeatingAlertsAll() { + let expect = self.expectation(description: #function) + fillWith(startDate: Self.historicDate, data: [ + (repeatingAlert, true, false), + (repeatingAlert, true, false) + ]) { + self.alertStore.lookupAllAcknowledgedUnretractedRepeatingAlerts(completion: self.expectSuccess { alerts in + XCTAssertEqual(alerts.count, 2) + self.assertEqual([self.repeatingAlert, self.repeatingAlert], alerts) + expect.fulfill() + }) + } + wait(for: [expect], timeout: Self.defaultTimeout) + } + + func testLookupAllAcknowledgedUnretractedRepeatingAlertsEmpty() { + let expect = self.expectation(description: #function) + alertStore.lookupAllAcknowledgedUnretractedRepeatingAlerts(completion: expectSuccess { alerts in + XCTAssertTrue(alerts.isEmpty) + expect.fulfill() + }) + wait(for: [expect], timeout: Self.defaultTimeout) + } + + func testLookupAllAcknowledgedUnretractedRepeatingAlertsSome() { + let expect = self.expectation(description: #function) + fillWith(startDate: Self.historicDate, data: [ + (repeatingAlert, true, true), + (repeatingAlert, true, false), + (alert1, true, false) + ]) { + self.alertStore.lookupAllAcknowledgedUnretractedRepeatingAlerts(completion: self.expectSuccess { alerts in + XCTAssertEqual(alerts.count, 1) + self.assertEqual([self.repeatingAlert], alerts) + expect.fulfill() + }) + } + wait(for: [expect], timeout: Self.defaultTimeout) + } + + func testLookUpAllMatching() { + let expect = self.expectation(description: #function) + fillWith(startDate: Self.historicDate, data: [ + (alert1, true, false), + (repeatingAlert, true, false) + ]) { + self.alertStore.lookupAllMatching(identifier: AlertStoreTests.repeatingAlertIdentifier, completion: self.expectSuccess { alerts in + XCTAssertEqual(alerts.count, 1) + self.assertEqual([self.repeatingAlert], alerts) + expect.fulfill() + }) + } + wait(for: [expect], timeout: Self.defaultTimeout) + } + + private func fillWith(startDate: Date, data: [(alert: Alert, acknowledged: Bool, retracted: Bool)], _ completion: @escaping () -> Void) { + let increment = 1.0 + if let value = data.first { + alertStore.recordIssued(alert: value.alert, at: startDate, completion: self.expectSuccess { + var next = startDate.addingTimeInterval(increment) + self.maybeRecordAcknowledge(acknowledged: value.acknowledged, identifier: value.alert.identifier, at: next) { + next = next.addingTimeInterval(increment) + self.maybeRecordRetracted(retracted: value.retracted, identifier: value.alert.identifier, at: next) { + self.fillWith(startDate: startDate.addingTimeInterval(increment).addingTimeInterval(increment), data: data.suffix(data.count - 1), completion) + } + } + }) + } else { + completion() + } + } + + private func maybeRecordAcknowledge(acknowledged: Bool, identifier: Alert.Identifier, at date: Date, _ completion: @escaping () -> Void) { + if acknowledged { + self.alertStore.recordAcknowledgement(of: identifier, at: date, completion: self.expectSuccess(completion)) + } else { + completion() + } + } + + private func maybeRecordRetracted(retracted: Bool, identifier: Alert.Identifier, at date: Date, _ completion: @escaping () -> Void) { + if retracted { + self.alertStore.recordRetraction(of: identifier, at: date, completion: self.expectSuccess(completion)) + } else { + completion() + } + } + + private func assertEqual(_ alerts: [Alert], _ storedAlerts: [StoredAlert], file: StaticString = #file, line: UInt = #line) { + XCTAssertEqual(alerts.count, storedAlerts.count, file: file, line: line) + if alerts.count == storedAlerts.count { + for (index, alert) in alerts.enumerated() { + XCTAssertEqual(alert.identifier, storedAlerts[index].identifier, file: file, line: line) + } + } + } + + private func assertEqual(_ alerts: [Alert], _ syncAlertObjects: [SyncAlertObject], file: StaticString = #file, line: UInt = #line) { + XCTAssertEqual(alerts.count, syncAlertObjects.count, file: file, line: line) + if alerts.count == syncAlertObjects.count { + for (index, alert) in alerts.enumerated() { + XCTAssertEqual(alert.identifier, syncAlertObjects[index].identifier, file: file, line: line) + } + } + } + + private func expectSuccess(file: StaticString = #file, line: UInt = #line, _ completion: @escaping (T) -> Void) -> ((Result) -> Void) { + return { + switch $0 { + case .failure(let error): XCTFail("Unexpected \(error)", file: file, line: line) + case .success(let value): completion(value) + } + } + } + + private func expectSuccess(file: StaticString = #file, line: UInt = #line, _ completion: @escaping (AlertStore.QueryAnchor, [SyncAlertObject]) -> Void) -> ((AlertStore.AlertQueryResult) -> Void) { + return { + switch $0 { + case .failure(let error): XCTFail("Unexpected \(error)", file: file, line: line) + case .success(let queryAnchor, let objects): completion(queryAnchor, objects) + } + } + } +} + +class AlertStoreLogCriticalEventLogTests: XCTestCase { + var alertStore: AlertStore! + var outputStream: MockOutputStream! + var progress: Progress! + + override func setUp() { + super.setUp() + + let alerts = [AlertStore.DatedAlert(date: dateFormatter.date(from: "2100-01-02T03:08:00Z")!, alert: Alert(identifier: Alert.Identifier(managerIdentifier: "m1", alertIdentifier: "a1"), foregroundContent: nil, backgroundContent: AlertStoreTests.backgroundContent, trigger: .immediate), syncIdentifier: UUID(uuidString: "52A046F7-F449-49B2-B003-7A378D0002DE")!), + AlertStore.DatedAlert(date: dateFormatter.date(from: "2100-01-02T03:10:00Z")!, alert: Alert(identifier: Alert.Identifier(managerIdentifier: "m2", alertIdentifier: "a2"), foregroundContent: nil, backgroundContent: AlertStoreTests.backgroundContent, trigger: .immediate), syncIdentifier: UUID(uuidString: "0929E349-972F-4B06-9808-68914A541515")!), + AlertStore.DatedAlert(date: dateFormatter.date(from: "2100-01-02T03:04:00Z")!, alert: Alert(identifier: Alert.Identifier(managerIdentifier: "m3", alertIdentifier: "a3"), foregroundContent: nil, backgroundContent: AlertStoreTests.backgroundContent, trigger: .immediate), syncIdentifier: UUID(uuidString: "285AEA4B-0DEE-41F4-8669-800E9582A6E7")!), + AlertStore.DatedAlert(date: dateFormatter.date(from: "2100-01-02T03:06:00Z")!, alert: Alert(identifier: Alert.Identifier(managerIdentifier: "m4", alertIdentifier: "a4"), foregroundContent: nil, backgroundContent: AlertStoreTests.backgroundContent, trigger: .immediate), syncIdentifier: UUID(uuidString: "4B3109BD-DE11-42BD-A777-D4783459C483")!), + AlertStore.DatedAlert(date: dateFormatter.date(from: "2100-01-02T03:02:00Z")!, alert: Alert(identifier: Alert.Identifier(managerIdentifier: "m5", alertIdentifier: "a5"), foregroundContent: nil, backgroundContent: AlertStoreTests.backgroundContent, trigger: .immediate), syncIdentifier: UUID(uuidString: "48C8ACC7-9DB7-411D-B5A3-CD907D464B78")!)] + + alertStore = AlertStore() + XCTAssertNil(alertStore.addAlerts(alerts: alerts)) + + outputStream = MockOutputStream() + progress = Progress() + } + + override func tearDown() { + alertStore = nil + + super.tearDown() + } + + func testExportProgressTotalUnitCount() { + switch alertStore.exportProgressTotalUnitCount(startDate: dateFormatter.date(from: "2100-01-02T03:03:00Z")!, + endDate: dateFormatter.date(from: "2100-01-02T03:09:00Z")!) { + case .failure(let error): + XCTFail("Unexpected failure: \(error)") + case .success(let progressTotalUnitCount): + XCTAssertEqual(progressTotalUnitCount, 3 * 1) + } + } + + func testExportProgressTotalUnitCountEmpty() { + switch alertStore.exportProgressTotalUnitCount(startDate: dateFormatter.date(from: "2100-01-02T03:00:00Z")!, + endDate: dateFormatter.date(from: "2100-01-02T03:01:00Z")!) { + case .failure(let error): + XCTFail("Unexpected failure: \(error)") + case .success(let progressTotalUnitCount): + XCTAssertEqual(progressTotalUnitCount, 0) + } + } + + func testExport() { + XCTAssertNil(alertStore.export(startDate: dateFormatter.date(from: "2100-01-02T03:03:00Z")!, + endDate: dateFormatter.date(from: "2100-01-02T03:09:00Z")!, + to: outputStream, + progress: progress)) + + XCTAssertEqual(outputStream.string, #""" + [ + {"acknowledgedDate":"2100-01-02T03:08:00.000Z","alertIdentifier":"a1","backgroundContent":"{\"acknowledgeActionButtonLabel\":\"OK\",\"body\":\"background\",\"title\":\"BACKGROUND\"}","interruptionLevel":"timeSensitive","issuedDate":"2100-01-02T03:08:00.000Z","managerIdentifier":"m1","modificationCounter":1,"syncIdentifier":"52A046F7-F449-49B2-B003-7A378D0002DE","triggerType":0}, + {"acknowledgedDate":"2100-01-02T03:04:00.000Z","alertIdentifier":"a3","backgroundContent":"{\"acknowledgeActionButtonLabel\":\"OK\",\"body\":\"background\",\"title\":\"BACKGROUND\"}","interruptionLevel":"timeSensitive","issuedDate":"2100-01-02T03:04:00.000Z","managerIdentifier":"m3","modificationCounter":3,"syncIdentifier":"285AEA4B-0DEE-41F4-8669-800E9582A6E7","triggerType":0}, + {"acknowledgedDate":"2100-01-02T03:06:00.000Z","alertIdentifier":"a4","backgroundContent":"{\"acknowledgeActionButtonLabel\":\"OK\",\"body\":\"background\",\"title\":\"BACKGROUND\"}","interruptionLevel":"timeSensitive","issuedDate":"2100-01-02T03:06:00.000Z","managerIdentifier":"m4","modificationCounter":4,"syncIdentifier":"4B3109BD-DE11-42BD-A777-D4783459C483","triggerType":0} + ] + """#) + XCTAssertEqual(progress.completedUnitCount, 3 * 1) + } + + func testExportEmpty() { + XCTAssertNil(alertStore.export(startDate: dateFormatter.date(from: "2100-01-02T03:00:00Z")!, + endDate: dateFormatter.date(from: "2100-01-02T03:01:00Z")!, + to: outputStream, + progress: progress)) + XCTAssertEqual(outputStream.string, "[]") + XCTAssertEqual(progress.completedUnitCount, 0) + } + + func testExportCancelled() { + progress.cancel() + XCTAssertEqual(alertStore.export(startDate: dateFormatter.date(from: "2100-01-02T03:03:00Z")!, + endDate: dateFormatter.date(from: "2100-01-02T03:09:00Z")!, + to: outputStream, + progress: progress) as? CriticalEventLogError, CriticalEventLogError.cancelled) + } + + private let dateFormatter = ISO8601DateFormatter() +} diff --git a/LoopTests/Managers/Alerts/InAppModalAlertSchedulerTests.swift b/LoopTests/Managers/Alerts/InAppModalAlertSchedulerTests.swift new file mode 100644 index 0000000000..ee2c47606b --- /dev/null +++ b/LoopTests/Managers/Alerts/InAppModalAlertSchedulerTests.swift @@ -0,0 +1,272 @@ +// +// InAppModalAlertSchedulerTests.swift +// LoopTests +// +// Created by Rick Pasetto on 4/15/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import LoopKit +import XCTest +@testable import Loop + +class InAppModalAlertSchedulerTests: XCTestCase { + + class MockAlertAction: UIAlertAction { + typealias Handler = ((UIAlertAction) -> Void) + var handler: Handler? + var mockTitle: String? + var mockStyle: Style + convenience init(title: String?, style: Style, handler: Handler?) { + self.init() + + mockTitle = title + mockStyle = style + self.handler = handler + } + override init() { + mockStyle = .default + super.init() + } + func callHandler() { + handler?(self) + } + } + + class MockAlertManagerResponder: AlertManagerResponder { + var identifierAcknowledged: Alert.Identifier? + func acknowledgeAlert(identifier: Alert.Identifier) { + identifierAcknowledged = identifier + } + } + + class MockViewController: UIViewController, AlertPresenter { + var viewControllerPresented: UIViewController? + var alertDismissed: UIAlertController? + var autoComplete = true + var completion: (() -> Void)? + override func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)? = nil) { + viewControllerPresented = viewControllerToPresent + if autoComplete { + completion?() + } else { + self.completion = completion + } + } + func dismissTopMost(animated: Bool, completion: (() -> Void)?) { + if autoComplete { + completion?() + } else { + self.completion = completion + } + } + func dismissAlert(_ alertToDismiss: UIAlertController, animated: Bool, completion: (() -> Void)?) { + alertDismissed = alertToDismiss + if autoComplete { + completion?() + } else { + self.completion = completion + } + } + func callCompletion() { + completion?() + } + } + + static let managerIdentifier = "managerIdentifier" + let alertIdentifier = Alert.Identifier(managerIdentifier: managerIdentifier, alertIdentifier: "bar") + let foregroundContent = Alert.Content(title: "FOREGROUND", body: "foreground", acknowledgeActionButtonLabel: "") + let backgroundContent = Alert.Content(title: "BACKGROUND", body: "background", acknowledgeActionButtonLabel: "") + + var mockTimer: Timer? + var mockTimerTimeInterval: TimeInterval? + var mockTimerRepeats: Bool? + var mockAlertManagerResponder: MockAlertManagerResponder! + var mockViewController: MockViewController! + var inAppModalAlertScheduler: InAppModalAlertScheduler! + + override func setUp() { + mockAlertManagerResponder = MockAlertManagerResponder() + mockViewController = MockViewController() + + let newTimerFunc: InAppModalAlertScheduler.TimerFactoryFunction = { timeInterval, repeats, block in + let timer = Timer(timeInterval: timeInterval, repeats: repeats) { _ in block?() } + self.mockTimer = timer + self.mockTimerTimeInterval = timeInterval + self.mockTimerRepeats = repeats + return timer + } + inAppModalAlertScheduler = InAppModalAlertScheduler(alertPresenter: mockViewController, + alertManagerResponder: mockAlertManagerResponder, + newActionFunc: MockAlertAction.init, + newTimerFunc: newTimerFunc) + } + + func testIssueImmediateAlert() { + let alert = Alert(identifier: alertIdentifier, foregroundContent: foregroundContent, backgroundContent: backgroundContent, trigger: .immediate) + inAppModalAlertScheduler.scheduleAlert(alert) + + waitOnMain() + let alertController = mockViewController.viewControllerPresented as? UIAlertController + XCTAssertNotNil(alertController) + XCTAssertEqual("FOREGROUND", alertController?.title) + } + + func testIssueImmediateAlertWithSound() { + let soundName = "soundName" + let alert = Alert(identifier: alertIdentifier, + foregroundContent: foregroundContent, + backgroundContent: backgroundContent, + trigger: .immediate, + sound: .sound(name: soundName)) + inAppModalAlertScheduler.scheduleAlert(alert) + + waitOnMain() + let alertController = mockViewController.viewControllerPresented as? UIAlertController + XCTAssertNotNil(alertController) + XCTAssertEqual("FOREGROUND", alertController?.title) + } + + func testIssueImmediateAlertWithVibrate() { + let alert = Alert(identifier: alertIdentifier, + foregroundContent: foregroundContent, + backgroundContent: backgroundContent, + trigger: .immediate, + sound: .vibrate) + inAppModalAlertScheduler.scheduleAlert(alert) + + waitOnMain() + let alertController = mockViewController.viewControllerPresented as? UIAlertController + XCTAssertNotNil(alertController) + XCTAssertEqual("FOREGROUND", alertController?.title) + } + + func testRemoveImmediateAlert() { + let alert = Alert(identifier: alertIdentifier, foregroundContent: foregroundContent, backgroundContent: backgroundContent, trigger: .immediate) + inAppModalAlertScheduler.scheduleAlert(alert) + + waitOnMain() + let alertControllerPresented = mockViewController.viewControllerPresented as? UIAlertController + XCTAssertNotNil(alertControllerPresented) + + var dismissed = false + inAppModalAlertScheduler.removePresentedAlert(identifier: alert.identifier) { + dismissed = true + } + + waitOnMain() + let alertDimissed = mockViewController.alertDismissed + XCTAssertNotNil(alertDimissed) + XCTAssertTrue(dismissed) + } + + func testIssueImmediateAlertTwiceOnlyOneShows() { + let alert = Alert(identifier: alertIdentifier, foregroundContent: foregroundContent, backgroundContent: backgroundContent, trigger: + .immediate) + mockViewController.autoComplete = false + inAppModalAlertScheduler.scheduleAlert(alert) + + waitOnMain() + mockViewController.viewControllerPresented = nil + inAppModalAlertScheduler.scheduleAlert(alert) + XCTAssertNil(mockViewController.viewControllerPresented) + } + + func testIssueImmediateAlertWithoutForegroundContentDoesNothing() { + let alert = Alert(identifier: alertIdentifier, foregroundContent: nil, backgroundContent: backgroundContent, trigger: .immediate) + inAppModalAlertScheduler.scheduleAlert(alert) + + waitOnMain() + XCTAssertNil(mockViewController.viewControllerPresented) + } + + func testIssueImmediateAlertAcknowledgement() { + let alert = Alert(identifier: alertIdentifier, foregroundContent: foregroundContent, backgroundContent: backgroundContent, trigger: .immediate) + inAppModalAlertScheduler.scheduleAlert(alert) + waitOnMain() + let action = (mockViewController.viewControllerPresented as? UIAlertController)?.actions[0] as? MockAlertAction + XCTAssertNotNil(action) + XCTAssertNil(mockAlertManagerResponder.identifierAcknowledged) + action?.callHandler() + XCTAssertEqual(alertIdentifier, mockAlertManagerResponder.identifierAcknowledged) + } + + func testIssueDelayedAlert() { + let alert = Alert(identifier: alertIdentifier, foregroundContent: foregroundContent, backgroundContent: backgroundContent, trigger: .delayed(interval: 0.1)) + mockViewController.autoComplete = false + inAppModalAlertScheduler.scheduleAlert(alert) + + waitOnMain() + // Timer should be created but won't fire yet + XCTAssertNil(mockViewController.viewControllerPresented) + XCTAssertNotNil(mockTimer) + XCTAssertEqual(0.1, mockTimerTimeInterval) + XCTAssert(mockTimerRepeats == false) + mockTimer?.fire() + + waitOnMain() + let alertController = mockViewController.viewControllerPresented as? UIAlertController + XCTAssertNotNil(alertController) + XCTAssertEqual("FOREGROUND", alertController?.title) + } + + func testIssueDelayedAlertTwiceOnlyOneWorks() { + let alert = Alert(identifier: alertIdentifier, foregroundContent: foregroundContent, backgroundContent: backgroundContent, trigger: .delayed(interval: 0.1)) + mockViewController.autoComplete = false + inAppModalAlertScheduler.scheduleAlert(alert) + + waitOnMain() + guard let firstTimer = mockTimer else { XCTFail(); return } + mockTimer = nil + // This should not schedule another timer + inAppModalAlertScheduler.scheduleAlert(alert) + + waitOnMain() + XCTAssertNil(mockTimer) + XCTAssertNil(mockViewController.viewControllerPresented) + firstTimer.fire() + + waitOnMain() + XCTAssertNil(mockTimer) + XCTAssertNotNil(mockViewController.viewControllerPresented) + } + + func testIssueDelayedAlertWithoutForegroundContentDoesNothing() { + let alert = Alert(identifier: alertIdentifier, foregroundContent: nil, backgroundContent: backgroundContent, trigger: .delayed(interval: 0.1)) + inAppModalAlertScheduler.scheduleAlert(alert) + + waitOnMain() + XCTAssertNil(mockViewController.viewControllerPresented) + } + + func testRetractAlert() { + let alert = Alert(identifier: alertIdentifier, foregroundContent: foregroundContent, backgroundContent: backgroundContent, trigger: .delayed(interval: 0.1)) + inAppModalAlertScheduler.scheduleAlert(alert) + + waitOnMain() + XCTAssert(mockTimer?.isValid == true) + inAppModalAlertScheduler.unscheduleAlert(identifier: alert.identifier) + + waitOnMain() + XCTAssert(mockTimer?.isValid == false) + } + + func testIssueRepeatingAlert() { + let alert = Alert(identifier: alertIdentifier, foregroundContent: foregroundContent, backgroundContent: backgroundContent, trigger: .repeating(repeatInterval: 0.1)) + mockViewController.autoComplete = false + inAppModalAlertScheduler.scheduleAlert(alert) + + waitOnMain() + // Timer should be created but won't fire yet + XCTAssertNil(mockViewController.viewControllerPresented) + XCTAssertNotNil(mockTimer) + XCTAssertEqual(0.1, mockTimerTimeInterval) + XCTAssert(mockTimerRepeats == true) + mockTimer?.fire() + + waitOnMain() + let alertController = mockViewController.viewControllerPresented as? UIAlertController + XCTAssertNotNil(alertController) + XCTAssertEqual("FOREGROUND", alertController?.title) + } +} diff --git a/LoopTests/Managers/Alerts/StoredAlertTests.swift b/LoopTests/Managers/Alerts/StoredAlertTests.swift new file mode 100644 index 0000000000..85fe753c7d --- /dev/null +++ b/LoopTests/Managers/Alerts/StoredAlertTests.swift @@ -0,0 +1,157 @@ +// +// StoredAlertTests.swift +// LoopTests +// +// Created by Darin Krauss on 8/27/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import XCTest +import CoreData +import LoopKit +@testable import Loop + +class StoredAlertEncodableTests: XCTestCase { + private var persistentContainer: NSPersistentContainer! + private var managedObjectContext: NSManagedObjectContext! + + override func setUp() { + super.setUp() + + let persistentStoreDescription = NSPersistentStoreDescription() + persistentStoreDescription.type = NSInMemoryStoreType + + persistentContainer = NSPersistentContainer(name: "AlertStore") + persistentContainer.persistentStoreDescriptions = [persistentStoreDescription] + persistentContainer.loadPersistentStores { (_, _) in } + + managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) + managedObjectContext.mergePolicy = NSMergeByPropertyStoreTrumpMergePolicy + managedObjectContext.automaticallyMergesChangesFromParent = true + managedObjectContext.persistentStoreCoordinator = persistentContainer.persistentStoreCoordinator + } + + override func tearDown() { + managedObjectContext = nil + persistentContainer = nil + + super.tearDown() + } + + func testInterruptionLevel() throws { + let backgroundContent = Alert.Content(title: "BACKGROUND", body: "background", acknowledgeActionButtonLabel: "OK") + managedObjectContext.performAndWait { + let alert = Alert(identifier: Alert.Identifier(managerIdentifier: "foo", alertIdentifier: "bar"), foregroundContent: nil, backgroundContent: backgroundContent, trigger: .immediate, interruptionLevel: .active) + let storedAlert = StoredAlert(from: alert, context: managedObjectContext, syncIdentifier: UUID(uuidString: "A7073F28-0322-4506-A733-CF6E0687BAF7")!) + XCTAssertEqual(.active, storedAlert.interruptionLevel) + storedAlert.issuedDate = dateFormatter.date(from: "2020-05-14T21:00:12Z")! + try! assertStoredAlertEncodable(storedAlert, encodesJSON: #""" + { + "alertIdentifier" : "bar", + "backgroundContent" : "{\"acknowledgeActionButtonLabel\":\"OK\",\"body\":\"background\",\"title\":\"BACKGROUND\"}", + "interruptionLevel" : "active", + "issuedDate" : "2020-05-14T21:00:12Z", + "managerIdentifier" : "foo", + "modificationCounter" : 1, + "syncIdentifier" : "A7073F28-0322-4506-A733-CF6E0687BAF7", + "triggerType" : 0 + } + """# + ) + + storedAlert.interruptionLevel = .critical + XCTAssertEqual(.critical, storedAlert.interruptionLevel) + try! assertStoredAlertEncodable(storedAlert, encodesJSON: #""" + { + "alertIdentifier" : "bar", + "backgroundContent" : "{\"acknowledgeActionButtonLabel\":\"OK\",\"body\":\"background\",\"title\":\"BACKGROUND\"}", + "interruptionLevel" : "critical", + "issuedDate" : "2020-05-14T21:00:12Z", + "managerIdentifier" : "foo", + "modificationCounter" : 1, + "syncIdentifier" : "A7073F28-0322-4506-A733-CF6E0687BAF7", + "triggerType" : 0 + } + """# + ) + } + } + + func testEncodable() throws { + managedObjectContext.performAndWait { + let storedAlert = StoredAlert(context: managedObjectContext) + storedAlert.acknowledgedDate = dateFormatter.date(from: "2020-05-14T22:38:14Z")! + storedAlert.alertIdentifier = "Alert Identifier 1" + storedAlert.backgroundContent = "Background Content 1" + storedAlert.foregroundContent = "Foreground Content 1" + storedAlert.issuedDate = dateFormatter.date(from: "2020-05-14T21:00:12Z")! + storedAlert.managerIdentifier = "Manager Identifier 1" + storedAlert.modificationCounter = 123 + storedAlert.retractedDate = dateFormatter.date(from: "2020-05-14T23:34:07Z")! + storedAlert.sound = "Sound 1" + storedAlert.triggerInterval = 900 + storedAlert.triggerType = Alert.Trigger.delayed(interval: .minutes(15)).storedType + storedAlert.metadata = "{\"one\": 1}" + try! assertStoredAlertEncodable(storedAlert, encodesJSON: """ + { + "acknowledgedDate" : "2020-05-14T22:38:14Z", + "alertIdentifier" : "Alert Identifier 1", + "backgroundContent" : "Background Content 1", + "foregroundContent" : "Foreground Content 1", + "interruptionLevel" : "timeSensitive", + "issuedDate" : "2020-05-14T21:00:12Z", + "managerIdentifier" : "Manager Identifier 1", + "metadata" : "{\\\"one\\\": 1}", + "modificationCounter" : 123, + "retractedDate" : "2020-05-14T23:34:07Z", + "sound" : "Sound 1", + "triggerInterval" : 900, + "triggerType" : 1 + } + """ + ) + } + } + + func testEncodableOptional() throws { + managedObjectContext.performAndWait { + let storedAlert = StoredAlert(context: managedObjectContext) + storedAlert.alertIdentifier = "Alert Identifier 2" + storedAlert.issuedDate = dateFormatter.date(from: "2020-05-14T21:00:12Z")! + storedAlert.managerIdentifier = "Manager Identifier 2" + storedAlert.modificationCounter = 234 + storedAlert.triggerType = Alert.Trigger.immediate.storedType + try! assertStoredAlertEncodable(storedAlert, encodesJSON: """ + { + "alertIdentifier" : "Alert Identifier 2", + "interruptionLevel" : "timeSensitive", + "issuedDate" : "2020-05-14T21:00:12Z", + "managerIdentifier" : "Manager Identifier 2", + "modificationCounter" : 234, + "triggerType" : 0 + } + """ + ) + } + } + + private func assertStoredAlertEncodable(_ original: StoredAlert, encodesJSON string: String, file: StaticString = #file, line: UInt = #line) throws { + let data = try encoder.encode(original) + XCTAssertEqual(String(data: data, encoding: .utf8), string, file: file, line: line) + } + + private let dateFormatter = ISO8601DateFormatter() + + private let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + encoder.dateEncodingStrategy = .iso8601 + return encoder + }() + + private let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + }() +} diff --git a/LoopTests/Managers/Alerts/UserNotificationAlertSchedulerTests.swift b/LoopTests/Managers/Alerts/UserNotificationAlertSchedulerTests.swift new file mode 100644 index 0000000000..df6e7fba69 --- /dev/null +++ b/LoopTests/Managers/Alerts/UserNotificationAlertSchedulerTests.swift @@ -0,0 +1,166 @@ +// +// UserNotificationAlertSchedulerTests.swift +// LoopTests +// +// Created by Rick Pasetto on 4/15/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import LoopKit +import XCTest +@testable import Loop + +class UserNotificationAlertSchedulerTests: XCTestCase { + + var userNotificationAlertScheduler: UserNotificationAlertScheduler! + + let alertIdentifier = Alert.Identifier(managerIdentifier: "foo", alertIdentifier: "bar") + let foregroundContent = Alert.Content(title: "FOREGROUND", body: "foreground", acknowledgeActionButtonLabel: "") + let backgroundContent = Alert.Content(title: "BACKGROUND", body: "background", acknowledgeActionButtonLabel: "") + + var mockUserNotificationCenter: MockUserNotificationCenter! + + override func setUp() { + mockUserNotificationCenter = MockUserNotificationCenter() + userNotificationAlertScheduler = + UserNotificationAlertScheduler(userNotificationCenter: mockUserNotificationCenter) + } + + func testIssueImmediateAlert() { + let alert = Alert(identifier: alertIdentifier, foregroundContent: foregroundContent, backgroundContent: backgroundContent, trigger: .immediate) + userNotificationAlertScheduler.scheduleAlert(alert, timestamp: Date.distantPast) + + waitOnMain() + + XCTAssertEqual(1, mockUserNotificationCenter.pendingRequests.count) + if let request = mockUserNotificationCenter.pendingRequests.first { + XCTAssertEqual(self.backgroundContent.title, request.content.title) + XCTAssertEqual(self.backgroundContent.body, request.content.body) + XCTAssertEqual(UNNotificationSound.default, request.content.sound) + XCTAssertEqual(alertIdentifier.value, request.content.threadIdentifier) + XCTAssertEqual([ + LoopNotificationUserInfoKey.managerIDForAlert.rawValue: alertIdentifier.managerIdentifier, + LoopNotificationUserInfoKey.alertTypeID.rawValue: alertIdentifier.alertIdentifier, + ], request.content.userInfo as? [String: String]) + XCTAssertNil(request.trigger) + } + } + + func testIssueImmediateCriticalAlert() { + let backgroundContent = Alert.Content(title: "BACKGROUND", body: "background", acknowledgeActionButtonLabel: "") + let alert = Alert(identifier: alertIdentifier, foregroundContent: foregroundContent, backgroundContent: backgroundContent, trigger: .immediate, interruptionLevel: .critical) + userNotificationAlertScheduler.scheduleAlert(alert, timestamp: Date.distantPast) + + waitOnMain() + + XCTAssertEqual(1, mockUserNotificationCenter.pendingRequests.count) + if let request = mockUserNotificationCenter.pendingRequests.first { + XCTAssertEqual(self.backgroundContent.title, request.content.title) + XCTAssertEqual(self.backgroundContent.body, request.content.body) + XCTAssertEqual(UNNotificationSound.defaultCritical, request.content.sound) + XCTAssertEqual(alertIdentifier.value, request.content.threadIdentifier) + XCTAssertEqual([ + LoopNotificationUserInfoKey.managerIDForAlert.rawValue: alertIdentifier.managerIdentifier, + LoopNotificationUserInfoKey.alertTypeID.rawValue: alertIdentifier.alertIdentifier, + ], request.content.userInfo as? [String: String]) + XCTAssertNil(request.trigger) + } + } + + func testIssueDelayedAlert() { + let alert = Alert(identifier: alertIdentifier, foregroundContent: foregroundContent, backgroundContent: backgroundContent, trigger: .delayed(interval: 0.1)) + userNotificationAlertScheduler.scheduleAlert(alert, timestamp: Date.distantPast) + + waitOnMain() + + XCTAssertEqual(1, mockUserNotificationCenter.pendingRequests.count) + if let request = mockUserNotificationCenter.pendingRequests.first { + XCTAssertEqual(self.backgroundContent.title, request.content.title) + XCTAssertEqual(self.backgroundContent.body, request.content.body) + XCTAssertEqual(UNNotificationSound.default, request.content.sound) + XCTAssertEqual(alertIdentifier.value, request.content.threadIdentifier) + XCTAssertEqual([ + LoopNotificationUserInfoKey.managerIDForAlert.rawValue: alertIdentifier.managerIdentifier, + LoopNotificationUserInfoKey.alertTypeID.rawValue: alertIdentifier.alertIdentifier, + ], request.content.userInfo as? [String: String]) + XCTAssertEqual(0.1, (request.trigger as? UNTimeIntervalNotificationTrigger)?.timeInterval) + XCTAssertEqual(false, (request.trigger as? UNTimeIntervalNotificationTrigger)?.repeats) + } + } + + func testIssueRepeatingAlert() { + let alert = Alert(identifier: alertIdentifier, foregroundContent: foregroundContent, backgroundContent: backgroundContent, trigger: .repeating(repeatInterval: 100)) + userNotificationAlertScheduler.scheduleAlert(alert, timestamp: Date.distantPast) + + waitOnMain() + + XCTAssertEqual(1, mockUserNotificationCenter.pendingRequests.count) + if let request = mockUserNotificationCenter.pendingRequests.first { + XCTAssertEqual(self.backgroundContent.title, request.content.title) + XCTAssertEqual(self.backgroundContent.body, request.content.body) + XCTAssertEqual(UNNotificationSound.default, request.content.sound) + XCTAssertEqual(alertIdentifier.value, request.content.threadIdentifier) + XCTAssertEqual([ + LoopNotificationUserInfoKey.managerIDForAlert.rawValue: alertIdentifier.managerIdentifier, + LoopNotificationUserInfoKey.alertTypeID.rawValue: alertIdentifier.alertIdentifier, + ], request.content.userInfo as? [String: String]) + XCTAssertEqual(100, (request.trigger as? UNTimeIntervalNotificationTrigger)?.timeInterval) + XCTAssertEqual(true, (request.trigger as? UNTimeIntervalNotificationTrigger)?.repeats) + } + } + + func testRetractAlert() { + let alert = Alert(identifier: alertIdentifier, foregroundContent: foregroundContent, backgroundContent: backgroundContent, trigger: .immediate) + userNotificationAlertScheduler.scheduleAlert(alert) + + waitOnMain() + mockUserNotificationCenter.deliverAll() + + userNotificationAlertScheduler.unscheduleAlert(identifier: alert.identifier) + + waitOnMain() + XCTAssertTrue(mockUserNotificationCenter.pendingRequests.isEmpty) + XCTAssertTrue(mockUserNotificationCenter.deliveredRequests.isEmpty) + } + + func testIssueMutedAlert() { + let alert = Alert(identifier: alertIdentifier, foregroundContent: foregroundContent, backgroundContent: backgroundContent, trigger: .immediate) + userNotificationAlertScheduler.scheduleAlert(alert, timestamp: Date.distantPast, muted: true) + + waitOnMain() + + XCTAssertEqual(1, mockUserNotificationCenter.pendingRequests.count) + if let request = mockUserNotificationCenter.pendingRequests.first { + XCTAssertEqual(self.backgroundContent.title, request.content.title) + XCTAssertEqual(self.backgroundContent.body, request.content.body) + XCTAssertNil(request.content.sound) + XCTAssertEqual(alertIdentifier.value, request.content.threadIdentifier) + XCTAssertEqual([ + LoopNotificationUserInfoKey.managerIDForAlert.rawValue: alertIdentifier.managerIdentifier, + LoopNotificationUserInfoKey.alertTypeID.rawValue: alertIdentifier.alertIdentifier, + ], request.content.userInfo as? [String: String]) + XCTAssertNil(request.trigger) + } + } + + func testIssueMutedCriticalAlert() { + let backgroundContent = Alert.Content(title: "BACKGROUND", body: "background", acknowledgeActionButtonLabel: "") + let alert = Alert(identifier: alertIdentifier, foregroundContent: foregroundContent, backgroundContent: backgroundContent, trigger: .immediate, interruptionLevel: .critical) + userNotificationAlertScheduler.scheduleAlert(alert, timestamp: Date.distantPast, muted: true) + + waitOnMain() + + XCTAssertEqual(1, mockUserNotificationCenter.pendingRequests.count) + if let request = mockUserNotificationCenter.pendingRequests.first { + XCTAssertEqual(self.backgroundContent.title, request.content.title) + XCTAssertEqual(self.backgroundContent.body, request.content.body) + XCTAssertEqual(UNNotificationSound.defaultCriticalSound(withAudioVolume: 0), request.content.sound) + XCTAssertEqual(alertIdentifier.value, request.content.threadIdentifier) + XCTAssertEqual([ + LoopNotificationUserInfoKey.managerIDForAlert.rawValue: alertIdentifier.managerIdentifier, + LoopNotificationUserInfoKey.alertTypeID.rawValue: alertIdentifier.alertIdentifier, + ], request.content.userInfo as? [String: String]) + XCTAssertNil(request.trigger) + } + } +} diff --git a/LoopTests/Managers/CGMStalenessMonitorTests.swift b/LoopTests/Managers/CGMStalenessMonitorTests.swift new file mode 100644 index 0000000000..89afce784b --- /dev/null +++ b/LoopTests/Managers/CGMStalenessMonitorTests.swift @@ -0,0 +1,108 @@ +// +// CGMStalenessMonitorTests.swift +// LoopTests +// +// Created by Pete Schwamb on 10/15/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import XCTest +import Foundation +import LoopKit +import HealthKit +@testable import Loop + +class CGMStalenessMonitorTests: XCTestCase { + + private var latestCGMGlucose: StoredGlucoseSample? + private var fetchExpectation: XCTestExpectation? + + private var storedGlucoseSample: StoredGlucoseSample { + return StoredGlucoseSample(uuid: UUID(), provenanceIdentifier: UUID().uuidString, syncIdentifier: "syncIdentifier", syncVersion: 1, startDate: Date().addingTimeInterval(-.minutes(5)), quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 120), condition: nil, trend: .flat, trendRate: HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: 0.0), isDisplayOnly: false, wasUserEntered: false, device: nil, healthKitEligibleDate: nil) + } + + private var newGlucoseSample: NewGlucoseSample { + return NewGlucoseSample(date: Date().addingTimeInterval(-.minutes(1)), quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 120), condition: nil, trend: .flat, trendRate: HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: 0.0), isDisplayOnly: false, wasUserEntered: false, syncIdentifier: "syncIdentifier") + } + + func testInitialValue() { + let monitor = CGMStalenessMonitor() + XCTAssert(monitor.cgmDataIsStale) + } + + func testStalenessWithRecentCMGSample() { + let monitor = CGMStalenessMonitor() + fetchExpectation = expectation(description: "Fetch latest cgm glucose") + latestCGMGlucose = storedGlucoseSample + + var receivedValues = [Bool]() + let exp = expectation(description: "expected values") + + let cancelable = monitor.$cgmDataIsStale.sink { value in + receivedValues.append(value) + if receivedValues.count == 2 { + exp.fulfill() + } + } + + monitor.delegate = self + waitForExpectations(timeout: 2) + + XCTAssertNotNil(cancelable) + XCTAssertEqual(receivedValues, [true, false]) + } + + func testStalenessWithNoRecentCGMData() { + let monitor = CGMStalenessMonitor() + fetchExpectation = expectation(description: "Fetch latest cgm glucose") + latestCGMGlucose = nil + + var receivedValues = [Bool]() + let exp = expectation(description: "expected values") + + let cancelable = monitor.$cgmDataIsStale.sink { value in + receivedValues.append(value) + if receivedValues.count == 2 { + exp.fulfill() + } + } + + monitor.delegate = self + waitForExpectations(timeout: 2) + + XCTAssertNotNil(cancelable) + XCTAssertEqual(receivedValues, [true, true]) + } + + func testStalenessNewReadingsArriving() { + let monitor = CGMStalenessMonitor() + fetchExpectation = expectation(description: "Fetch latest cgm glucose") + latestCGMGlucose = nil + + var receivedValues = [Bool]() + let exp = expectation(description: "expected values") + + let cancelable = monitor.$cgmDataIsStale.sink { value in + receivedValues.append(value) + if receivedValues.count == 2 { + exp.fulfill() + } + } + + monitor.delegate = self + + monitor.cgmGlucoseSamplesAvailable([newGlucoseSample]) + + waitForExpectations(timeout: 2) + + XCTAssertNotNil(cancelable) + XCTAssertEqual(receivedValues, [true, false]) + } +} + +extension CGMStalenessMonitorTests: CGMStalenessMonitorDelegate { + func getLatestCGMGlucose(since: Date, completion: @escaping (Result) -> Void) { + completion(.success(latestCGMGlucose)) + fetchExpectation?.fulfill() + } +} diff --git a/LoopTests/Managers/CriticalEventLogExportManagerTests.swift b/LoopTests/Managers/CriticalEventLogExportManagerTests.swift new file mode 100644 index 0000000000..a2f0a0e3a1 --- /dev/null +++ b/LoopTests/Managers/CriticalEventLogExportManagerTests.swift @@ -0,0 +1,306 @@ +// +// CriticalEventLogExportManagerTests.swift +// LoopTests +// +// Created by Darin Krauss on 8/27/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import XCTest +import Foundation +import LoopKit +@testable import Loop + +fileprivate let now = ISO8601DateFormatter().date(from: "2020-03-11T12:13:14-0700")! // Explicitly chosen near DST change + +class CriticalEventLogExportManagerTests: XCTestCase { + var fileManager: FileManager! + var logs: [MockCriticalEventLog]! + var directory: URL! + var historicalDuration: TimeInterval! + var manager: CriticalEventLogExportManager! + var delegate: MockCriticalEventLogExporterDelegate! + var url: URL! + + override func setUp() { + super.setUp() + + fileManager = FileManager.default + logs = [MockCriticalEventLog(name: "One", progressUnitCount: 1), + MockCriticalEventLog(name: "Two", progressUnitCount: 2), + MockCriticalEventLog(name: "Three", progressUnitCount: 3)] + directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + historicalDuration = .days(5) + manager = CriticalEventLogExportManager(logs: logs, directory: directory, historicalDuration: historicalDuration, fileManager: fileManager) + delegate = MockCriticalEventLogExporterDelegate() + url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + } + + override func tearDown() { + try? fileManager.removeItem(atPath: url.path) + try? fileManager.removeItem(atPath: directory.path) + + url = nil + delegate = nil + manager = nil + historicalDuration = nil + directory = nil + logs = nil + fileManager = nil + + super.tearDown() + } + + func testNextExportHistoricalDateWhenUpToDate() { + XCTAssertNoThrow(try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)) + XCTAssertTrue(fileManager.createFile(atPath: directory.appendingPathComponent("20200310T000000Z.zip").path, contents: nil)) + + XCTAssertEqual(manager.nextExportHistoricalDate(now: now), ISO8601DateFormatter().date(from: "2020-03-12T00:00:00Z")) + } + + func testNextExportHistoricalDateWhenNotUpToDate() { + XCTAssertNoThrow(try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)) + XCTAssertTrue(fileManager.createFile(atPath: directory.appendingPathComponent("20200309T000000Z.zip").path, contents: nil)) + + XCTAssertEqual(manager.nextExportHistoricalDate(now: now), ISO8601DateFormatter().date(from: "2020-03-11T19:13:14Z")) + } + + func testRetryExportHistoricalDate() { + XCTAssertEqual(manager.retryExportHistoricalDate(now: now), ISO8601DateFormatter().date(from: "2020-03-11T20:13:14Z")) + } + + func testExport() { + let completionExpectation = expectation(description: "Export completion") + + logs.forEach { $0.exportExpectation = self.expectation(description: $0.name, expectedFulfillmentCount: 5) } + + var exporter = manager.createExporter(to: url) + exporter.delegate = delegate + + exporter.export(now: now) { error in + XCTAssertNil(error) + XCTAssertFalse(exporter.isCancelled) + XCTAssertTrue(self.fileManager.isReadableFile(atPath: self.url.path)) + XCTAssertFalse(self.fileManager.isReadableFile(atPath: self.directory.appendingPathComponent("20200306T000000Z.zip").path)) + XCTAssertTrue(self.fileManager.isReadableFile(atPath: self.directory.appendingPathComponent("20200307T000000Z.zip").path)) + XCTAssertTrue(self.fileManager.isReadableFile(atPath: self.directory.appendingPathComponent("20200308T000000Z.zip").path)) + XCTAssertTrue(self.fileManager.isReadableFile(atPath: self.directory.appendingPathComponent("20200309T000000Z.zip").path)) + XCTAssertTrue(self.fileManager.isReadableFile(atPath: self.directory.appendingPathComponent("20200310T000000Z.zip").path)) + XCTAssertFalse(self.fileManager.isReadableFile(atPath: self.directory.appendingPathComponent("20200311T000000Z.zip").path)) + XCTAssertEqual(exporter.progress.fractionCompleted, 1.0) + XCTAssertTrue(exporter.progress.isFinished) + XCTAssertEqual(self.delegate.progress!, 1.0) + + completionExpectation.fulfill() + } + + wait(for: [completionExpectation] + logs.map { $0.exportExpectation! }, timeout: 10) + } + + func testExportPartial() { + let completionExpectation = expectation(description: "Export completion") + + logs.forEach { $0.exportExpectation = self.expectation(description: $0.name, expectedFulfillmentCount: 3) } + + XCTAssertNoThrow(try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)) + XCTAssertTrue(fileManager.createFile(atPath: directory.appendingPathComponent("20200307T000000Z.zip").path, contents: nil)) + XCTAssertTrue(fileManager.createFile(atPath: directory.appendingPathComponent("20200308T000000Z.zip").path, contents: nil)) + + var exporter = manager.createExporter(to: url) + exporter.delegate = delegate + + exporter.export(now: now) { error in + XCTAssertNil(error) + XCTAssertFalse(exporter.isCancelled) + XCTAssertTrue(self.fileManager.isReadableFile(atPath: self.url.path)) + XCTAssertFalse(self.fileManager.isReadableFile(atPath: self.directory.appendingPathComponent("20200306T000000Z.zip").path)) + XCTAssertTrue(self.fileManager.isReadableFile(atPath: self.directory.appendingPathComponent("20200309T000000Z.zip").path)) + XCTAssertTrue(self.fileManager.isReadableFile(atPath: self.directory.appendingPathComponent("20200310T000000Z.zip").path)) + XCTAssertFalse(self.fileManager.isReadableFile(atPath: self.directory.appendingPathComponent("20200311T000000Z.zip").path)) + XCTAssertEqual(exporter.progress.fractionCompleted, 1.0) + XCTAssertTrue(exporter.progress.isFinished) + XCTAssertEqual(self.delegate.progress!, 1.0) + + completionExpectation.fulfill() + } + + wait(for: [completionExpectation] + logs.map { $0.exportExpectation! }, timeout: 10) + } + + func testExportCancelled() { + let completionExpectation = expectation(description: "Export completion") + + let exporter = manager.createExporter(to: url) + exporter.cancel() + + exporter.export(now: now) { error in + XCTAssertEqual(error as? CriticalEventLogError, CriticalEventLogError.cancelled) + XCTAssertTrue(exporter.isCancelled) + XCTAssertFalse(self.fileManager.fileExists(atPath: self.url.path)) + + completionExpectation.fulfill() + } + + wait(for: [completionExpectation], timeout: 10) + } + + func testExportHistorical() { + let completionExpectation = expectation(description: "Export completion") + + logs.forEach { $0.exportExpectation = self.expectation(description: $0.name, expectedFulfillmentCount: 4) } + + var exporter = manager.createHistoricalExporter() + exporter.delegate = delegate + + exporter.export(now: now) { error in + XCTAssertNil(error) + XCTAssertFalse(exporter.isCancelled) + XCTAssertFalse(self.fileManager.isReadableFile(atPath: self.directory.appendingPathComponent("20200306T000000Z.zip").path)) + XCTAssertTrue(self.fileManager.isReadableFile(atPath: self.directory.appendingPathComponent("20200307T000000Z.zip").path)) + XCTAssertTrue(self.fileManager.isReadableFile(atPath: self.directory.appendingPathComponent("20200308T000000Z.zip").path)) + XCTAssertTrue(self.fileManager.isReadableFile(atPath: self.directory.appendingPathComponent("20200309T000000Z.zip").path)) + XCTAssertTrue(self.fileManager.isReadableFile(atPath: self.directory.appendingPathComponent("20200310T000000Z.zip").path)) + XCTAssertFalse(self.fileManager.isReadableFile(atPath: self.directory.appendingPathComponent("20200311T000000Z.zip").path)) + XCTAssertEqual(exporter.progress.fractionCompleted, 1.0) + XCTAssertTrue(exporter.progress.isFinished) + XCTAssertEqual(self.delegate.progress!, 1.0) + + completionExpectation.fulfill() + } + + wait(for: [completionExpectation] + logs.map { $0.exportExpectation! }, timeout: 10) + } + + func testExportHistoricalPartial() { + let completionExpectation = expectation(description: "Export completion") + + logs.forEach { $0.exportExpectation = self.expectation(description: $0.name, expectedFulfillmentCount: 2) } + + XCTAssertNoThrow(try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)) + XCTAssertTrue(fileManager.createFile(atPath: directory.appendingPathComponent("20200307T000000Z.zip").path, contents: nil)) + XCTAssertTrue(fileManager.createFile(atPath: directory.appendingPathComponent("20200308T000000Z.zip").path, contents: nil)) + + var exporter = manager.createHistoricalExporter() + exporter.delegate = delegate + + exporter.export(now: now) { error in + XCTAssertNil(error) + XCTAssertFalse(exporter.isCancelled) + XCTAssertFalse(self.fileManager.isReadableFile(atPath: self.directory.appendingPathComponent("20200306T000000Z.zip").path)) + XCTAssertTrue(self.fileManager.isReadableFile(atPath: self.directory.appendingPathComponent("20200309T000000Z.zip").path)) + XCTAssertTrue(self.fileManager.isReadableFile(atPath: self.directory.appendingPathComponent("20200310T000000Z.zip").path)) + XCTAssertFalse(self.fileManager.isReadableFile(atPath: self.directory.appendingPathComponent("20200311T000000Z.zip").path)) + XCTAssertEqual(exporter.progress.fractionCompleted, 1.0) + XCTAssertTrue(exporter.progress.isFinished) + XCTAssertEqual(self.delegate.progress!, 1.0) + + completionExpectation.fulfill() + } + + wait(for: [completionExpectation] + logs.map { $0.exportExpectation! }, timeout: 10) + } + + func testExportHistoricalPurge() { + let completionExpectation = expectation(description: "Export completion") + + XCTAssertNoThrow(try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)) + XCTAssertTrue(fileManager.createFile(atPath: directory.appendingPathComponent("20200305T000000Z.zip").path, contents: nil)) + XCTAssertTrue(fileManager.createFile(atPath: directory.appendingPathComponent("20200306T000000Z.zip").path, contents: nil)) + + var exporter = manager.createHistoricalExporter() + exporter.delegate = delegate + + exporter.export(now: now) { error in + XCTAssertNil(error) + XCTAssertFalse(exporter.isCancelled) + XCTAssertFalse(self.fileManager.isReadableFile(atPath: self.directory.appendingPathComponent("20200305T000000Z.zip").path)) + XCTAssertFalse(self.fileManager.isReadableFile(atPath: self.directory.appendingPathComponent("20200306T000000Z.zip").path)) + XCTAssertEqual(self.delegate.progress!, 1.0) + + completionExpectation.fulfill() + } + + wait(for: [completionExpectation], timeout: 10) + } + + func testExportHistoricalCancelled() { + let completionExpectation = expectation(description: "Export completion") + + let exporter = manager.createHistoricalExporter() + exporter.cancel() + + exporter.export(now: now) { error in + XCTAssertEqual(error as? CriticalEventLogError, CriticalEventLogError.cancelled) + XCTAssertTrue(exporter.isCancelled) + + completionExpectation.fulfill() + } + + wait(for: [completionExpectation], timeout: 10) + } +} + +class MockCriticalEventLog: CriticalEventLog { + var name: String + var progressUnitCount: Int64 + var error: Error? + var exportProgressTotalUnitCountExpectation: XCTestExpectation? + var exportExpectation: XCTestExpectation? + + init(name: String, progressUnitCount: Int64) { + self.name = name + self.progressUnitCount = progressUnitCount + } + + var exportName: String { name } + + func exportProgressTotalUnitCount(startDate: Date, endDate: Date?) -> Result { + exportProgressTotalUnitCountExpectation?.fulfill() + + if let error = error { + return .failure(error) + } + + let days = (endDate ?? now).timeIntervalSince(startDate).days.rounded(.down) + return .success(Int64(days) * progressUnitCount) + } + + func export(startDate: Date, endDate: Date, to stream: DataOutputStream, progress: Progress) -> Error? { + exportExpectation?.fulfill() + + guard !progress.isCancelled else { + return CriticalEventLogError.cancelled + } + + if let error = error { + return error + } + + do { + try stream.write(name) + } catch let error { + return error + } + + progress.completedUnitCount += progressUnitCount + return nil + } +} + +class MockCriticalEventLogExporterDelegate: CriticalEventLogExporterDelegate { + var progress: Double? + + func exportDidProgress(_ progress: Double) { + self.progress = progress + } +} + +fileprivate struct MockError: Error, Equatable {} + +fileprivate extension XCTestCase { + func expectation(description: String, expectedFulfillmentCount: Int) -> XCTestExpectation { + let expectation = self.expectation(description: description) + expectation.expectedFulfillmentCount = expectedFulfillmentCount + return expectation + } +} diff --git a/LoopTests/Managers/DoseEnactorTests.swift b/LoopTests/Managers/DoseEnactorTests.swift new file mode 100644 index 0000000000..bf722ec874 --- /dev/null +++ b/LoopTests/Managers/DoseEnactorTests.swift @@ -0,0 +1,226 @@ +// +// DoseEnactorTests.swift +// LoopTests +// +// Created by Pete Schwamb on 7/30/21. +// Copyright © 2021 LoopKit Authors. All rights reserved. +// + +import XCTest +import Foundation +import LoopKit +import HealthKit + +@testable import Loop + +enum MockPumpManagerError: Error { + case failed +} + +extension MockPumpManagerError: LocalizedError { + +} + +class MockPumpManager: PumpManager { + + var enactBolusCalled: ((Double, BolusActivationType) -> Void)? + + var enactTempBasalCalled: ((Double, TimeInterval) -> Void)? + + var enactTempBasalError: PumpManagerError? + + init() { + + } + + // PumpManager implementation + static var onboardingMaximumBasalScheduleEntryCount: Int = 24 + + static var onboardingSupportedBasalRates: [Double] = [1,2,3] + + static var onboardingSupportedBolusVolumes: [Double] = [1,2,3] + + static var onboardingSupportedMaximumBolusVolumes: [Double] = [1,2,3] + + let deliveryUnitsPerMinute = 1.5 + + var supportedBasalRates: [Double] = [1,2,3] + + var supportedBolusVolumes: [Double] = [1,2,3] + + var supportedMaximumBolusVolumes: [Double] = [1,2,3] + + var maximumBasalScheduleEntryCount: Int = 24 + + var minimumBasalScheduleEntryDuration: TimeInterval = .minutes(30) + + var pumpManagerDelegate: PumpManagerDelegate? + + var pumpRecordsBasalProfileStartEvents: Bool = false + + var pumpReservoirCapacity: Double = 50 + + var lastSync: Date? + + var status: PumpManagerStatus = + PumpManagerStatus( + timeZone: TimeZone.current, + device: HKDevice(name: "MockPumpManager", manufacturer: nil, model: nil, hardwareVersion: nil, firmwareVersion: nil, softwareVersion: nil, localIdentifier: nil, udiDeviceIdentifier: nil), + pumpBatteryChargeRemaining: nil, + basalDeliveryState: nil, + bolusState: .noBolus, + insulinType: .novolog) + + func addStatusObserver(_ observer: PumpManagerStatusObserver, queue: DispatchQueue) { + } + + func removeStatusObserver(_ observer: PumpManagerStatusObserver) { + } + + func ensureCurrentPumpData(completion: ((Date?) -> Void)?) { + completion?(Date()) + } + + func setMustProvideBLEHeartbeat(_ mustProvideBLEHeartbeat: Bool) { + } + + func createBolusProgressReporter(reportingOn dispatchQueue: DispatchQueue) -> DoseProgressReporter? { + return nil + } + + func enactBolus(units: Double, activationType: BolusActivationType, completion: @escaping (PumpManagerError?) -> Void) { + enactBolusCalled?(units, activationType) + completion(nil) + } + + func cancelBolus(completion: @escaping (PumpManagerResult) -> Void) { + completion(.success(nil)) + } + + func enactTempBasal(unitsPerHour: Double, for duration: TimeInterval, completion: @escaping (PumpManagerError?) -> Void) { + enactTempBasalCalled?(unitsPerHour, duration) + completion(enactTempBasalError) + } + + func suspendDelivery(completion: @escaping (Error?) -> Void) { + completion(nil) + } + + func resumeDelivery(completion: @escaping (Error?) -> Void) { + completion(nil) + } + + func syncBasalRateSchedule(items scheduleItems: [RepeatingScheduleValue], completion: @escaping (Result) -> Void) { + } + + func syncDeliveryLimits(limits deliveryLimits: DeliveryLimits, completion: @escaping (Result) -> Void) { + + } + + func estimatedDuration(toBolus units: Double) -> TimeInterval { + .minutes(units / deliveryUnitsPerMinute) + } + + static var pluginIdentifier: String = "MockPumpManager" + + var localizedTitle: String = "MockPumpManager" + + var delegateQueue: DispatchQueue! + + required init?(rawState: RawStateValue) { + + } + + var rawState: RawStateValue = [:] + + var isOnboarded: Bool = true + + var debugDescription: String = "MockPumpManager" + + func acknowledgeAlert(alertIdentifier: Alert.AlertIdentifier, completion: @escaping (Error?) -> Void) { + } + + func getSoundBaseURL() -> URL? { + return nil + } + + func getSounds() -> [Alert.Sound] { + return [.sound(name: "doesntExist")] + } +} + +class DoseEnactorTests: XCTestCase { + func testBasalAndBolusDosedSerially() { + let enactor = DoseEnactor() + let tempBasalRecommendation = TempBasalRecommendation(unitsPerHour: 0, duration: 0) // Cancel + let recommendation = AutomaticDoseRecommendation(basalAdjustment: tempBasalRecommendation, bolusUnits: 1.5) + let pumpManager = MockPumpManager() + + let tempBasalExpectation = expectation(description: "enactTempBasal called") + pumpManager.enactTempBasalCalled = { (rate, duration) in + tempBasalExpectation.fulfill() + } + + let bolusExpectation = expectation(description: "enactBolus called") + pumpManager.enactBolusCalled = { (amount, automatic) in + bolusExpectation.fulfill() + } + + enactor.enact(recommendation: recommendation, with: pumpManager) { error in + XCTAssertNil(error) + } + + wait(for: [tempBasalExpectation, bolusExpectation], timeout: 5, enforceOrder: true) + } + + func testBolusDoesNotIssueIfTempBasalAdjustmentFailed() { + let enactor = DoseEnactor() + let tempBasalRecommendation = TempBasalRecommendation(unitsPerHour: 0, duration: 0) // Cancel + let recommendation = AutomaticDoseRecommendation(basalAdjustment: tempBasalRecommendation, bolusUnits: 1.5) + let pumpManager = MockPumpManager() + + let tempBasalExpectation = expectation(description: "enactTempBasal called") + pumpManager.enactTempBasalCalled = { (rate, duration) in + tempBasalExpectation.fulfill() + } + + pumpManager.enactBolusCalled = { (amount, automatic) in + XCTFail("Should not enact bolus") + } + + pumpManager.enactTempBasalError = .configuration(MockPumpManagerError.failed) + + enactor.enact(recommendation: recommendation, with: pumpManager) { error in + XCTAssertNotNil(error) + } + + waitForExpectations(timeout: 2) + } + + func testTempBasalOnly() { + let enactor = DoseEnactor() + let tempBasalRecommendation = TempBasalRecommendation(unitsPerHour: 1.2, duration: .minutes(30)) // Cancel + let recommendation = AutomaticDoseRecommendation(basalAdjustment: tempBasalRecommendation, bolusUnits: 0) + let pumpManager = MockPumpManager() + + let tempBasalExpectation = expectation(description: "enactTempBasal called") + pumpManager.enactTempBasalCalled = { (rate, duration) in + XCTAssertEqual(1.2, rate) + XCTAssertEqual(.minutes(30), duration) + tempBasalExpectation.fulfill() + } + + pumpManager.enactBolusCalled = { (amount, automatic) in + XCTFail("Should not enact bolus") + } + + + enactor.enact(recommendation: recommendation, with: pumpManager) { error in + XCTAssertNil(error) + } + + waitForExpectations(timeout: 2) + } + + +} diff --git a/LoopTests/Managers/LoopAlgorithmTests.swift b/LoopTests/Managers/LoopAlgorithmTests.swift new file mode 100644 index 0000000000..6c51283872 --- /dev/null +++ b/LoopTests/Managers/LoopAlgorithmTests.swift @@ -0,0 +1,75 @@ +// +// LoopAlgorithmTests.swift +// LoopTests +// +// Created by Pete Schwamb on 8/17/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import XCTest +import LoopKit +import LoopCore +import HealthKit + +final class LoopAlgorithmTests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + public var bundle: Bundle { + return Bundle(for: type(of: self)) + } + + public func loadFixture(_ resourceName: String) -> T { + let path = bundle.path(forResource: resourceName, ofType: "json")! + return try! JSONSerialization.jsonObject(with: Data(contentsOf: URL(fileURLWithPath: path)), options: []) as! T + } + + func loadBasalRateScheduleFixture(_ resourceName: String) -> BasalRateSchedule { + let fixture: [JSONDictionary] = loadFixture(resourceName) + + let items = fixture.map { + return RepeatingScheduleValue(startTime: TimeInterval(minutes: $0["minutes"] as! Double), value: $0["rate"] as! Double) + } + + return BasalRateSchedule(dailyItems: items, timeZone: .utcTimeZone)! + } + + func loadPredictedGlucoseFixture(_ name: String) -> [PredictedGlucoseValue] { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + let url = bundle.url(forResource: name, withExtension: "json")! + return try! decoder.decode([PredictedGlucoseValue].self, from: try! Data(contentsOf: url)) + } + + + func testLiveCaptureWithFunctionalAlgorithm() throws { + // This matches the "testForecastFromLiveCaptureInputData" test of LoopDataManagerDosingTests, + // Using the same input data, but generating the forecast using the LoopAlgorithm.generatePrediction() + // function. + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let url = bundle.url(forResource: "live_capture_input", withExtension: "json")! + let predictionInput = try! decoder.decode(LoopPredictionInput.self, from: try! Data(contentsOf: url)) + + let prediction = try LoopAlgorithm.generatePrediction(input: predictionInput) + + let expectedPredictedGlucose = loadPredictedGlucoseFixture("live_capture_predicted_glucose") + + XCTAssertEqual(expectedPredictedGlucose.count, prediction.glucose.count) + + let defaultAccuracy = 1.0 / 40.0 + + for (expected, calculated) in zip(expectedPredictedGlucose, prediction.glucose) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: defaultAccuracy) + } + } +} diff --git a/LoopTests/Managers/LoopDataManagerDosingTests.swift b/LoopTests/Managers/LoopDataManagerDosingTests.swift new file mode 100644 index 0000000000..a1f26a0e92 --- /dev/null +++ b/LoopTests/Managers/LoopDataManagerDosingTests.swift @@ -0,0 +1,647 @@ +// +// LoopDataManagerDosingTests.swift +// LoopTests +// +// Created by Anna Quinlan on 10/19/22. +// Copyright © 2022 LoopKit Authors. All rights reserved. +// + +import XCTest +import HealthKit +import LoopKit +@testable import LoopCore +@testable import Loop + +class MockDelegate: LoopDataManagerDelegate { + let pumpManager = MockPumpManager() + + var bolusUnits: Double? + func loopDataManager(_ manager: Loop.LoopDataManager, estimateBolusDuration units: Double) -> TimeInterval? { + self.bolusUnits = units + return pumpManager.estimatedDuration(toBolus: units) + } + + var recommendation: AutomaticDoseRecommendation? + var error: LoopError? + func loopDataManager(_ manager: LoopDataManager, didRecommend automaticDose: (recommendation: AutomaticDoseRecommendation, date: Date), completion: @escaping (LoopError?) -> Void) { + self.recommendation = automaticDose.recommendation + completion(error) + } + func roundBasalRate(unitsPerHour: Double) -> Double { Double(Int(unitsPerHour / 0.05)) * 0.05 } + func roundBolusVolume(units: Double) -> Double { Double(Int(units / 0.05)) * 0.05 } + var pumpManagerStatus: PumpManagerStatus? + var cgmManagerStatus: CGMManagerStatus? + var pumpStatusHighlight: DeviceStatusHighlight? +} + +class LoopDataManagerDosingTests: LoopDataManagerTests { + // MARK: Functions to load fixtures + func loadLocalDateGlucoseEffect(_ name: String) -> [GlucoseEffect] { + let fixture: [JSONDictionary] = loadFixture(name) + let localDateFormatter = ISO8601DateFormatter.localTimeDate() + + return fixture.map { + return GlucoseEffect(startDate: localDateFormatter.date(from: $0["date"] as! String)!, quantity: HKQuantity(unit: HKUnit(from: $0["unit"] as! String), doubleValue:$0["amount"] as! Double)) + } + } + + func loadPredictedGlucoseFixture(_ name: String) -> [PredictedGlucoseValue] { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + let url = bundle.url(forResource: name, withExtension: "json")! + return try! decoder.decode([PredictedGlucoseValue].self, from: try! Data(contentsOf: url)) + } + + // MARK: Tests + func testForecastFromLiveCaptureInputData() { + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let url = bundle.url(forResource: "live_capture_input", withExtension: "json")! + let predictionInput = try! decoder.decode(LoopPredictionInput.self, from: try! Data(contentsOf: url)) + + // Therapy settings in the "live capture" input only have one value, so we can fake some schedules + // from the first entry of each therapy setting's history. + let basalRateSchedule = BasalRateSchedule(dailyItems: [ + RepeatingScheduleValue(startTime: 0, value: predictionInput.settings.basal.first!.value) + ]) + let insulinSensitivitySchedule = InsulinSensitivitySchedule( + unit: .milligramsPerDeciliter, + dailyItems: [ + RepeatingScheduleValue(startTime: 0, value: predictionInput.settings.sensitivity.first!.value.doubleValue(for: .milligramsPerDeciliter)) + ], + timeZone: .utcTimeZone + )! + let carbRatioSchedule = CarbRatioSchedule( + unit: .gram(), + dailyItems: [ + RepeatingScheduleValue(startTime: 0.0, value: predictionInput.settings.carbRatio.first!.value) + ], + timeZone: .utcTimeZone + )! + + let settings = LoopSettings( + dosingEnabled: false, + glucoseTargetRangeSchedule: glucoseTargetRangeSchedule, + insulinSensitivitySchedule: insulinSensitivitySchedule, + basalRateSchedule: basalRateSchedule, + carbRatioSchedule: carbRatioSchedule, + maximumBasalRatePerHour: 10, + maximumBolus: 5, + suspendThreshold: predictionInput.settings.suspendThreshold, + automaticDosingStrategy: .automaticBolus + ) + + let glucoseStore = MockGlucoseStore() + glucoseStore.storedGlucose = predictionInput.glucoseHistory + + let currentDate = glucoseStore.latestGlucose!.startDate + now = currentDate + + let doseStore = MockDoseStore() + doseStore.basalProfile = basalRateSchedule + doseStore.basalProfileApplyingOverrideHistory = doseStore.basalProfile + doseStore.sensitivitySchedule = insulinSensitivitySchedule + doseStore.doseHistory = predictionInput.doses + doseStore.lastAddedPumpData = predictionInput.doses.last!.startDate + let carbStore = MockCarbStore() + carbStore.insulinSensitivityScheduleApplyingOverrideHistory = insulinSensitivitySchedule + carbStore.carbRatioSchedule = carbRatioSchedule + carbStore.carbRatioScheduleApplyingOverrideHistory = carbRatioSchedule + carbStore.carbHistory = predictionInput.carbEntries + + + dosingDecisionStore = MockDosingDecisionStore() + automaticDosingStatus = AutomaticDosingStatus(automaticDosingEnabled: true, isAutomaticDosingAllowed: true) + loopDataManager = LoopDataManager( + lastLoopCompleted: currentDate, + basalDeliveryState: .active(currentDate), + settings: settings, + overrideHistory: TemporaryScheduleOverrideHistory(), + analyticsServicesManager: AnalyticsServicesManager(), + localCacheDuration: .days(1), + doseStore: doseStore, + glucoseStore: glucoseStore, + carbStore: carbStore, + dosingDecisionStore: dosingDecisionStore, + latestStoredSettingsProvider: MockLatestStoredSettingsProvider(), + now: { currentDate }, + pumpInsulinType: .novolog, + automaticDosingStatus: automaticDosingStatus, + trustedTimeOffset: { 0 } + ) + + let expectedPredictedGlucose = loadPredictedGlucoseFixture("live_capture_predicted_glucose") + + let updateGroup = DispatchGroup() + updateGroup.enter() + var predictedGlucose: [PredictedGlucoseValue]? + var recommendedBasal: TempBasalRecommendation? + self.loopDataManager.getLoopState { _, state in + predictedGlucose = state.predictedGlucoseIncludingPendingInsulin + recommendedBasal = state.recommendedAutomaticDose?.recommendation.basalAdjustment + updateGroup.leave() + } + // We need to wait until the task completes to get outputs + updateGroup.wait() + + XCTAssertNotNil(predictedGlucose) + + XCTAssertEqual(expectedPredictedGlucose.count, predictedGlucose!.count) + + for (expected, calculated) in zip(expectedPredictedGlucose, predictedGlucose!) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: defaultAccuracy) + } + } + + + func testFlatAndStable() { + setUp(for: .flatAndStable) + let predictedGlucoseOutput = loadLocalDateGlucoseEffect("flat_and_stable_predicted_glucose") + + let updateGroup = DispatchGroup() + updateGroup.enter() + var predictedGlucose: [PredictedGlucoseValue]? + var recommendedDose: AutomaticDoseRecommendation? + self.loopDataManager.getLoopState { _, state in + predictedGlucose = state.predictedGlucose + recommendedDose = state.recommendedAutomaticDose?.recommendation + updateGroup.leave() + } + // We need to wait until the task completes to get outputs + updateGroup.wait() + + XCTAssertNotNil(predictedGlucose) + XCTAssertEqual(predictedGlucoseOutput.count, predictedGlucose!.count) + + for (expected, calculated) in zip(predictedGlucoseOutput, predictedGlucose!) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: defaultAccuracy) + } + + let recommendedTempBasal = recommendedDose?.basalAdjustment + + XCTAssertEqual(1.40, recommendedTempBasal!.unitsPerHour, accuracy: defaultAccuracy) + } + + func testHighAndStable() { + setUp(for: .highAndStable) + let predictedGlucoseOutput = loadLocalDateGlucoseEffect("high_and_stable_predicted_glucose") + + let updateGroup = DispatchGroup() + updateGroup.enter() + var predictedGlucose: [PredictedGlucoseValue]? + var recommendedBasal: TempBasalRecommendation? + self.loopDataManager.getLoopState { _, state in + predictedGlucose = state.predictedGlucose + recommendedBasal = state.recommendedAutomaticDose?.recommendation.basalAdjustment + updateGroup.leave() + } + // We need to wait until the task completes to get outputs + updateGroup.wait() + + XCTAssertNotNil(predictedGlucose) + XCTAssertEqual(predictedGlucoseOutput.count, predictedGlucose!.count) + + for (expected, calculated) in zip(predictedGlucoseOutput, predictedGlucose!) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: defaultAccuracy) + } + + XCTAssertEqual(4.63, recommendedBasal!.unitsPerHour, accuracy: defaultAccuracy) + } + + func testHighAndFalling() { + setUp(for: .highAndFalling) + let predictedGlucoseOutput = loadLocalDateGlucoseEffect("high_and_falling_predicted_glucose") + + let updateGroup = DispatchGroup() + updateGroup.enter() + var predictedGlucose: [PredictedGlucoseValue]? + var recommendedTempBasal: TempBasalRecommendation? + self.loopDataManager.getLoopState { _, state in + predictedGlucose = state.predictedGlucose + recommendedTempBasal = state.recommendedAutomaticDose?.recommendation.basalAdjustment + updateGroup.leave() + } + // We need to wait until the task completes to get outputs + updateGroup.wait() + + XCTAssertNotNil(predictedGlucose) + XCTAssertEqual(predictedGlucoseOutput.count, predictedGlucose!.count) + + for (expected, calculated) in zip(predictedGlucoseOutput, predictedGlucose!) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: defaultAccuracy) + } + + XCTAssertEqual(0, recommendedTempBasal!.unitsPerHour, accuracy: defaultAccuracy) + } + + func testHighAndRisingWithCOB() { + setUp(for: .highAndRisingWithCOB) + let predictedGlucoseOutput = loadLocalDateGlucoseEffect("high_and_rising_with_cob_predicted_glucose") + + let updateGroup = DispatchGroup() + updateGroup.enter() + var predictedGlucose: [PredictedGlucoseValue]? + var recommendedBolus: ManualBolusRecommendation? + self.loopDataManager.getLoopState { _, state in + predictedGlucose = state.predictedGlucose + recommendedBolus = try? state.recommendBolus(consideringPotentialCarbEntry: nil, replacingCarbEntry: nil, considerPositiveVelocityAndRC: true) + updateGroup.leave() + } + // We need to wait until the task completes to get outputs + updateGroup.wait() + + XCTAssertNotNil(predictedGlucose) + XCTAssertEqual(predictedGlucoseOutput.count, predictedGlucose!.count) + + for (expected, calculated) in zip(predictedGlucoseOutput, predictedGlucose!) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: defaultAccuracy) + } + + XCTAssertEqual(1.6, recommendedBolus!.amount, accuracy: defaultAccuracy) + } + + func testLowAndFallingWithCOB() { + setUp(for: .lowAndFallingWithCOB) + let predictedGlucoseOutput = loadLocalDateGlucoseEffect("low_and_falling_predicted_glucose") + + let updateGroup = DispatchGroup() + updateGroup.enter() + var predictedGlucose: [PredictedGlucoseValue]? + var recommendedTempBasal: TempBasalRecommendation? + self.loopDataManager.getLoopState { _, state in + predictedGlucose = state.predictedGlucose + recommendedTempBasal = state.recommendedAutomaticDose?.recommendation.basalAdjustment + updateGroup.leave() + } + // We need to wait until the task completes to get outputs + updateGroup.wait() + + XCTAssertNotNil(predictedGlucose) + XCTAssertEqual(predictedGlucoseOutput.count, predictedGlucose!.count) + + for (expected, calculated) in zip(predictedGlucoseOutput, predictedGlucose!) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: defaultAccuracy) + } + + XCTAssertEqual(0, recommendedTempBasal!.unitsPerHour, accuracy: defaultAccuracy) + } + + func testLowWithLowTreatment() { + setUp(for: .lowWithLowTreatment) + let predictedGlucoseOutput = loadLocalDateGlucoseEffect("low_with_low_treatment_predicted_glucose") + + let updateGroup = DispatchGroup() + updateGroup.enter() + var predictedGlucose: [PredictedGlucoseValue]? + var recommendedTempBasal: TempBasalRecommendation? + self.loopDataManager.getLoopState { _, state in + predictedGlucose = state.predictedGlucose + recommendedTempBasal = state.recommendedAutomaticDose?.recommendation.basalAdjustment + updateGroup.leave() + } + // We need to wait until the task completes to get outputs + updateGroup.wait() + + XCTAssertNotNil(predictedGlucose) + XCTAssertEqual(predictedGlucoseOutput.count, predictedGlucose!.count) + + for (expected, calculated) in zip(predictedGlucoseOutput, predictedGlucose!) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: defaultAccuracy) + } + + XCTAssertEqual(0, recommendedTempBasal!.unitsPerHour, accuracy: defaultAccuracy) + } + + func waitOnDataQueue(timeout: TimeInterval = 1.0) { + let e = expectation(description: "dataQueue") + loopDataManager.getLoopState { _, _ in + e.fulfill() + } + wait(for: [e], timeout: timeout) + } + + func testValidateMaxTempBasalDoesntCancelTempBasalIfHigher() { + let dose = DoseEntry(type: .tempBasal, startDate: Date(), endDate: nil, value: 3.0, unit: .unitsPerHour, deliveredUnits: nil, description: nil, syncIdentifier: nil, scheduledBasalRate: nil) + setUp(for: .highAndStable, basalDeliveryState: .tempBasal(dose)) + // This wait is working around the issue presented by LoopDataManager.init(). It cancels the temp basal if + // `isClosedLoop` is false (which it is from `setUp` above). When that happens, it races with + // `maxTempBasalSavePreflight` below. This ensures only one happens at a time. + waitOnDataQueue() + let delegate = MockDelegate() + loopDataManager.delegate = delegate + var error: Error? + let exp = expectation(description: #function) + XCTAssertNil(delegate.recommendation) + loopDataManager.maxTempBasalSavePreflight(unitsPerHour: 5.0) { + error = $0 + exp.fulfill() + } + wait(for: [exp], timeout: 1.0) + XCTAssertNil(error) + XCTAssertNil(delegate.recommendation) + XCTAssertTrue(dosingDecisionStore.dosingDecisions.isEmpty) + } + + func testValidateMaxTempBasalCancelsTempBasalIfLower() { + let dose = DoseEntry(type: .tempBasal, startDate: Date(), endDate: nil, value: 5.0, unit: .unitsPerHour, deliveredUnits: nil, description: nil, syncIdentifier: nil, scheduledBasalRate: nil) + setUp(for: .highAndStable, basalDeliveryState: .tempBasal(dose)) + // This wait is working around the issue presented by LoopDataManager.init(). It cancels the temp basal if + // `isClosedLoop` is false (which it is from `setUp` above). When that happens, it races with + // `maxTempBasalSavePreflight` below. This ensures only one happens at a time. + waitOnDataQueue() + let delegate = MockDelegate() + loopDataManager.delegate = delegate + var error: Error? + let exp = expectation(description: #function) + XCTAssertNil(delegate.recommendation) + loopDataManager.maxTempBasalSavePreflight(unitsPerHour: 3.0) { + error = $0 + exp.fulfill() + } + wait(for: [exp], timeout: 1.0) + XCTAssertNil(error) + XCTAssertEqual(delegate.recommendation, AutomaticDoseRecommendation(basalAdjustment: .cancel)) + XCTAssertEqual(dosingDecisionStore.dosingDecisions.count, 1) + XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].reason, "maximumBasalRateChanged") + XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].automaticDoseRecommendation, AutomaticDoseRecommendation(basalAdjustment: .cancel)) + } + + func testChangingMaxBasalUpdatesLoopData() { + setUp(for: .highAndStable) + waitOnDataQueue() + var loopDataUpdated = false + let exp = expectation(description: #function) + let observer = NotificationCenter.default.addObserver(forName: .LoopDataUpdated, object: nil, queue: nil) { _ in + loopDataUpdated = true + exp.fulfill() + } + XCTAssertFalse(loopDataUpdated) + loopDataManager.mutateSettings { $0.maximumBasalRatePerHour = 2.0 } + wait(for: [exp], timeout: 1.0) + XCTAssertTrue(loopDataUpdated) + NotificationCenter.default.removeObserver(observer) + } + + func testOpenLoopCancelsTempBasal() { + let dose = DoseEntry(type: .tempBasal, startDate: Date(), value: 1.0, unit: .unitsPerHour) + setUp(for: .highAndStable, basalDeliveryState: .tempBasal(dose)) + waitOnDataQueue() + let delegate = MockDelegate() + loopDataManager.delegate = delegate + let exp = expectation(description: #function) + let observer = NotificationCenter.default.addObserver(forName: .LoopDataUpdated, object: nil, queue: nil) { _ in + exp.fulfill() + } + automaticDosingStatus.automaticDosingEnabled = false + wait(for: [exp], timeout: 1.0) + let expectedAutomaticDoseRecommendation = AutomaticDoseRecommendation(basalAdjustment: .cancel) + XCTAssertEqual(delegate.recommendation, expectedAutomaticDoseRecommendation) + XCTAssertEqual(dosingDecisionStore.dosingDecisions.count, 1) + XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].reason, "automaticDosingDisabled") + XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].automaticDoseRecommendation, expectedAutomaticDoseRecommendation) + NotificationCenter.default.removeObserver(observer) + } + + func testReceivedUnreliableCGMReadingCancelsTempBasal() { + let dose = DoseEntry(type: .tempBasal, startDate: Date(), value: 5.0, unit: .unitsPerHour) + setUp(for: .highAndStable, basalDeliveryState: .tempBasal(dose)) + waitOnDataQueue() + let delegate = MockDelegate() + loopDataManager.delegate = delegate + let exp = expectation(description: #function) + let observer = NotificationCenter.default.addObserver(forName: .LoopDataUpdated, object: nil, queue: nil) { _ in + exp.fulfill() + } + loopDataManager.receivedUnreliableCGMReading() + wait(for: [exp], timeout: 1.0) + let expectedAutomaticDoseRecommendation = AutomaticDoseRecommendation(basalAdjustment: .cancel) + XCTAssertEqual(delegate.recommendation, expectedAutomaticDoseRecommendation) + XCTAssertEqual(dosingDecisionStore.dosingDecisions.count, 1) + XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].reason, "unreliableCGMData") + XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].automaticDoseRecommendation, expectedAutomaticDoseRecommendation) + NotificationCenter.default.removeObserver(observer) + } + + func testLoopEnactsTempBasalWithoutManualBolusRecommendation() { + setUp(for: .highAndStable) + waitOnDataQueue() + let delegate = MockDelegate() + loopDataManager.delegate = delegate + let exp = expectation(description: #function) + let observer = NotificationCenter.default.addObserver(forName: .LoopCompleted, object: nil, queue: nil) { _ in + exp.fulfill() + } + loopDataManager.loop() + wait(for: [exp], timeout: 1.0) + let expectedAutomaticDoseRecommendation = AutomaticDoseRecommendation(basalAdjustment: TempBasalRecommendation(unitsPerHour: 4.55, duration: .minutes(30))) + XCTAssertEqual(delegate.recommendation, expectedAutomaticDoseRecommendation) + XCTAssertEqual(dosingDecisionStore.dosingDecisions.count, 1) + if dosingDecisionStore.dosingDecisions.count == 1 { + XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].reason, "loop") + XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].automaticDoseRecommendation, expectedAutomaticDoseRecommendation) + XCTAssertNil(dosingDecisionStore.dosingDecisions[0].manualBolusRecommendation) + XCTAssertNil(dosingDecisionStore.dosingDecisions[0].manualBolusRequested) + } + NotificationCenter.default.removeObserver(observer) + } + + func testLoopRecommendsTempBasalWithoutEnactingIfOpenLoop() { + setUp(for: .highAndStable) + automaticDosingStatus.automaticDosingEnabled = false + waitOnDataQueue() + let delegate = MockDelegate() + loopDataManager.delegate = delegate + let exp = expectation(description: #function) + let observer = NotificationCenter.default.addObserver(forName: .LoopCompleted, object: nil, queue: nil) { _ in + exp.fulfill() + } + loopDataManager.loop() + wait(for: [exp], timeout: 1.0) + let expectedAutomaticDoseRecommendation = AutomaticDoseRecommendation(basalAdjustment: TempBasalRecommendation(unitsPerHour: 4.55, duration: .minutes(30))) + XCTAssertNil(delegate.recommendation) + XCTAssertEqual(dosingDecisionStore.dosingDecisions.count, 1) + XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].reason, "loop") + XCTAssertEqual(dosingDecisionStore.dosingDecisions[0].automaticDoseRecommendation, expectedAutomaticDoseRecommendation) + XCTAssertNil(dosingDecisionStore.dosingDecisions[0].manualBolusRecommendation) + XCTAssertNil(dosingDecisionStore.dosingDecisions[0].manualBolusRequested) + NotificationCenter.default.removeObserver(observer) + } + + func testLoopGetStateRecommendsManualBolus() { + setUp(for: .highAndStable) + let exp = expectation(description: #function) + var recommendedBolus: ManualBolusRecommendation? + loopDataManager.getLoopState { (_, loopState) in + recommendedBolus = try? loopState.recommendBolus(consideringPotentialCarbEntry: nil, replacingCarbEntry: nil, considerPositiveVelocityAndRC: true) + exp.fulfill() + } + wait(for: [exp], timeout: 100000.0) + XCTAssertEqual(recommendedBolus!.amount, 1.82, accuracy: 0.01) + } + + func testLoopGetStateRecommendsManualBolusWithMomentum() { + setUp(for: .highAndRisingWithCOB) + let exp = expectation(description: #function) + var recommendedBolus: ManualBolusRecommendation? + loopDataManager.getLoopState { (_, loopState) in + recommendedBolus = try? loopState.recommendBolus(consideringPotentialCarbEntry: nil, replacingCarbEntry: nil, considerPositiveVelocityAndRC: true) + exp.fulfill() + } + wait(for: [exp], timeout: 1.0) + XCTAssertEqual(recommendedBolus!.amount, 1.62, accuracy: 0.01) + } + + func testLoopGetStateRecommendsManualBolusWithoutMomentum() { + setUp(for: .highAndRisingWithCOB) + let exp = expectation(description: #function) + var recommendedBolus: ManualBolusRecommendation? + loopDataManager.getLoopState { (_, loopState) in + recommendedBolus = try? loopState.recommendBolus(consideringPotentialCarbEntry: nil, replacingCarbEntry: nil, considerPositiveVelocityAndRC: false) + exp.fulfill() + } + wait(for: [exp], timeout: 1.0) + XCTAssertEqual(recommendedBolus!.amount, 1.52, accuracy: 0.01) + } + + func testIsClosedLoopAvoidsTriggeringTempBasalCancelOnCreation() { + let settings = LoopSettings( + dosingEnabled: false, + glucoseTargetRangeSchedule: glucoseTargetRangeSchedule, + maximumBasalRatePerHour: 5, + maximumBolus: 10, + suspendThreshold: suspendThreshold + ) + + let doseStore = MockDoseStore() + let glucoseStore = MockGlucoseStore(for: .flatAndStable) + let carbStore = MockCarbStore() + + let currentDate = Date() + + dosingDecisionStore = MockDosingDecisionStore() + automaticDosingStatus = AutomaticDosingStatus(automaticDosingEnabled: false, isAutomaticDosingAllowed: true) + let existingTempBasal = DoseEntry( + type: .tempBasal, + startDate: currentDate.addingTimeInterval(-.minutes(2)), + endDate: currentDate.addingTimeInterval(.minutes(28)), + value: 1.0, + unit: .unitsPerHour, + deliveredUnits: nil, + description: "Mock Temp Basal", + syncIdentifier: "asdf", + scheduledBasalRate: nil, + insulinType: .novolog, + automatic: true, + manuallyEntered: false, + isMutable: true) + loopDataManager = LoopDataManager( + lastLoopCompleted: currentDate.addingTimeInterval(-.minutes(5)), + basalDeliveryState: .tempBasal(existingTempBasal), + settings: settings, + overrideHistory: TemporaryScheduleOverrideHistory(), + analyticsServicesManager: AnalyticsServicesManager(), + localCacheDuration: .days(1), + doseStore: doseStore, + glucoseStore: glucoseStore, + carbStore: carbStore, + dosingDecisionStore: dosingDecisionStore, + latestStoredSettingsProvider: MockLatestStoredSettingsProvider(), + now: { currentDate }, + pumpInsulinType: .novolog, + automaticDosingStatus: automaticDosingStatus, + trustedTimeOffset: { 0 } + ) + let mockDelegate = MockDelegate() + loopDataManager.delegate = mockDelegate + + // Dose enacting happens asynchronously, as does receiving isClosedLoop signals + waitOnMain(timeout: 5) + XCTAssertNil(mockDelegate.recommendation) + } + + func testAutoBolusMaxIOBClamping() { + /// `maxBolus` is set to clamp the automatic dose + /// Autobolus without clamping: 0.65 U. Clamped recommendation: 0.2 U. + setUp(for: .highAndRisingWithCOB, maxBolus: 5, dosingStrategy: .automaticBolus) + + // This sets up dose rounding + let delegate = MockDelegate() + loopDataManager.delegate = delegate + + let updateGroup = DispatchGroup() + updateGroup.enter() + + var insulinOnBoard: InsulinValue? + var recommendedBolus: Double? + self.loopDataManager.getLoopState { _, state in + insulinOnBoard = state.insulinOnBoard + recommendedBolus = state.recommendedAutomaticDose?.recommendation.bolusUnits + updateGroup.leave() + } + updateGroup.wait() + + XCTAssertEqual(recommendedBolus!, 0.5, accuracy: 0.01) + XCTAssertEqual(insulinOnBoard?.value, 9.5) + + /// Set the `maximumBolus` to 10U so there's no clamping + updateGroup.enter() + self.loopDataManager.mutateSettings { settings in settings.maximumBolus = 10 } + self.loopDataManager.getLoopState { _, state in + insulinOnBoard = state.insulinOnBoard + recommendedBolus = state.recommendedAutomaticDose?.recommendation.bolusUnits + updateGroup.leave() + } + updateGroup.wait() + + XCTAssertEqual(recommendedBolus!, 0.65, accuracy: 0.01) + XCTAssertEqual(insulinOnBoard?.value, 9.5) + } + + func testTempBasalMaxIOBClamping() { + /// `maximumBolus` is set to 5U to clamp max IOB at 10U + /// Without clamping: 4.25 U/hr. Clamped recommendation: 2.0 U/hr. + setUp(for: .highAndRisingWithCOB, maxBolus: 5) + + // This sets up dose rounding + let delegate = MockDelegate() + loopDataManager.delegate = delegate + + let updateGroup = DispatchGroup() + updateGroup.enter() + + var insulinOnBoard: InsulinValue? + var recommendedBasal: TempBasalRecommendation? + self.loopDataManager.getLoopState { _, state in + insulinOnBoard = state.insulinOnBoard + recommendedBasal = state.recommendedAutomaticDose?.recommendation.basalAdjustment + updateGroup.leave() + } + updateGroup.wait() + + XCTAssertEqual(recommendedBasal!.unitsPerHour, 2.0, accuracy: 0.01) + XCTAssertEqual(insulinOnBoard?.value, 9.5) + + /// Set the `maximumBolus` to 10U so there's no clamping + updateGroup.enter() + self.loopDataManager.mutateSettings { settings in settings.maximumBolus = 10 } + self.loopDataManager.getLoopState { _, state in + insulinOnBoard = state.insulinOnBoard + recommendedBasal = state.recommendedAutomaticDose?.recommendation.basalAdjustment + updateGroup.leave() + } + updateGroup.wait() + + XCTAssertEqual(recommendedBasal!.unitsPerHour, 4.25, accuracy: 0.01) + XCTAssertEqual(insulinOnBoard?.value, 9.5) + } + +} diff --git a/LoopTests/Managers/LoopDataManagerTests.swift b/LoopTests/Managers/LoopDataManagerTests.swift new file mode 100644 index 0000000000..32c7d66f19 --- /dev/null +++ b/LoopTests/Managers/LoopDataManagerTests.swift @@ -0,0 +1,218 @@ +// +// LoopDataManagerTests.swift +// LoopTests +// +// Created by Anna Quinlan on 8/4/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import XCTest +import HealthKit +import LoopKit +@testable import LoopCore +@testable import Loop + +public typealias JSONDictionary = [String: Any] + +enum DosingTestScenario { + case liveCapture // Includes actual dosing history, bg history, etc. + case flatAndStable + case highAndStable + case highAndRisingWithCOB + case lowAndFallingWithCOB + case lowWithLowTreatment + case highAndFalling + + var fixturePrefix: String { + switch self { + case .liveCapture: + return "live_capture_" + case .flatAndStable: + return "flat_and_stable_" + case .highAndStable: + return "high_and_stable_" + case .highAndRisingWithCOB: + return "high_rising_with_cob_" + case .lowAndFallingWithCOB: + return "low_and_falling_with_cob_" + case .lowWithLowTreatment: + return "low_with_low_treatment_" + case .highAndFalling: + return "high_and_falling_" + } + } + + static let localDateFormatter = ISO8601DateFormatter.localTimeDate() + + static var dateFormatter: ISO8601DateFormatter = { + let dateFormatter = ISO8601DateFormatter() + dateFormatter.formatOptions = [.withInternetDateTime] + return dateFormatter + }() + + + var currentDate: Date { + switch self { + case .liveCapture: + return Self.dateFormatter.date(from: "2023-07-29T19:21:00Z")! + case .flatAndStable: + return Self.localDateFormatter.date(from: "2020-08-11T20:45:02")! + case .highAndStable: + return Self.localDateFormatter.date(from: "2020-08-12T12:39:22")! + case .highAndRisingWithCOB: + return Self.localDateFormatter.date(from: "2020-08-11T21:48:17")! + case .lowAndFallingWithCOB: + return Self.localDateFormatter.date(from: "2020-08-11T22:06:06")! + case .lowWithLowTreatment: + return Self.localDateFormatter.date(from: "2020-08-11T22:23:55")! + case .highAndFalling: + return Self.localDateFormatter.date(from: "2020-08-11T22:59:45")! + } + } + +} + +extension TimeZone { + static var fixtureTimeZone: TimeZone { + return TimeZone(secondsFromGMT: 25200)! + } + + static var utcTimeZone: TimeZone { + return TimeZone(secondsFromGMT: 0)! + } +} + +extension ISO8601DateFormatter { + static func localTimeDate(timeZone: TimeZone = .fixtureTimeZone) -> Self { + let formatter = self.init() + + formatter.formatOptions = .withInternetDateTime + formatter.formatOptions.subtract(.withTimeZone) + formatter.timeZone = timeZone + + return formatter + } +} + +class LoopDataManagerTests: XCTestCase { + // MARK: Constants for testing + let retrospectiveCorrectionEffectDuration = TimeInterval(hours: 1) + let retrospectiveCorrectionGroupingInterval = 1.01 + let retrospectiveCorrectionGroupingIntervalMultiplier = 1.01 + let inputDataRecencyInterval = TimeInterval(minutes: 15) + let dateFormatter = ISO8601DateFormatter.localTimeDate() + let defaultAccuracy = 1.0 / 40.0 + + var suspendThreshold: GlucoseThreshold { + return GlucoseThreshold(unit: HKUnit.milligramsPerDeciliter, value: 75) + } + + var adultExponentialInsulinModel: InsulinModel = ExponentialInsulinModel(actionDuration: 21600.0, peakActivityTime: 4500.0) + + var glucoseTargetRangeSchedule: GlucoseRangeSchedule { + return GlucoseRangeSchedule(unit: HKUnit.milligramsPerDeciliter, dailyItems: [ + RepeatingScheduleValue(startTime: TimeInterval(0), value: DoubleRange(minValue: 100, maxValue: 110)), + RepeatingScheduleValue(startTime: TimeInterval(28800), value: DoubleRange(minValue: 90, maxValue: 100)), + RepeatingScheduleValue(startTime: TimeInterval(75600), value: DoubleRange(minValue: 100, maxValue: 110)) + ], timeZone: .utcTimeZone)! + } + + // MARK: Mock stores + var now: Date! + var dosingDecisionStore: MockDosingDecisionStore! + var automaticDosingStatus: AutomaticDosingStatus! + var loopDataManager: LoopDataManager! + + func setUp(for test: DosingTestScenario, + basalDeliveryState: PumpManagerStatus.BasalDeliveryState? = nil, + maxBolus: Double = 10, + maxBasalRate: Double = 5.0, + dosingStrategy: AutomaticDosingStrategy = .tempBasalOnly) + { + let basalRateSchedule = loadBasalRateScheduleFixture("basal_profile") + let insulinSensitivitySchedule = InsulinSensitivitySchedule( + unit: .milligramsPerDeciliter, + dailyItems: [ + RepeatingScheduleValue(startTime: 0, value: 45), + RepeatingScheduleValue(startTime: 32400, value: 55) + ], + timeZone: .utcTimeZone + )! + let carbRatioSchedule = CarbRatioSchedule( + unit: .gram(), + dailyItems: [ + RepeatingScheduleValue(startTime: 0.0, value: 10.0), + ], + timeZone: .utcTimeZone + )! + + let settings = LoopSettings( + dosingEnabled: false, + glucoseTargetRangeSchedule: glucoseTargetRangeSchedule, + insulinSensitivitySchedule: insulinSensitivitySchedule, + basalRateSchedule: basalRateSchedule, + carbRatioSchedule: carbRatioSchedule, + maximumBasalRatePerHour: maxBasalRate, + maximumBolus: maxBolus, + suspendThreshold: suspendThreshold, + automaticDosingStrategy: dosingStrategy + ) + + let doseStore = MockDoseStore(for: test) + doseStore.basalProfile = basalRateSchedule + doseStore.basalProfileApplyingOverrideHistory = doseStore.basalProfile + doseStore.sensitivitySchedule = insulinSensitivitySchedule + let glucoseStore = MockGlucoseStore(for: test) + let carbStore = MockCarbStore(for: test) + carbStore.insulinSensitivityScheduleApplyingOverrideHistory = insulinSensitivitySchedule + carbStore.carbRatioSchedule = carbRatioSchedule + + let currentDate = glucoseStore.latestGlucose!.startDate + now = currentDate + + dosingDecisionStore = MockDosingDecisionStore() + automaticDosingStatus = AutomaticDosingStatus(automaticDosingEnabled: true, isAutomaticDosingAllowed: true) + loopDataManager = LoopDataManager( + lastLoopCompleted: currentDate, + basalDeliveryState: basalDeliveryState ?? .active(currentDate), + settings: settings, + overrideHistory: TemporaryScheduleOverrideHistory(), + analyticsServicesManager: AnalyticsServicesManager(), + localCacheDuration: .days(1), + doseStore: doseStore, + glucoseStore: glucoseStore, + carbStore: carbStore, + dosingDecisionStore: dosingDecisionStore, + latestStoredSettingsProvider: MockLatestStoredSettingsProvider(), + now: { currentDate }, + pumpInsulinType: .novolog, + automaticDosingStatus: automaticDosingStatus, + trustedTimeOffset: { 0 } + ) + } + + override func tearDownWithError() throws { + loopDataManager = nil + } +} + +extension LoopDataManagerTests { + public var bundle: Bundle { + return Bundle(for: type(of: self)) + } + + public func loadFixture(_ resourceName: String) -> T { + let path = bundle.path(forResource: resourceName, ofType: "json")! + return try! JSONSerialization.jsonObject(with: Data(contentsOf: URL(fileURLWithPath: path)), options: []) as! T + } + + func loadBasalRateScheduleFixture(_ resourceName: String) -> BasalRateSchedule { + let fixture: [JSONDictionary] = loadFixture(resourceName) + + let items = fixture.map { + return RepeatingScheduleValue(startTime: TimeInterval(minutes: $0["minutes"] as! Double), value: $0["rate"] as! Double) + } + + return BasalRateSchedule(dailyItems: items, timeZone: .utcTimeZone)! + } +} diff --git a/LoopTests/Managers/MealDetectionManagerTests.swift b/LoopTests/Managers/MealDetectionManagerTests.swift new file mode 100644 index 0000000000..3db48cc7eb --- /dev/null +++ b/LoopTests/Managers/MealDetectionManagerTests.swift @@ -0,0 +1,535 @@ +// +// MealDetectionManagerTests.swift +// LoopTests +// +// Created by Anna Quinlan on 11/28/22. +// Copyright © 2022 LoopKit Authors. All rights reserved. +// + +import XCTest +import HealthKit +import LoopCore +import LoopKit +@testable import Loop + +fileprivate class MockGlucoseSample: GlucoseSampleValue { + + let provenanceIdentifier = "" + let isDisplayOnly: Bool + let wasUserEntered: Bool + let condition: LoopKit.GlucoseCondition? = nil + let trendRate: HKQuantity? = nil + var trend: LoopKit.GlucoseTrend? + var syncIdentifier: String? + let quantity: HKQuantity = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 100) + let startDate: Date + + init(startDate: Date, isDisplayOnly: Bool = false, wasUserEntered: Bool = false) { + self.startDate = startDate + self.isDisplayOnly = isDisplayOnly + self.wasUserEntered = wasUserEntered + } +} + +enum MissedMealTestType { + private static var dateFormatter = ISO8601DateFormatter.localTimeDate() + + /// No meal is present + case noMeal + /// No meal is present, but if the counteraction effects aren't clamped properly it will look like there's a missed meal + case noMealCounteractionEffectsNeedClamping + // No meal is present and there is COB + case noMealWithCOB + /// Missed meal with no carbs on board + case missedMealNoCOB + /// Missed meal with carbs logged prior to it + case missedMealWithCOB + /// CGM data is noisy, but no meal is present + case noisyCGM + /// Realistic counteraction effects with multiple meals + case manyMeals + /// Test case to test dynamic computation of missed meal carb amount + case dynamicCarbAutofill + /// Test case for purely testing the notifications (not the algorithm) + case notificationTest + /// Test case for testing the algorithm with settings in mmol/L + case mmolUser +} + +extension MissedMealTestType { + var counteractionEffectFixture: String { + switch self { + case .missedMealNoCOB, .noMealWithCOB, .notificationTest: + return "missed_meal_counteraction_effect" + case .noMeal: + return "long_interval_counteraction_effect" + case .noMealCounteractionEffectsNeedClamping: + return "needs_clamping_counteraction_effect" + case .noisyCGM: + return "noisy_cgm_counteraction_effect" + case .manyMeals, .missedMealWithCOB: + return "realistic_report_counteraction_effect" + case .dynamicCarbAutofill, .mmolUser: + return "dynamic_autofill_counteraction_effect" + } + } + + var currentDate: Date { + switch self { + case .missedMealNoCOB, .noMealWithCOB, .notificationTest: + return Self.dateFormatter.date(from: "2022-10-17T23:28:45")! + case .noMeal, .noMealCounteractionEffectsNeedClamping: + return Self.dateFormatter.date(from: "2022-10-17T02:49:16")! + case .noisyCGM: + return Self.dateFormatter.date(from: "2022-10-19T20:46:23")! + case .missedMealWithCOB: + return Self.dateFormatter.date(from: "2022-10-19T19:50:15")! + case .manyMeals: + return Self.dateFormatter.date(from: "2022-10-19T21:50:15")! + case .dynamicCarbAutofill, .mmolUser: + return Self.dateFormatter.date(from: "2022-10-17T07:51:09")! + } + } + + var missedMealDate: Date? { + switch self { + case .missedMealNoCOB: + return Self.dateFormatter.date(from: "2022-10-17T21:55:00") + case .missedMealWithCOB: + return Self.dateFormatter.date(from: "2022-10-19T19:00:00") + case .manyMeals: + return Self.dateFormatter.date(from: "2022-10-19T20:40:00 ") + case .dynamicCarbAutofill, .mmolUser: + return Self.dateFormatter.date(from: "2022-10-17T07:20:00")! + default: + return nil + } + } + + var carbEntries: [NewCarbEntry] { + switch self { + case .missedMealWithCOB: + return [ + NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 30), + startDate: Self.dateFormatter.date(from: "2022-10-19T15:41:36")!, + foodType: nil, + absorptionTime: nil), + NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 10), + startDate: Self.dateFormatter.date(from: "2022-10-19T17:36:58")!, + foodType: nil, + absorptionTime: nil) + ] + case .noMealWithCOB: + return [ + NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 30), + startDate: Self.dateFormatter.date(from: "2022-10-17T22:40:00")!, + foodType: nil, + absorptionTime: nil) + ] + case .manyMeals: + return [ + NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 30), + startDate: Self.dateFormatter.date(from: "2022-10-19T15:41:36")!, + foodType: nil, + absorptionTime: nil), + NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 10), + startDate: Self.dateFormatter.date(from: "2022-10-19T17:36:58")!, + foodType: nil, + absorptionTime: nil), + NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 40), + startDate: Self.dateFormatter.date(from: "2022-10-19T19:11:43")!, + foodType: nil, + absorptionTime: nil) + ] + default: + return [] + } + } + + var carbSchedule: CarbRatioSchedule { + CarbRatioSchedule( + unit: .gram(), + dailyItems: [ + RepeatingScheduleValue(startTime: 0.0, value: 15.0), + ], + timeZone: .utcTimeZone + )! + } + + var insulinSensitivitySchedule: InsulinSensitivitySchedule { + let value = 50.0 + switch self { + case .mmolUser: + return InsulinSensitivitySchedule( + unit: HKUnit.millimolesPerLiter, + dailyItems: [ + RepeatingScheduleValue(startTime: 0.0, + value: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: value).doubleValue(for: .millimolesPerLiter)) + ], + timeZone: .utcTimeZone + )! + default: + return InsulinSensitivitySchedule( + unit: HKUnit.milligramsPerDeciliter, + dailyItems: [ + RepeatingScheduleValue(startTime: 0.0, value: value) + ], + timeZone: .utcTimeZone + )! + } + } +} + +class MealDetectionManagerTests: XCTestCase { + let dateFormatter = ISO8601DateFormatter.localTimeDate() + let pumpManager = MockPumpManager() + + var mealDetectionManager: MealDetectionManager! + var carbStore: CarbStore! + + var now: Date { + mealDetectionManager.test_currentDate! + } + + var bolusUnits: Double? + var bolusDurationEstimator: ((Double) -> TimeInterval?)! + + fileprivate var glucoseSamples: [MockGlucoseSample]! + + @discardableResult func setUp(for testType: MissedMealTestType) -> [GlucoseEffectVelocity] { + carbStore = CarbStore( + cacheStore: PersistenceController(directoryURL: URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true).appendingPathComponent(UUID().uuidString, isDirectory: true)), + cacheLength: .hours(24), + defaultAbsorptionTimes: (fast: .minutes(30), medium: .hours(3), slow: .hours(5)), + overrideHistory: TemporaryScheduleOverrideHistory(), + provenanceIdentifier: Bundle.main.bundleIdentifier!, + test_currentDate: testType.currentDate) + + // Set up schedules + carbStore.carbRatioSchedule = testType.carbSchedule + carbStore.insulinSensitivitySchedule = testType.insulinSensitivitySchedule + + // Add any needed carb entries to the carb store + let updateGroup = DispatchGroup() + testType.carbEntries.forEach { carbEntry in + updateGroup.enter() + carbStore.addCarbEntry(carbEntry) { result in + if case .failure(_) = result { + XCTFail("Failed to add carb entry to carb store") + } + + updateGroup.leave() + } + } + _ = updateGroup.wait(timeout: .now() + .seconds(5)) + + mealDetectionManager = MealDetectionManager( + carbRatioScheduleApplyingOverrideHistory: carbStore.carbRatioScheduleApplyingOverrideHistory, + insulinSensitivityScheduleApplyingOverrideHistory: carbStore.insulinSensitivityScheduleApplyingOverrideHistory, + maximumBolus: 5, + test_currentDate: testType.currentDate + ) + + glucoseSamples = [MockGlucoseSample(startDate: now)] + + bolusDurationEstimator = { units in + self.bolusUnits = units + return self.pumpManager.estimatedDuration(toBolus: units) + } + + // Fetch & return the counteraction effects for the test + return counteractionEffects(for: testType) + } + + private func counteractionEffects(for testType: MissedMealTestType) -> [GlucoseEffectVelocity] { + let fixture: [JSONDictionary] = loadFixture(testType.counteractionEffectFixture) + let dateFormatter = ISO8601DateFormatter.localTimeDate() + + return fixture.map { + GlucoseEffectVelocity(startDate: dateFormatter.date(from: $0["startDate"] as! String)!, + endDate: dateFormatter.date(from: $0["endDate"] as! String)!, + quantity: HKQuantity(unit: HKUnit(from: $0["unit"] as! String), + doubleValue:$0["value"] as! Double)) + } + } + + private func mealDetectionCarbEffects(using insulinCounteractionEffects: [GlucoseEffectVelocity]) -> [GlucoseEffect] { + let carbEffectStart = now.addingTimeInterval(-MissedMealSettings.maxRecency) + + var carbEffects: [GlucoseEffect] = [] + + let updateGroup = DispatchGroup() + updateGroup.enter() + carbStore.getGlucoseEffects(start: carbEffectStart, end: now, effectVelocities: insulinCounteractionEffects) { result in + defer { updateGroup.leave() } + + guard case .success((_, let effects)) = result else { + XCTFail("Failed to fetch glucose effects to check for missed meal") + return + } + carbEffects = effects + } + _ = updateGroup.wait(timeout: .now() + .seconds(5)) + + return carbEffects + } + + override func tearDown() { + mealDetectionManager.lastMissedMealNotification = nil + mealDetectionManager = nil + UserDefaults.standard.missedMealNotificationsEnabled = false + } + + // MARK: - Algorithm Tests + func testNoMissedMeal() { + let counteractionEffects = setUp(for: .noMeal) + + let updateGroup = DispatchGroup() + updateGroup.enter() + mealDetectionManager.hasMissedMeal(glucoseSamples: glucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in + XCTAssertEqual(status, .noMissedMeal) + updateGroup.leave() + } + updateGroup.wait() + } + + func testNoMissedMeal_WithCOB() { + let counteractionEffects = setUp(for: .noMealWithCOB) + + let updateGroup = DispatchGroup() + updateGroup.enter() + mealDetectionManager.hasMissedMeal(glucoseSamples: glucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in + XCTAssertEqual(status, .noMissedMeal) + updateGroup.leave() + } + updateGroup.wait() + } + + func testMissedMeal_NoCarbEntry() { + let testType = MissedMealTestType.missedMealNoCOB + let counteractionEffects = setUp(for: testType) + + let updateGroup = DispatchGroup() + updateGroup.enter() + mealDetectionManager.hasMissedMeal(glucoseSamples: glucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in + XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 55)) + updateGroup.leave() + } + updateGroup.wait() + } + + func testDynamicCarbAutofill() { + let testType = MissedMealTestType.dynamicCarbAutofill + let counteractionEffects = setUp(for: testType) + + let updateGroup = DispatchGroup() + updateGroup.enter() + mealDetectionManager.hasMissedMeal(glucoseSamples: glucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in + XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 25)) + updateGroup.leave() + } + updateGroup.wait() + } + + func testMissedMeal_MissedMealAndCOB() { + let testType = MissedMealTestType.missedMealWithCOB + let counteractionEffects = setUp(for: testType) + + let updateGroup = DispatchGroup() + updateGroup.enter() + mealDetectionManager.hasMissedMeal(glucoseSamples: glucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in + XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 50)) + updateGroup.leave() + } + updateGroup.wait() + } + + func testNoisyCGM() { + let counteractionEffects = setUp(for: .noisyCGM) + + let updateGroup = DispatchGroup() + updateGroup.enter() + mealDetectionManager.hasMissedMeal(glucoseSamples: glucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in + XCTAssertEqual(status, .noMissedMeal) + updateGroup.leave() + } + updateGroup.wait() + } + + func testManyMeals() { + let testType = MissedMealTestType.manyMeals + let counteractionEffects = setUp(for: testType) + + let updateGroup = DispatchGroup() + updateGroup.enter() + mealDetectionManager.hasMissedMeal(glucoseSamples: glucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in + XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 40)) + updateGroup.leave() + } + updateGroup.wait() + } + + func testMMOLUser() { + let testType = MissedMealTestType.mmolUser + let counteractionEffects = setUp(for: testType) + + let updateGroup = DispatchGroup() + updateGroup.enter() + mealDetectionManager.hasMissedMeal(glucoseSamples: glucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in + XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 25)) + updateGroup.leave() + } + updateGroup.wait() + } + + // MARK: - Notification Tests + func testNoMissedMealLastNotificationTime() { + setUp(for: .notificationTest) + UserDefaults.standard.missedMealNotificationsEnabled = true + + let status = MissedMealStatus.noMissedMeal + mealDetectionManager.manageMealNotifications(for: status, bolusDurationEstimator: { _ in nil }) + + XCTAssertNil(mealDetectionManager.lastMissedMealNotification) + } + + func testMissedMealUpdatesLastNotificationTime() { + setUp(for: .notificationTest) + UserDefaults.standard.missedMealNotificationsEnabled = true + + let status = MissedMealStatus.hasMissedMeal(startTime: now, carbAmount: 40) + mealDetectionManager.manageMealNotifications(for: status, bolusDurationEstimator: { _ in nil }) + + XCTAssertEqual(mealDetectionManager.lastMissedMealNotification?.deliveryTime, now) + XCTAssertEqual(mealDetectionManager.lastMissedMealNotification?.carbAmount, 40) + } + + func testMissedMealWithoutNotificationsEnabled() { + setUp(for: .notificationTest) + UserDefaults.standard.missedMealNotificationsEnabled = false + + let status = MissedMealStatus.hasMissedMeal(startTime: now, carbAmount: 40) + mealDetectionManager.manageMealNotifications(for: status, bolusDurationEstimator: { _ in nil }) + + XCTAssertNil(mealDetectionManager.lastMissedMealNotification) + } + + func testMissedMealWithTooRecentNotificationTime() { + setUp(for: .notificationTest) + UserDefaults.standard.missedMealNotificationsEnabled = true + + let oldTime = now.addingTimeInterval(.hours(1)) + let oldNotification = MissedMealNotification(deliveryTime: oldTime, carbAmount: 40) + mealDetectionManager.lastMissedMealNotification = oldNotification + + let status = MissedMealStatus.hasMissedMeal(startTime: now, carbAmount: MissedMealSettings.minCarbThreshold) + mealDetectionManager.manageMealNotifications(for: status, bolusDurationEstimator: { _ in nil }) + + XCTAssertEqual(mealDetectionManager.lastMissedMealNotification, oldNotification) + } + + func testMissedMealCarbClamping() { + setUp(for: .notificationTest) + UserDefaults.standard.missedMealNotificationsEnabled = true + + let status = MissedMealStatus.hasMissedMeal(startTime: now, carbAmount: 120) + mealDetectionManager.manageMealNotifications(for: status, bolusDurationEstimator: { _ in nil }) + + XCTAssertEqual(mealDetectionManager.lastMissedMealNotification?.deliveryTime, now) + XCTAssertEqual(mealDetectionManager.lastMissedMealNotification?.carbAmount, 75) + } + + func testMissedMealNoPendingBolus() { + setUp(for: .notificationTest) + UserDefaults.standard.missedMealNotificationsEnabled = true + + let status = MissedMealStatus.hasMissedMeal(startTime: now, carbAmount: 40) + mealDetectionManager.manageMealNotifications(for: status, pendingAutobolusUnits: 0, bolusDurationEstimator: bolusDurationEstimator) + + /// The bolus units time delegate should never be called if there are 0 pending units + XCTAssertNil(bolusUnits) + XCTAssertEqual(mealDetectionManager.lastMissedMealNotification?.deliveryTime, now) + XCTAssertEqual(mealDetectionManager.lastMissedMealNotification?.carbAmount, 40) + } + + func testMissedMealLongPendingBolus() { + setUp(for: .notificationTest) + UserDefaults.standard.missedMealNotificationsEnabled = true + + let status = MissedMealStatus.hasMissedMeal(startTime: now, carbAmount: 40) + mealDetectionManager.manageMealNotifications(for: status, pendingAutobolusUnits: 10, bolusDurationEstimator: bolusDurationEstimator) + + XCTAssertEqual(bolusUnits, 10) + /// There shouldn't be a delay in delivering notification, since the autobolus will take the length of the notification window to deliver + XCTAssertEqual(mealDetectionManager.lastMissedMealNotification?.deliveryTime, now) + XCTAssertEqual(mealDetectionManager.lastMissedMealNotification?.carbAmount, 40) + } + + func testNoMissedMealShortPendingBolus_DelaysNotificationTime() { + setUp(for: .notificationTest) + UserDefaults.standard.missedMealNotificationsEnabled = true + + let status = MissedMealStatus.hasMissedMeal(startTime: now, carbAmount: 30) + mealDetectionManager.manageMealNotifications(for: status, pendingAutobolusUnits: 2, bolusDurationEstimator: bolusDurationEstimator) + + let expectedDeliveryTime = now.addingTimeInterval(TimeInterval(80)) + XCTAssertEqual(bolusUnits, 2) + XCTAssertEqual(mealDetectionManager.lastMissedMealNotification?.deliveryTime, expectedDeliveryTime) + + mealDetectionManager.lastMissedMealNotification = nil + mealDetectionManager.manageMealNotifications(for: status, pendingAutobolusUnits: 4.5, bolusDurationEstimator: bolusDurationEstimator) + + let expectedDeliveryTime2 = now.addingTimeInterval(TimeInterval(minutes: 3)) + XCTAssertEqual(bolusUnits, 4.5) + XCTAssertEqual(mealDetectionManager.lastMissedMealNotification?.deliveryTime, expectedDeliveryTime2) + } + + func testHasCalibrationPoints_NoNotification() { + let testType = MissedMealTestType.manyMeals + let counteractionEffects = setUp(for: testType) + + let calibratedGlucoseSamples = [MockGlucoseSample(startDate: now), MockGlucoseSample(startDate: now, isDisplayOnly: true)] + + let updateGroup = DispatchGroup() + updateGroup.enter() + mealDetectionManager.hasMissedMeal(glucoseSamples: calibratedGlucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in + XCTAssertEqual(status, .noMissedMeal) + updateGroup.leave() + } + updateGroup.wait() + + let manualGlucoseSamples = [MockGlucoseSample(startDate: now), MockGlucoseSample(startDate: now, wasUserEntered: true)] + updateGroup.enter() + mealDetectionManager.hasMissedMeal(glucoseSamples: manualGlucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in + XCTAssertEqual(status, .noMissedMeal) + updateGroup.leave() + } + updateGroup.wait() + } + + func testHasTooOldCalibrationPoint_NoImpactOnNotificationDelivery() { + let testType = MissedMealTestType.manyMeals + let counteractionEffects = setUp(for: testType) + + let tooOldCalibratedGlucoseSamples = [MockGlucoseSample(startDate: now, isDisplayOnly: false), MockGlucoseSample(startDate: now.addingTimeInterval(-MissedMealSettings.maxRecency-1), isDisplayOnly: true)] + + let updateGroup = DispatchGroup() + updateGroup.enter() + mealDetectionManager.hasMissedMeal(glucoseSamples: tooOldCalibratedGlucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in + XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 40)) + updateGroup.leave() + } + updateGroup.wait() + } +} + +extension MealDetectionManagerTests { + public var bundle: Bundle { + return Bundle(for: type(of: self)) + } + + public func loadFixture(_ resourceName: String) -> T { + let path = bundle.path(forResource: resourceName, ofType: "json")! + return try! JSONSerialization.jsonObject(with: Data(contentsOf: URL(fileURLWithPath: path)), options: []) as! T + } +} diff --git a/LoopTests/Managers/SupportManagerTests.swift b/LoopTests/Managers/SupportManagerTests.swift new file mode 100644 index 0000000000..48fa42e4d8 --- /dev/null +++ b/LoopTests/Managers/SupportManagerTests.swift @@ -0,0 +1,125 @@ +// +// SupportManagerTests.swift +// LoopTests +// +// Created by Rick Pasetto on 9/10/21. +// Copyright © 2021 LoopKit Authors. All rights reserved. +// + +import XCTest +import LoopKit +import LoopKitUI +import SwiftUI +@testable import Loop + +class SupportManagerTests: XCTestCase { + enum MockError: Error { case nothing } + + class Mixin { + @ViewBuilder + func supportMenuItem(supportInfoProvider: SupportInfoProvider, urlHandler: @escaping (URL) -> Void) -> some View {} + + func softwareUpdateView(bundleIdentifier: String, currentVersion: String, guidanceColors: GuidanceColors, openAppStore: (() -> Void)?) -> AnyView? { + nil + } + var mockResult: Result = .success(.default) + func checkVersion(bundleIdentifier: String, currentVersion: String) async -> VersionUpdate? { + switch mockResult { + case .success(let update): + return update + case .failure: + return nil + } + } + weak var delegate: SupportUIDelegate? + } + class MockSupport: Mixin, SupportUI { + static var pluginIdentifier: String { "SupportManagerTestsMockSupport" } + override init() { super.init() } + required init?(rawState: RawStateValue) { super.init() } + var rawState: RawStateValue = [:] + + func getScenarios(from scenarioURLs: [URL]) -> [LoopScenario] { [] } + func loopWillReset() {} + func loopDidReset() {} + func configurationMenuItems() -> [LoopKitUI.CustomMenuItem] { return [] } + } + + class AnotherMockSupport: Mixin, SupportUI { + static var pluginIdentifier: String { "SupportManagerTestsAnotherMockSupport" } + override init() { super.init() } + required init?(rawState: RawStateValue) { super.init() } + var rawState: RawStateValue = [:] + + func getScenarios(from scenarioURLs: [URL]) -> [LoopScenario] { [] } + func loopWillReset() {} + func loopDidReset() {} + func configurationMenuItems() -> [LoopKitUI.CustomMenuItem] { return [] } + } + + class MockAlertIssuer: AlertIssuer { + func issueAlert(_ alert: LoopKit.Alert) { + } + + func retractAlert(identifier: LoopKit.Alert.Identifier) { + } + } + + class MockDeviceSupportDelegate: DeviceSupportDelegate { + var availableSupports: [LoopKitUI.SupportUI] = [] + + var pumpManagerStatus: LoopKit.PumpManagerStatus? + + var cgmManagerStatus: LoopKit.CGMManagerStatus? + + func generateDiagnosticReport(_ completion: @escaping (String) -> Void) { + completion("Mock Issue Report") + } + } + + var supportManager: SupportManager! + var mockSupport: SupportManagerTests.MockSupport! + var mockAlertIssuer: MockAlertIssuer! + var pluginManager = PluginManager() + var mocKDeviceSupportDelegate = MockDeviceSupportDelegate() + + + override func setUp() { + mockAlertIssuer = MockAlertIssuer() + supportManager = SupportManager(pluginManager: pluginManager, deviceSupportDelegate: mocKDeviceSupportDelegate, staticSupportTypes: [], alertIssuer: mockAlertIssuer) + mockSupport = SupportManagerTests.MockSupport() + supportManager.addSupport(mockSupport) + } + + func testVersionCheckOneService() async throws { + let result = await supportManager.checkVersion() + XCTAssertEqual(VersionUpdate.noUpdateNeeded, result) + mockSupport.mockResult = .success(.required) + + let result2 = await supportManager.checkVersion() + XCTAssertEqual(.required, result2) + } + + func testVersionCheckOneServiceError() async throws { + // Error doesn't really do anything but log + mockSupport.mockResult = .failure(MockError.nothing) + let result = await supportManager.checkVersion() + XCTAssertEqual(VersionUpdate.noUpdateNeeded, result) + } + + func testVersionCheckMultipleServices() async throws { + let anotherSupport = AnotherMockSupport() + supportManager.addSupport(anotherSupport) + let result = await supportManager.checkVersion() + XCTAssertEqual(VersionUpdate.noUpdateNeeded, result) + + anotherSupport.mockResult = .success(.required) + let result2 = await supportManager.checkVersion() + XCTAssertEqual(.required, result2) + + let result3 = await supportManager.checkVersion() + mockSupport.mockResult = .success(.recommended) + XCTAssertEqual(.required, result3) + } + +} diff --git a/LoopTests/Managers/ZipArchiveTests.swift b/LoopTests/Managers/ZipArchiveTests.swift new file mode 100644 index 0000000000..aeca57fa4b --- /dev/null +++ b/LoopTests/Managers/ZipArchiveTests.swift @@ -0,0 +1,127 @@ +// +// ZipArchiveTests.swift +// LoopTests +// +// Created by Darin Krauss on 9/14/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import XCTest +import Loop +import LoopKit +import ZIPFoundation + + +class ZipArchiveTests: XCTestCase { + var url: URL! + var archive: ZipArchive! + var outputStream: DataOutputStream? + + override func setUp() { + url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + archive = ZipArchive(url: url) + } + + override func tearDown() { + try? outputStream?.finish(sync: true) + archive.close() + try? FileManager.default.removeItem(at: url) + } + + func testClose() { + XCTAssertNil(archive.close()) + } + + func testCloseMultiple() { + XCTAssertNil(archive.close()) + XCTAssertNil(archive.close()) + } + + func testCreateWriteCloseArchiveFile() { + outputStream = archive.createArchiveFile(withPath: "testCreateWriteCloseArchiveFile") + XCTAssertNotNil(outputStream) + XCTAssertNil(outputStream?.streamError) + XCTAssertNoThrow(try outputStream?.write("testCreateWriteCloseArchiveFile")) + XCTAssertNoThrow(try outputStream?.finish(sync: true)) + XCTAssertNil(archive.close()) + } + + func testCreateWriteArchiveFileAfterClose() { + outputStream = archive.createArchiveFile(withPath: "testCreateWriteArchiveFileAfterClose") + XCTAssertNotNil(outputStream) + XCTAssertNoThrow(try outputStream?.finish(sync: true)) + XCTAssertThrowsError(try outputStream?.write("testCreateWriteArchiveFileAfterClose")) + } + + func testCreateArchiveFileWithContents() { + let contentsURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + XCTAssertNoThrow(try "testCreateArchiveFileWithContents".data(using: .utf8)!.write(to: contentsURL)) + XCTAssertNil(archive.createArchiveFile(withPath: "testCreateArchiveFileWithContents", contentsOf: contentsURL)) + XCTAssertNil(archive.close()) + + let archive = Archive(url: url, accessMode: .read) + XCTAssertNotNil(archive) + + let entry = archive!["testCreateArchiveFileWithContents"] + XCTAssertNotNil(entry) + + XCTAssertEqual(entry!.type, .file) + XCTAssertEqual(entry!.path, "testCreateArchiveFileWithContents") + + var extractedData = Data() + + let _ = try? archive!.extract(entry!, consumer: { (data) in + extractedData.append(data) + }) + + XCTAssertEqual(String(data: extractedData, encoding: .utf8), "testCreateArchiveFileWithContents") + } + + func testCreateArchiveFileWithMultipleFiles() { + let contentsURL1 = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + XCTAssertNoThrow(try "testCreateArchiveFileWithContents1".data(using: .utf8)!.write(to: contentsURL1)) + let contentsURL2 = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + XCTAssertNoThrow(try "testCreateArchiveFileWithContents2".data(using: .utf8)!.write(to: contentsURL2)) + + XCTAssertNil(archive.createArchiveFile(withPath: "testCreateArchiveFileWithContents1", contentsOf: contentsURL1)) + XCTAssertNil(archive.createArchiveFile(withPath: "testCreateArchiveFileWithContents2", contentsOf: contentsURL2)) + XCTAssertNil(archive.close()) + + let archive = Archive(url: url, accessMode: .read) + XCTAssertNotNil(archive) + + let entry1 = archive!["testCreateArchiveFileWithContents1"] + XCTAssertNotNil(entry1) + XCTAssertEqual(entry1!.type, .file) + XCTAssertEqual(entry1!.path, "testCreateArchiveFileWithContents1") + var extractedData1 = Data() + let _ = try? archive!.extract(entry1!, consumer: { (data) in + extractedData1.append(data) + }) + XCTAssertEqual(String(data: extractedData1, encoding: .utf8), "testCreateArchiveFileWithContents1") + + let entry2 = archive!["testCreateArchiveFileWithContents2"] + XCTAssertNotNil(entry2) + XCTAssertEqual(entry2!.type, .file) + XCTAssertEqual(entry2!.path, "testCreateArchiveFileWithContents2") + var extractedData2 = Data() + let _ = try? archive!.extract(entry2!, consumer: { (data) in + extractedData2.append(data) + }) + XCTAssertEqual(String(data: extractedData2, encoding: .utf8), "testCreateArchiveFileWithContents2") + } + +} + +fileprivate extension OutputStream { + func write(_ string: String) throws { + if let streamError = streamError { + throw streamError + } + let bytes = [UInt8](string.utf8) + write(bytes, maxLength: bytes.count) + if let streamError = streamError { + throw streamError + } + } +} diff --git a/LoopTests/Mock Stores/HKHealthStoreMock.swift b/LoopTests/Mock Stores/HKHealthStoreMock.swift new file mode 100644 index 0000000000..6f8127d3e4 --- /dev/null +++ b/LoopTests/Mock Stores/HKHealthStoreMock.swift @@ -0,0 +1,82 @@ +// +// HKHealthStoreMock.swift +// LoopTests +// +// Created by Anna Quinlan on 11/28/22. +// Copyright © 2022 LoopKit Authors. All rights reserved. +// + +import HealthKit +import Foundation +import LoopKit + + +class HKHealthStoreMock: HKHealthStore { + var saveError: Error? + var deleteError: Error? + var queryResults: (samples: [HKSample]?, error: Error?)? + var lastQuery: HKQuery? + var authorizationStatus: HKAuthorizationStatus? + + private var saveHandler: ((_ objects: [HKObject], _ success: Bool, _ error: Error?) -> Void)? + private var deleteObjectsHandler: ((_ objectType: HKObjectType, _ predicate: NSPredicate, _ success: Bool, _ count: Int, _ error: Error?) -> Void)? + + let queue = DispatchQueue(label: "HKHealthStoreMock") + + override func save(_ object: HKObject, withCompletion completion: @escaping (Bool, Error?) -> Void) { + queue.async { + self.saveHandler?([object], self.saveError == nil, self.saveError) + completion(self.saveError == nil, self.saveError) + } + } + + override func save(_ objects: [HKObject], withCompletion completion: @escaping (Bool, Error?) -> Void) { + queue.async { + self.saveHandler?(objects, self.saveError == nil, self.saveError) + completion(self.saveError == nil, self.saveError) + } + } + + override func delete(_ objects: [HKObject], withCompletion completion: @escaping (Bool, Error?) -> Void) { + queue.async { + completion(self.deleteError == nil, self.deleteError) + } + } + + override func deleteObjects(of objectType: HKObjectType, predicate: NSPredicate, withCompletion completion: @escaping (Bool, Int, Error?) -> Void) { + queue.async { + self.deleteObjectsHandler?(objectType, predicate, self.deleteError == nil, 0, self.deleteError) + completion(self.deleteError == nil, 0, self.deleteError) + } + } + + func setSaveHandler(_ saveHandler: ((_ objects: [HKObject], _ success: Bool, _ error: Error?) -> Void)?) { + queue.sync { + self.saveHandler = saveHandler + } + } + + override func requestAuthorization(toShare typesToShare: Set?, read typesToRead: Set?, completion: @escaping (Bool, Error?) -> Void) { + DispatchQueue.main.async { + completion(true, nil) + } + } + + override func authorizationStatus(for type: HKObjectType) -> HKAuthorizationStatus { + return authorizationStatus ?? .notDetermined + } + + func setDeletedObjectsHandler(_ deleteObjectsHandler: ((_ objectType: HKObjectType, _ predicate: NSPredicate, _ success: Bool, _ count: Int, _ error: Error?) -> Void)?) { + queue.sync { + self.deleteObjectsHandler = deleteObjectsHandler + } + } +} + +extension HKHealthStoreMock { + + override func execute(_ query: HKQuery) { + self.lastQuery = query + } +} + diff --git a/LoopTests/Mock Stores/MockCarbStore.swift b/LoopTests/Mock Stores/MockCarbStore.swift new file mode 100644 index 0000000000..4a5c016eb5 --- /dev/null +++ b/LoopTests/Mock Stores/MockCarbStore.swift @@ -0,0 +1,177 @@ +// +// MockCarbStore.swift +// LoopTests +// +// Created by Anna Quinlan on 8/7/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import HealthKit +import LoopKit +@testable import Loop + +class MockCarbStore: CarbStoreProtocol { + var carbHistory: [StoredCarbEntry]? + + init(for scenario: DosingTestScenario = .flatAndStable) { + self.scenario = scenario // The store returns different effect values based on the scenario + self.carbHistory = loadHistoricCarbEntries(scenario: scenario) + } + + var scenario: DosingTestScenario + + var sampleType: HKSampleType = HKSampleType.quantityType(forIdentifier: HKQuantityTypeIdentifier.dietaryCarbohydrates)! + + var preferredUnit: HKUnit! = .gram() + + var delegate: CarbStoreDelegate? + + var carbRatioSchedule: CarbRatioSchedule? + + var insulinSensitivitySchedule: InsulinSensitivitySchedule? + + var insulinSensitivityScheduleApplyingOverrideHistory: InsulinSensitivitySchedule? = InsulinSensitivitySchedule( + unit: HKUnit.milligramsPerDeciliter, + dailyItems: [ + RepeatingScheduleValue(startTime: 0.0, value: 45.0), + RepeatingScheduleValue(startTime: 32400.0, value: 55.0) + ], + timeZone: .utcTimeZone + )! + + var carbRatioScheduleApplyingOverrideHistory: CarbRatioSchedule? = CarbRatioSchedule( + unit: .gram(), + dailyItems: [ + RepeatingScheduleValue(startTime: 0.0, value: 10.0), + RepeatingScheduleValue(startTime: 32400.0, value: 12.0) + ], + timeZone: .utcTimeZone + )! + + var maximumAbsorptionTimeInterval: TimeInterval { + return defaultAbsorptionTimes.slow * 2 + } + + var delta: TimeInterval = .minutes(5) + + var defaultAbsorptionTimes: CarbStore.DefaultAbsorptionTimes = (fast: .minutes(30), medium: .hours(3), slow: .hours(5)) + + var authorizationRequired: Bool = false + + var sharingDenied: Bool = false + + func authorize(toShare: Bool, read: Bool, _ completion: @escaping (HealthKitSampleStoreResult) -> Void) { + completion(.success(true)) + } + + func replaceCarbEntry(_ oldEntry: StoredCarbEntry, withEntry newEntry: NewCarbEntry, completion: @escaping (CarbStoreResult) -> Void) { + completion(.failure(.notConfigured)) + } + + func addCarbEntry(_ entry: NewCarbEntry, completion: @escaping (CarbStoreResult) -> Void) { + completion(.failure(.notConfigured)) + } + + func getCarbStatus(start: Date, end: Date?, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (CarbStoreResult<[CarbStatus]>) -> Void) { + completion(.failure(.notConfigured)) + } + + func generateDiagnosticReport(_ completion: @escaping (String) -> Void) { + completion("") + } + + func glucoseEffects(of samples: [Sample], startingAt start: Date, endingAt end: Date?, effectVelocities: [LoopKit.GlucoseEffectVelocity]) throws -> [LoopKit.GlucoseEffect] where Sample : LoopKit.CarbEntry { + return [] + } + + func getCarbsOnBoardValues(start: Date, end: Date?, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (CarbStoreResult<[CarbValue]>) -> Void) { + completion(.success([])) + } + + func carbsOnBoard(at date: Date, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (CarbStoreResult) -> Void) { + completion(.failure(.notConfigured)) + } + + func getTotalCarbs(since start: Date, completion: @escaping (CarbStoreResult) -> Void) { + completion(.failure(.notConfigured)) + } + + func deleteCarbEntry(_ entry: StoredCarbEntry, completion: @escaping (CarbStoreResult) -> Void) { + completion(.failure(.notConfigured)) + } + + func getGlucoseEffects(start: Date, end: Date?, effectVelocities: [LoopKit.GlucoseEffectVelocity], completion: @escaping (LoopKit.CarbStoreResult<(entries: [LoopKit.StoredCarbEntry], effects: [LoopKit.GlucoseEffect])>) -> Void) + { + if let carbHistory, let carbRatioScheduleApplyingOverrideHistory, let insulinSensitivityScheduleApplyingOverrideHistory { + let foodStart = start.addingTimeInterval(-CarbMath.maximumAbsorptionTimeInterval) + let samples = carbHistory.filterDateRange(foodStart, end) + let carbDates = samples.map { $0.startDate } + let maxCarbDate = carbDates.max()! + let minCarbDate = carbDates.min()! + let carbRatio = carbRatioScheduleApplyingOverrideHistory.between(start: minCarbDate, end: maxCarbDate) + let insulinSensitivity = insulinSensitivityScheduleApplyingOverrideHistory.quantitiesBetween(start: minCarbDate, end: maxCarbDate) + let effects = samples.map( + to: effectVelocities, + carbRatio: carbRatio, + insulinSensitivity: insulinSensitivity + ).dynamicGlucoseEffects( + from: start, + to: end, + carbRatios: carbRatio, + insulinSensitivities: insulinSensitivity + ) + completion(.success((entries: samples, effects: effects))) + + } else { + let fixture: [JSONDictionary] = loadFixture(fixtureToLoad) + + let dateFormatter = ISO8601DateFormatter.localTimeDate() + + return completion(.success(([], fixture.map { + return GlucoseEffect(startDate: dateFormatter.date(from: $0["date"] as! String)!, quantity: HKQuantity(unit: HKUnit(from: $0["unit"] as! String), doubleValue:$0["amount"] as! Double)) + }))) + } + } +} + +extension MockCarbStore { + public var bundle: Bundle { + return Bundle(for: type(of: self)) + } + + public func loadFixture(_ resourceName: String) -> T { + let path = bundle.path(forResource: resourceName, ofType: "json")! + return try! JSONSerialization.jsonObject(with: Data(contentsOf: URL(fileURLWithPath: path)), options: []) as! T + } + + var fixtureToLoad: String { + switch scenario { + case .liveCapture: + fatalError("live capture scenario computes effects from carb entries, does not used pre-canned effects") + case .flatAndStable: + return "flat_and_stable_carb_effect" + case .highAndStable: + return "high_and_stable_carb_effect" + case .highAndRisingWithCOB: + return "high_and_rising_with_cob_carb_effect" + case .lowAndFallingWithCOB: + return "low_and_falling_carb_effect" + case .lowWithLowTreatment: + return "low_with_low_treatment_carb_effect" + case .highAndFalling: + return "high_and_falling_carb_effect" + } + } + + public func loadHistoricCarbEntries(scenario: DosingTestScenario) -> [StoredCarbEntry]? { + if let url = bundle.url(forResource: scenario.fixturePrefix + "carb_entries", withExtension: "json"), + let data = try? Data(contentsOf: url) + { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try? decoder.decode([StoredCarbEntry].self, from: data) + } else { + return nil + } + } +} diff --git a/LoopTests/Mock Stores/MockDoseStore.swift b/LoopTests/Mock Stores/MockDoseStore.swift new file mode 100644 index 0000000000..207596f31b --- /dev/null +++ b/LoopTests/Mock Stores/MockDoseStore.swift @@ -0,0 +1,171 @@ +// +// MockDoseStore.swift +// LoopTests +// +// Created by Anna Quinlan on 8/7/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import HealthKit +import LoopKit +@testable import Loop + +class MockDoseStore: DoseStoreProtocol { + var doseHistory: [DoseEntry]? + var sensitivitySchedule: InsulinSensitivitySchedule? + + init(for scenario: DosingTestScenario = .flatAndStable) { + self.scenario = scenario // The store returns different effect values based on the scenario + self.pumpEventQueryAfterDate = scenario.currentDate + self.lastAddedPumpData = scenario.currentDate + self.doseHistory = loadHistoricDoses(scenario: scenario) + } + + static let dateFormatter = ISO8601DateFormatter.localTimeDate() + + var scenario: DosingTestScenario + + var basalProfileApplyingOverrideHistory: BasalRateSchedule? + + var delegate: DoseStoreDelegate? + + var device: HKDevice? + + var pumpRecordsBasalProfileStartEvents: Bool = false + + var pumpEventQueryAfterDate: Date + + var basalProfile: BasalRateSchedule? + + // Default to the adult exponential insulin model + var insulinModelProvider: InsulinModelProvider = StaticInsulinModelProvider(ExponentialInsulinModelPreset.rapidActingAdult) + + var longestEffectDuration: TimeInterval = ExponentialInsulinModelPreset.rapidActingAdult.effectDuration + + var insulinSensitivitySchedule: InsulinSensitivitySchedule? + + var sampleType: HKSampleType = HKQuantityType.quantityType(forIdentifier: .insulinDelivery)! + + var authorizationRequired: Bool = false + + var sharingDenied: Bool = false + + var lastReservoirValue: ReservoirValue? + + var lastAddedPumpData: Date + + func addPumpEvents(_ events: [NewPumpEvent], lastReconciliation: Date?, replacePendingEvents: Bool, completion: @escaping (DoseStore.DoseStoreError?) -> Void) { + completion(nil) + } + + func addReservoirValue(_ unitVolume: Double, at date: Date, completion: @escaping (ReservoirValue?, ReservoirValue?, Bool, DoseStore.DoseStoreError?) -> Void) { + completion(nil, nil, false, nil) + } + + func insulinOnBoard(at date: Date, completion: @escaping (DoseStoreResult) -> Void) { + completion(.success(.init(startDate: scenario.currentDate, value: 9.5))) + } + + func generateDiagnosticReport(_ completion: @escaping (String) -> Void) { + completion("") + } + + func addDoses(_ doses: [DoseEntry], from device: HKDevice?, completion: @escaping (Error?) -> Void) { + completion(nil) + } + + func getInsulinOnBoardValues(start: Date, end: Date?, basalDosingEnd: Date?, completion: @escaping (DoseStoreResult<[InsulinValue]>) -> Void) { + completion(.failure(.configurationError)) + } + + func getNormalizedDoseEntries(start: Date, end: Date?, completion: @escaping (DoseStoreResult<[DoseEntry]>) -> Void) { + completion(.failure(.configurationError)) + } + + func executePumpEventQuery(fromQueryAnchor queryAnchor: DoseStore.QueryAnchor?, limit: Int, completion: @escaping (DoseStore.PumpEventQueryResult) -> Void) { + completion(.failure(DoseStore.DoseStoreError.configurationError)) + } + + func getTotalUnitsDelivered(since startDate: Date, completion: @escaping (DoseStoreResult) -> Void) { + completion(.failure(.configurationError)) + } + + func getGlucoseEffects(start: Date, end: Date? = nil, basalDosingEnd: Date? = Date(), completion: @escaping (_ result: DoseStoreResult<[GlucoseEffect]>) -> Void) { + if let doseHistory, let sensitivitySchedule, let basalProfile = basalProfileApplyingOverrideHistory { + // To properly know glucose effects at startDate, we need to go back another DIA hours + let doseStart = start.addingTimeInterval(-longestEffectDuration) + let doses = doseHistory.filterDateRange(doseStart, end) + let trimmedDoses = doses.map { (dose) -> DoseEntry in + guard dose.type != .bolus else { + return dose + } + return dose.trimmed(to: basalDosingEnd) + } + + let annotatedDoses = trimmedDoses.annotated(with: basalProfile) + + let glucoseEffects = annotatedDoses.glucoseEffects(insulinModelProvider: self.insulinModelProvider, longestEffectDuration: self.longestEffectDuration, insulinSensitivity: sensitivitySchedule, from: start, to: end) + completion(.success(glucoseEffects.filterDateRange(start, end))) + } else { + return completion(.success(getCannedGlucoseEffects())) + } + } + + func getCannedGlucoseEffects() -> [GlucoseEffect] { + let fixture: [JSONDictionary] = loadFixture(fixtureToLoad) + let dateFormatter = ISO8601DateFormatter.localTimeDate() + + return fixture.map { + return GlucoseEffect( + startDate: dateFormatter.date(from: $0["date"] as! String)!, + quantity: HKQuantity( + unit: HKUnit(from: $0["unit"] as! String), + doubleValue: $0["amount"] as! Double + ) + ) + } + } +} + +extension MockDoseStore { + public var bundle: Bundle { + return Bundle(for: type(of: self)) + } + + public func loadFixture(_ resourceName: String) -> T { + let path = bundle.path(forResource: resourceName, ofType: "json")! + return try! JSONSerialization.jsonObject(with: Data(contentsOf: URL(fileURLWithPath: path)), options: []) as! T + } + + var fixtureToLoad: String { + switch scenario { + case .liveCapture: + fatalError("live capture scenario computes effects from doses, does not used pre-canned effects") + case .flatAndStable: + return "flat_and_stable_insulin_effect" + case .highAndStable: + return "high_and_stable_insulin_effect" + case .highAndRisingWithCOB: + return "high_and_rising_with_cob_insulin_effect" + case .lowAndFallingWithCOB: + return "low_and_falling_insulin_effect" + case .lowWithLowTreatment: + return "low_with_low_treatment_insulin_effect" + case .highAndFalling: + return "high_and_falling_insulin_effect" + } + } + + public func loadHistoricDoses(scenario: DosingTestScenario) -> [DoseEntry]? { + if let url = bundle.url(forResource: scenario.fixturePrefix + "doses", withExtension: "json"), + let data = try? Data(contentsOf: url) + { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try? decoder.decode([DoseEntry].self, from: data) + } else { + return nil + } + } + +} diff --git a/LoopTests/Mock Stores/MockDosingDecisionStore.swift b/LoopTests/Mock Stores/MockDosingDecisionStore.swift new file mode 100644 index 0000000000..f8e4191d8e --- /dev/null +++ b/LoopTests/Mock Stores/MockDosingDecisionStore.swift @@ -0,0 +1,19 @@ +// +// MockDosingDecisionStore.swift +// LoopTests +// +// Created by Anna Quinlan on 8/19/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import LoopKit +@testable import Loop + +class MockDosingDecisionStore: DosingDecisionStoreProtocol { + var dosingDecisions: [StoredDosingDecision] = [] + + func storeDosingDecision(_ dosingDecision: StoredDosingDecision, completion: @escaping () -> Void) { + dosingDecisions.append(dosingDecision) + completion() + } +} diff --git a/LoopTests/Mock Stores/MockGlucoseStore.swift b/LoopTests/Mock Stores/MockGlucoseStore.swift new file mode 100644 index 0000000000..19a6bc22e8 --- /dev/null +++ b/LoopTests/Mock Stores/MockGlucoseStore.swift @@ -0,0 +1,214 @@ +// +// MockGlucoseStore.swift +// LoopTests +// +// Created by Anna Quinlan on 8/7/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import HealthKit +import LoopKit +@testable import Loop + +class MockGlucoseStore: GlucoseStoreProtocol { + + init(for scenario: DosingTestScenario = .flatAndStable) { + self.scenario = scenario // The store returns different effect values based on the scenario + storedGlucose = loadHistoricGlucose(scenario: scenario) + } + + let dateFormatter = ISO8601DateFormatter.localTimeDate() + + var scenario: DosingTestScenario + + var storedGlucose: [StoredGlucoseSample]? + + var latestGlucose: GlucoseSampleValue? { + if let storedGlucose { + return storedGlucose.last + } else { + return StoredGlucoseSample( + sample: HKQuantitySample( + type: HKQuantityType.quantityType(forIdentifier: .bloodGlucose)!, + quantity: HKQuantity(unit: HKUnit.milligramsPerDeciliter, doubleValue: latestGlucoseValue), + start: glucoseStartDate, + end: glucoseStartDate + ) + ) + } + } + + var preferredUnit: HKUnit? + + var sampleType: HKSampleType = HKSampleType.quantityType(forIdentifier: HKQuantityTypeIdentifier.bloodGlucose)! + + var delegate: GlucoseStoreDelegate? + + var managedDataInterval: TimeInterval? + + var healthKitStorageDelay = TimeInterval(0) + + var authorizationRequired: Bool = false + + var sharingDenied: Bool = false + + func authorize(toShare: Bool, read: Bool, _ completion: @escaping (HealthKitSampleStoreResult) -> Void) { + completion(.success(true)) + } + + func addGlucoseSamples(_ values: [NewGlucoseSample], completion: @escaping (Result<[StoredGlucoseSample], Error>) -> Void) { + // Using the dose store error because we don't need to create GlucoseStore errors just for the mock store + completion(.failure(DoseStore.DoseStoreError.configurationError)) + } + + func getGlucoseSamples(start: Date?, end: Date?, completion: @escaping (Result<[StoredGlucoseSample], Error>) -> Void) { + completion(.success([latestGlucose as! StoredGlucoseSample])) + } + + func generateDiagnosticReport(_ completion: @escaping (String) -> Void) { + completion("") + } + + func purgeAllGlucoseSamples(healthKitPredicate: NSPredicate, completion: @escaping (Error?) -> Void) { + // Using the dose store error because we don't need to create GlucoseStore errors just for the mock store + completion(DoseStore.DoseStoreError.configurationError) + } + + func executeGlucoseQuery(fromQueryAnchor queryAnchor: GlucoseStore.QueryAnchor?, limit: Int, completion: @escaping (GlucoseStore.GlucoseQueryResult) -> Void) { + // Using the dose store error because we don't need to create GlucoseStore errors just for the mock store + completion(.failure(DoseStore.DoseStoreError.configurationError)) + } + + func counteractionEffects(for samples: [Sample], to effects: [GlucoseEffect]) -> [GlucoseEffectVelocity] where Sample : GlucoseSampleValue { + samples.counteractionEffects(to: effects) + } + + func getRecentMomentumEffect(for date: Date? = nil, _ completion: @escaping (_ effects: Result<[GlucoseEffect], Error>) -> Void) { + if let storedGlucose { + let samples = storedGlucose.filterDateRange((date ?? Date()).addingTimeInterval(-GlucoseMath.momentumDataInterval), nil) + completion(.success(samples.linearMomentumEffect())) + } else { + let fixture: [JSONDictionary] = loadFixture(momentumEffectToLoad) + let dateFormatter = ISO8601DateFormatter.localTimeDate() + + return completion(.success(fixture.map { + return GlucoseEffect(startDate: dateFormatter.date(from: $0["date"] as! String)!, quantity: HKQuantity(unit: HKUnit(from: $0["unit"] as! String), doubleValue: $0["amount"] as! Double)) + } + )) + } + } + + func getCounteractionEffects(start: Date, end: Date? = nil, to effects: [GlucoseEffect], _ completion: @escaping (_ effects: Result<[GlucoseEffectVelocity], Error>) -> Void) { + if let storedGlucose { + let samples = storedGlucose.filterDateRange(start, end) + completion(.success(self.counteractionEffects(for: samples, to: effects))) + } else { + let fixture: [JSONDictionary] = loadFixture(counteractionEffectToLoad) + let dateFormatter = ISO8601DateFormatter.localTimeDate() + + completion(.success(fixture.map { + return GlucoseEffectVelocity(startDate: dateFormatter.date(from: $0["startDate"] as! String)!, endDate: dateFormatter.date(from: $0["endDate"] as! String)!, quantity: HKQuantity(unit: HKUnit(from: $0["unit"] as! String), doubleValue:$0["value"] as! Double)) + })) + } + } +} + +extension MockGlucoseStore { + public var bundle: Bundle { + return Bundle(for: type(of: self)) + } + + public func loadFixture(_ resourceName: String) -> T { + let path = bundle.path(forResource: resourceName, ofType: "json")! + return try! JSONSerialization.jsonObject(with: Data(contentsOf: URL(fileURLWithPath: path)), options: []) as! T + } + + public func loadHistoricGlucose(scenario: DosingTestScenario) -> [StoredGlucoseSample]? { + if let url = bundle.url(forResource: scenario.fixturePrefix + "historic_glucose", withExtension: "json"), + let data = try? Data(contentsOf: url) + { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try? decoder.decode([StoredGlucoseSample].self, from: data) + } else { + return nil + } + } + + var counteractionEffectToLoad: String { + switch scenario { + case .liveCapture: + fatalError("live capture scenario computes counteraction effects from input data, does not used pre-canned effects") + case .flatAndStable: + return "flat_and_stable_counteraction_effect" + case .highAndStable: + return "high_and_stable_counteraction_effect" + case .highAndRisingWithCOB: + return "high_and_rising_with_cob_counteraction_effect" + case .lowAndFallingWithCOB: + return "low_and_falling_counteraction_effect" + case .lowWithLowTreatment: + return "low_with_low_treatment_counteraction_effect" + case .highAndFalling: + return "high_and_falling_counteraction_effect" + } + } + + var momentumEffectToLoad: String { + switch scenario { + case .liveCapture: + fatalError("live capture scenario computes momentu effects from input data, does not used pre-canned effects") + case .flatAndStable: + return "flat_and_stable_momentum_effect" + case .highAndStable: + return "high_and_stable_momentum_effect" + case .highAndRisingWithCOB: + return "high_and_rising_with_cob_momentum_effect" + case .lowAndFallingWithCOB: + return "low_and_falling_momentum_effect" + case .lowWithLowTreatment: + return "low_with_low_treatment_momentum_effect" + case .highAndFalling: + return "high_and_falling_momentum_effect" + } + } + + var glucoseStartDate: Date { + switch scenario { + case .liveCapture: + fatalError("live capture scenario uses actual glucose input data") + case .flatAndStable: + return dateFormatter.date(from: "2020-08-11T20:45:02")! + case .highAndStable: + return dateFormatter.date(from: "2020-08-12T12:39:22")! + case .highAndRisingWithCOB: + return dateFormatter.date(from: "2020-08-11T21:48:17")! + case .lowAndFallingWithCOB: + return dateFormatter.date(from: "2020-08-11T22:06:06")! + case .lowWithLowTreatment: + return dateFormatter.date(from: "2020-08-11T22:23:55")! + case .highAndFalling: + return dateFormatter.date(from: "2020-08-11T22:59:45")! + } + } + + var latestGlucoseValue: Double { + switch scenario { + case .liveCapture: + fatalError("live capture scenario uses actual glucose input data") + case .flatAndStable: + return 123.42849966275706 + case .highAndStable: + return 200.0 + case .highAndRisingWithCOB: + return 129.93174411197853 + case .lowAndFallingWithCOB: + return 75.10768374646841 + case .lowWithLowTreatment: + return 81.22399763523448 + case .highAndFalling: + return 200.0 + } + } +} + diff --git a/LoopTests/Mock Stores/MockSettingsStore.swift b/LoopTests/Mock Stores/MockSettingsStore.swift new file mode 100644 index 0000000000..7e21268236 --- /dev/null +++ b/LoopTests/Mock Stores/MockSettingsStore.swift @@ -0,0 +1,17 @@ +// +// MockSettingsStore.swift +// LoopTests +// +// Created by Anna Quinlan on 8/19/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import LoopKit +@testable import Loop + +class MockLatestStoredSettingsProvider: LatestStoredSettingsProvider { + var latestSettings: StoredSettings { StoredSettings() } + func storeSettings(_ settings: StoredSettings, completion: @escaping () -> Void) { + completion() + } +} diff --git a/LoopTests/Models/CarbBackfillRequestUserInfoTests.swift b/LoopTests/Models/CarbBackfillRequestUserInfoTests.swift new file mode 100644 index 0000000000..e794194333 --- /dev/null +++ b/LoopTests/Models/CarbBackfillRequestUserInfoTests.swift @@ -0,0 +1,72 @@ +// +// CarbBackfillRequestUserInfoTests.swift +// LoopTests +// +// Created by Darin Krauss on 8/21/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import XCTest + +@testable import Loop + +class CarbBackfillRequestUserInfoTests: XCTestCase { + private lazy var startDate: Date = { Date(timeIntervalSinceReferenceDate: 0) }() + private lazy var rawValue: CarbBackfillRequestUserInfo.RawValue = { + return [ + "v": 1, + "name": "CarbBackfillRequestUserInfo", + "sd": startDate, + ] + }() + + func testDefaultInitializer() { + let info = CarbBackfillRequestUserInfo(startDate: self.startDate) + XCTAssertEqual(info.version, 1) + XCTAssertEqual(info.startDate, self.startDate) + } + + func testRawValueInitializer() { + let info = CarbBackfillRequestUserInfo(rawValue: self.rawValue) + XCTAssertEqual(info?.version, 1) + XCTAssertEqual(info?.startDate, self.startDate) + } + + func testRawValueInitializerMissingVersion() { + var rawValue = self.rawValue + rawValue["v"] = nil + XCTAssertNil(CarbBackfillRequestUserInfo(rawValue: rawValue)) + } + + func testRawValueInitializerInvalidVersion() { + var rawValue = self.rawValue + rawValue["v"] = 2 + XCTAssertNil(CarbBackfillRequestUserInfo(rawValue: rawValue)) + } + + func testRawValueInitializerMissingName() { + var rawValue = self.rawValue + rawValue["name"] = nil + XCTAssertNil(CarbBackfillRequestUserInfo(rawValue: rawValue)) + } + + func testRawValueInitializerInvalidName() { + var rawValue = self.rawValue + rawValue["name"] = "Invalid" + XCTAssertNil(CarbBackfillRequestUserInfo(rawValue: rawValue)) + } + + func testRawValueInitializerMissingStartDate() { + var rawValue = self.rawValue + rawValue["sd"] = nil + XCTAssertNil(CarbBackfillRequestUserInfo(rawValue: rawValue)) + } + + func testRawValue() { + let rawValue = CarbBackfillRequestUserInfo(startDate: self.startDate).rawValue + XCTAssertEqual(rawValue.count, 3) + XCTAssertEqual(rawValue["v"] as? Int, 1) + XCTAssertEqual(rawValue["name"] as? String, "CarbBackfillRequestUserInfo") + XCTAssertEqual(rawValue["sd"] as? Date, self.startDate) + } +} diff --git a/LoopTests/Models/SetBolusUserInfoTests.swift b/LoopTests/Models/SetBolusUserInfoTests.swift new file mode 100644 index 0000000000..a486abff15 --- /dev/null +++ b/LoopTests/Models/SetBolusUserInfoTests.swift @@ -0,0 +1,109 @@ +// +// SetBolusUserInfoTests.swift +// LoopTests +// +// Created by Darin Krauss on 10/6/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import XCTest +import HealthKit +import LoopKit +@testable import Loop + +class SetBolusUserInfoTests: XCTestCase { + private var value = 4.56 + private var startDate = dateFormatter.date(from: "2020-05-14T22:45:00Z")! + private var contextDate = dateFormatter.date(from: "2020-05-14T22:38:14Z")! + private var carbEntry = NewCarbEntry(date: dateFormatter.date(from: "2020-05-14T22:39:34Z")!, + quantity: HKQuantity(unit: .gram(), doubleValue: 17), + startDate: dateFormatter.date(from: "2020-05-14T22:00:00Z")!, + foodType: "Pizza", + absorptionTime: .hours(5)) + private var activationType = BolusActivationType.manualRecommendationAccepted + + private lazy var rawValue: SetBolusUserInfo.RawValue = { + return [ + "v": 1, + "name": "SetBolusUserInfo", + "bv": value, + "sd": startDate, + "cd": contextDate, + "ce": carbEntry.rawValue, + "at": activationType.rawValue, + ] + }() + + func testDefaultInitializer() { + let info = SetBolusUserInfo(value: value, startDate: startDate, contextDate: contextDate, carbEntry: carbEntry, activationType: activationType) + XCTAssertEqual(info.value, value) + XCTAssertEqual(info.startDate, startDate) + XCTAssertEqual(info.contextDate, contextDate) + XCTAssertEqual(info.carbEntry, carbEntry) + XCTAssertEqual(info.activationType, activationType) + } + + func testRawValueInitializer() { + let info = SetBolusUserInfo(rawValue: rawValue) + XCTAssertEqual(info?.value, value) + XCTAssertEqual(info?.startDate, startDate) + XCTAssertEqual(info?.contextDate, contextDate) + XCTAssertEqual(info?.carbEntry, carbEntry) + } + + func testRawValueInitializerMissingVersion() { + var rawValue = self.rawValue + rawValue["v"] = nil + XCTAssertNil(SetBolusUserInfo(rawValue: rawValue)) + } + + func testRawValueInitializerInvalidVersion() { + var rawValue = self.rawValue + rawValue["v"] = 2 + XCTAssertNil(SetBolusUserInfo(rawValue: rawValue)) + } + + func testRawValueInitializerMissingName() { + var rawValue = self.rawValue + rawValue["name"] = nil + XCTAssertNil(SetBolusUserInfo(rawValue: rawValue)) + } + + func testRawValueInitializerInvalidName() { + var rawValue = self.rawValue + rawValue["name"] = "Invalid" + XCTAssertNil(SetBolusUserInfo(rawValue: rawValue)) + } + + func testRawValueInitializerMissingValue() { + var rawValue = self.rawValue + rawValue["bv"] = nil + XCTAssertNil(SetBolusUserInfo(rawValue: rawValue)) + } + + func testRawValueInitializerMissingStartDate() { + var rawValue = self.rawValue + rawValue["sd"] = nil + XCTAssertNil(SetBolusUserInfo(rawValue: rawValue)) + } + + func testRawValue() { + let info = SetBolusUserInfo(value: value, startDate: startDate, contextDate: contextDate, carbEntry: carbEntry, activationType: activationType) + let rawValue = info.rawValue + XCTAssertEqual(rawValue.count, 7) + XCTAssertEqual(rawValue["v"] as? Int, 1) + XCTAssertEqual(rawValue["name"] as? String, "SetBolusUserInfo") + XCTAssertEqual(rawValue["bv"] as? Double, value) + XCTAssertEqual(rawValue["sd"] as? Date, startDate) + XCTAssertEqual(rawValue["cd"] as? Date, contextDate) + XCTAssertEqual(rawValue["at"] as? BolusActivationType.RawValue, activationType.rawValue) + let carbEntryRawValue = rawValue["ce"] as? NewCarbEntry.RawValue + XCTAssertEqual(carbEntryRawValue?["date"] as? Date, carbEntry.date) + XCTAssertEqual(carbEntryRawValue?["grams"] as? Double, carbEntry.quantity.doubleValue(for: .gram())) + XCTAssertEqual(carbEntryRawValue?["startDate"] as? Date, carbEntry.startDate) + XCTAssertEqual(carbEntryRawValue?["foodType"] as? String, carbEntry.foodType) + XCTAssertEqual(carbEntryRawValue?["absorptionTime"] as? TimeInterval, carbEntry.absorptionTime) + } + + private static let dateFormatter = ISO8601DateFormatter() +} diff --git a/LoopTests/Models/SimpleBolusCalculatorTests.swift b/LoopTests/Models/SimpleBolusCalculatorTests.swift new file mode 100644 index 0000000000..069cb54368 --- /dev/null +++ b/LoopTests/Models/SimpleBolusCalculatorTests.swift @@ -0,0 +1,149 @@ +// +// SimpleBolusCalculatorTests.swift +// LoopTests +// +// Created by Pete Schwamb on 9/28/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import Foundation + +import XCTest +import HealthKit +import LoopKit + +@testable import Loop + +class SimpleBolusCalculatorTests: XCTestCase { + + let correctionRangeSchedule = GlucoseRangeSchedule( + unit: .milligramsPerDeciliter, + dailyItems: [ + RepeatingScheduleValue(startTime: 0, value: DoubleRange(minValue: 100.0, maxValue: 110.0)) + ])! + + let carbRatioSchedule = CarbRatioSchedule(unit: .gram(), dailyItems: [RepeatingScheduleValue(startTime: 0, value: 10)])! + let sensitivitySchedule = InsulinSensitivitySchedule(unit: .milligramsPerDeciliter, dailyItems: [RepeatingScheduleValue(startTime: .hours(0), value: 80)])! + + func testMealRecommendation() { + let recommendation = SimpleBolusCalculator.recommendedInsulin( + mealCarbs: HKQuantity(unit: .gram(), doubleValue: 40), + manualGlucose: nil, + activeInsulin: HKQuantity(unit: .internationalUnit(), doubleValue: 0), + carbRatioSchedule: carbRatioSchedule, + correctionRangeSchedule: correctionRangeSchedule, + sensitivitySchedule: sensitivitySchedule) + + XCTAssertEqual(4.0, recommendation.doubleValue(for: .internationalUnit())) + } + + func testCorrectionRecommendation() { + let recommendation = SimpleBolusCalculator.recommendedInsulin( + mealCarbs: nil, + manualGlucose: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 180), + activeInsulin: HKQuantity(unit: .internationalUnit(), doubleValue: 0), + carbRatioSchedule: carbRatioSchedule, + correctionRangeSchedule: correctionRangeSchedule, + sensitivitySchedule: sensitivitySchedule) + + XCTAssertEqual(0.94, recommendation.doubleValue(for: .internationalUnit()), accuracy: 0.01) + } + + func testCorrectionRecommendationWithIOB() { + let recommendation = SimpleBolusCalculator.recommendedInsulin( + mealCarbs: nil, + manualGlucose: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 180), + activeInsulin: HKQuantity(unit: .internationalUnit(), doubleValue: 10), + carbRatioSchedule: carbRatioSchedule, + correctionRangeSchedule: correctionRangeSchedule, + sensitivitySchedule: sensitivitySchedule) + + XCTAssertEqual(0.0, recommendation.doubleValue(for: .internationalUnit()), accuracy: 0.01) + } + + func testCorrectionRecommendationWithNegativeIOB() { + let recommendation = SimpleBolusCalculator.recommendedInsulin( + mealCarbs: nil, + manualGlucose: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 180), + activeInsulin: HKQuantity(unit: .internationalUnit(), doubleValue: -1), + carbRatioSchedule: carbRatioSchedule, + correctionRangeSchedule: correctionRangeSchedule, + sensitivitySchedule: sensitivitySchedule) + + XCTAssertEqual(0.94, recommendation.doubleValue(for: .internationalUnit()), accuracy: 0.01) + } + + + func testCorrectionRecommendationWhenInRange() { + let recommendation = SimpleBolusCalculator.recommendedInsulin( + mealCarbs: nil, + manualGlucose: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 110), + activeInsulin: HKQuantity(unit: .internationalUnit(), doubleValue: 0), + carbRatioSchedule: carbRatioSchedule, + correctionRangeSchedule: correctionRangeSchedule, + sensitivitySchedule: sensitivitySchedule) + + XCTAssertEqual(0.0, recommendation.doubleValue(for: .internationalUnit()), accuracy: 0.01) + } + + func testCorrectionAndCarbsRecommendationWhenBelowRange() { + let recommendation = SimpleBolusCalculator.recommendedInsulin( + mealCarbs: HKQuantity(unit: .gram(), doubleValue: 40), + manualGlucose: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 70), + activeInsulin: HKQuantity(unit: .internationalUnit(), doubleValue: 0), + carbRatioSchedule: carbRatioSchedule, + correctionRangeSchedule: correctionRangeSchedule, + sensitivitySchedule: sensitivitySchedule) + + XCTAssertEqual(3.56, recommendation.doubleValue(for: .internationalUnit()), accuracy: 0.01) + } + + func testCarbsEntryWithActiveInsulinAndNoGlucose() { + let recommendation = SimpleBolusCalculator.recommendedInsulin( + mealCarbs: HKQuantity(unit: .gram(), doubleValue: 20), + manualGlucose: nil, + activeInsulin: HKQuantity(unit: .internationalUnit(), doubleValue: 4), + carbRatioSchedule: carbRatioSchedule, + correctionRangeSchedule: correctionRangeSchedule, + sensitivitySchedule: sensitivitySchedule) + + XCTAssertEqual(2, recommendation.doubleValue(for: .internationalUnit()), accuracy: 0.01) + } + + func testCarbsEntryWithActiveInsulinAndCarbsAndNoCorrection() { + let recommendation = SimpleBolusCalculator.recommendedInsulin( + mealCarbs: HKQuantity(unit: .gram(), doubleValue: 20), + manualGlucose: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 100), + activeInsulin: HKQuantity(unit: .internationalUnit(), doubleValue: 4), + carbRatioSchedule: carbRatioSchedule, + correctionRangeSchedule: correctionRangeSchedule, + sensitivitySchedule: sensitivitySchedule) + + XCTAssertEqual(2, recommendation.doubleValue(for: .internationalUnit()), accuracy: 0.01) + } + + func testPredictionShouldBeZeroWhenGlucoseBelowMealBolusRecommendationLimit() { + let recommendation = SimpleBolusCalculator.recommendedInsulin( + mealCarbs: HKQuantity(unit: .gram(), doubleValue: 20), + manualGlucose: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 54), + activeInsulin: HKQuantity(unit: .internationalUnit(), doubleValue: 4), + carbRatioSchedule: carbRatioSchedule, + correctionRangeSchedule: correctionRangeSchedule, + sensitivitySchedule: sensitivitySchedule) + + XCTAssertEqual(0, recommendation.doubleValue(for: .internationalUnit()), accuracy: 0.01) + } + + func testPredictionShouldBeZeroWhenGlucoseBelowBolusRecommendationLimit() { + let recommendation = SimpleBolusCalculator.recommendedInsulin( + mealCarbs: nil, + manualGlucose: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 69), + activeInsulin: HKQuantity(unit: .internationalUnit(), doubleValue: 4), + carbRatioSchedule: carbRatioSchedule, + correctionRangeSchedule: correctionRangeSchedule, + sensitivitySchedule: sensitivitySchedule) + + XCTAssertEqual(0, recommendation.doubleValue(for: .internationalUnit()), accuracy: 0.01) + } + +} diff --git a/LoopTests/Models/TestLocalizedError.swift b/LoopTests/Models/TestLocalizedError.swift new file mode 100644 index 0000000000..2cd63eb362 --- /dev/null +++ b/LoopTests/Models/TestLocalizedError.swift @@ -0,0 +1,23 @@ +// +// TestLocalizedError.swift +// LoopTests +// +// Created by Darin Krauss on 10/21/21. +// Copyright © 2021 LoopKit Authors. All rights reserved. +// + +import Foundation + +struct TestLocalizedError: LocalizedError { + public let errorDescription: String? + public let failureReason: String? + public let helpAnchor: String? + public let recoverySuggestion: String? + + init(errorDescription: String? = nil, failureReason: String? = nil, helpAnchor: String? = nil, recoverySuggestion: String? = nil) { + self.errorDescription = errorDescription + self.failureReason = failureReason + self.helpAnchor = helpAnchor + self.recoverySuggestion = recoverySuggestion + } +} diff --git a/LoopTests/Models/WatchHistoricalCarbsTests.swift b/LoopTests/Models/WatchHistoricalCarbsTests.swift new file mode 100644 index 0000000000..767fbefaf3 --- /dev/null +++ b/LoopTests/Models/WatchHistoricalCarbsTests.swift @@ -0,0 +1,101 @@ +// +// WatchHistoricalCarbs.swift +// LoopTests +// +// Created by Darin Krauss on 8/21/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import XCTest +import LoopKit + +@testable import Loop + +class WatchHistoricalCarbsTests: XCTestCase { + private lazy var objects: [SyncCarbObject] = { + return [SyncCarbObject(absorptionTime: .hours(5), + createdByCurrentApp: true, + foodType: "Pizza", + grams: 45, + startDate: Date(timeIntervalSinceReferenceDate: .hours(100)), + uuid: UUID(), + provenanceIdentifier: "com.loopkit.Loop", + syncIdentifier: UUID().uuidString, + syncVersion: 4, + userCreatedDate: Date(timeIntervalSinceReferenceDate: .hours(98)), + userUpdatedDate: Date(timeIntervalSinceReferenceDate: .hours(99)), + userDeletedDate: nil, + operation: .update, + addedDate: Date(timeIntervalSinceReferenceDate: .hours(97)), + supercededDate: nil), + SyncCarbObject(absorptionTime: .hours(3), + createdByCurrentApp: false, + foodType: "Pasta", + grams: 25, + startDate: Date(timeIntervalSinceReferenceDate: .hours(110)), + uuid: UUID(), + provenanceIdentifier: "com.abc.Example", + syncIdentifier: UUID().uuidString, + syncVersion: 1, + userCreatedDate: Date(timeIntervalSinceReferenceDate: .hours(108)), + userUpdatedDate: nil, + userDeletedDate: nil, + operation: .create, + addedDate: Date(timeIntervalSinceReferenceDate: .hours(107)), + supercededDate: nil), + SyncCarbObject(absorptionTime: .minutes(30), + createdByCurrentApp: true, + foodType: "Sugar", + grams: 15, + startDate: Date(timeIntervalSinceReferenceDate: .hours(120)), + uuid: UUID(), + provenanceIdentifier: "com.loopkit.Loop", + syncIdentifier: UUID().uuidString, + syncVersion: 1, + userCreatedDate: Date(timeIntervalSinceReferenceDate: .hours(118)), + userUpdatedDate: nil, + userDeletedDate: nil, + operation: .create, + addedDate: Date(timeIntervalSinceReferenceDate: .hours(117)), + supercededDate: nil) + ] + }() + private lazy var objectsEncoded: Data = { + let encoder = PropertyListEncoder() + encoder.outputFormat = .binary + return try! encoder.encode(self.objects) + }() + private lazy var rawValue: WatchHistoricalCarbs.RawValue = { + return [ + "o": objectsEncoded + ] + }() + + func testDefaultInitializer() { + let carbs = WatchHistoricalCarbs(objects: self.objects) + XCTAssertEqual(carbs.objects, self.objects) + } + + func testRawValueInitializer() { + let carbs = WatchHistoricalCarbs(rawValue: self.rawValue) + XCTAssertEqual(carbs?.objects, self.objects) + } + + func testRawValueInitializerMissingObjects() { + var rawValue = self.rawValue + rawValue["o"] = nil + XCTAssertNil(WatchHistoricalCarbs(rawValue: rawValue)) + } + + func testRawValueInitializerInvalidObjects() { + var rawValue = self.rawValue + rawValue["o"] = Data() + XCTAssertNil(WatchHistoricalCarbs(rawValue: rawValue)) + } + + func testRawValue() { + let rawValue = WatchHistoricalCarbs(objects: self.objects).rawValue + XCTAssertEqual(rawValue.count, 1) + XCTAssertEqual(rawValue["o"] as? Data, self.objectsEncoded) + } +} diff --git a/LoopTests/Models/WatchHistoricalGlucoseTest.swift b/LoopTests/Models/WatchHistoricalGlucoseTest.swift new file mode 100644 index 0000000000..4478af76f7 --- /dev/null +++ b/LoopTests/Models/WatchHistoricalGlucoseTest.swift @@ -0,0 +1,83 @@ +// +// WatchHistoricalGlucoseTest.swift +// LoopTests +// +// Created by Darin Krauss on 10/13/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import XCTest +import HealthKit +import LoopKit + +@testable import Loop + +class WatchHistoricalGlucoseTests: XCTestCase { + private lazy var device = HKDevice(name: "NAME", manufacturer: "MANUFACTURER", model: "MODEL", hardwareVersion: "HARDWAREVERSION", firmwareVersion: "FIRMWAREVERSION", softwareVersion: "SOFTWAREVERSION", localIdentifier: "LOCALIDENTIFIER", udiDeviceIdentifier: "UDIDEVICEIDENTIFIER") + private lazy var samples: [StoredGlucoseSample] = { + return [StoredGlucoseSample(uuid: UUID(), + provenanceIdentifier: UUID().uuidString, + syncIdentifier: UUID().uuidString, + syncVersion: 4, + startDate: Date(timeIntervalSinceReferenceDate: .hours(100)), + quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 123.45), + condition: nil, + trend: nil, + trendRate: nil, + isDisplayOnly: false, + wasUserEntered: true, + device: device, + healthKitEligibleDate: Date(timeIntervalSinceReferenceDate: .hours(100)).addingTimeInterval(.hours(3))), + StoredGlucoseSample(uuid: UUID(), + provenanceIdentifier: UUID().uuidString, + syncIdentifier: UUID().uuidString, + syncVersion: 2, + startDate: Date(timeIntervalSinceReferenceDate: .hours(99)), + quantity: HKQuantity(unit: .millimolesPerLiter, doubleValue: 7.2), + condition: nil, + trend: .up, + trendRate: HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: 1.0), + isDisplayOnly: true, + wasUserEntered: false, + device: device, + healthKitEligibleDate: nil), + StoredGlucoseSample(uuid: nil, + provenanceIdentifier: UUID().uuidString, + syncIdentifier: nil, + syncVersion: nil, + startDate: Date(timeIntervalSinceReferenceDate: .hours(98)), + quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 187.65), + condition: .aboveRange, + trend: .downDownDown, + trendRate: HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: -4.0), + isDisplayOnly: false, + wasUserEntered: false, + device: nil, + healthKitEligibleDate: nil), + ] + }() + + func testDefaultInitializer() { + let glucose = WatchHistoricalGlucose(samples: self.samples) + XCTAssertEqual(glucose.samples, self.samples) + } + + func testRawValueInitializerMissingSamples() { + let rawValue: WatchHistoricalGlucose.RawValue = [:] + XCTAssertNil(WatchHistoricalGlucose(rawValue: rawValue)) + } + + func testRawValueInitializerInvalidSamples() { + let rawValue: WatchHistoricalGlucose.RawValue = [ + "sample": Data() + ] + XCTAssertNil(WatchHistoricalGlucose(rawValue: rawValue)) + } + + func testRawValue() { + let rawValue = WatchHistoricalGlucose(samples: self.samples).rawValue + XCTAssertEqual(rawValue.count, 1) + XCTAssertNotNil(rawValue["samples"] as? Data) + XCTAssertEqual(WatchHistoricalGlucose(rawValue: rawValue)?.samples, self.samples) + } +} diff --git a/LoopTests/ViewModels/BolusEntryViewModelTests.swift b/LoopTests/ViewModels/BolusEntryViewModelTests.swift new file mode 100644 index 0000000000..7f2c421ebf --- /dev/null +++ b/LoopTests/ViewModels/BolusEntryViewModelTests.swift @@ -0,0 +1,1014 @@ +// +// BolusEntryViewModelTests.swift +// LoopTests +// +// Created by Rick Pasetto on 9/28/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import HealthKit +import LoopCore +import LoopKit +import LoopKitUI +import SwiftUI +import XCTest +@testable import Loop + +@MainActor +class BolusEntryViewModelTests: XCTestCase { + + // Some of the tests depend on a date on the hour + static let now = ISO8601DateFormatter().date(from: "2020-03-11T07:00:00-0700")! + static let exampleStartDate = now - .hours(2) + static let exampleEndDate = now - .hours(1) + static fileprivate let exampleGlucoseValue = MockGlucoseValue(quantity: exampleManualGlucoseQuantity, startDate: exampleStartDate) + static let exampleManualGlucoseQuantity = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 123.4) + static let exampleManualGlucoseSample = + HKQuantitySample(type: HKQuantityType.quantityType(forIdentifier: .bloodGlucose)!, + quantity: exampleManualGlucoseQuantity, + start: exampleStartDate, + end: exampleEndDate) + static let exampleManualStoredGlucoseSample = StoredGlucoseSample(sample: exampleManualGlucoseSample) + + static let exampleCGMGlucoseQuantity = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 100.4) + static let exampleCGMGlucoseSample = + HKQuantitySample(type: HKQuantityType.quantityType(forIdentifier: .bloodGlucose)!, + quantity: exampleCGMGlucoseQuantity, + start: exampleStartDate, + end: exampleEndDate) + + static let exampleCarbQuantity = HKQuantity(unit: .gram(), doubleValue: 234.5) + + static let exampleBolusQuantity = HKQuantity(unit: .internationalUnit(), doubleValue: 1.0) + static let noBolus = HKQuantity(unit: .internationalUnit(), doubleValue: 0.0) + + static let exampleGlucoseRangeSchedule = GlucoseRangeSchedule(unit: .milligramsPerDeciliter, dailyItems: [ + RepeatingScheduleValue(startTime: TimeInterval(0), value: DoubleRange(minValue: 100, maxValue: 110)), + RepeatingScheduleValue(startTime: TimeInterval(28800), value: DoubleRange(minValue: 90, maxValue: 100)), + RepeatingScheduleValue(startTime: TimeInterval(75600), value: DoubleRange(minValue: 100, maxValue: 110)) + ], timeZone: .utcTimeZone)! + + static let mockUUID = UUID() + + static let exampleScheduleOverrideSettings = TemporaryScheduleOverrideSettings(unit: .millimolesPerLiter, targetRange: nil, insulinNeedsScaleFactor: nil) + static let examplePreMealOverride = TemporaryScheduleOverride(context: .preMeal, settings: exampleScheduleOverrideSettings, startDate: exampleStartDate, duration: .indefinite, enactTrigger: .local, syncIdentifier: mockUUID) + static let exampleCustomScheduleOverride = TemporaryScheduleOverride(context: .custom, settings: exampleScheduleOverrideSettings, startDate: exampleStartDate, duration: .indefinite, enactTrigger: .local, syncIdentifier: mockUUID) + + var bolusEntryViewModel: BolusEntryViewModel! + fileprivate var delegate: MockBolusEntryViewModelDelegate! + var now: Date = BolusEntryViewModelTests.now + + let mockOriginalCarbEntry = StoredCarbEntry( + startDate: BolusEntryViewModelTests.exampleStartDate, + quantity: BolusEntryViewModelTests.exampleCarbQuantity, + uuid: UUID(), + provenanceIdentifier: "provenanceIdentifier", + syncIdentifier: "syncIdentifier", + syncVersion: 0, + foodType: "foodType", + absorptionTime: 1, + createdByCurrentApp: true, + userCreatedDate: BolusEntryViewModelTests.now, + userUpdatedDate: BolusEntryViewModelTests.now) + let mockPotentialCarbEntry = NewCarbEntry(quantity: BolusEntryViewModelTests.exampleCarbQuantity, startDate: BolusEntryViewModelTests.exampleStartDate, foodType: "foodType", absorptionTime: 1) + let mockFinalCarbEntry = StoredCarbEntry( + startDate: BolusEntryViewModelTests.exampleStartDate, + quantity: BolusEntryViewModelTests.exampleCarbQuantity, + uuid: UUID(), + provenanceIdentifier: "provenanceIdentifier", + syncIdentifier: "syncIdentifier", + syncVersion: 1, + foodType: "foodType", + absorptionTime: 1, + createdByCurrentApp: true, + userCreatedDate: BolusEntryViewModelTests.now, + userUpdatedDate: BolusEntryViewModelTests.now) + let mockUUID = BolusEntryViewModelTests.mockUUID.uuidString + let queue = DispatchQueue(label: "BolusEntryViewModelTests") + var saveAndDeliverSuccess = false + + override func setUp(completion: @escaping (Error?) -> Void) { + now = Self.now + delegate = MockBolusEntryViewModelDelegate() + delegate.mostRecentGlucoseDataDate = now + delegate.mostRecentPumpDataDate = now + saveAndDeliverSuccess = false + Task { + await setUpViewModel() + completion(nil) + } + } + + func setUpViewModel(originalCarbEntry: StoredCarbEntry? = nil, potentialCarbEntry: NewCarbEntry? = nil, selectedCarbAbsorptionTimeEmoji: String? = nil) async { + bolusEntryViewModel = BolusEntryViewModel(delegate: delegate, + now: { self.now }, + screenWidth: 512, + debounceIntervalMilliseconds: 0, + uuidProvider: { self.mockUUID }, + timeZone: TimeZone(abbreviation: "GMT")!, + originalCarbEntry: originalCarbEntry, + potentialCarbEntry: potentialCarbEntry, + selectedCarbAbsorptionTimeEmoji: selectedCarbAbsorptionTimeEmoji) + bolusEntryViewModel.authenticationHandler = { _ in return true } + + bolusEntryViewModel.maximumBolus = HKQuantity(unit: .internationalUnit(), doubleValue: 10) + + await bolusEntryViewModel.generateRecommendationAndStartObserving() + } + + func testInitialConditions() throws { + XCTAssertEqual(0, bolusEntryViewModel.glucoseValues.count) + XCTAssertEqual(0, bolusEntryViewModel.predictedGlucoseValues.count) + XCTAssertNil(bolusEntryViewModel.activeCarbs) + XCTAssertNil(bolusEntryViewModel.activeInsulin) + XCTAssertEqual(bolusEntryViewModel.targetGlucoseSchedule, BolusEntryViewModelTests.exampleGlucoseRangeSchedule) + XCTAssertNil(bolusEntryViewModel.preMealOverride) + XCTAssertNil(bolusEntryViewModel.scheduleOverride) + + XCTAssertFalse(bolusEntryViewModel.isManualGlucoseEntryEnabled) + + XCTAssertNil(bolusEntryViewModel.manualGlucoseQuantity) + XCTAssertEqual(HKQuantity(unit: .internationalUnit(), doubleValue: 0), bolusEntryViewModel.recommendedBolus) + XCTAssertEqual(HKQuantity(unit: .internationalUnit(), doubleValue: 0), bolusEntryViewModel.enteredBolus) + + XCTAssertNil(bolusEntryViewModel.activeAlert) + XCTAssertNil(bolusEntryViewModel.activeNotice) + } + + func testChartDateInterval() throws { + // TODO: Test different screen widths + // TODO: Test different insulin models + // TODO: Test different chart history settings + let expected = DateInterval(start: now - .hours(2), duration: .hours(8)) + XCTAssertEqual(expected, bolusEntryViewModel.chartDateInterval) + } + + // MARK: updating state + + func testUpdateDisableManualGlucoseEntryIfNecessary() async throws { + bolusEntryViewModel.isManualGlucoseEntryEnabled = true + bolusEntryViewModel.manualGlucoseQuantity = Self.exampleManualGlucoseQuantity + await bolusEntryViewModel.update() + XCTAssertFalse(bolusEntryViewModel.isManualGlucoseEntryEnabled) + XCTAssertNil(bolusEntryViewModel.manualGlucoseQuantity) + XCTAssertEqual(.glucoseNoLongerStale, bolusEntryViewModel.activeAlert) + } + + func testUpdateDisableManualGlucoseEntryIfNecessaryStaleGlucose() async throws { + delegate.mostRecentGlucoseDataDate = Date.distantPast + bolusEntryViewModel.isManualGlucoseEntryEnabled = true + bolusEntryViewModel.manualGlucoseQuantity = Self.exampleManualGlucoseQuantity + await bolusEntryViewModel.update() + XCTAssertTrue(bolusEntryViewModel.isManualGlucoseEntryEnabled) + XCTAssertEqual(Self.exampleManualGlucoseQuantity, bolusEntryViewModel.manualGlucoseQuantity) + XCTAssertNil(bolusEntryViewModel.activeAlert) + } + + func testUpdateGlucoseValues() async throws { + XCTAssertEqual(0, bolusEntryViewModel.glucoseValues.count) + delegate.getGlucoseSamplesResponse = [StoredGlucoseSample(sample: Self.exampleCGMGlucoseSample)] + await bolusEntryViewModel.update() + XCTAssertEqual(1, bolusEntryViewModel.glucoseValues.count) + XCTAssertEqual([100.4], bolusEntryViewModel.glucoseValues.map { + return $0.quantity.doubleValue(for: .milligramsPerDeciliter) + }) + } + + func testUpdateGlucoseValuesWithManual() async throws { + XCTAssertEqual(0, bolusEntryViewModel.glucoseValues.count) + bolusEntryViewModel.manualGlucoseQuantity = Self.exampleManualGlucoseQuantity + delegate.getGlucoseSamplesResponse = [StoredGlucoseSample(sample: Self.exampleCGMGlucoseSample)] + await bolusEntryViewModel.update() + XCTAssertEqual([100.4, 123.4], bolusEntryViewModel.glucoseValues.map { + return $0.quantity.doubleValue(for: .milligramsPerDeciliter) + }) + } + + func testManualEntryClearsEnteredBolus() throws { + bolusEntryViewModel.enteredBolus = Self.exampleBolusQuantity + bolusEntryViewModel.manualGlucoseQuantity = Self.exampleManualGlucoseQuantity + XCTAssertEqual(HKQuantity(unit: .internationalUnit(), doubleValue: 0), bolusEntryViewModel.enteredBolus) + } + + func testUpdatePredictedGlucoseValues() async throws { + let prediction = [PredictedGlucoseValue(startDate: Self.exampleStartDate, quantity: Self.exampleCGMGlucoseQuantity)] + delegate.loopState.predictGlucoseValueResult = prediction + await bolusEntryViewModel.update() + XCTAssertEqual(prediction, bolusEntryViewModel.predictedGlucoseValues.map { PredictedGlucoseValue(startDate: $0.startDate, quantity: $0.quantity) }) + } + + func testUpdatePredictedGlucoseValuesWithManual() async throws { + let prediction = [PredictedGlucoseValue(startDate: Self.exampleStartDate, quantity: Self.exampleCGMGlucoseQuantity)] + delegate.loopState.predictGlucoseValueResult = prediction + await bolusEntryViewModel.update() + + bolusEntryViewModel.manualGlucoseQuantity = Self.exampleManualGlucoseQuantity + XCTAssertEqual(prediction, + bolusEntryViewModel.predictedGlucoseValues.map { + PredictedGlucoseValue(startDate: $0.startDate, quantity: $0.quantity) + }) + } + + func testUpdateSettings() async throws { + XCTAssertNil(bolusEntryViewModel.preMealOverride) + XCTAssertNil(bolusEntryViewModel.scheduleOverride) + XCTAssertEqual(bolusEntryViewModel.targetGlucoseSchedule, BolusEntryViewModelTests.exampleGlucoseRangeSchedule) + let newGlucoseTargetRangeSchedule = GlucoseRangeSchedule(unit: .milligramsPerDeciliter, dailyItems: [ + RepeatingScheduleValue(startTime: TimeInterval(0), value: DoubleRange(minValue: 100, maxValue: 110)), + RepeatingScheduleValue(startTime: TimeInterval(28800), value: DoubleRange(minValue: 90, maxValue: 100)), + RepeatingScheduleValue(startTime: TimeInterval(75600), value: DoubleRange(minValue: 100, maxValue: 110)) + ], timeZone: .utcTimeZone)! + var newSettings = LoopSettings(dosingEnabled: true, + glucoseTargetRangeSchedule: newGlucoseTargetRangeSchedule, + maximumBasalRatePerHour: 1.0, + maximumBolus: 10.0, + suspendThreshold: GlucoseThreshold(unit: .milligramsPerDeciliter, value: 100.0)) + let settings = TemporaryScheduleOverrideSettings(unit: .millimolesPerLiter, targetRange: nil, insulinNeedsScaleFactor: nil) + newSettings.preMealOverride = TemporaryScheduleOverride(context: .preMeal, settings: settings, startDate: Self.exampleStartDate, duration: .indefinite, enactTrigger: .local, syncIdentifier: UUID()) + newSettings.scheduleOverride = TemporaryScheduleOverride(context: .custom, settings: settings, startDate: Self.exampleStartDate, duration: .indefinite, enactTrigger: .local, syncIdentifier: UUID()) + delegate.settings = newSettings + bolusEntryViewModel.updateSettings() + await bolusEntryViewModel.update() + + XCTAssertEqual(newSettings.preMealOverride, bolusEntryViewModel.preMealOverride) + XCTAssertEqual(newSettings.scheduleOverride, bolusEntryViewModel.scheduleOverride) + XCTAssertEqual(newGlucoseTargetRangeSchedule, bolusEntryViewModel.targetGlucoseSchedule) + } + + func testUpdateSettingsWithCarbs() async throws { + await setUpViewModel(originalCarbEntry: mockOriginalCarbEntry, potentialCarbEntry: mockPotentialCarbEntry) + XCTAssertNil(bolusEntryViewModel.preMealOverride) + XCTAssertNil(bolusEntryViewModel.scheduleOverride) + XCTAssertEqual(bolusEntryViewModel.targetGlucoseSchedule, BolusEntryViewModelTests.exampleGlucoseRangeSchedule) + let newGlucoseTargetRangeSchedule = GlucoseRangeSchedule(unit: .milligramsPerDeciliter, dailyItems: [ + RepeatingScheduleValue(startTime: TimeInterval(0), value: DoubleRange(minValue: 100, maxValue: 110)), + RepeatingScheduleValue(startTime: TimeInterval(28800), value: DoubleRange(minValue: 90, maxValue: 100)), + RepeatingScheduleValue(startTime: TimeInterval(75600), value: DoubleRange(minValue: 100, maxValue: 110)) + ], timeZone: .utcTimeZone)! + var newSettings = LoopSettings(dosingEnabled: true, + glucoseTargetRangeSchedule: newGlucoseTargetRangeSchedule, + maximumBasalRatePerHour: 1.0, + maximumBolus: 10.0, + suspendThreshold: GlucoseThreshold(unit: .milligramsPerDeciliter, value: 100.0)) + newSettings.preMealOverride = Self.examplePreMealOverride + newSettings.scheduleOverride = Self.exampleCustomScheduleOverride + delegate.settings = newSettings + bolusEntryViewModel.updateSettings() + + // Pre-meal override should be ignored if we have carbs (LOOP-1964), and cleared in settings + XCTAssertEqual(newSettings.scheduleOverride, bolusEntryViewModel.scheduleOverride) + XCTAssertEqual(newGlucoseTargetRangeSchedule, bolusEntryViewModel.targetGlucoseSchedule) + + // ... but restored if we cancel without bolusing + bolusEntryViewModel = nil + } + + func testManualGlucoseChangesPredictedGlucoseValues() async throws { + bolusEntryViewModel.manualGlucoseQuantity = Self.exampleManualGlucoseQuantity + let prediction = [PredictedGlucoseValue(startDate: Self.exampleStartDate, quantity: Self.exampleCGMGlucoseQuantity)] + delegate.loopState.predictGlucoseValueResult = prediction + await bolusEntryViewModel.update() + + XCTAssertEqual(prediction, + bolusEntryViewModel.predictedGlucoseValues.map { + PredictedGlucoseValue(startDate: $0.startDate, quantity: $0.quantity) + }) + } + + func testUpdateInsulinOnBoard() async throws { + delegate.insulinOnBoardResult = .success(InsulinValue(startDate: Self.exampleStartDate, value: 1.5)) + XCTAssertNil(bolusEntryViewModel.activeInsulin) + await bolusEntryViewModel.update() + XCTAssertEqual(HKQuantity(unit: .internationalUnit(), doubleValue: 1.5), bolusEntryViewModel.activeInsulin) + } + + func testUpdateCarbsOnBoard() async throws { + delegate.carbsOnBoardResult = .success(CarbValue(startDate: Self.exampleStartDate, endDate: Self.exampleEndDate, value: Self.exampleCarbQuantity.doubleValue(for: .gram()))) + XCTAssertNil(bolusEntryViewModel.activeCarbs) + await bolusEntryViewModel.update() + XCTAssertEqual(Self.exampleCarbQuantity, bolusEntryViewModel.activeCarbs) + } + + func testUpdateCarbsOnBoardFailure() async throws { + delegate.carbsOnBoardResult = .failure(CarbStore.CarbStoreError.notConfigured) + await bolusEntryViewModel.update() + XCTAssertNil(bolusEntryViewModel.activeCarbs) + } + + func testUpdateRecommendedBolusNoNotice() async throws { + await setUpViewModel(originalCarbEntry: mockOriginalCarbEntry, potentialCarbEntry: mockPotentialCarbEntry) + XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) + let recommendation = ManualBolusRecommendation(amount: 1.25, pendingInsulin: 4.321) + delegate.loopState.bolusRecommendationResult = recommendation + await bolusEntryViewModel.update() + XCTAssertTrue(bolusEntryViewModel.isBolusRecommended) + let recommendedBolus = bolusEntryViewModel.recommendedBolus + XCTAssertNotNil(recommendedBolus) + XCTAssertEqual(recommendation.amount, recommendedBolus?.doubleValue(for: .internationalUnit())) + let consideringPotentialCarbEntryPassed = try XCTUnwrap(delegate.loopState.consideringPotentialCarbEntryPassed) + XCTAssertEqual(mockPotentialCarbEntry, consideringPotentialCarbEntryPassed) + let replacingCarbEntryPassed = try XCTUnwrap(delegate.loopState.replacingCarbEntryPassed) + XCTAssertEqual(mockOriginalCarbEntry, replacingCarbEntryPassed) + XCTAssertNil(bolusEntryViewModel.activeNotice) + } + + func testUpdateRecommendedBolusWithNotice() async throws { + delegate.settings.suspendThreshold = GlucoseThreshold(unit: .milligramsPerDeciliter, value: Self.exampleCGMGlucoseQuantity.doubleValue(for: .milligramsPerDeciliter)) + XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) + let recommendation = ManualBolusRecommendation(amount: 1.25, pendingInsulin: 4.321, notice: BolusRecommendationNotice.glucoseBelowSuspendThreshold(minGlucose: Self.exampleGlucoseValue)) + delegate.loopState.bolusRecommendationResult = recommendation + await bolusEntryViewModel.update() + XCTAssertTrue(bolusEntryViewModel.isBolusRecommended) + let recommendedBolus = bolusEntryViewModel.recommendedBolus + XCTAssertNotNil(recommendedBolus) + XCTAssertEqual(recommendation.amount, recommendedBolus?.doubleValue(for: .internationalUnit())) + XCTAssertEqual(BolusEntryViewModel.Notice.predictedGlucoseBelowSuspendThreshold(suspendThreshold: Self.exampleCGMGlucoseQuantity), bolusEntryViewModel.activeNotice) + } + + func testUpdateRecommendedBolusWithNoticeMissingSuspendThreshold() async throws { + XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) + delegate.settings.suspendThreshold = nil + let recommendation = ManualBolusRecommendation(amount: 1.25, pendingInsulin: 4.321, notice: BolusRecommendationNotice.glucoseBelowSuspendThreshold(minGlucose: Self.exampleGlucoseValue)) + delegate.loopState.bolusRecommendationResult = recommendation + await bolusEntryViewModel.update() + XCTAssertTrue(bolusEntryViewModel.isBolusRecommended) + let recommendedBolus = bolusEntryViewModel.recommendedBolus + XCTAssertNotNil(recommendedBolus) + XCTAssertEqual(recommendation.amount, recommendedBolus?.doubleValue(for: .internationalUnit())) + XCTAssertNil(bolusEntryViewModel.activeNotice) + } + + func testUpdateRecommendedBolusWithOtherNotice() async throws { + XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) + let recommendation = ManualBolusRecommendation(amount: 1.25, pendingInsulin: 4.321, notice: BolusRecommendationNotice.currentGlucoseBelowTarget(glucose: Self.exampleGlucoseValue)) + delegate.loopState.bolusRecommendationResult = recommendation + await bolusEntryViewModel.update() + XCTAssertTrue(bolusEntryViewModel.isBolusRecommended) + let recommendedBolus = bolusEntryViewModel.recommendedBolus + XCTAssertNotNil(recommendedBolus) + XCTAssertEqual(recommendation.amount, recommendedBolus?.doubleValue(for: .internationalUnit())) + XCTAssertNil(bolusEntryViewModel.activeNotice) + } + + func testUpdateRecommendedBolusThrowsMissingDataError() async throws { + XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) + delegate.loopState.bolusRecommendationError = LoopError.missingDataError(.glucose) + await bolusEntryViewModel.update() + XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) + let recommendedBolus = bolusEntryViewModel.recommendedBolus + XCTAssertNil(recommendedBolus) + XCTAssertEqual(.staleGlucoseData, bolusEntryViewModel.activeNotice) + } + + func testUpdateRecommendedBolusThrowsPumpDataTooOld() async throws { + XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) + delegate.loopState.bolusRecommendationError = LoopError.pumpDataTooOld(date: now) + await bolusEntryViewModel.update() + XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) + let recommendedBolus = bolusEntryViewModel.recommendedBolus + XCTAssertNil(recommendedBolus) + XCTAssertEqual(.stalePumpData, bolusEntryViewModel.activeNotice) + } + + func testUpdateRecommendedBolusThrowsGlucoseTooOld() async throws { + XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) + delegate.loopState.bolusRecommendationError = LoopError.glucoseTooOld(date: now) + await bolusEntryViewModel.update() + XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) + let recommendedBolus = bolusEntryViewModel.recommendedBolus + XCTAssertNil(recommendedBolus) + XCTAssertEqual(.staleGlucoseData, bolusEntryViewModel.activeNotice) + } + + func testUpdateRecommendedBolusThrowsInvalidFutureGlucose() async throws { + XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) + delegate.loopState.bolusRecommendationError = LoopError.invalidFutureGlucose(date: now) + await bolusEntryViewModel.update() + XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) + let recommendedBolus = bolusEntryViewModel.recommendedBolus + XCTAssertNil(recommendedBolus) + XCTAssertEqual(.futureGlucoseData, bolusEntryViewModel.activeNotice) + } + + func testUpdateRecommendedBolusThrowsOtherError() async throws { + XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) + delegate.loopState.bolusRecommendationError = LoopError.pumpSuspended + await bolusEntryViewModel.update() + XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) + let recommendedBolus = bolusEntryViewModel.recommendedBolus + XCTAssertNil(recommendedBolus) + XCTAssertNil(bolusEntryViewModel.activeNotice) + } + + func testUpdateRecommendedBolusWithManual() async throws { + await setUpViewModel(originalCarbEntry: mockOriginalCarbEntry, potentialCarbEntry: mockPotentialCarbEntry) + bolusEntryViewModel.manualGlucoseQuantity = Self.exampleManualGlucoseQuantity + XCTAssertFalse(bolusEntryViewModel.isBolusRecommended) + let recommendation = ManualBolusRecommendation(amount: 1.25, pendingInsulin: 4.321) + delegate.loopState.bolusRecommendationResult = recommendation + await bolusEntryViewModel.update() + XCTAssertTrue(bolusEntryViewModel.isBolusRecommended) + let recommendedBolus = bolusEntryViewModel.recommendedBolus + XCTAssertNotNil(recommendedBolus) + XCTAssertEqual(recommendation.amount, recommendedBolus?.doubleValue(for: .internationalUnit())) + let consideringPotentialCarbEntryPassed = try XCTUnwrap(delegate.loopState.consideringPotentialCarbEntryPassed) + XCTAssertEqual(mockPotentialCarbEntry, consideringPotentialCarbEntryPassed) + let replacingCarbEntryPassed = try XCTUnwrap(delegate.loopState.replacingCarbEntryPassed) + XCTAssertEqual(mockOriginalCarbEntry, replacingCarbEntryPassed) + XCTAssertNil(bolusEntryViewModel.activeNotice) + } + + // MARK: save data and bolus delivery + + func testDeliverBolusOnlyRecommendationChanged() async throws { + bolusEntryViewModel.enteredBolus = Self.exampleBolusQuantity + + let success = await bolusEntryViewModel.saveAndDeliver() + + XCTAssertEqual(1.0, delegate.enactedBolusUnits) + XCTAssertEqual(.manualRecommendationChanged, delegate.enactedBolusActivationType) + XCTAssertTrue(success) + XCTAssertTrue(delegate.glucoseSamplesAdded.isEmpty) + XCTAssertTrue(delegate.carbEntriesAdded.isEmpty) + XCTAssertEqual(1, delegate.bolusDosingDecisionsAdded.count) + XCTAssertEqual(delegate.bolusDosingDecisionsAdded.first?.0.reason, .normalBolus) + XCTAssertEqual(delegate.bolusDosingDecisionsAdded.first?.0.manualBolusRequested, 1.0) + XCTAssertEqual(delegate.bolusDosingDecisionsAdded.first?.1, now) + } + + func testBolusTooSmall() async throws { + bolusEntryViewModel.enteredBolus = HKQuantity(unit: .internationalUnit(), doubleValue: 0.01) + let success = await bolusEntryViewModel.saveAndDeliver() + XCTAssertEqual(.bolusTooSmall, bolusEntryViewModel.activeAlert) + XCTAssertNil(delegate.enactedBolusUnits) + XCTAssertFalse(success) + XCTAssertEqual(0, delegate.bolusDosingDecisionsAdded.count) + } + + + func testDeliverBolusOnlyRecommendationAccepted() async throws { + bolusEntryViewModel.recommendedBolus = Self.exampleBolusQuantity + bolusEntryViewModel.enteredBolus = Self.exampleBolusQuantity + + let success = await bolusEntryViewModel.saveAndDeliver() + + XCTAssertEqual(1.0, delegate.enactedBolusUnits) + XCTAssertEqual(.manualRecommendationAccepted, delegate.enactedBolusActivationType) + XCTAssertTrue(success) + XCTAssertTrue(delegate.glucoseSamplesAdded.isEmpty) + XCTAssertTrue(delegate.carbEntriesAdded.isEmpty) + XCTAssertEqual(1, delegate.bolusDosingDecisionsAdded.count) + XCTAssertEqual(delegate.bolusDosingDecisionsAdded.first?.0.reason, .normalBolus) + XCTAssertEqual(delegate.bolusDosingDecisionsAdded.first?.0.manualBolusRequested, 1.0) + XCTAssertEqual(delegate.bolusDosingDecisionsAdded.first?.1, now) + } + + func testDeliverBolusOnlyNoRecommendation() async throws { + bolusEntryViewModel.recommendedBolus = nil + bolusEntryViewModel.enteredBolus = Self.exampleBolusQuantity + + let success = await bolusEntryViewModel.saveAndDeliver() + + XCTAssertEqual(1.0, delegate.enactedBolusUnits) + XCTAssertEqual(.manualNoRecommendation, delegate.enactedBolusActivationType) + XCTAssertTrue(success) + XCTAssertTrue(delegate.glucoseSamplesAdded.isEmpty) + XCTAssertTrue(delegate.carbEntriesAdded.isEmpty) + XCTAssertEqual(1, delegate.bolusDosingDecisionsAdded.count) + XCTAssertEqual(delegate.bolusDosingDecisionsAdded.first?.0.reason, .normalBolus) + XCTAssertEqual(delegate.bolusDosingDecisionsAdded.first?.0.manualBolusRequested, 1.0) + XCTAssertEqual(delegate.bolusDosingDecisionsAdded.first?.1, now) + } + + struct MockError: Error {} + func testDeliverBolusAuthFail() async throws { + bolusEntryViewModel.enteredBolus = Self.exampleBolusQuantity + + // Mock failed authentication + bolusEntryViewModel.authenticationHandler = { _ in return false } + + let success = await bolusEntryViewModel.saveAndDeliver() + + XCTAssertNil(delegate.enactedBolusUnits) + XCTAssertNil(delegate.enactedBolusActivationType) + XCTAssertFalse(success) + XCTAssertTrue(delegate.glucoseSamplesAdded.isEmpty) + XCTAssertTrue(delegate.carbEntriesAdded.isEmpty) + XCTAssertTrue(delegate.bolusDosingDecisionsAdded.isEmpty) + } + + private func saveAndDeliver(_ bolus: HKQuantity, file: StaticString = #file, line: UInt = #line) async throws { + bolusEntryViewModel.enteredBolus = bolus + + self.saveAndDeliverSuccess = await bolusEntryViewModel.saveAndDeliver() + } + + func testSaveManualGlucoseNoBolus() async throws { + bolusEntryViewModel.manualGlucoseQuantity = Self.exampleManualGlucoseQuantity + + bolusEntryViewModel.enteredBolus = BolusEntryViewModelTests.noBolus + + delegate.addGlucoseSamplesResult = .success([Self.exampleManualStoredGlucoseSample]) + + let saveAndDeliverSuccess = await bolusEntryViewModel.saveAndDeliver() + + let expectedGlucoseSample = NewGlucoseSample(date: now, quantity: Self.exampleManualGlucoseQuantity, condition: nil, trend: nil, trendRate: nil, isDisplayOnly: false, wasUserEntered: true, syncIdentifier: mockUUID) + + XCTAssertEqual([expectedGlucoseSample], delegate.glucoseSamplesAdded) + + XCTAssertTrue(delegate.carbEntriesAdded.isEmpty) + XCTAssertEqual(1, delegate.bolusDosingDecisionsAdded.count) + XCTAssertEqual(delegate.bolusDosingDecisionsAdded.first?.0.reason, .normalBolus) + XCTAssertEqual(delegate.bolusDosingDecisionsAdded.first?.0.manualBolusRequested, 0.0) + + let addedGlucose = delegate.bolusDosingDecisionsAdded.first!.0.manualGlucoseSample + XCTAssertEqual(addedGlucose?.quantity, Self.exampleManualGlucoseQuantity) + XCTAssertEqual(addedGlucose?.startDate, now) + + XCTAssertEqual(delegate.bolusDosingDecisionsAdded.first?.1, now) + XCTAssertNil(delegate.enactedBolusUnits) + XCTAssertNil(delegate.enactedBolusActivationType) + XCTAssertTrue(saveAndDeliverSuccess) + } + + func testSaveCarbGlucoseNoBolus() async throws { + await setUpViewModel(originalCarbEntry: mockOriginalCarbEntry, potentialCarbEntry: mockPotentialCarbEntry) + + delegate.addGlucoseSamplesResult = .success([Self.exampleManualStoredGlucoseSample]) + delegate.addCarbEntryResult = .success(mockFinalCarbEntry) + + try await saveAndDeliver(BolusEntryViewModelTests.noBolus) + + XCTAssertTrue(delegate.glucoseSamplesAdded.isEmpty) + XCTAssertEqual(1, delegate.carbEntriesAdded.count) + XCTAssertEqual(delegate.bolusDosingDecisionsAdded.first?.0.reason, .normalBolus) + XCTAssertEqual(delegate.bolusDosingDecisionsAdded.first?.0.originalCarbEntry, mockOriginalCarbEntry) + XCTAssertEqual(delegate.bolusDosingDecisionsAdded.first?.0.carbEntry, mockFinalCarbEntry) + XCTAssertEqual(delegate.bolusDosingDecisionsAdded.first?.0.manualBolusRequested, 0.0) + + XCTAssertEqual(mockOriginalCarbEntry, delegate.carbEntriesAdded.first?.1) + + XCTAssertEqual(delegate.bolusDosingDecisionsAdded.first?.1, now) + XCTAssertNil(delegate.enactedBolusUnits) + XCTAssertNil(delegate.enactedBolusActivationType) + XCTAssertTrue(saveAndDeliverSuccess) + } + + func testSaveManualGlucoseAndBolus() async throws { + bolusEntryViewModel.manualGlucoseQuantity = Self.exampleManualGlucoseQuantity + + delegate.addGlucoseSamplesResult = .success([Self.exampleManualStoredGlucoseSample]) + + try await saveAndDeliver(BolusEntryViewModelTests.exampleBolusQuantity) + + let expectedGlucoseSample = NewGlucoseSample(date: now, quantity: Self.exampleManualGlucoseQuantity, condition: nil, trend: nil, trendRate: nil, isDisplayOnly: false, wasUserEntered: true, syncIdentifier: mockUUID) + XCTAssertEqual([expectedGlucoseSample], delegate.glucoseSamplesAdded) + + XCTAssertTrue(delegate.carbEntriesAdded.isEmpty) + XCTAssertEqual(1, delegate.bolusDosingDecisionsAdded.count) + XCTAssertEqual(delegate.bolusDosingDecisionsAdded.first?.0.reason, .normalBolus) + XCTAssertEqual(delegate.bolusDosingDecisionsAdded.first?.0.manualBolusRequested, 1.0) + + let addedGlucose = delegate.bolusDosingDecisionsAdded.first!.0.manualGlucoseSample + XCTAssertEqual(addedGlucose?.quantity, Self.exampleManualGlucoseQuantity) + XCTAssertEqual(addedGlucose?.startDate, now) + + XCTAssertEqual(delegate.bolusDosingDecisionsAdded.first?.1, now) + XCTAssertEqual(1.0, delegate.enactedBolusUnits) + XCTAssertEqual(.manualRecommendationChanged, delegate.enactedBolusActivationType) + XCTAssertTrue(saveAndDeliverSuccess) + } + + func testSaveCarbAndBolus() async throws { + await setUpViewModel(originalCarbEntry: mockOriginalCarbEntry, potentialCarbEntry: mockPotentialCarbEntry) + + delegate.addCarbEntryResult = .success(mockFinalCarbEntry) + + try await saveAndDeliver(BolusEntryViewModelTests.exampleBolusQuantity) + + XCTAssertTrue(delegate.glucoseSamplesAdded.isEmpty) + XCTAssertEqual(1, delegate.carbEntriesAdded.count) + XCTAssertEqual(mockPotentialCarbEntry, delegate.carbEntriesAdded.first?.0) + XCTAssertEqual(mockOriginalCarbEntry, delegate.carbEntriesAdded.first?.1) + XCTAssertEqual(1, delegate.bolusDosingDecisionsAdded.count) + XCTAssertEqual(delegate.bolusDosingDecisionsAdded.first?.0.reason, .normalBolus) + XCTAssertEqual(delegate.bolusDosingDecisionsAdded.first?.0.originalCarbEntry, mockOriginalCarbEntry) + XCTAssertEqual(delegate.bolusDosingDecisionsAdded.first?.0.carbEntry, mockFinalCarbEntry) + XCTAssertEqual(delegate.bolusDosingDecisionsAdded.first?.0.manualBolusRequested, 1.0) + XCTAssertEqual(delegate.bolusDosingDecisionsAdded.first?.1, now) + XCTAssertEqual(1.0, delegate.enactedBolusUnits) + XCTAssertEqual(.manualRecommendationChanged, delegate.enactedBolusActivationType) + XCTAssertTrue(saveAndDeliverSuccess) + } + + func testSaveCarbAndBolusClearsSavedPreMealOverride() async throws { + await setUpViewModel(originalCarbEntry: mockOriginalCarbEntry, potentialCarbEntry: mockPotentialCarbEntry) + // set up user specified pre-meal override + let newGlucoseTargetRangeSchedule = GlucoseRangeSchedule(unit: .millimolesPerLiter, dailyItems: [ + RepeatingScheduleValue(startTime: TimeInterval(0), value: DoubleRange(minValue: 100, maxValue: 110)), + RepeatingScheduleValue(startTime: TimeInterval(28800), value: DoubleRange(minValue: 90, maxValue: 100)), + RepeatingScheduleValue(startTime: TimeInterval(75600), value: DoubleRange(minValue: 100, maxValue: 110)) + ], timeZone: .utcTimeZone)! + var newSettings = LoopSettings(dosingEnabled: true, + glucoseTargetRangeSchedule: newGlucoseTargetRangeSchedule, + maximumBasalRatePerHour: 1.0, + maximumBolus: 10.0, + suspendThreshold: GlucoseThreshold(unit: .milligramsPerDeciliter, value: 100.0)) + newSettings.preMealOverride = Self.examplePreMealOverride + newSettings.scheduleOverride = Self.exampleCustomScheduleOverride + delegate.settings = newSettings + bolusEntryViewModel.updateSettings() + + delegate.addCarbEntryResult = .success(mockFinalCarbEntry) + + try await saveAndDeliver(BolusEntryViewModelTests.exampleBolusQuantity) + + XCTAssertTrue(saveAndDeliverSuccess) + + // ... make sure the "restoring" of the saved pre-meal override does not happen + bolusEntryViewModel = nil + } + + func testSaveManualGlucoseAndCarbAndBolus() async throws { + await setUpViewModel(originalCarbEntry: mockOriginalCarbEntry, potentialCarbEntry: mockPotentialCarbEntry) + bolusEntryViewModel.manualGlucoseQuantity = Self.exampleManualGlucoseQuantity + + delegate.addGlucoseSamplesResult = .success([Self.exampleManualStoredGlucoseSample]) + delegate.addCarbEntryResult = .success(mockFinalCarbEntry) + + try await saveAndDeliver(BolusEntryViewModelTests.exampleBolusQuantity) + + let expectedGlucoseSample = NewGlucoseSample(date: now, quantity: Self.exampleManualGlucoseQuantity, condition: nil, trend: nil, trendRate: nil, isDisplayOnly: false, wasUserEntered: true, syncIdentifier: mockUUID) + XCTAssertEqual([expectedGlucoseSample], delegate.glucoseSamplesAdded) + + XCTAssertEqual(1, delegate.carbEntriesAdded.count) + XCTAssertEqual(mockPotentialCarbEntry, delegate.carbEntriesAdded.first?.0) + XCTAssertEqual(mockOriginalCarbEntry, delegate.carbEntriesAdded.first?.1) + XCTAssertEqual(1, delegate.bolusDosingDecisionsAdded.count) + XCTAssertEqual(delegate.bolusDosingDecisionsAdded.first?.0.reason, .normalBolus) + XCTAssertEqual(delegate.bolusDosingDecisionsAdded.first?.0.manualBolusRequested, 1.0) + + XCTAssertEqual(delegate.bolusDosingDecisionsAdded.first?.0.originalCarbEntry, mockOriginalCarbEntry) + XCTAssertEqual(delegate.bolusDosingDecisionsAdded.first?.0.carbEntry, mockFinalCarbEntry) + + let addedGlucose = delegate.bolusDosingDecisionsAdded.first!.0.manualGlucoseSample + XCTAssertEqual(addedGlucose?.quantity, Self.exampleManualGlucoseQuantity) + XCTAssertEqual(addedGlucose?.startDate, now) + + XCTAssertEqual(delegate.bolusDosingDecisionsAdded.first?.1, now) + XCTAssertEqual(1.0, delegate.enactedBolusUnits) + XCTAssertEqual(.manualRecommendationChanged, delegate.enactedBolusActivationType) + XCTAssertTrue(saveAndDeliverSuccess) + } + + // MARK: Display strings + + func testEnteredBolusAmountString() throws { + XCTAssertEqual("0", bolusEntryViewModel.enteredBolusAmountString) + } + + func testMaximumBolusAmountString() throws { + XCTAssertEqual("10", bolusEntryViewModel.maximumBolusAmountString) + } + + func testCarbEntryAmountAndEmojiStringNil() throws { + XCTAssertNil(bolusEntryViewModel.carbEntryAmountAndEmojiString) + } + + func testCarbEntryAmountAndEmojiString() async throws { + await setUpViewModel(originalCarbEntry: mockOriginalCarbEntry, potentialCarbEntry: mockPotentialCarbEntry) + + XCTAssertEqual("234 g foodType", bolusEntryViewModel.carbEntryAmountAndEmojiString) + } + + func testCarbEntryAmountAndEmojiStringNoFoodType() async throws { + let potentialCarbEntry = NewCarbEntry(quantity: BolusEntryViewModelTests.exampleCarbQuantity, startDate: Self.exampleStartDate, foodType: nil, absorptionTime: 1) + await setUpViewModel(originalCarbEntry: mockOriginalCarbEntry, potentialCarbEntry: potentialCarbEntry) + + XCTAssertEqual("234 g", bolusEntryViewModel.carbEntryAmountAndEmojiString) + } + + func testCarbEntryAmountAndEmojiStringWithEmoji() async throws { + let potentialCarbEntry = NewCarbEntry(quantity: BolusEntryViewModelTests.exampleCarbQuantity, startDate: Self.exampleStartDate, foodType: nil, absorptionTime: 1) + await setUpViewModel(originalCarbEntry: mockOriginalCarbEntry, potentialCarbEntry: potentialCarbEntry, selectedCarbAbsorptionTimeEmoji: "😀") + + XCTAssertEqual("234 g 😀", bolusEntryViewModel.carbEntryAmountAndEmojiString) + } + + func testCarbEntryDateAndAbsorptionTimeStringNil() throws { + XCTAssertNil(bolusEntryViewModel.carbEntryDateAndAbsorptionTimeString) + } + + func testCarbEntryDateAndAbsorptionTimeString() async throws { + await setUpViewModel(originalCarbEntry: mockOriginalCarbEntry, potentialCarbEntry: mockPotentialCarbEntry) + + XCTAssertEqual("12:00 PM + 0m", bolusEntryViewModel.carbEntryDateAndAbsorptionTimeString) + } + + func testCarbEntryDateAndAbsorptionTimeString2() async throws { + let potentialCarbEntry = NewCarbEntry(quantity: BolusEntryViewModelTests.exampleCarbQuantity, startDate: Self.exampleStartDate, foodType: nil, absorptionTime: nil) + await setUpViewModel(originalCarbEntry: mockOriginalCarbEntry, potentialCarbEntry: potentialCarbEntry) + + XCTAssertEqual("12:00 PM", bolusEntryViewModel.carbEntryDateAndAbsorptionTimeString) + } + + func testIsManualGlucosePromptVisible() throws { + XCTAssertFalse(bolusEntryViewModel.isManualGlucosePromptVisible) + bolusEntryViewModel.activeNotice = .staleGlucoseData + bolusEntryViewModel.isManualGlucoseEntryEnabled = true + XCTAssertFalse(bolusEntryViewModel.isManualGlucosePromptVisible) + bolusEntryViewModel.activeNotice = .staleGlucoseData + bolusEntryViewModel.isManualGlucoseEntryEnabled = false + XCTAssertTrue(bolusEntryViewModel.isManualGlucosePromptVisible) + } + + func testIsNoticeVisible() throws { + XCTAssertFalse(bolusEntryViewModel.isNoticeVisible) + bolusEntryViewModel.activeNotice = .stalePumpData + XCTAssertTrue(bolusEntryViewModel.isNoticeVisible) + bolusEntryViewModel.activeNotice = .staleGlucoseData + bolusEntryViewModel.isManualGlucoseEntryEnabled = false + XCTAssertTrue(bolusEntryViewModel.isNoticeVisible) + bolusEntryViewModel.isManualGlucoseEntryEnabled = true + XCTAssertFalse(bolusEntryViewModel.isNoticeVisible) + } + + // MARK: action button tests + + func testPrimaryButtonDefault() { + XCTAssertEqual(.actionButton, bolusEntryViewModel.primaryButton) + } + + func testPrimaryButtonBolusEntry() { + bolusEntryViewModel.enteredBolus = Self.exampleBolusQuantity + XCTAssertEqual(.actionButton, bolusEntryViewModel.primaryButton) + } + + func testPrimaryButtonManual() { + bolusEntryViewModel.activeNotice = .staleGlucoseData + bolusEntryViewModel.isManualGlucoseEntryEnabled = false + XCTAssertEqual(.manualGlucoseEntry, bolusEntryViewModel.primaryButton) + } + + func testPrimaryButtonManualPrompt() { + bolusEntryViewModel.isManualGlucoseEntryEnabled = true + XCTAssertEqual(.actionButton, bolusEntryViewModel.primaryButton) + } + + func testActionButtonDefault() { + XCTAssertEqual(.enterBolus, bolusEntryViewModel.actionButtonAction) + } + + func testActionButtonManualGlucose() { + bolusEntryViewModel.manualGlucoseQuantity = Self.exampleManualGlucoseQuantity + XCTAssertEqual(.saveWithoutBolusing, bolusEntryViewModel.actionButtonAction) + } + + func testActionButtonPotentialCarbEntry() async { + await setUpViewModel(potentialCarbEntry: mockPotentialCarbEntry) + XCTAssertEqual(.saveWithoutBolusing, bolusEntryViewModel.actionButtonAction) + } + + func testActionButtonManualGlucoseAndPotentialCarbEntry() async { + await setUpViewModel(potentialCarbEntry: mockPotentialCarbEntry) + bolusEntryViewModel.manualGlucoseQuantity = Self.exampleManualGlucoseQuantity + XCTAssertEqual(.saveWithoutBolusing, bolusEntryViewModel.actionButtonAction) + } + + func testActionButtonDeliverOnly() { + bolusEntryViewModel.enteredBolus = Self.exampleBolusQuantity + XCTAssertEqual(.deliver, bolusEntryViewModel.actionButtonAction) + } + + func testActionButtonSaveAndDeliverManualGlucose() { + bolusEntryViewModel.manualGlucoseQuantity = Self.exampleManualGlucoseQuantity + bolusEntryViewModel.enteredBolus = Self.exampleBolusQuantity + XCTAssertEqual(.saveAndDeliver, bolusEntryViewModel.actionButtonAction) + } + + func testActionButtonSaveAndDeliverPotentialCarbEntry() async { + await setUpViewModel(potentialCarbEntry: mockPotentialCarbEntry) + bolusEntryViewModel.enteredBolus = Self.exampleBolusQuantity + XCTAssertEqual(.saveAndDeliver, bolusEntryViewModel.actionButtonAction) + } + + func testActionButtonSaveAndDeliverBothManualGlucoseAndPotentialCarbEntry() async { + await setUpViewModel(potentialCarbEntry: mockPotentialCarbEntry) + bolusEntryViewModel.manualGlucoseQuantity = Self.exampleManualGlucoseQuantity + bolusEntryViewModel.enteredBolus = Self.exampleBolusQuantity + XCTAssertEqual(.saveAndDeliver, bolusEntryViewModel.actionButtonAction) + } +} + +// MARK: utilities + +fileprivate class MockLoopState: LoopState { + + var carbsOnBoard: CarbValue? + + var insulinOnBoard: InsulinValue? + + var error: LoopError? + + var insulinCounteractionEffects: [GlucoseEffectVelocity] = [] + + var predictedGlucose: [PredictedGlucoseValue]? + + var predictedGlucoseIncludingPendingInsulin: [PredictedGlucoseValue]? + + var recommendedAutomaticDose: (recommendation: AutomaticDoseRecommendation, date: Date)? + + var retrospectiveGlucoseDiscrepancies: [GlucoseChange]? + + var totalRetrospectiveCorrection: HKQuantity? + + var predictGlucoseValueResult: [PredictedGlucoseValue] = [] + func predictGlucose(using inputs: PredictionInputEffect, potentialBolus: DoseEntry?, potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, includingPendingInsulin: Bool, considerPositiveVelocityAndRC: Bool) throws -> [PredictedGlucoseValue] { + return predictGlucoseValueResult + } + + func predictGlucoseFromManualGlucose(_ glucose: NewGlucoseSample, potentialBolus: DoseEntry?, potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, includingPendingInsulin: Bool, considerPositiveVelocityAndRC: Bool) throws -> [PredictedGlucoseValue] { + return predictGlucoseValueResult + } + + var bolusRecommendationResult: ManualBolusRecommendation? + var bolusRecommendationError: Error? + var consideringPotentialCarbEntryPassed: NewCarbEntry?? + var replacingCarbEntryPassed: StoredCarbEntry?? + func recommendBolus(consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, considerPositiveVelocityAndRC: Bool) throws -> ManualBolusRecommendation? { + consideringPotentialCarbEntryPassed = potentialCarbEntry + replacingCarbEntryPassed = replacedCarbEntry + if let error = bolusRecommendationError { throw error } + return bolusRecommendationResult + } + + func recommendBolusForManualGlucose(_ glucose: NewGlucoseSample, consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, considerPositiveVelocityAndRC: Bool) throws -> ManualBolusRecommendation? { + consideringPotentialCarbEntryPassed = potentialCarbEntry + replacingCarbEntryPassed = replacedCarbEntry + if let error = bolusRecommendationError { throw error } + return bolusRecommendationResult + } +} + +public enum BolusEntryViewTestError: Error { + case responseUndefined +} + +fileprivate class MockBolusEntryViewModelDelegate: BolusEntryViewModelDelegate { + + fileprivate var loopState = MockLoopState() + + private let dataAccessQueue = DispatchQueue(label: "com.loopKit.tests.dataAccessQueue", qos: .utility) + + + func updateRemoteRecommendation() { + } + + func roundBolusVolume(units: Double) -> Double { + // 0.05 units for rates between 0.05-30U/hr + // 0 is not a supported bolus volume + let supportedBolusVolumes = (1...600).map { Double($0) / 20.0 } + return ([0.0] + supportedBolusVolumes).enumerated().min( by: { abs($0.1 - units) < abs($1.1 - units) } )!.1 + } + + func insulinActivityDuration(for type: InsulinType?) -> TimeInterval { + return .hours(6) + .minutes(10) + } + + var pumpInsulinType: InsulinType? + + var displayGlucosePreference: DisplayGlucosePreference = DisplayGlucosePreference(displayGlucoseUnit: .milligramsPerDeciliter) + + func withLoopState(do block: @escaping (LoopState) -> Void) { + dataAccessQueue.async { + block(self.loopState) + } + } + + func saveGlucose(sample: LoopKit.NewGlucoseSample) async -> LoopKit.StoredGlucoseSample? { + glucoseSamplesAdded.append(sample) + return StoredGlucoseSample(sample: sample.quantitySample) + } + + var glucoseSamplesAdded = [NewGlucoseSample]() + var addGlucoseSamplesResult: Swift.Result<[StoredGlucoseSample], Error> = .failure(BolusEntryViewTestError.responseUndefined) + func addGlucoseSamples(_ samples: [NewGlucoseSample], completion: ((Swift.Result<[StoredGlucoseSample], Error>) -> Void)?) { + glucoseSamplesAdded.append(contentsOf: samples) + completion?(addGlucoseSamplesResult) + } + + var carbEntriesAdded = [(NewCarbEntry, StoredCarbEntry?)]() + var addCarbEntryResult: Result = .failure(BolusEntryViewTestError.responseUndefined) + func addCarbEntry(_ carbEntry: NewCarbEntry, replacing replacingEntry: StoredCarbEntry?, completion: @escaping (Result) -> Void) { + carbEntriesAdded.append((carbEntry, replacingEntry)) + completion(addCarbEntryResult) + } + + var bolusDosingDecisionsAdded = [(BolusDosingDecision, Date)]() + func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) { + bolusDosingDecisionsAdded.append((bolusDosingDecision, date)) + } + + var enactedBolusUnits: Double? + var enactedBolusActivationType: BolusActivationType? + func enactBolus(units: Double, activationType: BolusActivationType, completion: @escaping (Error?) -> Void) { + enactedBolusUnits = units + enactedBolusActivationType = activationType + } + + var getGlucoseSamplesResponse: [StoredGlucoseSample] = [] + func getGlucoseSamples(start: Date?, end: Date?, completion: @escaping (Swift.Result<[StoredGlucoseSample], Error>) -> Void) { + completion(.success(getGlucoseSamplesResponse)) + } + + var insulinOnBoardResult: DoseStoreResult? + func insulinOnBoard(at date: Date, completion: @escaping (DoseStoreResult) -> Void) { + if let insulinOnBoardResult = insulinOnBoardResult { + completion(insulinOnBoardResult) + } else { + completion(.failure(.configurationError)) + } + } + + var carbsOnBoardResult: CarbStoreResult? + func carbsOnBoard(at date: Date, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (CarbStoreResult) -> Void) { + if let carbsOnBoardResult = carbsOnBoardResult { + completion(carbsOnBoardResult) + } + } + + var ensureCurrentPumpDataCompletion: ((Date?) -> Void)? + func ensureCurrentPumpData(completion: @escaping (Date?) -> Void) { + ensureCurrentPumpDataCompletion = completion + } + + var mostRecentGlucoseDataDate: Date? + + var mostRecentPumpDataDate: Date? + + var isPumpConfigured: Bool = true + + var preferredGlucoseUnit: HKUnit = .milligramsPerDeciliter + + var insulinModel: InsulinModel? = MockInsulinModel() + + var settings: LoopSettings = LoopSettings( + dosingEnabled: true, + glucoseTargetRangeSchedule: BolusEntryViewModelTests.exampleGlucoseRangeSchedule, + maximumBasalRatePerHour: 3.0, + maximumBolus: 10.0, + suspendThreshold: GlucoseThreshold(unit: .internationalUnit(), value: 75)) { + didSet { + NotificationCenter.default.post(name: .LoopDataUpdated, object: nil, userInfo: [ + LoopDataManager.LoopUpdateContextKey: LoopDataManager.LoopUpdateContext.preferences.rawValue + ]) + } + } + +} + +fileprivate struct MockInsulinModel: InsulinModel { + func percentEffectRemaining(at time: TimeInterval) -> Double { 0 } + var effectDuration: TimeInterval = 0 + var delay: TimeInterval = 0 + var debugDescription: String = "" +} + +fileprivate struct MockGlucoseValue: GlucoseValue { + var quantity: HKQuantity + var startDate: Date +} + +fileprivate extension TimeInterval { + static func milliseconds(_ milliseconds: Double) -> TimeInterval { + return milliseconds / 1000 + } +} + +extension BolusDosingDecision: Equatable { + init(for reason: Reason, originalCarbEntry: StoredCarbEntry? = nil, carbEntry: StoredCarbEntry? = nil, manualGlucoseSample: StoredGlucoseSample? = nil, manualBolusRequested: Double? = nil) { + self.init(for: reason) + self.originalCarbEntry = originalCarbEntry + self.carbEntry = carbEntry + self.manualGlucoseSample = manualGlucoseSample + self.manualBolusRequested = manualBolusRequested + } + + public static func ==(lhs: BolusDosingDecision, rhs: BolusDosingDecision) -> Bool { + return lhs.originalCarbEntry == rhs.originalCarbEntry && + lhs.carbEntry == rhs.carbEntry && + lhs.manualGlucoseSample == rhs.manualGlucoseSample && + lhs.insulinOnBoard == rhs.insulinOnBoard && + lhs.carbsOnBoard == rhs.carbsOnBoard && + lhs.glucoseTargetRangeSchedule == rhs.glucoseTargetRangeSchedule && + lhs.predictedGlucose == rhs.predictedGlucose && + lhs.manualBolusRecommendation == rhs.manualBolusRecommendation && + lhs.manualBolusRequested == rhs.manualBolusRequested + } +} + +extension ManualBolusRecommendationWithDate: Equatable { + public static func == (lhs: ManualBolusRecommendationWithDate, rhs: ManualBolusRecommendationWithDate) -> Bool { + return lhs.recommendation == rhs.recommendation && lhs.date == rhs.date + } +} diff --git a/LoopTests/ViewModels/CGMStatusHUDViewModelTests.swift b/LoopTests/ViewModels/CGMStatusHUDViewModelTests.swift new file mode 100644 index 0000000000..ea743eb008 --- /dev/null +++ b/LoopTests/ViewModels/CGMStatusHUDViewModelTests.swift @@ -0,0 +1,346 @@ +// +// CGMStatusHUDViewModelTests.swift +// LoopTests +// +// Created by Nathaniel Hamming on 2020-09-21. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import XCTest +import HealthKit +import LoopKit +@testable import LoopUI + +class CGMStatusHUDViewModelTests: XCTestCase { + + private var viewModel: CGMStatusHUDViewModel! + private var staleGlucoseValueHandlerWasCalled = false + private var testExpect: XCTestExpectation! + + override func setUpWithError() throws { + staleGlucoseValueHandlerWasCalled = false + viewModel = CGMStatusHUDViewModel(staleGlucoseValueHandler: staleGlucoseValueHandler) + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testInitialization() throws { + XCTAssertEqual(CGMStatusHUDViewModel.staleGlucoseRepresentation, "– – –") + XCTAssertNil(viewModel.trend) + XCTAssertEqual(viewModel.unitsString, "–") + XCTAssertEqual(viewModel.glucoseValueString, "– – –") + XCTAssertTrue(viewModel.accessibilityString.isEmpty) + XCTAssertEqual(viewModel.glucoseValueTintColor, .label) + XCTAssertEqual(viewModel.glucoseTrendTintColor, .glucoseTintColor) + XCTAssertNil(viewModel.manualGlucoseTrendIconOverride) + XCTAssertNil(viewModel.statusHighlight) + } + + func testSetGlucoseQuantityCGM() { + let glucoseDisplay = TestGlucoseDisplay(isStateValid: true, + trendType: .down, + trendRate: HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: -1.0), + isLocal: true, + glucoseRangeCategory: .urgentLow) + let glucoseStartDate = Date() + let staleGlucoseAge: TimeInterval = .minutes(15) + viewModel.setGlucoseQuantity(90, + at: glucoseStartDate, + unit: .milligramsPerDeciliter, + staleGlucoseAge: staleGlucoseAge, + glucoseDisplay: glucoseDisplay, + wasUserEntered: false, + isDisplayOnly: false) + + XCTAssertNil(viewModel.manualGlucoseTrendIconOverride) + XCTAssertNil(viewModel.statusHighlight) + XCTAssertEqual(viewModel.glucoseValueString, "90") + XCTAssertEqual(viewModel.trend, .down) + XCTAssertEqual(viewModel.glucoseTrendTintColor, glucoseDisplay.glucoseRangeCategory?.trendColor) + XCTAssertEqual(viewModel.glucoseValueTintColor, glucoseDisplay.glucoseRangeCategory?.glucoseColor) + XCTAssertEqual(viewModel.unitsString, HKUnit.milligramsPerDeciliter.localizedShortUnitString) + } + + func testSetGlucoseQuantityCGMStale() { + let glucoseDisplay = TestGlucoseDisplay(isStateValid: true, + trendType: .down, + trendRate: HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: -1.0), + isLocal: true, + glucoseRangeCategory: .urgentLow) + let glucoseStartDate = Date() + let staleGlucoseAge: TimeInterval = .minutes(-1) + viewModel.setGlucoseQuantity(90, + at: glucoseStartDate, + unit: .milligramsPerDeciliter, + staleGlucoseAge: staleGlucoseAge, + glucoseDisplay: glucoseDisplay, + wasUserEntered: false, + isDisplayOnly: false) + + XCTAssertNil(viewModel.manualGlucoseTrendIconOverride) + XCTAssertNil(viewModel.statusHighlight) + XCTAssertEqual(viewModel.glucoseValueString, "– – –") + XCTAssertNil(viewModel.trend) + XCTAssertNotEqual(viewModel.glucoseTrendTintColor, glucoseDisplay.glucoseRangeCategory?.trendColor) + XCTAssertEqual(viewModel.glucoseTrendTintColor, .glucoseTintColor) + XCTAssertNotEqual(viewModel.glucoseValueTintColor, glucoseDisplay.glucoseRangeCategory?.glucoseColor) + XCTAssertEqual(viewModel.glucoseValueTintColor, .label) + XCTAssertEqual(viewModel.unitsString, HKUnit.milligramsPerDeciliter.localizedShortUnitString) + } + + func testSetGlucoseQuantityCGMStaleDelayed() { + testExpect = self.expectation(description: #function) + let glucoseDisplay = TestGlucoseDisplay(isStateValid: true, + trendType: .down, + trendRate: HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: -1.0), + isLocal: true, + glucoseRangeCategory: .urgentLow) + let glucoseStartDate = Date() + let staleGlucoseAge: TimeInterval = .seconds(0.01) + viewModel.setGlucoseQuantity(90, + at: glucoseStartDate, + unit: .milligramsPerDeciliter, + staleGlucoseAge: staleGlucoseAge, + glucoseDisplay: glucoseDisplay, + wasUserEntered: false, + isDisplayOnly: false) + wait(for: [testExpect], timeout: 1.0) + XCTAssertTrue(staleGlucoseValueHandlerWasCalled) + XCTAssertNil(viewModel.manualGlucoseTrendIconOverride) + XCTAssertNil(viewModel.statusHighlight) + XCTAssertEqual(viewModel.glucoseValueString, "– – –") + XCTAssertNil(viewModel.trend) + XCTAssertNotEqual(viewModel.glucoseTrendTintColor, glucoseDisplay.glucoseRangeCategory?.trendColor) + XCTAssertEqual(viewModel.glucoseTrendTintColor, .glucoseTintColor) + XCTAssertNotEqual(viewModel.glucoseValueTintColor, glucoseDisplay.glucoseRangeCategory?.glucoseColor) + XCTAssertEqual(viewModel.glucoseValueTintColor, .label) + XCTAssertEqual(viewModel.unitsString, HKUnit.milligramsPerDeciliter.localizedShortUnitString) + } + + func testSetGlucoseQuantityManualGlucose() { + let glucoseDisplay = TestGlucoseDisplay(isStateValid: true, + trendType: .down, + trendRate: HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: -1.0), + isLocal: true, + glucoseRangeCategory: .urgentLow) + let glucoseStartDate = Date() + let staleGlucoseAge: TimeInterval = .minutes(15) + viewModel.setGlucoseQuantity(90, + at: glucoseStartDate, + unit: .milligramsPerDeciliter, + staleGlucoseAge: staleGlucoseAge, + glucoseDisplay: glucoseDisplay, + wasUserEntered: true, + isDisplayOnly: false) + + XCTAssertNil(viewModel.manualGlucoseTrendIconOverride) + XCTAssertNil(viewModel.statusHighlight) + XCTAssertEqual(viewModel.glucoseValueString, "90") + XCTAssertNil(viewModel.trend) + XCTAssertNotEqual(viewModel.glucoseTrendTintColor, glucoseDisplay.glucoseRangeCategory?.trendColor) + XCTAssertEqual(viewModel.glucoseTrendTintColor, .glucoseTintColor) + XCTAssertEqual(viewModel.glucoseValueTintColor, glucoseDisplay.glucoseRangeCategory?.glucoseColor) + XCTAssertEqual(viewModel.unitsString, HKUnit.milligramsPerDeciliter.localizedShortUnitString) + } + + func testSetGlucoseQuantityCalibrationDoesNotShow() { + let glucoseDisplay = TestGlucoseDisplay(isStateValid: true, + trendType: .down, + trendRate: HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: -1.0), + isLocal: true, + glucoseRangeCategory: .urgentLow) + let glucoseStartDate = Date() + let staleGlucoseAge: TimeInterval = .minutes(15) + viewModel.setGlucoseQuantity(90, + at: glucoseStartDate, + unit: .milligramsPerDeciliter, + staleGlucoseAge: staleGlucoseAge, + glucoseDisplay: glucoseDisplay, + wasUserEntered: true, + isDisplayOnly: true) + + XCTAssertNil(viewModel.manualGlucoseTrendIconOverride) + XCTAssertEqual(viewModel.glucoseValueString, "90") + XCTAssertEqual(viewModel.trend, .down) + XCTAssertEqual(viewModel.glucoseTrendTintColor, glucoseDisplay.glucoseRangeCategory?.trendColor) + XCTAssertEqual(viewModel.glucoseValueTintColor, glucoseDisplay.glucoseRangeCategory?.glucoseColor) + XCTAssertEqual(viewModel.unitsString, HKUnit.milligramsPerDeciliter.localizedShortUnitString) + } + + func testSetManualGlucoseIconOverride() { + let statusHighlight1 = TestStatusHighlight(localizedMessage: "Test 1", + imageName: "plus.circle", + state: .normalCGM) + + let statusHighlight2 = TestStatusHighlight(localizedMessage: "Test 2", + imageName: "exclamationmark.circle", + state: .critical) + + // set status highlight + XCTAssertNil(viewModel.manualGlucoseTrendIconOverride) + viewModel.statusHighlight = statusHighlight1 + XCTAssertEqual(viewModel.statusHighlight as! TestStatusHighlight, statusHighlight1) + + // ensure status highlight icon is set to the manual glucose override icon + // when there is a manual glucose override icon, the status highlight isn't returned to be presented + let glucoseDisplay = TestGlucoseDisplay(isStateValid: true, + trendType: .down, + trendRate: HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: -1.0), + isLocal: true, + glucoseRangeCategory: .urgentLow) + let glucoseStartDate = Date() + let staleGlucoseAge: TimeInterval = .minutes(15) + viewModel.setGlucoseQuantity(90, + at: glucoseStartDate, + unit: .milligramsPerDeciliter, + staleGlucoseAge: staleGlucoseAge, + glucoseDisplay: glucoseDisplay, + wasUserEntered: true, + isDisplayOnly: false) + + XCTAssertEqual(viewModel.glucoseValueString, "90") + XCTAssertNil(viewModel.trend) + XCTAssertNil(viewModel.statusHighlight) + XCTAssertEqual(viewModel.manualGlucoseTrendIconOverride, statusHighlight1.image) + XCTAssertEqual(viewModel.glucoseTrendTintColor, statusHighlight1.state.color) + + // ensure updating the status highlight icon also updates the manual glucose override icon + viewModel.statusHighlight = statusHighlight2 + XCTAssertNil(viewModel.statusHighlight) + XCTAssertEqual(viewModel.glucoseValueString, "90") + XCTAssertNil(viewModel.trend) + XCTAssertEqual(viewModel.manualGlucoseTrendIconOverride, statusHighlight2.image) + XCTAssertEqual(viewModel.glucoseTrendTintColor, statusHighlight2.state.color) + } + + func testManualGlucoseOverridesStatusHighlight() { + // add manual glucose + let glucoseDisplay = TestGlucoseDisplay(isStateValid: true, + trendType: .down, + trendRate: HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: -1.0), + isLocal: true, + glucoseRangeCategory: .urgentLow) + let staleGlucoseAge: TimeInterval = .minutes(15) + viewModel.setGlucoseQuantity(90, + at: Date(), + unit: .milligramsPerDeciliter, + staleGlucoseAge: staleGlucoseAge, + glucoseDisplay: glucoseDisplay, + wasUserEntered: true, + isDisplayOnly: false) + + // check that manual glucose is displayed + XCTAssertEqual(viewModel.glucoseValueString, "90") + XCTAssertNil(viewModel.trend) + XCTAssertNil(viewModel.statusHighlight) + XCTAssertNil(viewModel.manualGlucoseTrendIconOverride) + XCTAssertEqual(viewModel.glucoseTrendTintColor, .glucoseTintColor) + + // add status highlight + let statusHighlight1 = TestStatusHighlight(localizedMessage: "Test 1", + imageName: "plus.circle", + state: .normalCGM) + viewModel.statusHighlight = statusHighlight1 + + // check that manual glucose is still displayed (this time with status highlight icon) + XCTAssertEqual(viewModel.glucoseValueString, "90") + XCTAssertNil(viewModel.trend) + XCTAssertNil(viewModel.statusHighlight) + XCTAssertEqual(viewModel.manualGlucoseTrendIconOverride, statusHighlight1.image) + XCTAssertEqual(viewModel.glucoseTrendTintColor, statusHighlight1.state.color) + + // add CGM glucose + viewModel.setGlucoseQuantity(95, + at: Date(), + unit: .milligramsPerDeciliter, + staleGlucoseAge: staleGlucoseAge, + glucoseDisplay: glucoseDisplay, + wasUserEntered: false, + isDisplayOnly: false) + + // check that status highlight is displayed + XCTAssertEqual(viewModel.glucoseValueString, "95") + XCTAssertEqual(viewModel.trend, .down) + XCTAssertEqual(viewModel.statusHighlight as! TestStatusHighlight, statusHighlight1) + XCTAssertNil(viewModel.manualGlucoseTrendIconOverride) + + // remove status highlight + viewModel.statusHighlight = nil + + // check that CGM glucose is displayed + XCTAssertEqual(viewModel.glucoseValueString, "95") + XCTAssertEqual(viewModel.trend, .down) + XCTAssertNil(viewModel.statusHighlight) + XCTAssertNil(viewModel.manualGlucoseTrendIconOverride) + + // add status highlight + let statusHighlight2 = TestStatusHighlight(localizedMessage: "Test 2", + imageName: "exclamationmark.circle", + state: .critical) + viewModel.statusHighlight = statusHighlight2 + + // check that status highlight is displayed + XCTAssertEqual(viewModel.glucoseValueString, "95") + XCTAssertEqual(viewModel.trend, .down) + XCTAssertEqual(viewModel.statusHighlight as! TestStatusHighlight, statusHighlight2) + XCTAssertNil(viewModel.manualGlucoseTrendIconOverride) + + // add manual glucose + viewModel.setGlucoseQuantity(100, + at: Date(), + unit: .milligramsPerDeciliter, + staleGlucoseAge: staleGlucoseAge, + glucoseDisplay: glucoseDisplay, + wasUserEntered: true, + isDisplayOnly: false) + + // check that manual glucose is still displayed (again with status highlight icon) + XCTAssertEqual(viewModel.glucoseValueString, "100") + XCTAssertNil(viewModel.trend) + XCTAssertNil(viewModel.statusHighlight) + XCTAssertEqual(viewModel.manualGlucoseTrendIconOverride, statusHighlight2.image) + XCTAssertEqual(viewModel.glucoseTrendTintColor, statusHighlight2.state.color) + + // add stale manual glucose + viewModel.setGlucoseQuantity(100, + at: Date(), + unit: .milligramsPerDeciliter, + staleGlucoseAge: .minutes(-1), + glucoseDisplay: glucoseDisplay, + wasUserEntered: true, + isDisplayOnly: false) + + // check that the status highlight is displayed + XCTAssertEqual(viewModel.statusHighlight as! TestStatusHighlight, statusHighlight2) + XCTAssertNil(viewModel.manualGlucoseTrendIconOverride) + } +} + +extension CGMStatusHUDViewModelTests { + func staleGlucoseValueHandler() { + self.staleGlucoseValueHandlerWasCalled = true + testExpect.fulfill() + } + + struct TestStatusHighlight: DeviceStatusHighlight, Equatable { + var localizedMessage: String + + var imageName: String + + var state: DeviceStatusHighlightState + } + + struct TestGlucoseDisplay: GlucoseDisplayable { + var isStateValid: Bool + + var trendType: GlucoseTrend? + + var trendRate: HKQuantity? + + var isLocal: Bool + + var glucoseRangeCategory: GlucoseRangeCategory? + } +} diff --git a/LoopTests/ViewModels/ManualEntryDoseViewModelTests.swift b/LoopTests/ViewModels/ManualEntryDoseViewModelTests.swift new file mode 100644 index 0000000000..55104e5a1b --- /dev/null +++ b/LoopTests/ViewModels/ManualEntryDoseViewModelTests.swift @@ -0,0 +1,138 @@ +// +// ManualEntryDoseViewModelTests.swift +// LoopTests +// +// Created by Pete Schwamb on 1/2/21. +// Copyright © 2021 LoopKit Authors. All rights reserved. +// + +import HealthKit +import LoopCore +import LoopKit +import XCTest +@testable import Loop + +class ManualEntryDoseViewModelTests: XCTestCase { + + static let now = Date.distantFuture + + var now: Date = BolusEntryViewModelTests.now + + var manualEntryDoseViewModel: ManualEntryDoseViewModel! + + static let exampleBolusQuantity = HKQuantity(unit: .internationalUnit(), doubleValue: 1.0) + + static let noBolus = HKQuantity(unit: .internationalUnit(), doubleValue: 0.0) + + var authenticateOverrideCompletion: ((Swift.Result) -> Void)? + private func authenticateOverride(_ message: String, _ completion: @escaping (Swift.Result) -> Void) { + authenticateOverrideCompletion = completion + } + + var saveAndDeliverSuccess = false + + fileprivate var delegate: MockManualEntryDoseViewModelDelegate! + + static let mockUUID = UUID() + let mockUUID = ManualEntryDoseViewModelTests.mockUUID.uuidString + + override func setUpWithError() throws { + now = Self.now + delegate = MockManualEntryDoseViewModelDelegate() + delegate.mostRecentGlucoseDataDate = now + delegate.mostRecentPumpDataDate = now + saveAndDeliverSuccess = false + setUpViewModel() + } + + func setUpViewModel() { + manualEntryDoseViewModel = ManualEntryDoseViewModel(delegate: delegate, + now: { self.now }, + screenWidth: 512, + debounceIntervalMilliseconds: 0, + uuidProvider: { self.mockUUID }, + timeZone: TimeZone(abbreviation: "GMT")!) + manualEntryDoseViewModel.authenticate = authenticateOverride + } + + func testDoseLogging() throws { + XCTAssertEqual(.novolog, manualEntryDoseViewModel.selectedInsulinType) + manualEntryDoseViewModel.enteredBolus = Self.exampleBolusQuantity + + try saveAndDeliver(ManualEntryDoseViewModelTests.exampleBolusQuantity) + XCTAssertEqual(delegate.manualEntryBolusUnits, Self.exampleBolusQuantity.doubleValue(for: .internationalUnit())) + XCTAssertEqual(delegate.manuallyEnteredDoseInsulinType, .novolog) + } + + private func saveAndDeliver(_ bolus: HKQuantity, file: StaticString = #file, line: UInt = #line) throws { + manualEntryDoseViewModel.enteredBolus = bolus + manualEntryDoseViewModel.saveManualDose { self.saveAndDeliverSuccess = true } + if bolus != ManualEntryDoseViewModelTests.noBolus { + let authenticateOverrideCompletion = try XCTUnwrap(self.authenticateOverrideCompletion, file: file, line: line) + authenticateOverrideCompletion(.success(())) + } + } +} + +fileprivate class MockManualEntryDoseViewModelDelegate: ManualDoseViewModelDelegate { + + func insulinActivityDuration(for type: InsulinType?) -> TimeInterval { + return .hours(6) + .minutes(10) + } + + var pumpInsulinType: InsulinType? + + var manualEntryBolusUnits: Double? + var manualEntryDoseStartDate: Date? + var manuallyEnteredDoseInsulinType: InsulinType? + func addManuallyEnteredDose(startDate: Date, units: Double, insulinType: InsulinType?) { + manualEntryBolusUnits = units + manualEntryDoseStartDate = startDate + manuallyEnteredDoseInsulinType = insulinType + } + + var loopStateCallBlock: ((LoopState) -> Void)? + func withLoopState(do block: @escaping (LoopState) -> Void) { + loopStateCallBlock = block + } + + var enactedBolusUnits: Double? + func enactBolus(units: Double, automatic: Bool, completion: @escaping (Error?) -> Void) { + enactedBolusUnits = units + } + + var getGlucoseSamplesResponse: [StoredGlucoseSample] = [] + func getGlucoseSamples(start: Date?, end: Date?, completion: @escaping (Swift.Result<[StoredGlucoseSample], Error>) -> Void) { + completion(.success(getGlucoseSamplesResponse)) + } + + var insulinOnBoardResult: DoseStoreResult? + func insulinOnBoard(at date: Date, completion: @escaping (DoseStoreResult) -> Void) { + if let insulinOnBoardResult = insulinOnBoardResult { + completion(insulinOnBoardResult) + } + } + + var carbsOnBoardResult: CarbStoreResult? + func carbsOnBoard(at date: Date, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (CarbStoreResult) -> Void) { + if let carbsOnBoardResult = carbsOnBoardResult { + completion(carbsOnBoardResult) + } + } + + var ensureCurrentPumpDataCompletion: (() -> Void)? + func ensureCurrentPumpData(completion: @escaping () -> Void) { + ensureCurrentPumpDataCompletion = completion + } + + var mostRecentGlucoseDataDate: Date? + + var mostRecentPumpDataDate: Date? + + var isPumpConfigured: Bool = true + + var preferredGlucoseUnit: HKUnit = .milligramsPerDeciliter + + var settings: LoopSettings = LoopSettings() +} + diff --git a/LoopTests/ViewModels/SimpleBolusViewModelTests.swift b/LoopTests/ViewModels/SimpleBolusViewModelTests.swift new file mode 100644 index 0000000000..94c1fd8661 --- /dev/null +++ b/LoopTests/ViewModels/SimpleBolusViewModelTests.swift @@ -0,0 +1,339 @@ +// +// SimpleBolusViewModelTests.swift +// LoopTests +// +// Created by Pete Schwamb on 10/12/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import XCTest +import HealthKit +import LoopKit +import LoopKitUI +import LoopCore + +@testable import Loop + +class SimpleBolusViewModelTests: XCTestCase { + + enum MockError: Error { + case authentication + } + + var addedGlucose: [NewGlucoseSample] = [] + var addedCarbEntry: NewCarbEntry? + var storedBolusDecision: BolusDosingDecision? + var enactedBolus: (units: Double, activationType: BolusActivationType)? + var currentIOB: InsulinValue = SimpleBolusViewModelTests.noIOB + var currentRecommendation: Double = 0 + var displayGlucosePreference: DisplayGlucosePreference = DisplayGlucosePreference(displayGlucoseUnit: .milligramsPerDeciliter) + + static var noIOB = InsulinValue(startDate: Date(), value: 0) + static var someIOB = InsulinValue(startDate: Date(), value: 2.4) + + override func setUp() { + addedGlucose = [] + addedCarbEntry = nil + enactedBolus = nil + currentRecommendation = 0 + } + + func testFailedAuthenticationShouldNotSaveDataOrBolus() { + let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false) + viewModel.authenticate = { (description, completion) in + completion(.failure(MockError.authentication)) + } + + viewModel.enteredBolusString = "3" + + let saveExpectation = expectation(description: "Save completion callback") + + viewModel.saveAndDeliver { (success) in + saveExpectation.fulfill() + } + + waitForExpectations(timeout: 2) + + XCTAssertNil(enactedBolus) + XCTAssertNil(addedCarbEntry) + XCTAssert(addedGlucose.isEmpty) + + } + + func testIssuingBolus() { + let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false) + viewModel.authenticate = { (description, completion) in + completion(.success) + } + + viewModel.enteredBolusString = "3" + + let saveExpectation = expectation(description: "Save completion callback") + + viewModel.saveAndDeliver { (success) in + saveExpectation.fulfill() + } + + waitForExpectations(timeout: 2) + + XCTAssertNil(addedCarbEntry) + XCTAssert(addedGlucose.isEmpty) + + XCTAssertEqual(3.0, enactedBolus?.units) + + } + + func testMealCarbsAndManualGlucoseWithRecommendation() { + let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false) + viewModel.authenticate = { (description, completion) in + completion(.success) + } + + currentRecommendation = 2.5 + + viewModel.enteredCarbString = "20" + viewModel.manualGlucoseString = "180" + + let saveExpectation = expectation(description: "Save completion callback") + + viewModel.saveAndDeliver { (success) in + saveExpectation.fulfill() + } + + waitForExpectations(timeout: 2) + + XCTAssertEqual(20, addedCarbEntry?.quantity.doubleValue(for: .gram())) + XCTAssertEqual(180, addedGlucose.first?.quantity.doubleValue(for: .milligramsPerDeciliter)) + + XCTAssertEqual(2.5, enactedBolus?.units) + + XCTAssertEqual(storedBolusDecision?.manualBolusRecommendation?.recommendation.amount, 2.5) + XCTAssertEqual(storedBolusDecision?.carbEntry?.quantity, addedCarbEntry?.quantity) + } + + func testMealCarbsWithUserOverridingRecommendation() { + let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false) + viewModel.authenticate = { (description, completion) in + completion(.success) + } + + currentRecommendation = 2.5 + + // This triggers a recommendation update + viewModel.enteredCarbString = "20" + + XCTAssertEqual("2.5", viewModel.recommendedBolus) + XCTAssertEqual("2.5", viewModel.enteredBolusString) + + viewModel.enteredBolusString = "0.1" + + let saveExpectation = expectation(description: "Save completion callback") + + viewModel.saveAndDeliver { (success) in + saveExpectation.fulfill() + } + + waitForExpectations(timeout: 2) + + XCTAssertEqual(20, addedCarbEntry?.quantity.doubleValue(for: .gram())) + + XCTAssertEqual(0.1, enactedBolus?.units) + + XCTAssertEqual(0.1, storedBolusDecision?.manualBolusRequested) + XCTAssertEqual(2.5, storedBolusDecision?.manualBolusRecommendation?.recommendation.amount) + XCTAssertEqual(addedCarbEntry?.quantity, storedBolusDecision?.carbEntry?.quantity) + } + + func testDeleteCarbsRemovesRecommendation() { + let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false) + viewModel.authenticate = { (description, completion) in + completion(.success) + } + + currentRecommendation = 2.5 + + viewModel.enteredCarbString = "20" + + XCTAssertEqual("2.5", viewModel.recommendedBolus) + XCTAssertEqual("2.5", viewModel.enteredBolusString) + + viewModel.enteredCarbString = "" + + XCTAssertEqual("–", viewModel.recommendedBolus) + XCTAssertEqual("0", viewModel.enteredBolusString) + } + + func testDeleteCurrentGlucoseRemovesRecommendation() { + let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false) + viewModel.authenticate = { (description, completion) in + completion(.success) + } + + currentRecommendation = 3.0 + + viewModel.manualGlucoseString = "180" + + XCTAssertEqual("3", viewModel.recommendedBolus) + XCTAssertEqual("3", viewModel.enteredBolusString) + + viewModel.manualGlucoseString = "" + + XCTAssertEqual("–", viewModel.recommendedBolus) + XCTAssertEqual("0", viewModel.enteredBolusString) + } + + func testDeleteCurrentGlucoseRemovesActiveInsulin() { + let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false) + viewModel.authenticate = { (description, completion) in + completion(.success) + } + + currentIOB = SimpleBolusViewModelTests.someIOB + + viewModel.manualGlucoseString = "180" + + XCTAssertEqual("2.4", viewModel.activeInsulin) + + viewModel.manualGlucoseString = "" + + XCTAssertNil(viewModel.activeInsulin) + } + + func testManualGlucoseStringMatchesDisplayGlucoseUnit() { + // used "260" mg/dL ("14.4" mmol/L) since 14.40 mmol/L -> 259 mg/dL and 14.43 mmol/L -> 260 mg/dL + let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false) + XCTAssertEqual(viewModel.manualGlucoseString, "") + viewModel.manualGlucoseString = "260" + XCTAssertEqual(viewModel.manualGlucoseString, "260") + self.displayGlucosePreference.unitDidChange(to: .millimolesPerLiter) + XCTAssertEqual(viewModel.manualGlucoseString, "14.4") + self.displayGlucosePreference.unitDidChange(to: .milligramsPerDeciliter) + XCTAssertEqual(viewModel.manualGlucoseString, "260") + self.displayGlucosePreference.unitDidChange(to: .millimolesPerLiter) + XCTAssertEqual(viewModel.manualGlucoseString, "14.4") + + viewModel.manualGlucoseString = "14.0" + XCTAssertEqual(viewModel.manualGlucoseString, "14.0") + viewModel.manualGlucoseString = "14.4" + XCTAssertEqual(viewModel.manualGlucoseString, "14.4") + self.displayGlucosePreference.unitDidChange(to: .milligramsPerDeciliter) + XCTAssertEqual(viewModel.manualGlucoseString, "259") + } + + func testGlucoseEntryWarnings() { + let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false) + + currentRecommendation = 2 + viewModel.manualGlucoseString = "180" + XCTAssertNil(viewModel.activeNotice) + XCTAssert(viewModel.bolusRecommended) + + currentRecommendation = 0 + viewModel.manualGlucoseString = "72" + XCTAssertEqual(viewModel.activeNotice, .glucoseBelowSuspendThreshold) + XCTAssert(!viewModel.bolusRecommended) + XCTAssert(!viewModel.actionButtonDisabled) + + viewModel.manualGlucoseString = "69" + XCTAssertEqual(viewModel.activeNotice, .glucoseBelowRecommendationLimit) + viewModel.manualGlucoseString = "54" + XCTAssertEqual(viewModel.activeNotice, .glucoseBelowRecommendationLimit) + viewModel.manualGlucoseString = "800" + XCTAssertEqual(viewModel.activeNotice, .glucoseOutOfAllowedInputRange) + XCTAssert(viewModel.actionButtonDisabled) + viewModel.manualGlucoseString = "9" + XCTAssertEqual(viewModel.activeNotice, .glucoseOutOfAllowedInputRange) + XCTAssert(viewModel.actionButtonDisabled) + + viewModel.manualGlucoseString = "" + viewModel.enteredCarbString = "400" + XCTAssertEqual(viewModel.activeNotice, .carbohydrateEntryTooLarge) + XCTAssert(viewModel.actionButtonDisabled) + } + + func testGlucoseEntryWarningsForMealBolus() { + let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: true) + viewModel.manualGlucoseString = "69" + viewModel.enteredCarbString = "25" + XCTAssertEqual(viewModel.activeNotice, .glucoseWarning) + } + + func testOutOfBoundsGlucoseShowsNoRecommendation() { + let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: true) + viewModel.manualGlucoseString = "699" + XCTAssert(!viewModel.bolusRecommended) + } + + func testOutOfBoundsCarbsShowsNoRecommendation() { + let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: true) + viewModel.enteredCarbString = "400" + XCTAssert(!viewModel.bolusRecommended) + } + + func testMaxBolusWarnings() { + let viewModel = SimpleBolusViewModel(delegate: self, displayMealEntry: false) + viewModel.enteredBolusString = "20" + XCTAssertEqual(viewModel.activeNotice, .maxBolusExceeded) + + currentRecommendation = 20 + viewModel.manualGlucoseString = "250" + viewModel.enteredCarbString = "150" + XCTAssertEqual(viewModel.recommendedBolus, "20") + XCTAssertEqual(viewModel.enteredBolusString, "3") + XCTAssertEqual(viewModel.activeNotice, .recommendationExceedsMaxBolus) + } +} + +extension SimpleBolusViewModelTests: SimpleBolusViewModelDelegate { + func addGlucose(_ samples: [NewGlucoseSample], completion: @escaping (Swift.Result<[StoredGlucoseSample], Error>) -> Void) { + addedGlucose = samples + completion(.success([])) + } + + func addCarbEntry(_ carbEntry: NewCarbEntry, replacing replacingEntry: StoredCarbEntry?, completion: @escaping (Result) -> Void) { + + addedCarbEntry = carbEntry + let storedCarbEntry = StoredCarbEntry( + startDate: carbEntry.startDate, + quantity: carbEntry.quantity, + uuid: UUID(), + provenanceIdentifier: UUID().uuidString, + syncIdentifier: UUID().uuidString, + syncVersion: 1, + foodType: carbEntry.foodType, + absorptionTime: carbEntry.absorptionTime, + createdByCurrentApp: true, + userCreatedDate: Date(), + userUpdatedDate: nil) + completion(.success(storedCarbEntry)) + } + + func enactBolus(units: Double, activationType: BolusActivationType) { + enactedBolus = (units: units, activationType: activationType) + } + + func insulinOnBoard(at date: Date, completion: @escaping (DoseStoreResult) -> Void) { + completion(.success(currentIOB)) + } + + func computeSimpleBolusRecommendation(at date: Date, mealCarbs: HKQuantity?, manualGlucose: HKQuantity?) -> BolusDosingDecision? { + + var decision = BolusDosingDecision(for: .simpleBolus) + decision.manualBolusRecommendation = ManualBolusRecommendationWithDate(recommendation: ManualBolusRecommendation(amount: currentRecommendation, pendingInsulin: 0, notice: .none), + date: date) + decision.insulinOnBoard = currentIOB + return decision + } + + func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) { + storedBolusDecision = bolusDosingDecision + } + + var maximumBolus: Double { + return 3.0 + } + + var suspendThreshold: HKQuantity { + return HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 80) + } +} diff --git a/LoopUI/Common/LocalizedString.swift b/LoopUI/Common/LocalizedString.swift new file mode 100644 index 0000000000..a5662f63d5 --- /dev/null +++ b/LoopUI/Common/LocalizedString.swift @@ -0,0 +1,21 @@ +// +// LocalizedString.swift +// LoopUI +// +// Created by Kathryn DiSimone on 8/15/18. +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +import Foundation + +private class FrameworkBundle { + static let main = Bundle(for: FrameworkBundle.self) +} + +func LocalizedString(_ key: String, tableName: String? = nil, value: String? = nil, comment: String) -> String { + if let value = value { + return NSLocalizedString(key, tableName: tableName, bundle: FrameworkBundle.main, value: value, comment: comment) + } else { + return NSLocalizedString(key, tableName: tableName, bundle: FrameworkBundle.main, comment: comment) + } +} diff --git a/LoopUI/Extensions/AuthenticationTableViewCell+NibLoadable.swift b/LoopUI/Extensions/AuthenticationTableViewCell+NibLoadable.swift new file mode 100644 index 0000000000..aaa46dfe6b --- /dev/null +++ b/LoopUI/Extensions/AuthenticationTableViewCell+NibLoadable.swift @@ -0,0 +1,13 @@ +// +// AuthenticationTableViewCell+NibLoadable.swift +// LoopUI +// +// Created by Darin Krauss on 6/18/19. +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import LoopKitUI + + +/// Allow AuthenticationTableViewCell to be loaded from nib +extension AuthenticationTableViewCell: NibLoadable { } diff --git a/LoopUI/Extensions/Color.swift b/LoopUI/Extensions/Color.swift new file mode 100644 index 0000000000..f12a2ba746 --- /dev/null +++ b/LoopUI/Extensions/Color.swift @@ -0,0 +1,57 @@ +// +// Color.swift +// LoopUI +// +// Created by Nathaniel Hamming on 2020-07-28. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +// MARK: - Color palette for common elements +extension Color { + static let carbs = Color("carbs") + + static let fresh = Color("fresh") + + static let glucose = Color("glucose") + + static let insulin = Color("insulin") + + // The loopAccent color is intended to be use as the app accent color. + public static let loopAccent = Color("accent") + + public static let warning = Color("warning") +} + + +// Color version of the UIColor context colors +extension Color { + public static let agingColor = warning + + public static let axisLabelColor = secondary + + public static let axisLineColor = clear + + public static let cellBackgroundColor = Color(UIColor.cellBackgroundColor) + + public static let carbTintColor = carbs + + public static let critical = red + + public static let destructive = critical + + public static let glucoseTintColor = glucose + + public static let gridColor = Color(UIColor.gridColor) + + public static let invalid = critical + + public static let insulinTintColor = insulin + + public static let pumpStatusNormal = insulin + + public static let staleColor = critical + + public static let unknownColor = Color(UIColor.unknownColor) +} diff --git a/LoopUI/Extensions/Comparable.swift b/LoopUI/Extensions/Comparable.swift new file mode 100644 index 0000000000..8ffc4dcaa2 --- /dev/null +++ b/LoopUI/Extensions/Comparable.swift @@ -0,0 +1,23 @@ +// +// Comparable.swift +// LoopKit +// +// Created by Michael Pangburn on 11/20/18. +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +extension Comparable { + func clamped(to range: ClosedRange) -> Self { + if self < range.lowerBound { + return range.lowerBound + } else if self > range.upperBound { + return range.upperBound + } else { + return self + } + } + + mutating func clamp(to range: ClosedRange) { + self = clamped(to: range) + } +} diff --git a/LoopUI/Extensions/DateFormatter.swift b/LoopUI/Extensions/DateFormatter.swift new file mode 100644 index 0000000000..7ae8e7e0fa --- /dev/null +++ b/LoopUI/Extensions/DateFormatter.swift @@ -0,0 +1,16 @@ +// +// DateFormatter.swift +// LoopUI +// +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import Foundation + +extension DateFormatter { + convenience init(dateStyle: Style = .none, timeStyle: Style = .none) { + self.init() + self.dateStyle = dateStyle + self.timeStyle = timeStyle + } +} diff --git a/LoopUI/Extensions/DeviceLifecycleProgressState.swift b/LoopUI/Extensions/DeviceLifecycleProgressState.swift new file mode 100644 index 0000000000..46c97b2009 --- /dev/null +++ b/LoopUI/Extensions/DeviceLifecycleProgressState.swift @@ -0,0 +1,27 @@ +// +// DeviceLifecycleProgressState.swift +// LoopUI +// +// Created by Nathaniel Hamming on 2020-07-28. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import LoopKit +import SwiftUI + +extension DeviceLifecycleProgressState { + public var color: UIColor { + switch self { + case .critical: + return .critical + case .dimmed: + return UIColor(Color.secondary) + case .normalCGM: + return .glucose + case .normalPump: + return .insulin + case .warning: + return .warning + } + } +} diff --git a/LoopUI/Extensions/DeviceStatusHighlight.swift b/LoopUI/Extensions/DeviceStatusHighlight.swift new file mode 100644 index 0000000000..68e12f05b7 --- /dev/null +++ b/LoopUI/Extensions/DeviceStatusHighlight.swift @@ -0,0 +1,34 @@ +// +// DeviceStatusHighlight.swift +// LoopUI +// +// Created by Nathaniel Hamming on 2020-07-28. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import LoopKit + +extension DeviceStatusHighlight { + public var image: UIImage? { + if let image = UIImage(named: imageName) { + return image + } else { + return UIImage(systemName: imageName) + } + } +} + +extension DeviceStatusElementState { + public var color: UIColor { + switch self { + case .normalCGM: + return .glucose + case .normalPump: + return .insulin + case .warning: + return .warning + case .critical: + return .critical + } + } +} diff --git a/LoopUI/Extensions/DismissibleHostingController.swift b/LoopUI/Extensions/DismissibleHostingController.swift new file mode 100644 index 0000000000..47670f1b27 --- /dev/null +++ b/LoopUI/Extensions/DismissibleHostingController.swift @@ -0,0 +1,28 @@ +// +// DismissibleHostingController.swift +// LoopUI +// +// Created by Nathaniel Hamming on 2020-08-04. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopKitUI + +extension DismissibleHostingController { + public convenience init( + rootView: Content, + dismissalMode: DismissalMode = .modalDismiss, + isModalInPresentation: Bool = true, + onDisappear: @escaping () -> Void = {} + ) { + self.init(content: rootView, + dismissalMode: dismissalMode, + isModalInPresentation: isModalInPresentation, + onDisappear: onDisappear, + guidanceColors: GuidanceColors.default, + carbTintColor: .carbTintColor, + glucoseTintColor: .glucoseTintColor, + insulinTintColor: .insulinTintColor) + } +} diff --git a/LoopUI/Extensions/GlucoseRangeCategory.swift b/LoopUI/Extensions/GlucoseRangeCategory.swift new file mode 100644 index 0000000000..a35449b2f6 --- /dev/null +++ b/LoopUI/Extensions/GlucoseRangeCategory.swift @@ -0,0 +1,33 @@ +// +// GlucoseRangeCategory.swift +// LoopUI +// +// Created by Nathaniel Hamming on 2020-07-28. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import LoopKit + +extension GlucoseRangeCategory { + public var glucoseColor: UIColor { + switch self { + case .normal, .high, .low: + return .label + case .urgentLow, .belowRange: + return .critical + case .aboveRange: + return .warning + } + } + + public var trendColor: UIColor { + switch self { + case .normal: + return .glucose + case .urgentLow, .belowRange: + return .critical + case .low, .high, .aboveRange: + return .warning + } + } +} diff --git a/LoopUI/Extensions/GuidanceColors.swift b/LoopUI/Extensions/GuidanceColors.swift new file mode 100644 index 0000000000..9613a90091 --- /dev/null +++ b/LoopUI/Extensions/GuidanceColors.swift @@ -0,0 +1,15 @@ +// +// GuidanceColors.swift +// LoopUI +// +// Created by Nathaniel Hamming on 2020-08-04. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import LoopKitUI + +extension GuidanceColors { + public static var `default`: GuidanceColors { + return GuidanceColors(acceptable: .primary, warning: .warning, critical: .critical) + } +} diff --git a/LoopUI/Extensions/Image.swift b/LoopUI/Extensions/Image.swift new file mode 100644 index 0000000000..67b93bb2b1 --- /dev/null +++ b/LoopUI/Extensions/Image.swift @@ -0,0 +1,23 @@ +// +// Image.swift +// LoopUI +// +// Created by Rick Pasetto on 6/25/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +private class FrameworkBundle { + static let main = Bundle(for: FrameworkBundle.self) +} + +extension Image { + init(frameworkImage name: String, decorative: Bool = false) { + if decorative { + self.init(decorative: name, bundle: FrameworkBundle.main) + } else { + self.init(name, bundle: FrameworkBundle.main) + } + } +} diff --git a/LoopUI/Extensions/NSNumberFormatter.swift b/LoopUI/Extensions/NSNumberFormatter.swift deleted file mode 100644 index 155738f3da..0000000000 --- a/LoopUI/Extensions/NSNumberFormatter.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// NSNumberFormatter.swift -// Loop -// -// Created by Nate Racklyeft on 9/5/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import Foundation -import HealthKit - - -extension NumberFormatter { - public static func glucoseFormatter(for unit: HKUnit) -> NumberFormatter { - let numberFormatter = NumberFormatter() - numberFormatter.numberStyle = .decimal - numberFormatter.minimumFractionDigits = unit.preferredFractionDigits - numberFormatter.maximumFractionDigits = unit.preferredFractionDigits - return numberFormatter - } -} diff --git a/LoopUI/Extensions/NibLoadable.swift b/LoopUI/Extensions/NibLoadable.swift new file mode 100644 index 0000000000..00edfe2f6c --- /dev/null +++ b/LoopUI/Extensions/NibLoadable.swift @@ -0,0 +1,22 @@ +// +// NibLoadable.swift +// Loop +// +// Created by Nate Racklyeft on 7/2/16. +// Copyright © 2016 Nathan Racklyeft. All rights reserved. +// + +import UIKit +import LoopCore + + +public protocol NibLoadable: IdentifiableClass { + static func nib() -> UINib +} + + +extension NibLoadable { + public static func nib() -> UINib { + return UINib(nibName: className, bundle: Bundle(for: self)) + } +} diff --git a/LoopUI/Extensions/UIColor.swift b/LoopUI/Extensions/UIColor.swift index 414ffa7361..db638084f8 100644 --- a/LoopUI/Extensions/UIColor.swift +++ b/LoopUI/Extensions/UIColor.swift @@ -8,71 +8,51 @@ import UIKit - +// MARK: - Color palette for common elements extension UIColor { - @nonobjc public static var tintColor: UIColor? = nil - - @nonobjc public static let secondaryLabelColor = UIColor.HIGGrayColor() - - @nonobjc public static let cellBackgroundColor = UIColor(white: 239 / 255, alpha: 1) - - @nonobjc public static let gridColor = UIColor(white: 193 / 255, alpha: 1) - - @nonobjc public static let glucoseTintColor = UIColor(red: 0 / 255, green: 176 / 255, blue: 255 / 255, alpha: 1) - - @nonobjc public static let IOBTintColor = UIColor.HIGOrangeColor() - - @nonobjc public static let COBTintColor = UIColor(red: 99 / 255, green: 218 / 255, blue: 56 / 255, alpha: 1) - - @nonobjc public static let doseTintColor = UIColor.HIGOrangeColor() - - @nonobjc public static let freshColor = UIColor.HIGGreenColor() - - @nonobjc public static let agingColor = UIColor.HIGYellowColor() - - @nonobjc public static let staleColor = UIColor.HIGRedColor() - - @nonobjc public static let unknownColor = UIColor(red: 198 / 255, green: 199 / 255, blue: 201 / 255, alpha: 1) - - @nonobjc public static let deleteColor = UIColor.HIGRedColor() - - // MARK: - HIG colors - // See: https://developer.apple.com/ios/human-interface-guidelines/visual-design/color/ - - private static func HIGTealBlueColor() -> UIColor { - return UIColor(red: 90 / 255, green: 200 / 255, blue: 250 / 255, alpha: 1) - } - - private static func HIGYellowColor() -> UIColor { - return UIColor(red: 1, green: 204 / 255, blue: 0, alpha: 1) - } - - private static func HIGOrangeColor() -> UIColor { - return UIColor(red: 1, green: 149 / 255, blue: 0 / 255, alpha: 1) - } - - private static func HIGPinkColor() -> UIColor { - return UIColor(red: 1, green: 45 / 255, blue: 85 / 255, alpha: 1) - } - - private static func HIGBlueColor() -> UIColor { - return UIColor(red: 0, green: 122 / 255, blue: 1, alpha: 1) - } - - private static func HIGGreenColor() -> UIColor { - return UIColor(red: 76 / 255, green: 217 / 255, blue: 100 / 255, alpha: 1) - } - - private static func HIGRedColor() -> UIColor { - return UIColor(red: 1, green: 59 / 255, blue: 48 / 255, alpha: 1) - } - - private static func HIGPurpleColor() -> UIColor { - return UIColor(red: 88 / 255, green: 86 / 255, blue: 214 / 255, alpha: 1) - } - - private static func HIGGrayColor() -> UIColor { - return UIColor(red: 142 / 255, green: 143 / 255, blue: 147 / 255, alpha: 1) - } + @nonobjc static let carbs = UIColor(named: "carbs") ?? systemGreen + + @nonobjc static let fresh = UIColor(named: "fresh") ?? HIGGreenColor() + + @nonobjc static let glucose = UIColor(named: "glucose") ?? systemTeal + + @nonobjc static let insulin = UIColor(named: "insulin") ?? systemOrange + + // The loopAccent color is intended to be use as the app accent color. + @nonobjc public static let loopAccent = UIColor(named: "accent") ?? systemBlue + + @nonobjc public static let warning = UIColor(named: "warning") ?? systemYellow +} +// MARK: - Context for colors +extension UIColor { + @nonobjc public static let agingColor = warning + + @nonobjc public static let axisLabelColor = secondaryLabel + + @nonobjc public static let axisLineColor = clear + + @nonobjc public static let cellBackgroundColor = secondarySystemBackground + + @nonobjc public static let carbTintColor = carbs + + @nonobjc public static let critical = systemRed + + @nonobjc public static let destructive = critical + + @nonobjc public static let freshColor = fresh + + @nonobjc public static let glucoseTintColor = glucose + + @nonobjc public static let gridColor = systemGray3 + + @nonobjc public static let invalid = critical + + @nonobjc public static let insulinTintColor = insulin + + @nonobjc public static let pumpStatusNormal = insulin + + @nonobjc public static let staleColor = critical + + @nonobjc public static let unknownColor = systemGray4 } diff --git a/LoopUI/HUDAssets.xcassets/Contents.json b/LoopUI/HUDAssets.xcassets/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/LoopUI/HUDAssets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/LoopUI/Assets.xcassets/Contents.json b/LoopUI/HUDAssets.xcassets/battery/Contents.json similarity index 100% rename from LoopUI/Assets.xcassets/Contents.json rename to LoopUI/HUDAssets.xcassets/battery/Contents.json diff --git a/LoopUI/Assets.xcassets/battery/battery.imageset/Contents.json b/LoopUI/HUDAssets.xcassets/battery/battery.imageset/Contents.json similarity index 100% rename from LoopUI/Assets.xcassets/battery/battery.imageset/Contents.json rename to LoopUI/HUDAssets.xcassets/battery/battery.imageset/Contents.json diff --git a/LoopUI/Assets.xcassets/battery/battery.imageset/battery.pdf b/LoopUI/HUDAssets.xcassets/battery/battery.imageset/battery.pdf similarity index 100% rename from LoopUI/Assets.xcassets/battery/battery.imageset/battery.pdf rename to LoopUI/HUDAssets.xcassets/battery/battery.imageset/battery.pdf diff --git a/LoopUI/Assets.xcassets/battery/battery_mask.imageset/Contents.json b/LoopUI/HUDAssets.xcassets/battery/battery_mask.imageset/Contents.json similarity index 100% rename from LoopUI/Assets.xcassets/battery/battery_mask.imageset/Contents.json rename to LoopUI/HUDAssets.xcassets/battery/battery_mask.imageset/Contents.json diff --git a/LoopUI/Assets.xcassets/battery/battery_mask.imageset/battery_mask.pdf b/LoopUI/HUDAssets.xcassets/battery/battery_mask.imageset/battery_mask.pdf similarity index 100% rename from LoopUI/Assets.xcassets/battery/battery_mask.imageset/battery_mask.pdf rename to LoopUI/HUDAssets.xcassets/battery/battery_mask.imageset/battery_mask.pdf diff --git a/LoopUI/Assets.xcassets/battery/Contents.json b/LoopUI/HUDAssets.xcassets/reservoir/Contents.json similarity index 100% rename from LoopUI/Assets.xcassets/battery/Contents.json rename to LoopUI/HUDAssets.xcassets/reservoir/Contents.json diff --git a/LoopUI/Assets.xcassets/reservoir/reservoir.imageset/Contents.json b/LoopUI/HUDAssets.xcassets/reservoir/reservoir.imageset/Contents.json similarity index 100% rename from LoopUI/Assets.xcassets/reservoir/reservoir.imageset/Contents.json rename to LoopUI/HUDAssets.xcassets/reservoir/reservoir.imageset/Contents.json diff --git a/LoopUI/Assets.xcassets/reservoir/reservoir.imageset/reservoir.pdf b/LoopUI/HUDAssets.xcassets/reservoir/reservoir.imageset/reservoir.pdf similarity index 100% rename from LoopUI/Assets.xcassets/reservoir/reservoir.imageset/reservoir.pdf rename to LoopUI/HUDAssets.xcassets/reservoir/reservoir.imageset/reservoir.pdf diff --git a/LoopUI/Assets.xcassets/reservoir/reservoir_mask.imageset/Contents.json b/LoopUI/HUDAssets.xcassets/reservoir/reservoir_mask.imageset/Contents.json similarity index 100% rename from LoopUI/Assets.xcassets/reservoir/reservoir_mask.imageset/Contents.json rename to LoopUI/HUDAssets.xcassets/reservoir/reservoir_mask.imageset/Contents.json diff --git a/LoopUI/Assets.xcassets/reservoir/reservoir_mask.imageset/reservoir_mask.pdf b/LoopUI/HUDAssets.xcassets/reservoir/reservoir_mask.imageset/reservoir_mask.pdf similarity index 100% rename from LoopUI/Assets.xcassets/reservoir/reservoir_mask.imageset/reservoir_mask.pdf rename to LoopUI/HUDAssets.xcassets/reservoir/reservoir_mask.imageset/reservoir_mask.pdf diff --git a/LoopUI/HUDAssets.xcassets/status-bar-symbols/Contents.json b/LoopUI/HUDAssets.xcassets/status-bar-symbols/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/LoopUI/HUDAssets.xcassets/status-bar-symbols/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/LoopUI/HUDAssets.xcassets/status-bar-symbols/bluetooth.disabled.symbolset/Contents.json b/LoopUI/HUDAssets.xcassets/status-bar-symbols/bluetooth.disabled.symbolset/Contents.json new file mode 100644 index 0000000000..cb7f4aab6d --- /dev/null +++ b/LoopUI/HUDAssets.xcassets/status-bar-symbols/bluetooth.disabled.symbolset/Contents.json @@ -0,0 +1,12 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "symbols" : [ + { + "filename" : "bluetooth.disabled.svg", + "idiom" : "universal" + } + ] +} diff --git a/LoopUI/HUDAssets.xcassets/status-bar-symbols/bluetooth.disabled.symbolset/bluetooth.disabled.svg b/LoopUI/HUDAssets.xcassets/status-bar-symbols/bluetooth.disabled.symbolset/bluetooth.disabled.svg new file mode 100644 index 0000000000..33fca247e2 --- /dev/null +++ b/LoopUI/HUDAssets.xcassets/status-bar-symbols/bluetooth.disabled.symbolset/bluetooth.disabled.svg @@ -0,0 +1,156 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LoopUI/HUDAssets.xcassets/status-bar-symbols/drop.circle.fill.symbolset/Contents.json b/LoopUI/HUDAssets.xcassets/status-bar-symbols/drop.circle.fill.symbolset/Contents.json new file mode 100644 index 0000000000..47da447df3 --- /dev/null +++ b/LoopUI/HUDAssets.xcassets/status-bar-symbols/drop.circle.fill.symbolset/Contents.json @@ -0,0 +1,12 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "symbols" : [ + { + "filename" : "drop.circle.fill.svg", + "idiom" : "universal" + } + ] +} diff --git a/LoopUI/HUDAssets.xcassets/status-bar-symbols/drop.circle.fill.symbolset/drop.circle.fill.svg b/LoopUI/HUDAssets.xcassets/status-bar-symbols/drop.circle.fill.symbolset/drop.circle.fill.svg new file mode 100644 index 0000000000..c244db6653 --- /dev/null +++ b/LoopUI/HUDAssets.xcassets/status-bar-symbols/drop.circle.fill.symbolset/drop.circle.fill.svg @@ -0,0 +1,156 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LoopUI/HUDView.xib b/LoopUI/HUDView.xib index 22535f225a..9da3d7b032 100644 --- a/LoopUI/HUDView.xib +++ b/LoopUI/HUDView.xib @@ -1,22 +1,17 @@ - - - - + + - - + - - @@ -24,17 +19,16 @@ - + - - - + - + @@ -66,53 +59,53 @@ - + - - - - + + - + + - + - + @@ -123,41 +116,40 @@ - + - - - + - + - + @@ -166,130 +158,12 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + - - - - - - diff --git a/LoopUI/Info.plist b/LoopUI/Info.plist index 095f91358f..ff26182ff1 100644 --- a/LoopUI/Info.plist +++ b/LoopUI/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 1.2.0 + $(LOOP_MARKETING_VERSION) CFBundleVersion $(CURRENT_PROJECT_VERSION) NSPrincipalClass diff --git a/LoopUI/InfoPlist.xcstrings b/LoopUI/InfoPlist.xcstrings new file mode 100644 index 0000000000..796050aaed --- /dev/null +++ b/LoopUI/InfoPlist.xcstrings @@ -0,0 +1,227 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "CFBundleDisplayName" : { + "comment" : "Bundle display name", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "LoopUI" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "LoopUI" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "LoopUI" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "LoopUI" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "LoopUI" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "LoopUI" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "LoopUI" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "LoopUI" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "LoopUI" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "LoopUI" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "LoopUI" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "LoopUI" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "LoopUI" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "LoopUI" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "LoopUI" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "LoopUI" + } + } + } + }, + "CFBundleName" : { + "comment" : "Bundle name", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME)" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME)" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME)" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME)" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME)" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME)" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME)" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME)" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME)" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME)" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME)" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME)" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME)" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME)" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME)" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME)" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME)" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "$(PRODUCT_NAME)" + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/LoopUI/Localizable.xcstrings b/LoopUI/Localizable.xcstrings new file mode 100644 index 0000000000..19df68c2c1 --- /dev/null +++ b/LoopUI/Localizable.xcstrings @@ -0,0 +1,3729 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "\n%1$@\n\n%2$@ is operating with Closed Loop in the ON position." : { + "comment" : "Green closed loop ON message (1: last loop string) (2: app name)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "\n%1$@\n\n%2$@ kører med Lukket Loop i ON-position." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "\n%1$@\n\n%2$@ wird mit Closed Loop in der Position EIN betrieben." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@\n\n%2$@ está funcionando con Loop Cerrado en posición de prendido." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "\n%1$@\n\n %2$@ fonctionne avec la boucle fermée en position MARCHE." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "\n%1$@\n\n%2$@ sta funzionando con il Loop chiuso attivato." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@\n\n%2$@ opererer med Closed Loop i ON-posisjon." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "\n%1$@\n\n%2$@ werkt met Gesloten Loop in de AAN stand" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "\n%1$@\n\n %2$@ działa z zamkniętą pętlą w pozycji WŁĄCZONA." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "\n%1$@\n\n %2$@ operează cu bucla închisă în poziția ON." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "\n%1$@\n\n%2$@ работает с замкнутым циклом в положении ON." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "\n%1$@\n\n%2$@ Kapalı Döngü AÇIK konumdayken çalışıyor." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "\n %1$@\n\n%2$@当前处于闭环模式。" + } + } + } + }, + "\n%1$@\n\nTap your CGM and insulin pump status icons for more information. %2$@ will continue trying to complete a loop, but check for potential communication issues with your pump and CGM." : { + "comment" : "Red loop message (1: last loop string) (2: app name)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "\n%1$@\n\nTryk på dine CGM- og insulinpumpestatussymboler for at få flere oplysninger. %2$@ fortsætter med at forsøge at fuldføre et loop, men kontrollér, om der er potentielle kommunikationsproblemer med din pumpe og CGM." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "\n%1$@\n\nTippe auf die Statussymbole für das CGM oder die Insulinpumpe, um weitere Informationen zu erhalten. %2$@ wird weiterhin versuchen, einen Loop abzuschließen, aber achte auf mögliche Kommunikationsprobleme mit Deiner Pumpe und CGM." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "\n%1$@\n\nPulse los iconos de estado del CGM y de la bomba de insulina para obtener más información. %2$@ continuará intentando completarel loop, pero compruebe si existen posibles problemas de comunicación con la bomba y el CGM." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "\n%1$@\n\nAppuyer sur les icônes du CGM ou de la pompe à insulie pour plus d'information. %2$@ va continuer de tenter de compléter une boucle, mais vérifiez pour des problèmes de communication potentiels avec votre pompe et le CGM." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "\n%1$@ \n \nTocca le icone di stato del CGM e della pompa per ulteriori informazioni. %2$@ continuerà a provare a completare un ciclo, ma controlla eventuali problemi di comunicazione con la pompa e il CGM." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@\n\n Trykk på statusikonene for CGM og insulinpumpe for mer informasjon. %2$@ vil fortsette å prøve å fullføre en sløyfe, men se etter potensielle kommunikasjonsproblemer med pumpen og CGM." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "\n%1$@\n\nTik op je CGM- en insulinepompstatuspictogram voor meer informatie. %2$@ blijft proberen een loop te voltooien, maar let op mogelijke communicatieproblemen met je pomp en CGM." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "\n%1$@\n\nStuknij ikonę stanu CGM i pompy insulinowej, aby uzyskać więcej informacji. %2$@ będzie nadal próbował ukończyć pętlę, ale uważaj na potencjalne problemy z komunikacją z pompą i CGM." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "\n%1$@\n\nAtingeți pictogramele de stare ale CGM și ale pompei de insulină pentru mai multe informații. %2$@ va continua să încerce să finalizeze o buclă, dar verificați eventualele probleme de comunicare cu pompa și CGM-ul dvs." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "\n%1$@\n\nДля получения дополнительной информации коснитесь значков состояния мониторинга и инсулиновой помпы. %2$@ будет продолжать попытки завершить цикл, но следите за возможными проблемами связи с вашей помпой и мониторингом." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "\n%1$@\n\nDaha fazla bilgi için CGM ve insülin pompası durum simgelerine dokunun. %2$@ döngüyü tamamlamaya çalışacak, ancak pompanız ve CGM ile olası iletişim sorunlarını kontrol edin." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ \n\n点按你的 CGM 和泵状态图标以查看更多信息。%2$@ 将继续尝试完成循环,但请注意,泵和 CGM 之间可能存在通信问题。" + } + } + } + }, + "\n%1$@\n\nTap your CGM and insulin pump status icons for more information. %2$@ will continue trying to complete a loop, but watch for potential communication issues with your pump and CGM." : { + "comment" : "Yellow loop message (1: last loop string) (2: app name)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "\n%1$@\n\nTryk på dine CGM- og insulinpumpestatussymboler for at få flere oplysninger. %2$@ fortsætter med at forsøge at fuldføre et loop, men hold øje med potentielle kommunikationsproblemer med din pumpe og CGM." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "\n%1$@\n\nTippe auf die Statussymbole für das CGM oder die Insulinpumpe, um weitere Informationen zu erhalten. %2$@ wird weiterhin versuchen, einen Loop abzuschließen, aber achte auf mögliche Kommunikationsprobleme mit Deiner Pumpe und CGM." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "\n%1$@\n\nPulse los iconos de estado del CGM y de la bomba de insulina para obtener más información. %2$@ seguirá intentando completar el loop, pero esté atento a posibles problemas de comunicación con la bomba y el CGM." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "\n%1$@\n\nAppuyer sur les icônes du CGM ou de la pompe à insuline pour plus d'information. %2$@ va continuer de tenter de compléter une boucle, mais surveillez pour des problèmes de communication potentiels avec votre pompe et le CGM." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "\n%1$@ \n \nTocca le icone di stato del CGM e della pompa per ulteriori informazioni. %2$@ continuerà a provare a completare un ciclo, ma fai attenzione a potenziali problemi di comunicazione con la pompa e il CGM." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@\n\nTrykk på statusikonene for CGM og insulinpumpe for mer informasjon. %2$@ vil fortsette å prøve å fullføre en loop, men se etter potensielle kommunikasjonsproblemer med pumpen og CGM." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "\n%1$@\n\nTik op je CGM- en insulinepompstatuspictogram voor meer informatie. %2$@ blijft proberen een loop te voltooien, maar let op mogelijke communicatieproblemen met je pomp en CGM." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "\n%1$@\n\nStuknij ikony stanu CGM i pompy insulinowej, aby uzyskać więcej informacji. %2$@ będzie nadal próbował ukończyć pętlę, ale uważaj na potencjalne problemy z komunikacją z pompą i CGM." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "\n%1$@\n\n Atingeți pictogramele de stare pentru CGM și pompa de insulină pentru mai multe informații. %2$@ va continua să încerce să finalizeze o buclă, dar urmăriți eventualele probleme de comunicare cu pompa și CGM." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "\n%1$@\n\nДля получения дополнительной информации коснитесь значков состояния мониторинга и инсулиновой помпы. %2$@ будет продолжать попытки завершить цикл, но следите за возможными проблемами связи с вашей помпой и мониторингом." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "\n%1$@\n\nDaha fazla bilgi için CGM ve insülin pompası durum simgelerinize dokunun. %2$@ döngüyü tamamlamaya çalışacak, ancak pompanız ve CGM ile olası iletişim sorunlarına dikkat edin." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ \n\n点按你的 CGM 和泵状态图标以查看更多信息。%2$@ 将继续尝试完成循环,但请注意,泵和 CGM 之间可能存在通信问题。" + } + } + } + }, + "\n%1$@ is operating with Closed Loop in the OFF position. Your pump and CGM will continue operating, but the app will not adjust dosing automatically.\n\n%2$@" : { + "comment" : "Green closed loop OFF message (1: app name)(2: reason for open loop)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "\n%1$@ kører med Lukket Loop i OFF-positionen. Din pumpe og CGM fortsætter med at fungere, men appen justerer ikke doseringen automatisk.\n\n%2$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "\n%1$@ arbeitet mit geschlossenem Regelkreis in der AUS-Position. Deine Pumpe und CGM funktionieren weiter, aber die App passt die Dosierung nicht automatisch an.\n\n%2$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "\n%1$@ está funcionando con el circuito cerrado en la posición de apagado. La bomba y el CGM seguirán funcionando, pero la aplicación no ajustará la dosis automáticamente.\n\n%2$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "\n%1$@ fonctionne avec la boucle fermée en position ARRÊT. Votre pompe et votre CGM continueront de fonctionner, mais l'application n'ajustera pas automatiquement le dosage.\n\n%2$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "\n%1$@ è in funzione con il Loop chiuso disattivato. La pompa e il CGM continueranno a funzionare, ma l'app non regolerà automaticamente il dosaggio. \n\n%2$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ opererer med Lukket Loop i AV-posisjon. Pumpen og CGM vil fortsette å fungere, men appen vil ikke justere doseringen automatisk.\n\n%2$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "\n%1$@ werkt met Gesloten Loop in de UIT stand. Je pomp en CGM blijven werken, maar de app past de dosering niet automatisch aan.\n\n%2$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "\n%1$@ działa z zamkniętą pętlą w pozycji WYŁĄCZONA. Twoja pompa i CGM będą nadal działać, ale aplikacja nie będzie automatycznie dostosowywać dawkowania. \n\n %2$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "\n%1$@ operează cu bucla închisă în poziția OFF. Pompa și CGM vor continua să funcționeze, dar aplicația nu va ajusta automat dozarea. \n\n %2$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "\n%1$@ работает с замкнутым контуром в положении ВЫКЛ. Ваша помпа и мониторинг будут продолжать работать, но приложение не будет автоматически регулировать дозировку.\n\n%2$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "\n%1$@ KAPALI konumda ve Kapalı Döngü ile çalışıyor. Pompanız ve CGM çalışmaya devam edecek, ancak uygulama dozajı otomatik olarak ayarlamayacaktır. \n\n%2$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "\n %1$@当前处于非闭环模式。胰岛素泵和连续血糖监测仪仍将正常运行,但应用程序不会自动调整胰岛素剂量。\n \n %2$@" + } + } + } + }, + "– – –" : { + "comment" : "No glucose value representation (3 dashes for mg/dL)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "---" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "– – –" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "– – –" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "– – –" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "– – –" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "– – –" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "– – –" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "---" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "– – –" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "---" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "---" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "– – –" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "– –" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "– – –" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "---" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "– – –" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "– – –" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "– – –" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "– – –" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "– – –" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "---" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "– – –" + } + } + } + }, + "%@ ago" : { + "comment" : "Format string describing the time interval since the last completion date. (1: The localized date components", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ ago" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ siden" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "vor %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hace %@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Il y a %@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ ago" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ fa" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 前" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ siden" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ geleden" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ temu" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ atrás" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ în urmă" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ назад" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "pred %@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ sedan" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ önce" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ trước đó" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "之前%@" + } + } + } + }, + "%@ U" : { + "comment" : "The format string describing the basal rate.", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ U" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ E" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ IE" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ U" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ U" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ U" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ U" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ U" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ U" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ E" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ E" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ J" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ U" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ U" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ ед" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ j" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ E" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ Ü" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ U" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 单位" + } + } + } + }, + "%1$@ ago" : { + "comment" : "Format string describing last completion. (1: time ago", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ siden" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ vor" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hace %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Il y a %@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ fa" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ siden" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ geleden" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ temu" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ în urmă" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ назад" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ önce" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ 之前" + } + } + } + }, + "%1$@ ago at %2$@" : { + "comment" : "Format string describing last completion. (1: time ago, (2: the date", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ siden ved %2$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vor %1$@ am %2$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hace %1$@ a las %2$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Il y a %@ à %2$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ fa a %2$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ siden kl. %2$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ geleden om %2$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ temu o %2$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Acum %1$@ la %2$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ назад в %2$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ önce %2$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ 之前的 %2$@" + } + } + } + }, + "%1$@ at %2$@" : { + "comment" : "Accessbility format value describing glucose: (1: glucose number)(2: glucose time)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ at %2$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ ved %2$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ in %2$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ en %2$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ klo %2$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ à %2$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ at %2$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ a %2$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ - %2$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ kl. %2$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ om %2$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ o %2$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ at %2$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ la %2$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ в %2$@ " + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ at %2$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ lúc %2$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%2$@ 分钟时为 %1$@" + } + } + } + }, + "%1$@ units per hour at %2$@" : { + "comment" : "Accessibility format string describing the basal rate. (1: localized basal rate value)(2: last updated time)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ units per hour at %2$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ enheder per time ved %2$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ Einheiten pro Stunde in %2$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ unidades por hora en %2$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ yksikköä tunnissa klo %2$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ unités par heure à %2$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ units per hour at %2$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ unità all'ora a %2$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ U/時 @ %2$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ enheter pr. time kl. %2$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ eenheden per uur om %2$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ jednostek na godzinę o %2$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ unidades por hora em %2$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ unități pe oră la %2$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ ед/час в %2$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ enheter/h kl. %2$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "her %2$@ saatte %1$@ ünite" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ units một giờ lúc %2$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ 每小时的单位 %2$@" + } + } + } + }, + "%1$@ v%2$@" : { + "comment" : "The format string for the app name and version number. (1: bundle name)(2: bundle version)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v %2$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ версии %2$@" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v %2$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ v%2$@" + } + } + } + }, + "%1$@/min" : { + "comment" : "Format string describing glucose units per minute (1: glucose unit string)", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@/min" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@/min" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@/min" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@/min" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@/min" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@/min" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@/min" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@/min" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@/min" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@/分" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@/min" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@/min" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@/min" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@/min" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@/min" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@/минут" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@/min" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@/dk" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@/phút" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@/分钟" + } + } + } + }, + "<1 min ago" : { + "comment" : "Format string describing last completion", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "<1 min siden" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "vor <1 min" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hace <1 minuto" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "moins qu'une minute" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "<1 minuto fa" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "< 1 min siden" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "<1 min geleden" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "< 1 minutę temu" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "< 1 min în urmă" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "<1 мин. назад" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "< 1 dakika önce" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "<1 分钟之前" + } + } + } + }, + "Closed loop" : { + "comment" : "Accessibility hint describing completion HUD for a closed loop", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "Closed loop" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lukket loop" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geschlossener Loop" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Asa cerrada" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suljettu säätö" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Boucle fermée" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Closed loop" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop chiuso" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "クローズドループ" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lukket Loop" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gesloten loop" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pętla zamknięta" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ciclo fechado" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop automat" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Алгоритм замкнутого цикла" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sluten loop" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kapalı Döngü" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Closed loop" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "闭环模式" + } + } + } + }, + "Closed Loop OFF" : { + "comment" : "Title of green open loop OFF message", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lukket loop FRA" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Closed Loop AUS" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Asa cerrada APAGADA" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suljettu säätö pois päältä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Boucle Ouverte" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop chiuso disattivato" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lukket Loop AV" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gesloten Loop UIT" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pętla zamknięta WYŁĄCZONA" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Buclă închisă dezactivată" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Замкнутый цикл ВЫКЛ" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sluten Loop är AV" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kapalı Döngü KAPALI" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "闭环模式已关闭" + } + } + } + }, + "Closed Loop ON" : { + "comment" : "Title of green closed loop ON message", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lukket loop TIL" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Closed Loop AN" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Asa cerrada ACTIVADA" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suljettu säätö päällä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Boucle Fermée" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop chiuso attivato" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lukket Loop PÅ" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gesloten Loop AAN" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zamknięta pętla WŁĄCZONA" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Buclă închisă activată" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Замкнутый контур ВКЛ" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sluten Loop är PÅ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kapalı Döngü AÇIK" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "闭环模式已开启" + } + } + } + }, + "dB" : { + "comment" : "The short unit display string for decibles", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "дБ" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + } + } + }, + "g" : { + "comment" : "The short unit display string for grams", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "г" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "gr" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "克" + } + } + } + }, + "HIGH" : { + "comment" : "String displayed instead of a glucose value above the CGM range", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "مرتفع" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "HØJ" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "HOCH" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "ALTO" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "KORKEA" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "HAUT" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "גבוה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "HIGH" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "ALTO" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "HØY" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "HOOG" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "WYSOKI" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "HIGH" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "HIPER" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ВЫСОКИЙ" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "VYSOKÁ" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "HÖGT" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "YÜKSEK" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "CAO" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "高" + } + } + } + }, + "Last completed loop %1$@." : { + "comment" : "Last loop time completed message (1: last loop time string)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sidst afsluttet Loop %1$@." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop wurde nicht erfolgreich abgeschlossen seit %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Último loop completado %1$@." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dernière boucle terminée %1$@." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ultimo ciclo completato %1$@." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sist fullført loop %1$@." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Laatst voltooide loop %1$@." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ostatnia ukończona pętla %1$@ ." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ultima buclă finalizată %1$@ ." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Последняя завершенная петля %1$@." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Son tamamlanan döngü %1$@ ." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "上次闭环 %1$@" + } + } + } + }, + "Loop Failure" : { + "comment" : "Title of red loop message", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "فشل في الحلقة المغلقة" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop-fejl" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop fehlgeschlagen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Falla del Loop" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loopin häiriö" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Echec de Loop" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop Failure" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Malfunzionamento di Loop" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ループの不良" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop-feil" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loopstoring" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Awaria pętli" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Falha no Loop" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eșec buclă" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ошибка в работе петли" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loopfel" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Döngü Hatası" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop lỗi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop失败" + } + } + } + }, + "Loop ran %@ ago" : { + "comment" : "Accessbility format label describing the time interval since the last completion date. (1: The localized date components)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop ran %@ ago" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop kørte for %@ siden" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop lief seit %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop corrió hace %@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Viimeisin säätö %@ sitten" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop a bouclé il y a %@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop ran %@ ago" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop eseguito %@ fa" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ループは %@ 前に作動しました" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop kjørte %@ siden" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop heeft %@ geleden gedraaid" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pętla była uruchomiona %@ temu" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ciclo executado %@ atrás" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop a rulat acum %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Алгоритм цикла запущен %@ назад" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop kördes för %@ sedan" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Döngü %@ önce çalıştı" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop hoạt động %@ trước đó" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "闭环已停止运行%@" + } + } + } + }, + "Loop Warning" : { + "comment" : "Title of yellow loop message", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop advarsel" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop Warnung" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Advertencia de Loop" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avertissement de Loop" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avviso Loop" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop Varsel" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loopwaarschuwing" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ostrzeżenie o pętli" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avertizare Loop" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Предупреждение о петле" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Döngü Uyarısı" + } + } + } + }, + "LOW" : { + "comment" : "String displayed instead of a glucose value below the CGM range", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "منخفض" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "LAV" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "NIEDRIG" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "BAJO" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "MATALA" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "BAS" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "נמוך" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "LOW" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "BASSO" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "LAV" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "LAAG" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "NISKI" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "LOW" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "HIPO" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "НИЗКИЙ" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "NÍZKA" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "LÅGT" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "DÜŞÜK" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "THẤP" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "低" + } + } + } + }, + "mg/dL" : { + "comment" : "The short unit display string for milligrams of glucose per decilter", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dl" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "мг/дл" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dl" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + } + } + }, + "mmol/L" : { + "comment" : "The short unit display string for millimoles of glucose per liter", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ммоль/л" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + } + } + }, + "Needs attention" : { + "comment" : "Accessibility label component for glucose HUD describing an invalid state", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "Needs attention" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Handling påkrævet" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erfordert Aufmerksamkeit" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Necesita atención" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tarvitsee huomiota" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nécessite de l'attention" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Needs attention" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Richiede attenzione" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "注意が必要です" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trenger tilsyn" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aandacht vereist" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wymaga uwagi" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Precisa de atenção" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Necesită atenție" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Требует внимания" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kräver åtgärd" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İlgilenmeniz gerekiyor" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cần chú ý" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请注意" + } + } + } + }, + "Open loop" : { + "comment" : "Accessbility hint describing completion HUD for an open loop", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open loop" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Åben Loop" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Offener Loop" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Asa abierta" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avoin säätö" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop ouverte" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open loop" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop aperto" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "オープンループ" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Åpen Loop" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open loop" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Otwarta Loop" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ciclo aberto" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loop manual" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Алгоритм открытого цикла" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Öppen loop" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Açık döngü" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open loop" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "关闭闭环" + } + } + } + }, + "QUANTITY_VALUE_AND_UNIT" : { + "comment" : "Format string for combining localized numeric value and unit. (1: numeric value)(2: unit)", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ %2$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@%2$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + } + } + }, + "Tap Settings to toggle Closed Loop ON if you wish for the app to automate your insulin." : { + "comment" : "Instructions for user to close loop if it is allowed.", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tryk på Indstillinger for at slå Lukket Loop til, hvis du ønsker, at appen skal automatisere dit insulin." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tippe auf Einstellungen, um Closed Loop einzuschalten, wenn Du möchtest, dass die App Dein Insulin automatisiert." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pulse Ajustes para activar el loop cerrado si desea que la aplicación automatice su administracion de insulina." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Appuyez sur Paramètres pour activer la boucle fermée si vous souhaitez que l'application automatise l'administration d'insuline." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tocca \"Impostazioni\" per attivare Loop chiuso se si desidera che l'app automatizzi l'insulina." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trykk på Innstillinger for å slå Closed Loop PÅ om du ønsker at appen skal automatisere insulinet ditt." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tik op Instellingen om Gesloten Loop AAN te zetten als je wilt dat de app je insulinetoediening automatiseert." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stuknij Ustawienia, aby włączyć Pętlę zamkniętą, jeśli chcesz, aby aplikacja zautomatyzowała podawanie insuliny." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atingeți Setări pentru a activa bucla închisă dacă doriți ca aplicația să vă automatizeze administrarea de insulină." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нажмите \"Настройки\", чтобы включить \"Замкнутый цикл\", если вы хотите, чтобы приложение автоматизировало подачу инсулина." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uygulamanın insülininizi otomatikleştirmesini istiyorsanız, Kapalı Döngüyü AÇIK duruma getirmek için Ayarlar'a dokunun." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "如果您希望应用程序自动控制您的胰岛素,请点击“设置”,打开“闭环模式”开关。" + } + } + } + }, + "U" : { + "comment" : "The short unit display string for international units of insulin", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "وحدة" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "E" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "IE" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "U" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "U" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "U" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "U" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "U" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "U" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "E" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "E" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "J" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "U" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "U" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ед" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "j" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "E" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ü" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "U" + } + } + } + }, + "Unknown" : { + "comment" : "Accessibility value for an unknown value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unknown" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ukendt" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unbekannt" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Desconocido" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tuntematon" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inconnu" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unknown" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sconosciuto" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "不明" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ukjent" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Onbekend" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nieznany" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Desconhecido" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Necunoscut" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Неизвестно" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Neznáme" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Okänd" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bilinmeyen" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không nhận ra" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未知" + } + } + } + }, + "Waiting for first run" : { + "comment" : "Accessibility label describing completion HUD waiting for first run", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "Waiting for first run" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Venter på første kørsel" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Warten auf die erste Ausführung" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Esperando el primer uso" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Odotetaan ensimmäistä säätöä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "En attente de la première exécution" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Waiting for first run" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "In attesa della prima esecuzione" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "開始待機中" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Venter på første kjøring" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aan het wachten op eerste run" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oczekiwanie na pierwsze uruchomienie" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aguardando a primeira execução" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Se așteaptă prima rulare" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ожидает первичного запуска" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Väntar på första körning" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "İlk çalıştırma için bekleniyor" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đang chờ cho lần chạy đầu tiên" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "等待第一次运行" + } + } + } + }, + "was at %1$@" : { + "comment" : "Format string describing last completion. (1: the date", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "var %1$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zuletzt abgeschlossen %1$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "estaba en %1$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "était à %1$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "era a %1$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "var på %1$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "was om %1$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "był na %1$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "a fost la %1$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "был в %1$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ de idi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "是在%1$@" + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/LoopUI/Models/ChartLineModel.swift b/LoopUI/Models/ChartLineModel.swift new file mode 100644 index 0000000000..346de2b871 --- /dev/null +++ b/LoopUI/Models/ChartLineModel.swift @@ -0,0 +1,23 @@ +// +// ChartLineModel.swift +// Loop +// +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +import SwiftCharts + + +extension ChartLineModel { + /// Creates a model configured with the dashed prediction line style + /// + /// - Parameters: + /// - points: The points to construct the line + /// - color: The line color + /// - width: The line width + /// - Returns: A new line model + static func predictionLine(points: [T], color: UIColor, width: CGFloat) -> ChartLineModel { + // TODO: Bug in ChartPointsLineLayer requires a non-zero animation to draw the dash pattern + return self.init(chartPoints: points, lineColor: color, lineWidth: width, animDuration: 0.0001, animDelay: 0, dashPattern: [6, 5]) + } +} diff --git a/LoopUI/Models/SensorDisplayable.swift b/LoopUI/Models/SensorDisplayable.swift deleted file mode 100644 index a6a18d6bfc..0000000000 --- a/LoopUI/Models/SensorDisplayable.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// SensorDisplayable.swift -// Loop -// -// Created by Nate Racklyeft on 8/2/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import Foundation - - -public protocol SensorDisplayable { - /// Returns whether the current state is valid - var isStateValid: Bool { get } - - /// Describes the state of the sensor in the current localization - var stateDescription: String { get } - - /// Enumerates the trend of the sensor values - var trendType: GlucoseTrend? { get } - - /// Returns wheter the data is from a locally-connected device - var isLocal: Bool { get } -} diff --git a/LoopUI/StatusBarHUDView.xib b/LoopUI/StatusBarHUDView.xib new file mode 100644 index 0000000000..f603d7cf91 --- /dev/null +++ b/LoopUI/StatusBarHUDView.xib @@ -0,0 +1,370 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LoopUI/StatusHighlightHUDView.xib b/LoopUI/StatusHighlightHUDView.xib new file mode 100644 index 0000000000..d15db6dd39 --- /dev/null +++ b/LoopUI/StatusHighlightHUDView.xib @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LoopUI/ViewModel/CGMStatusHUDViewModel.swift b/LoopUI/ViewModel/CGMStatusHUDViewModel.swift new file mode 100644 index 0000000000..a26834e4b3 --- /dev/null +++ b/LoopUI/ViewModel/CGMStatusHUDViewModel.swift @@ -0,0 +1,191 @@ +// +// CGMStatusHUDViewModel.swift +// LoopUI +// +// Created by Nathaniel Hamming on 2020-06-24. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import HealthKit +import LoopKit + +public class CGMStatusHUDViewModel { + + static let staleGlucoseRepresentation: String = NSLocalizedString("– – –", comment: "No glucose value representation (3 dashes for mg/dL)") + + var trend: GlucoseTrend? + + var unitsString: String = "–" + + var glucoseValueString: String = CGMStatusHUDViewModel.staleGlucoseRepresentation + + var accessibilityString: String = "" + + var glucoseValueTintColor: UIColor = .label + + var glucoseTrendTintColor: UIColor = .glucoseTintColor + + var glucoseTrendIcon: UIImage? { + guard let manualGlucoseTrendIconOverride = manualGlucoseTrendIconOverride else { + return trend?.image + } + return manualGlucoseTrendIconOverride + } + + private var glucoseValueCurrent: Bool { + guard let isStaleAt = isStaleAt else { return true } + return Date() < isStaleAt + } + + private var isManualGlucose: Bool = false + + private var isManualGlucoseCurrent: Bool { + return isManualGlucose && glucoseValueCurrent + } + + var manualGlucoseTrendIconOverride: UIImage? + + private var storedStatusHighlight: DeviceStatusHighlight? + + var statusHighlight: DeviceStatusHighlight? { + get { + guard !isManualGlucoseCurrent else { + // if there is a current manual glucose, don't provide the stored status highlight + return nil + } + return storedStatusHighlight + } + set { + storedStatusHighlight = newValue + if isManualGlucoseCurrent { + // If there is a current manual glucose, it displays the current status highlight icon + setManualGlucoseTrendIconOverride() + } + + if let localizedMessage = storedStatusHighlight?.localizedMessage.replacingOccurrences(of: "\n", with: " "), + let statusStateMessage = storedStatusHighlight?.state.localizedDescription + { + accessibilityString = localizedMessage + ", " + statusStateMessage + } + } + } + + var isVisible: Bool = true { + didSet { + if oldValue != isVisible { + if !isVisible { + stalenessTimer?.invalidate() + stalenessTimer = nil + } else { + startStalenessTimerIfNeeded() + } + } + } + } + + private var stalenessTimer: Timer? + + private var isStaleAt: Date? { + didSet { + if oldValue != isStaleAt { + stalenessTimer?.invalidate() + stalenessTimer = nil + } + } + } + + private func startStalenessTimerIfNeeded() { + if let fireDate = isStaleAt, + isVisible, + stalenessTimer == nil + { + stalenessTimer = Timer(fire: fireDate, interval: 0, repeats: false) { (_) in + self.displayStaleGlucoseValue() + self.staleGlucoseValueHandler() + } + RunLoop.main.add(stalenessTimer!, forMode: .default) + } + } + + private lazy var timeFormatter = DateFormatter(timeStyle: .short) + + var staleGlucoseValueHandler: () -> Void + + init(staleGlucoseValueHandler: @escaping () -> Void) { + self.staleGlucoseValueHandler = staleGlucoseValueHandler + } + + func setGlucoseQuantity(_ glucoseQuantity: Double, + at glucoseStartDate: Date, + unit: HKUnit, + staleGlucoseAge: TimeInterval, + glucoseDisplay: GlucoseDisplayable?, + wasUserEntered: Bool, + isDisplayOnly: Bool) + { + var accessibilityStrings = [String]() + + // reset state + manualGlucoseTrendIconOverride = nil + trend = nil + + let time = timeFormatter.string(from: glucoseStartDate) + + isStaleAt = glucoseStartDate.addingTimeInterval(staleGlucoseAge) + + glucoseValueTintColor = glucoseDisplay?.glucoseRangeCategory?.glucoseColor ?? .label + + let numberFormatter = NumberFormatter.glucoseFormatter(for: unit) + if let valueString = numberFormatter.string(from: glucoseQuantity) { + if glucoseValueCurrent { + startStalenessTimerIfNeeded() + switch glucoseDisplay?.glucoseRangeCategory { + case .some(.belowRange): + glucoseValueString = LocalizedString("LOW", comment: "String displayed instead of a glucose value below the CGM range") + case .some(.aboveRange): + glucoseValueString = LocalizedString("HIGH", comment: "String displayed instead of a glucose value above the CGM range") + default: + glucoseValueString = valueString + } + } else { + displayStaleGlucoseValue() + } + accessibilityStrings.append(String(format: LocalizedString("%1$@ at %2$@", comment: "Accessbility format value describing glucose: (1: glucose number)(2: glucose time)"), valueString, time)) + } + + // Only a user-entered glucose value that is *not* display-only (i.e. a calibration) is considered a manual glucose entry. + isManualGlucose = wasUserEntered && !isDisplayOnly + if isManualGlucoseCurrent { + // a manual glucose value presents any status highlight icon instead of a trend icon + setManualGlucoseTrendIconOverride() + } else if let trend = glucoseDisplay?.trendType, glucoseValueCurrent { + self.trend = trend + glucoseTrendTintColor = glucoseDisplay?.glucoseRangeCategory?.trendColor ?? .glucoseTintColor + accessibilityStrings.append(trend.localizedDescription) + } else { + glucoseTrendTintColor = .glucoseTintColor + } + + if let statusStateMessage = storedStatusHighlight?.state.localizedDescription, + let localizedMessage = storedStatusHighlight?.localizedMessage.replacingOccurrences(of: "\n", with: " ") + { + accessibilityStrings.append(localizedMessage + ", " + statusStateMessage) + } + + unitsString = unit.localizedShortUnitString + accessibilityString = accessibilityStrings.joined(separator: ", ") + } + + func displayStaleGlucoseValue() { + glucoseValueString = CGMStatusHUDViewModel.staleGlucoseRepresentation + glucoseValueTintColor = .label + trend = nil + glucoseTrendTintColor = .glucoseTintColor + manualGlucoseTrendIconOverride = nil + } + + func setManualGlucoseTrendIconOverride() { + manualGlucoseTrendIconOverride = storedStatusHighlight?.image + glucoseTrendTintColor = storedStatusHighlight?.state.color ?? .glucoseTintColor + } +} diff --git a/LoopUI/Views/BasalRateHUDView.swift b/LoopUI/Views/BasalRateHUDView.swift index cd253c3ab6..d8992f0169 100644 --- a/LoopUI/Views/BasalRateHUDView.swift +++ b/LoopUI/Views/BasalRateHUDView.swift @@ -7,30 +7,38 @@ // import UIKit - +import LoopKitUI public final class BasalRateHUDView: BaseHUDView { + + override public var orderPriority: HUDViewOrderPriority { + return 3 + } @IBOutlet private weak var basalStateView: BasalStateView! @IBOutlet private weak var basalRateLabel: UILabel! { didSet { basalRateLabel?.text = String(format: basalRateFormatString, "–") - basalRateLabel?.textColor = .doseTintColor + basalRateLabel?.textColor = .secondaryLabel - accessibilityValue = NSLocalizedString("Unknown", comment: "Accessibility value for an unknown value") + accessibilityValue = LocalizedString("Unknown", comment: "Accessibility value for an unknown value") } } - private lazy var basalRateFormatString = NSLocalizedString("%@ U", comment: "The format string describing the basal rate.") + public override func tintColorDidChange() { + super.tintColorDidChange() + } + + private lazy var basalRateFormatString = LocalizedString("%@ U", comment: "The format string describing the basal rate.") public func setNetBasalRate(_ rate: Double, percent: Double, at date: Date) { let time = timeFormatter.string(from: date) caption?.text = time - if let rateString = decimalFormatter.string(from: NSNumber(value: rate)) { + if let rateString = decimalFormatter.string(from: rate) { basalRateLabel?.text = String(format: basalRateFormatString, rateString) - accessibilityValue = String(format: NSLocalizedString("%1$@ units per hour at %2$@", comment: "Accessibility format string describing the basal rate. (1: localized basal rate value)(2: last updated time)"), rateString, time) + accessibilityValue = String(format: LocalizedString("%1$@ units per hour at %2$@", comment: "Accessibility format string describing the basal rate. (1: localized basal rate value)(2: last updated time)"), rateString, time) } else { basalRateLabel?.text = nil accessibilityValue = nil diff --git a/LoopUI/Views/BasalStateView.swift b/LoopUI/Views/BasalStateView.swift index 59332eb91a..15d51c7bf5 100644 --- a/LoopUI/Views/BasalStateView.swift +++ b/LoopUI/Views/BasalStateView.swift @@ -29,22 +29,30 @@ public final class BasalStateView: UIView { super.init(frame: frame) shapeLayer.lineWidth = 2 - shapeLayer.fillColor = UIColor.doseTintColor.withAlphaComponent(0.5).cgColor - shapeLayer.strokeColor = UIColor.doseTintColor.cgColor + updateTintColor() } required public init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) shapeLayer.lineWidth = 2 - shapeLayer.fillColor = UIColor.doseTintColor.withAlphaComponent(0.5).cgColor - shapeLayer.strokeColor = UIColor.doseTintColor.cgColor + updateTintColor() } override public func layoutSubviews() { super.layoutSubviews() } + public override func tintColorDidChange() { + super.tintColorDidChange() + updateTintColor() + } + + private func updateTintColor() { + shapeLayer.fillColor = tintColor.withAlphaComponent(0.5).cgColor + shapeLayer.strokeColor = tintColor.cgColor + } + private func drawPath() -> CGPath { let startX = bounds.minX let endX = bounds.maxX @@ -75,7 +83,7 @@ public final class BasalStateView: UIView { animation.fromValue = shapeLayer.path ?? drawPath() animation.toValue = path animation.duration = 1 - animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut) + animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) shapeLayer.add(animation, forKey: type(of: self).AnimationKey) } diff --git a/LoopUI/Views/BaseHUDView.swift b/LoopUI/Views/BaseHUDView.swift deleted file mode 100644 index a0d2ad853e..0000000000 --- a/LoopUI/Views/BaseHUDView.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// HUDView.swift -// Naterade -// -// Created by Nathan Racklyeft on 5/1/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import UIKit - -public class BaseHUDView: UIView { - - @IBOutlet weak var caption: UILabel! { - didSet { - caption?.text = "—" - } - } - -} diff --git a/LoopUI/Views/BatteryLevelHUDView.swift b/LoopUI/Views/BatteryLevelHUDView.swift deleted file mode 100644 index 8fcd1688d3..0000000000 --- a/LoopUI/Views/BatteryLevelHUDView.swift +++ /dev/null @@ -1,56 +0,0 @@ -// -// BatteryLevelHUDView.swift -// Naterade -// -// Created by Nathan Racklyeft on 5/2/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import UIKit - - -public final class BatteryLevelHUDView: BaseHUDView { - - @IBOutlet private weak var levelMaskView: LevelMaskView! - - override public func awakeFromNib() { - super.awakeFromNib() - - tintColor = .unknownColor - - accessibilityValue = NSLocalizedString("Unknown", comment: "Accessibility value for an unknown value") - } - - private lazy var numberFormatter: NumberFormatter = { - let formatter = NumberFormatter() - formatter.numberStyle = .percent - - return formatter - }() - - - public var batteryLevel: Double? { - didSet { - if let value = batteryLevel, let level = numberFormatter.string(from: NSNumber(value: value)) { - caption.text = level - accessibilityValue = level - } else { - caption.text = nil - } - - switch batteryLevel { - case .none: - tintColor = .unknownColor - case let x? where x > 0.25: - tintColor = .secondaryLabelColor - case let x? where x > 0.10: - tintColor = .agingColor - default: - tintColor = .staleColor - } - - levelMaskView.value = batteryLevel ?? 1.0 - } - } - -} diff --git a/LoopUI/Views/CGMStatusHUDView.swift b/LoopUI/Views/CGMStatusHUDView.swift new file mode 100644 index 0000000000..ad659445fd --- /dev/null +++ b/LoopUI/Views/CGMStatusHUDView.swift @@ -0,0 +1,142 @@ +// +// CGMStatusHUDView.swift +// LoopUI +// +// Created by Nathaniel Hamming on 2020-06-05. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import UIKit +import HealthKit +import LoopKit +import LoopKitUI + +public final class CGMStatusHUDView: DeviceStatusHUDView, NibLoadable { + + private var viewModel: CGMStatusHUDViewModel! + + @IBOutlet public weak var glucoseValueHUD: GlucoseValueHUDView! + + @IBOutlet public weak var glucoseTrendHUD: GlucoseTrendHUDView! + + override public var orderPriority: HUDViewOrderPriority { + return 1 + } + + public var isVisible: Bool { + get { + viewModel.isVisible + } + set { + if viewModel.isVisible != newValue { + viewModel.isVisible = newValue + } + } + } + + override public init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + public required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + setup() + } + + override func setup() { + super.setup() + statusHighlightView.setIconPosition(.right) + viewModel = CGMStatusHUDViewModel(staleGlucoseValueHandler: { [weak self] in + self?.updateDisplay() + }) + } + + override public func tintColorDidChange() { + super.tintColorDidChange() + + glucoseValueHUD.tintColor = viewModel.glucoseValueTintColor + glucoseTrendHUD.tintColor = viewModel.glucoseTrendTintColor + } + + override public func presentStatusHighlight(_ statusHighlight: DeviceStatusHighlight?) { + viewModel.statusHighlight = statusHighlight + super.presentStatusHighlight(viewModel.statusHighlight) + } + + override func presentStatusHighlight() { + defer { + // when the status highlight is updated, the trend icon may also need to be updated + updateTrendIcon() + // when the status highlight is updated, the accessibility string is updated + accessibilityValue = viewModel.accessibilityString + } + + guard statusStackView.arrangedSubviews.contains(glucoseValueHUD), + statusStackView.arrangedSubviews.contains(glucoseTrendHUD) else + { + return + } + + // need to also hide these view, since they will be added back to the stack at some point + glucoseValueHUD.isHidden = true + glucoseTrendHUD.isHidden = true + statusStackView.removeArrangedSubview(glucoseValueHUD) + statusStackView.removeArrangedSubview(glucoseTrendHUD) + + super.presentStatusHighlight() + } + + override public func dismissStatusHighlight() { + defer { + // when the status highlight is updated, the trend icon may also need to be updated + updateTrendIcon() + // when the status highlight is updated, the accessibility string is updated + accessibilityValue = viewModel.accessibilityString + } + + guard statusStackView.arrangedSubviews.contains(statusHighlightView) else { + return + } + + super.dismissStatusHighlight() + + statusStackView.addArrangedSubview(glucoseValueHUD) + statusStackView.addArrangedSubview(glucoseTrendHUD) + glucoseValueHUD.isHidden = false + glucoseTrendHUD.isHidden = false + } + + public func setGlucoseQuantity(_ glucoseQuantity: Double, + at glucoseStartDate: Date, + unit: HKUnit, + staleGlucoseAge: TimeInterval, + glucoseDisplay: GlucoseDisplayable?, + wasUserEntered: Bool, + isDisplayOnly: Bool) + { + viewModel.setGlucoseQuantity(glucoseQuantity, + at: glucoseStartDate, + unit: unit, + staleGlucoseAge: staleGlucoseAge, + glucoseDisplay: glucoseDisplay, + wasUserEntered: wasUserEntered, + isDisplayOnly: isDisplayOnly) + + updateDisplay() + } + + func updateDisplay() { + glucoseValueHUD.glucoseLabel.text = viewModel.glucoseValueString + glucoseValueHUD.unitLabel.text = viewModel.unitsString + glucoseValueHUD.tintColor = viewModel.glucoseValueTintColor + presentStatusHighlight(viewModel.statusHighlight) + + accessibilityValue = viewModel.accessibilityString + } + + func updateTrendIcon() { + glucoseTrendHUD.setIcon(viewModel.glucoseTrendIcon) + glucoseTrendHUD.tintColor = viewModel.glucoseTrendTintColor + } +} diff --git a/LoopUI/Views/DeviceStatusHUDView.swift b/LoopUI/Views/DeviceStatusHUDView.swift new file mode 100644 index 0000000000..904f9d44cb --- /dev/null +++ b/LoopUI/Views/DeviceStatusHUDView.swift @@ -0,0 +1,146 @@ +// +// DeviceStatusHUDView.swift +// LoopUI +// +// Created by Nathaniel Hamming on 2020-06-05. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import UIKit +import HealthKit +import LoopKit +import LoopKitUI + +@objc open class DeviceStatusHUDView: BaseHUDView { + + var statusHighlightView: StatusHighlightHUDView! { + didSet { + statusHighlightView.isHidden = true + } + } + + @IBOutlet private var statusBadgeView: StatusBadgeHUDView! { + didSet { + statusBadgeView.isHidden = true + } + } + + @IBOutlet private weak var progressView: UIProgressView! { + didSet { + progressView.isHidden = true + progressView.tintColor = .systemGray + // round the edges of the progress view + progressView.layer.cornerRadius = 2 + progressView.clipsToBounds = true + progressView.layer.sublayers!.last!.cornerRadius = 2 + progressView.subviews.last!.clipsToBounds = true + } + } + + @IBOutlet private weak var backgroundView: UIView! { + didSet { + backgroundView.backgroundColor = .systemBackground + backgroundView.layer.cornerRadius = 23 + } + } + + @IBOutlet weak var statusStackView: UIStackView! + + public var lifecycleProgress: DeviceLifecycleProgress? { + didSet { + guard let lifecycleProgress = lifecycleProgress else { + resetProgress() + return + } + + progressView.isHidden = false + progressView.progress = Float(lifecycleProgress.percentComplete.clamped(to: 0...1)) + progressView.tintColor = lifecycleProgress.progressState.color + } + } + + public var adjustViewsForNarrowDisplay: Bool = false { + didSet { + if adjustViewsForNarrowDisplay != oldValue { + NSLayoutConstraint.activate([ + statusHighlightView.icon.widthAnchor.constraint(equalToConstant: 26), + statusHighlightView.icon.heightAnchor.constraint(equalToConstant: 26), + ]) + } else { + NSLayoutConstraint.activate([ + statusHighlightView.icon.widthAnchor.constraint(equalToConstant: 34), + statusHighlightView.icon.heightAnchor.constraint(equalToConstant: 34), + ]) + } + } + } + + private func resetProgress() { + progressView.isHidden = true + progressView.progress = 0 + } + + func setup() { + if statusHighlightView == nil { + statusHighlightView = StatusHighlightHUDView(frame: self.frame) + } + } + + public func presentStatusHighlight(_ statusHighlight: DeviceStatusHighlight?) { + guard let statusHighlight = statusHighlight else { + dismissStatusHighlight() + return + } + + presentStatusHighlight(withMessage: statusHighlight.localizedMessage, + image: statusHighlight.image, + color: statusHighlight.state.color) + } + + private func presentStatusHighlight(withMessage message: String, + image: UIImage?, + color: UIColor) + { + statusHighlightView.messageLabel.text = message + statusHighlightView.messageLabel.tintColor = .label + statusHighlightView.icon.image = image + statusHighlightView.icon.tintColor = color + presentStatusHighlight() + } + + func presentStatusHighlight() { + statusStackView?.addArrangedSubview(statusHighlightView) + statusHighlightView.isHidden = false + } + + func dismissStatusHighlight() { + // need to also hide this view, since it will be added back to the stack at some point + statusHighlightView.isHidden = true + statusStackView?.removeArrangedSubview(statusHighlightView) + } + + public func presentStatusBadge(_ statusBadge: DeviceStatusBadge?) { + guard let statusBadge = statusBadge else { + dismissStatusBadge() + return + } + + presentStatusBadge(withIcon: statusBadge.image, + color: statusBadge.state.color) + } + + private func presentStatusBadge(withIcon badgeIcon: UIImage?, + color: UIColor) { + statusBadgeView.setBadgeIcon(badgeIcon) + statusBadgeView.tintColor = color + presentStatusBadge() + } + + private func presentStatusBadge() { + statusBadgeView.isHidden = false + } + + private func dismissStatusBadge() { + statusBadgeView.isHidden = true + } +} diff --git a/LoopUI/Views/GlucoseHUDView.swift b/LoopUI/Views/GlucoseHUDView.swift index fe6cf7d830..8201e82a51 100644 --- a/LoopUI/Views/GlucoseHUDView.swift +++ b/LoopUI/Views/GlucoseHUDView.swift @@ -8,34 +8,97 @@ import UIKit import HealthKit - +import LoopKit +import LoopKitUI public final class GlucoseHUDView: BaseHUDView { + + static let staleGlucoseRepresentation: String = NSLocalizedString("– – –", comment: "No glucose value representation (3 dashes for mg/dL)") + + private var stalenessTimer: Timer? + + private var isStaleAt: Date? { + didSet { + if oldValue != isStaleAt { + stalenessTimer?.invalidate() + stalenessTimer = nil + } + } + } + + public var isVisible: Bool = true { + didSet { + if oldValue != isVisible { + if !isVisible { + stalenessTimer?.invalidate() + stalenessTimer = nil + } else { + startStalenessTimerIfNeeded() + } + } + } + } + + private func startStalenessTimerIfNeeded() { + if let fireDate = isStaleAt, isVisible, stalenessTimer == nil { + + stalenessTimer = Timer(fire: fireDate, interval: 0, repeats: false) { (_) in + self.glucoseLabel.text = GlucoseHUDView.staleGlucoseRepresentation + } + RunLoop.main.add(stalenessTimer!, forMode: .default) + } + } + + override public var orderPriority: HUDViewOrderPriority { + return 2 + } @IBOutlet private weak var unitLabel: UILabel! { didSet { unitLabel.text = "–" - unitLabel.textColor = .glucoseTintColor + unitLabel.textColor = tintColor } } @IBOutlet private weak var glucoseLabel: UILabel! { didSet { - glucoseLabel.text = "–" - glucoseLabel.textColor = .glucoseTintColor + glucoseLabel.text = GlucoseHUDView.staleGlucoseRepresentation + glucoseLabel.textColor = tintColor } } @IBOutlet private weak var alertLabel: UILabel! { didSet { alertLabel.alpha = 0 - alertLabel.backgroundColor = UIColor.agingColor alertLabel.textColor = UIColor.white alertLabel.layer.cornerRadius = 9 alertLabel.clipsToBounds = true } } + public override func tintColorDidChange() { + super.tintColorDidChange() + + unitLabel.textColor = tintColor + glucoseLabel.textColor = tintColor + } + + override public func stateColorsDidUpdate() { + super.stateColorsDidUpdate() + updateColor() + } + + private func updateColor() { + switch sensorAlertState { + case .missing, .invalid: + alertLabel.backgroundColor = stateColors?.warning + case .remote: + alertLabel.backgroundColor = stateColors?.unknown + case .ok: + alertLabel.backgroundColor = stateColors?.normal + } + } + private enum SensorAlertState { case ok case missing @@ -51,35 +114,42 @@ public final class GlucoseHUDView: BaseHUDView { case .ok: alertLabelAlpha = 0 case .missing, .invalid: - alertLabel.backgroundColor = UIColor.agingColor alertLabel.text = "!" case .remote: - alertLabel.backgroundColor = UIColor.unknownColor alertLabel.text = "☁︎" } + updateColor() + UIView.animate(withDuration: 0.25, animations: { self.alertLabel.alpha = alertLabelAlpha }) } } - public func set(glucoseQuantity: Double, at glucoseStartDate: Date, unitString: String, from sensor: SensorDisplayable?) { + public func setGlucoseQuantity(_ glucoseQuantity: Double, at glucoseStartDate: Date, unit: HKUnit, staleGlucoseAge: TimeInterval, sensor: GlucoseDisplayable?) { var accessibilityStrings = [String]() let time = timeFormatter.string(from: glucoseStartDate) caption?.text = time - let unit = HKUnit(from: unitString) + + isStaleAt = glucoseStartDate.addingTimeInterval(staleGlucoseAge) + let glucoseValueCurrent = Date() < isStaleAt! let numberFormatter = NumberFormatter.glucoseFormatter(for: unit) - if let valueString = numberFormatter.string(from: NSNumber(value: glucoseQuantity)) { - glucoseLabel.text = valueString - accessibilityStrings.append(String(format: NSLocalizedString("%1$@ at %2$@", comment: "Accessbility format value describing glucose: (1: glucose number)(2: glucose time)"), valueString, time)) + if let valueString = numberFormatter.string(from: glucoseQuantity) { + if glucoseValueCurrent { + glucoseLabel.text = valueString + startStalenessTimerIfNeeded() + } else { + glucoseLabel.text = GlucoseHUDView.staleGlucoseRepresentation + } + accessibilityStrings.append(String(format: LocalizedString("%1$@ at %2$@", comment: "Accessbility format value describing glucose: (1: glucose number)(2: glucose time)"), valueString, time)) } - var unitStrings = [unit.glucoseUnitDisplayString] + var unitStrings = [unit.localizedShortUnitString] - if let trend = sensor?.trendType { + if let trend = sensor?.trendType, glucoseValueCurrent { unitStrings.append(trend.symbol) accessibilityStrings.append(trend.localizedDescription) } @@ -88,7 +158,7 @@ public final class GlucoseHUDView: BaseHUDView { sensorAlertState = .missing } else if sensor!.isStateValid == false { sensorAlertState = .invalid - accessibilityStrings.append(NSLocalizedString("Needs attention", comment: "Accessibility label component for glucose HUD describing an invalid state")) + accessibilityStrings.append(LocalizedString("Needs attention", comment: "Accessibility label component for glucose HUD describing an invalid state")) } else if sensor!.isLocal == false { sensorAlertState = .remote } else { @@ -97,14 +167,10 @@ public final class GlucoseHUDView: BaseHUDView { unitLabel.text = unitStrings.joined(separator: " ") accessibilityValue = accessibilityStrings.joined(separator: ", ") + + } - private lazy var timeFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateStyle = .none - formatter.timeStyle = .short - - return formatter - }() + private lazy var timeFormatter = DateFormatter(timeStyle: .short) } diff --git a/LoopUI/Views/GlucoseTrendHUDView.swift b/LoopUI/Views/GlucoseTrendHUDView.swift new file mode 100644 index 0000000000..c31dd812da --- /dev/null +++ b/LoopUI/Views/GlucoseTrendHUDView.swift @@ -0,0 +1,35 @@ +// +// GlucoseTrendHUDView.swift +// LoopUI +// +// Created by Nathaniel Hamming on 2020-06-05. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import UIKit +import HealthKit +import LoopKit +import LoopKitUI + +public final class GlucoseTrendHUDView: BaseHUDView { + + override public var orderPriority: HUDViewOrderPriority { + return 2 + } + + @IBOutlet private weak var trendIcon: UIImageView! { + didSet { + trendIcon.tintColor = .glucoseTintColor + } + } + + public override func tintColorDidChange() { + super.tintColorDidChange() + + trendIcon.tintColor = tintColor + } + + public func setIcon(_ icon: UIImage?) { + trendIcon.image = icon + } +} diff --git a/LoopUI/Views/GlucoseValueHUDView.swift b/LoopUI/Views/GlucoseValueHUDView.swift new file mode 100644 index 0000000000..4a0858e746 --- /dev/null +++ b/LoopUI/Views/GlucoseValueHUDView.swift @@ -0,0 +1,44 @@ +// +// GlucoseValueHUDView.swift +// LoopUI +// +// Created by Nathaniel Hamming on 2020-06-05. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import UIKit +import HealthKit +import LoopKit +import LoopKitUI + +public final class GlucoseValueHUDView: BaseHUDView { + + override public var orderPriority: HUDViewOrderPriority { + return 1 + } + + @IBOutlet public weak var unitLabel: UILabel! { + didSet { + unitLabel.text = "–" + unitLabel.textColor = .secondaryLabel + } + } + + @IBOutlet public weak var glucoseLabel: UILabel! { + didSet { + glucoseLabel.text = CGMStatusHUDViewModel.staleGlucoseRepresentation + glucoseLabel.textColor = .label + } + } + + public override func tintColorDidChange() { + super.tintColorDidChange() + + switch self.tintColor { + case UIColor.label: + glucoseLabel.textColor = tintColor + default: + glucoseLabel.textColor = tintColor + } + } +} diff --git a/LoopUI/Views/HUDView.swift b/LoopUI/Views/HUDView.swift index 7e92062c76..eaa8c88301 100644 --- a/LoopUI/Views/HUDView.swift +++ b/LoopUI/Views/HUDView.swift @@ -7,24 +7,46 @@ // import UIKit +import LoopKitUI -public class HUDView: UIStackView, NibLoadable { +public class HUDView: UIView, NibLoadable { @IBOutlet public weak var loopCompletionHUD: LoopCompletionHUDView! @IBOutlet public weak var glucoseHUD: GlucoseHUDView! @IBOutlet public weak var basalRateHUD: BasalRateHUDView! - @IBOutlet public weak var reservoirVolumeHUD: ReservoirVolumeHUDView! - @IBOutlet public weak var batteryHUD: BatteryLevelHUDView! + + private var stackView: UIStackView! func setup() { - let stackView = HUDView.nib().instantiate(withOwner: self, options: nil)[0] as! UIStackView + stackView = (HUDView.nib().instantiate(withOwner: self, options: nil)[0] as! UIStackView) + stackView.translatesAutoresizingMaskIntoConstraints = false self.addSubview(stackView) // Use AutoLayout to have the stack view fill its entire container. - let horizontalConstraint = NSLayoutConstraint(item: stackView, attribute: .centerX, relatedBy: .equal, toItem: self, attribute: .centerX, multiplier: 1, constant: 0) - let verticalConstraint = NSLayoutConstraint(item: stackView, attribute: .centerY, relatedBy: .equal, toItem: self, attribute: .centerY, multiplier: 1, constant: 0) - let widthConstraint = NSLayoutConstraint(item: stackView, attribute: .width, relatedBy: .equal, toItem: self, attribute: .width, multiplier: 1, constant: 0) - let heightConstraint = NSLayoutConstraint(item: stackView, attribute: .height, relatedBy: .equal, toItem: self, attribute: .height, multiplier: 1, constant: 0) - self.addConstraints([horizontalConstraint, verticalConstraint, widthConstraint, heightConstraint]) + NSLayoutConstraint.activate([ + stackView.centerXAnchor.constraint(equalTo: centerXAnchor), + stackView.centerYAnchor.constraint(equalTo: centerYAnchor), + stackView.widthAnchor.constraint(equalTo: widthAnchor), + stackView.heightAnchor.constraint(equalTo: heightAnchor), + ]) + } + + public func removePumpManagerProvidedViews() { + let standardViews: [UIView] = [loopCompletionHUD, glucoseHUD, basalRateHUD] + let pumpManagerViews = stackView.subviews.filter { !standardViews.contains($0) } + for view in pumpManagerViews { + view.removeFromSuperview() + } + } + + public func addHUDView(_ viewToAdd: BaseHUDView) { + let insertIndex = stackView.arrangedSubviews.firstIndex { (view) -> Bool in + guard let hudView = view as? BaseHUDView else { + return false + } + return viewToAdd.orderPriority <= hudView.orderPriority + } + + stackView.insertArrangedSubview(viewToAdd, at: insertIndex ?? stackView.arrangedSubviews.count) } public override init(frame: CGRect) { @@ -32,7 +54,7 @@ public class HUDView: UIStackView, NibLoadable { setup() } - public required init(coder aDecoder: NSCoder) { + public required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) setup() } diff --git a/LoopUI/Views/LevelMaskView.swift b/LoopUI/Views/LevelMaskView.swift deleted file mode 100644 index ec7840fd71..0000000000 --- a/LoopUI/Views/LevelMaskView.swift +++ /dev/null @@ -1,85 +0,0 @@ -// -// LevelMaskView.swift -// Loop -// -// Created by Nate Racklyeft on 8/28/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import UIKit - -// Displays a variable-height level indicator, masked by an image. -// Inspired by https://github.com/carekit-apple/CareKit/blob/master/CareKit/CareCard/OCKHeartView.h - -public class LevelMaskView: UIView { - var firstDataUpdate = true - - var value: Double = 1.0 { - didSet { - animateFill(duration: firstDataUpdate ? 0 : 1.25) - firstDataUpdate = false - } - } - - @IBInspectable var maskImage: UIImage? { - didSet { - fillView?.removeFromSuperview() - mask?.removeFromSuperview() - maskImageView?.removeFromSuperview() - - guard let maskImage = maskImage else { return } - - mask = UIView() - maskImageView = UIImageView(image: maskImage) - maskImageView!.contentMode = .center - mask!.addSubview(maskImageView!) - - clipsToBounds = true - - fillView = UIView() - fillView!.backgroundColor = tintColor - addSubview(fillView!) - } - } - - private var fillView: UIView? - - private var maskImageView: UIView? - - override public func layoutSubviews() { - super.layoutSubviews() - - guard let maskImage = maskImage else { return } - - let maskImageSize = maskImage.size - - mask?.frame = CGRect(origin: .zero, size: maskImageSize) - mask?.center = CGPoint(x: bounds.midX, y: bounds.midY) - maskImageView?.frame = mask?.bounds ?? bounds - - if (fillView?.layer.animationKeys()?.count ?? 0) == 0 { - updateFillViewFrame() - } - } - - override public func tintColorDidChange() { - super.tintColorDidChange() - - fillView?.backgroundColor = tintColor - } - - private func animateFill(duration: TimeInterval) { - UIView.animate(withDuration: duration, delay: 0, options: .beginFromCurrentState, animations: { - self.updateFillViewFrame() - }, completion: nil) - } - - private func updateFillViewFrame() { - guard let maskViewFrame = mask?.frame else { return } - - var fillViewFrame = maskViewFrame - fillViewFrame.origin.y = maskViewFrame.maxY - fillViewFrame.size.height = -CGFloat(value) * maskViewFrame.height - fillView?.frame = fillViewFrame - } -} diff --git a/LoopUI/Views/LoopCompletionHUDView.swift b/LoopUI/Views/LoopCompletionHUDView.swift index 1d3da43a1a..b0e6b1387b 100644 --- a/LoopUI/Views/LoopCompletionHUDView.swift +++ b/LoopUI/Views/LoopCompletionHUDView.swift @@ -7,10 +7,22 @@ // import UIKit +import LoopKitUI +import LoopCore public final class LoopCompletionHUDView: BaseHUDView { @IBOutlet private weak var loopStateView: LoopStateView! + + override public var orderPriority: HUDViewOrderPriority { + return 2 + } + + private(set) var freshness = LoopCompletionFreshness.stale { + didSet { + updateTintColor() + } + } override public func awakeFromNib() { super.awakeFromNib() @@ -18,26 +30,33 @@ public final class LoopCompletionHUDView: BaseHUDView { updateDisplay(nil) } - public var dosingEnabled = false { + public var loopIconClosed = false { didSet { - loopStateView.open = !dosingEnabled + loopStateView.open = !loopIconClosed } } public var lastLoopCompleted: Date? { didSet { - updateTimer = nil - loopInProgress = false - assertTimer() + if lastLoopCompleted != oldValue { + loopInProgress = false + } } } public var loopInProgress = false { didSet { loopStateView.animated = loopInProgress + + if !loopInProgress { + updateTimer = nil + assertTimer() + } } } + public var closedLoopDisallowedLocalizedDescription: String? + public func assertTimer(_ active: Bool = true) { if active && window != nil, let date = lastLoopCompleted { initTimer(date) @@ -46,6 +65,26 @@ public final class LoopCompletionHUDView: BaseHUDView { } } + override public func stateColorsDidUpdate() { + super.stateColorsDidUpdate() + updateTintColor() + } + + private func updateTintColor() { + let tintColor: UIColor? + + switch freshness { + case .fresh: + tintColor = stateColors?.normal + case .aging: + tintColor = stateColors?.warning + case .stale: + tintColor = stateColors?.error + } + + self.tintColor = tintColor + } + private func initTimer(_ startDate: Date) { let updateInterval = TimeInterval(minutes: 1) @@ -59,7 +98,7 @@ public final class LoopCompletionHUDView: BaseHUDView { ) updateTimer = timer - RunLoop.main.add(timer, forMode: RunLoopMode.defaultRunLoopMode) + RunLoop.main.add(timer, forMode: .default) } private var updateTimer: Timer? { @@ -70,45 +109,90 @@ public final class LoopCompletionHUDView: BaseHUDView { } } - private lazy var formatter: DateComponentsFormatter = { + private lazy var formatterFull: DateComponentsFormatter = { let formatter = DateComponentsFormatter() - formatter.allowedUnits = [.hour, .minute] + formatter.allowedUnits = [.day, .hour, .minute] + formatter.maximumUnitCount = 1 + formatter.unitsStyle = .full + + return formatter + }() + + private var lastLoopMessage: String = "" + + private lazy var timeAgoFormatter: DateComponentsFormatter = { + let formatter = DateComponentsFormatter() + + formatter.allowedUnits = [.day, .hour, .minute] formatter.maximumUnitCount = 1 formatter.unitsStyle = .short return formatter }() + private lazy var timeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .none + formatter.timeStyle = .short + return formatter + }() + + private lazy var timeDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + formatter.locale = Locale.current + return formatter + }() + @objc private func updateDisplay(_: Timer?) { + lastLoopMessage = "" + let timeAgoToIncludeTimeStamp: TimeInterval = .minutes(20) + let timeAgoToIncludeDate: TimeInterval = .hours(4) if let date = lastLoopCompleted { let ago = abs(min(0, date.timeIntervalSinceNow)) - switch ago { - case let t where t.minutes <= 5: - loopStateView.freshness = .fresh - case let t where t.minutes <= 15: - loopStateView.freshness = .aging - default: - loopStateView.freshness = .stale - } - - if let timeString = formatter.string(from: ago) { - caption.text = String(format: NSLocalizedString("%@ ago", comment: "Format string describing the time interval since the last completion date. (1: The localized date components"), timeString) - accessibilityLabel = String(format: NSLocalizedString("Loop ran %@ ago", comment: "Accessbility format label describing the time interval since the last completion date. (1: The localized date components)"), timeString) + freshness = LoopCompletionFreshness(age: ago) + + if let timeString = timeAgoFormatter.string(from: ago) { + switch traitCollection.preferredContentSizeCategory { + case UIContentSizeCategory.extraSmall, + UIContentSizeCategory.small, + UIContentSizeCategory.medium, + UIContentSizeCategory.large: + // Use a longer form only for smaller text sizes + caption?.text = String(format: LocalizedString("%@ ago", comment: "Format string describing the time interval since the last completion date. (1: The localized date components"), timeString) + default: + caption?.text = timeString + } + + accessibilityLabel = String(format: LocalizedString("Loop ran %@ ago", comment: "Accessbility format label describing the time interval since the last completion date. (1: The localized date components)"), timeString) + + var fullTimeStr: String = "" + if ago >= timeAgoToIncludeDate { + fullTimeStr = String(format: LocalizedString("was at %1$@", comment: "Format string describing last completion. (1: the date"), timeDateFormatter.string(from: date)) + } else if ago >= timeAgoToIncludeTimeStamp { + fullTimeStr = String(format: LocalizedString("%1$@ ago at %2$@", comment: "Format string describing last completion. (1: time ago, (2: the date"), timeAgoFormatter.string(from: ago)!, timeFormatter.string(from: date)) + } else if ago < .minutes(1) { + fullTimeStr = String(format: LocalizedString("<1 min ago", comment: "Format string describing last completion")) + } else { + fullTimeStr = String(format: LocalizedString("%1$@ ago", comment: "Format string describing last completion. (1: time ago"), timeAgoFormatter.string(from: ago)!) + } + lastLoopMessage = String(format: LocalizedString("Last completed loop %1$@.", comment: "Last loop time completed message (1: last loop time string)"), fullTimeStr) } else { - caption.text = "—" + caption?.text = "–" accessibilityLabel = nil } } else { - caption.text = "—" - accessibilityLabel = NSLocalizedString("Waiting for first run", comment: "Acessibility label describing completion HUD waiting for first run") + caption?.text = "–" + accessibilityLabel = LocalizedString("Waiting for first run", comment: "Accessibility label describing completion HUD waiting for first run") } - if dosingEnabled { - accessibilityHint = NSLocalizedString("Closed loop", comment: "Accessibility hint describing completion HUD for a closed loop") + if loopIconClosed { + accessibilityHint = LocalizedString("Closed loop", comment: "Accessibility hint describing completion HUD for a closed loop") } else { - accessibilityHint = NSLocalizedString("Open loop", comment: "Accessbility hint describing completion HUD for an open loop") + accessibilityHint = LocalizedString("Open loop", comment: "Accessbility hint describing completion HUD for an open loop") } } @@ -118,3 +202,25 @@ public final class LoopCompletionHUDView: BaseHUDView { assertTimer() } } + +extension LoopCompletionHUDView { + public var loopCompletionMessage: (title: String, message: String) { + switch freshness { + case .fresh: + if loopStateView.open { + let reason = closedLoopDisallowedLocalizedDescription ?? LocalizedString("Tap Settings to toggle Closed Loop ON if you wish for the app to automate your insulin.", comment: "Instructions for user to close loop if it is allowed.") + return (title: LocalizedString("Closed Loop OFF", comment: "Title of green open loop OFF message"), + message: String(format: LocalizedString("\n%1$@ is operating with Closed Loop in the OFF position. Your pump and CGM will continue operating, but the app will not adjust dosing automatically.\n\n%2$@", comment: "Green closed loop OFF message (1: app name)(2: reason for open loop)"), Bundle.main.bundleDisplayName, reason)) + } else { + return (title: LocalizedString("Closed Loop ON", comment: "Title of green closed loop ON message"), + message: String(format: LocalizedString("\n%1$@\n\n%2$@ is operating with Closed Loop in the ON position.", comment: "Green closed loop ON message (1: last loop string) (2: app name)"), lastLoopMessage, Bundle.main.bundleDisplayName)) + } + case .aging: + return (title: LocalizedString("Loop Warning", comment: "Title of yellow loop message"), + message: String(format: LocalizedString("\n%1$@\n\nTap your CGM and insulin pump status icons for more information. %2$@ will continue trying to complete a loop, but watch for potential communication issues with your pump and CGM.", comment: "Yellow loop message (1: last loop string) (2: app name)"), lastLoopMessage, Bundle.main.bundleDisplayName)) + case .stale: + return (title: LocalizedString("Loop Failure", comment: "Title of red loop message"), + message: String(format: LocalizedString("\n%1$@\n\nTap your CGM and insulin pump status icons for more information. %2$@ will continue trying to complete a loop, but check for potential communication issues with your pump and CGM.", comment: "Red loop message (1: last loop string) (2: app name)"), lastLoopMessage, Bundle.main.bundleDisplayName)) + } + } +} diff --git a/LoopUI/Views/LoopStateView.swift b/LoopUI/Views/LoopStateView.swift index 96c1ffbd35..eedc483de4 100644 --- a/LoopUI/Views/LoopStateView.swift +++ b/LoopUI/Views/LoopStateView.swift @@ -8,33 +8,17 @@ import UIKit -public final class LoopStateView: UIView { +final class LoopStateView: UIView { var firstDataUpdate = true - enum Freshness { - case fresh - case aging - case stale - case unknown - - var color: UIColor { - switch self { - case .fresh: - return UIColor.freshColor - case .aging: - return UIColor.agingColor - case .stale: - return UIColor.staleColor - case .unknown: - return UIColor.unknownColor - } - } + override func tintColorDidChange() { + super.tintColorDidChange() + + updateTintColor() } - var freshness = Freshness.unknown { - didSet { - shapeLayer.strokeColor = freshness.color.cgColor - } + private func updateTintColor() { + shapeLayer.strokeColor = tintColor.cgColor } var open = false { @@ -45,7 +29,7 @@ public final class LoopStateView: UIView { } } - override public class var layerClass : AnyClass { + override class var layerClass : AnyClass { return CAShapeLayer.self } @@ -58,22 +42,22 @@ public final class LoopStateView: UIView { shapeLayer.lineWidth = 8 shapeLayer.fillColor = UIColor.clear.cgColor - shapeLayer.strokeColor = freshness.color.cgColor + updateTintColor() shapeLayer.path = drawPath() } - required public init?(coder aDecoder: NSCoder) { + required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) shapeLayer.lineWidth = 8 shapeLayer.fillColor = UIColor.clear.cgColor - shapeLayer.strokeColor = freshness.color.cgColor + updateTintColor() shapeLayer.path = drawPath() } - override public func layoutSubviews() { + override func layoutSubviews() { super.layoutSubviews() shapeLayer.path = drawPath() @@ -84,8 +68,8 @@ public final class LoopStateView: UIView { let lineWidth = lineWidth ?? shapeLayer.lineWidth let radius = min(bounds.width / 2, bounds.height / 2) - lineWidth / 2 - let startAngle = open ? CGFloat(-M_PI_4) : 0 - let endAngle = open ? CGFloat(5 * M_PI_4) : CGFloat(2 * M_PI) + let startAngle = open ? -CGFloat.pi / 4 : 0 + let endAngle = open ? 5 * CGFloat.pi / 4 : 2 * CGFloat.pi let path = UIBezierPath( arcCenter: center, @@ -117,7 +101,7 @@ public final class LoopStateView: UIView { group.duration = firstDataUpdate ? 0 : 1 group.repeatCount = HUGE group.autoreverses = true - group.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut) + group.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) shapeLayer.add(group, forKey: type(of: self).AnimationKey) } else { diff --git a/LoopUI/Views/PumpStatusHUDView.swift b/LoopUI/Views/PumpStatusHUDView.swift new file mode 100644 index 0000000000..7b6aaeb889 --- /dev/null +++ b/LoopUI/Views/PumpStatusHUDView.swift @@ -0,0 +1,92 @@ +// +// PumpStatusHUDView.swift +// LoopUI +// +// Created by Nathaniel Hamming on 2020-06-09. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import UIKit +import HealthKit +import LoopKit +import LoopKitUI + +public final class PumpStatusHUDView: DeviceStatusHUDView, NibLoadable { + + @IBOutlet public weak var basalRateHUD: BasalRateHUDView! + + @IBOutlet public weak var pumpManagerProvidedHUD: BaseHUDView! + + override public var orderPriority: HUDViewOrderPriority { + return 3 + } + + public override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + public required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + setup() + } + + override func setup() { + super.setup() + statusHighlightView.setIconPosition(.left) + } + + public override func tintColorDidChange() { + super.tintColorDidChange() + + basalRateHUD.tintColor = tintColor + } + + override public func presentStatusHighlight() { + guard !statusStackView.arrangedSubviews.contains(statusHighlightView) else { + return + } + + // need to also hide these view, since they will be added back to the stack at some point + basalRateHUD.isHidden = true + statusStackView.removeArrangedSubview(basalRateHUD) + + if let pumpManagerProvidedHUD = pumpManagerProvidedHUD { + pumpManagerProvidedHUD.isHidden = true + statusStackView.removeArrangedSubview(pumpManagerProvidedHUD) + } + + super.presentStatusHighlight() + } + + override public func dismissStatusHighlight() { + guard statusStackView.arrangedSubviews.contains(statusHighlightView) else { + return + } + + super.dismissStatusHighlight() + + statusStackView.addArrangedSubview(basalRateHUD) + basalRateHUD.isHidden = false + + if let pumpManagerProvidedHUD = pumpManagerProvidedHUD { + statusStackView.addArrangedSubview(pumpManagerProvidedHUD) + pumpManagerProvidedHUD.isHidden = false + } + } + + public func removePumpManagerProvidedHUD() { + guard let pumpManagerProvidedHUD = pumpManagerProvidedHUD else { + return + } + + statusStackView.removeArrangedSubview(pumpManagerProvidedHUD) + pumpManagerProvidedHUD.removeFromSuperview() + } + + public func addPumpManagerProvidedHUDView(_ pumpManagerProvidedHUD: BaseHUDView) { + self.pumpManagerProvidedHUD = pumpManagerProvidedHUD + statusStackView.addArrangedSubview(self.pumpManagerProvidedHUD) + } + +} diff --git a/LoopUI/Views/ReservoirVolumeHUDView.swift b/LoopUI/Views/ReservoirVolumeHUDView.swift deleted file mode 100644 index e3eb5df7fb..0000000000 --- a/LoopUI/Views/ReservoirVolumeHUDView.swift +++ /dev/null @@ -1,74 +0,0 @@ -// -// ReservoirVolumeHUDView.swift -// Naterade -// -// Created by Nathan Racklyeft on 5/2/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import UIKit - -public final class ReservoirVolumeHUDView: BaseHUDView { - - @IBOutlet private weak var levelMaskView: LevelMaskView! - - @IBOutlet private weak var volumeLabel: UILabel! - - override public func awakeFromNib() { - super.awakeFromNib() - - tintColor = .unknownColor - volumeLabel.isHidden = true - - accessibilityValue = NSLocalizedString("Unknown", comment: "Accessibility value for an unknown value") - } - - public var reservoirLevel: Double? { - didSet { - levelMaskView.value = reservoirLevel ?? 1.0 - - switch reservoirLevel { - case .none: - tintColor = .unknownColor - volumeLabel.isHidden = true - case let x? where x > 0.25: - tintColor = .secondaryLabelColor - volumeLabel.isHidden = true - case let x? where x > 0.10: - tintColor = .agingColor - volumeLabel.textColor = tintColor - volumeLabel.isHidden = false - default: - tintColor = .staleColor - volumeLabel.textColor = tintColor - volumeLabel.isHidden = false - } - } - } - - private lazy var timeFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateStyle = .none - formatter.timeStyle = .short - - return formatter - }() - - private lazy var numberFormatter: NumberFormatter = { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - formatter.maximumFractionDigits = 0 - - return formatter - }() - - public func setReservoirVolume(volume: Double, at date: Date) { - if let units = numberFormatter.string(from: NSNumber(value: volume)) { - volumeLabel.text = String(format: NSLocalizedString("%@U", comment: "Format string for reservoir volume. (1: The localized volume)"), units) - let time = timeFormatter.string(from: date) - caption?.text = time - - accessibilityValue = String(format: NSLocalizedString("%1$@ units remaining at %2$@", comment: "Accessibility format string for (1: localized volume)(2: time)"), units, time) - } - } -} diff --git a/LoopUI/Views/StatusBadgeHUDView.swift b/LoopUI/Views/StatusBadgeHUDView.swift new file mode 100644 index 0000000000..f99cda98da --- /dev/null +++ b/LoopUI/Views/StatusBadgeHUDView.swift @@ -0,0 +1,27 @@ +// +// StatusBadgeHUDView.swift +// LoopUI +// +// Created by Nathaniel Hamming on 2021-02-11. +// Copyright © 2021 LoopKit Authors. All rights reserved. +// + +import UIKit + +public final class StatusBadgeHUDView: UIView { + + @IBOutlet private weak var badgeIcon: UIImageView! { + didSet { + // badge common (default) color is warning + badgeIcon.tintColor = .warning + } + } + + public func setBadgeIcon(_ icon: UIImage?) { + badgeIcon.image = icon + } + + override public func tintColorDidChange() { + badgeIcon.tintColor = self.tintColor + } +} diff --git a/LoopUI/Views/StatusBarHUDView.swift b/LoopUI/Views/StatusBarHUDView.swift new file mode 100644 index 0000000000..3bd851e2e2 --- /dev/null +++ b/LoopUI/Views/StatusBarHUDView.swift @@ -0,0 +1,72 @@ +// +// StatusBarHUDView.swift +// LoopUI +// +// Created by Nathaniel Hamming on 2020-06-05. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import UIKit +import LoopKitUI + +public class StatusBarHUDView: UIView, NibLoadable { + + @IBOutlet public weak var cgmStatusHUD: CGMStatusHUDView! + + @IBOutlet public weak var loopCompletionHUD: LoopCompletionHUDView! + + @IBOutlet public weak var pumpStatusHUD: PumpStatusHUDView! + + public var containerView: UIStackView! + + public var adjustViewsForNarrowDisplay: Bool = false { + didSet { + if adjustViewsForNarrowDisplay != oldValue { + cgmStatusHUD.adjustViewsForNarrowDisplay = adjustViewsForNarrowDisplay + pumpStatusHUD.adjustViewsForNarrowDisplay = adjustViewsForNarrowDisplay + containerView.spacing = adjustViewsForNarrowDisplay ? 8.0 : 16.0 + } + } + } + + override public var bounds: CGRect { + didSet { + // need to adjust for narrow display. The labels in the status bar need more space when the bounds width is less than 350 points. + adjustViewsForNarrowDisplay = bounds.width < 350 + } + } + + override public init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + public required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + setup() + } + + func setup() { + containerView = (StatusBarHUDView.nib().instantiate(withOwner: self, options: nil)[0] as! UIStackView) + containerView.translatesAutoresizingMaskIntoConstraints = false + self.addSubview(containerView) + + // Use AutoLayout to have the stack view fill its entire container. + NSLayoutConstraint.activate([ + containerView.centerXAnchor.constraint(equalTo: centerXAnchor), + containerView.centerYAnchor.constraint(equalTo: centerYAnchor), + containerView.widthAnchor.constraint(equalTo: widthAnchor), + containerView.heightAnchor.constraint(equalTo: heightAnchor), + ]) + + self.backgroundColor = UIColor.secondarySystemBackground + } + + public func removePumpManagerProvidedView() { + pumpStatusHUD.removePumpManagerProvidedHUD() + } + + public func addPumpManagerProvidedHUDView(_ pumpManagerProvidedHUD: BaseHUDView) { + pumpStatusHUD.addPumpManagerProvidedHUDView(pumpManagerProvidedHUD) + } +} diff --git a/LoopUI/Views/StatusHighlightHUDView.swift b/LoopUI/Views/StatusHighlightHUDView.swift new file mode 100644 index 0000000000..564b6ca0c0 --- /dev/null +++ b/LoopUI/Views/StatusHighlightHUDView.swift @@ -0,0 +1,74 @@ +// +// StatusHighlightHUDView.swift +// LoopUI +// +// Created by Nathaniel Hamming on 2020-06-05. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import UIKit + +public class StatusHighlightHUDView: UIView, NibLoadable { + + private var stackView: UIStackView! + + @IBOutlet public weak var messageLabel: UILabel! + + @IBOutlet public weak var icon: UIImageView! { + didSet { + icon.tintColor = tintColor + } + } + + public enum IconPosition { + case left + case right + } + + private var iconPosition: IconPosition = .right { + didSet { + stackView.removeArrangedSubview(messageLabel) + stackView.removeArrangedSubview(icon) + switch iconPosition { + case .left: + stackView.addArrangedSubview(icon) + stackView.addArrangedSubview(messageLabel) + messageLabel.textAlignment = .left + case .right: + stackView.addArrangedSubview(messageLabel) + stackView.addArrangedSubview(icon) + messageLabel.textAlignment = .right + } + } + } + + public override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + public required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + setup() + } + + func setup() { + stackView = (StatusHighlightHUDView.nib().instantiate(withOwner: self, options: nil)[0] as! UIStackView) + stackView.translatesAutoresizingMaskIntoConstraints = false + self.addSubview(stackView) + + // Use AutoLayout to have the stack view fill its entire container. + NSLayoutConstraint.activate([ + stackView.centerXAnchor.constraint(equalTo: centerXAnchor), + stackView.centerYAnchor.constraint(equalTo: centerYAnchor), + stackView.widthAnchor.constraint(equalTo: widthAnchor), + stackView.heightAnchor.constraint(equalTo: heightAnchor), + ]) + } + + public func setIconPosition(_ iconPosition: IconPosition) { + if self.iconPosition != iconPosition { + self.iconPosition = iconPosition + } + } +} diff --git a/README.md b/README.md index 182c0f3b27..1e593d4a4a 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # Loop for iOS -![App Icon](/Loop/Assets.xcassets/AppIcon.appiconset/Icon-40%402x.png?raw=true) ![WatchApp Icon](/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-40%402x.png?raw=true) +![App Icon](/Loop/DerivedAssetsBase.xcassets/AppIcon.appiconset/Icon-Small-40%402x.png?raw=true) [![Build Status](https://travis-ci.org/LoopKit/Loop.svg?branch=master)](https://travis-ci.org/LoopKit/Loop) -[![Join the chat at https://gitter.im/LoopKit/Loop](https://badges.gitter.im/LoopKit/Loop.svg)](https://gitter.im/LoopKit/Loop?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) +[![Join the chat at https://loop.zulipchat.com](https://img.shields.io/badge/zulip-join_chat-brightgreen.svg)](https://loop.zulipchat.com) Loop is an app template for building an automated insulin delivery system. It is a stone resting on the boulders of work done by many others. @@ -26,73 +26,11 @@ Please understand that this project: Screenshot of bolus failure notification on Apple Watch Screenshot of bolus failure notification on Apple Watch -# Requirements +# Documentation - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Insulin Pump
MM 515/7152MM 522/7222MM 523/7233MM 554/7543
CGMDexcom G41
Dexcom G5
MM CGM4
+Please visit the [Loop Docs](https://loopkit.github.io/loopdocs/) for installation, algorithm, and other details. -
1. Offline access to glucose requires a Receiver with Share and the [Share2 app](https://itunes.apple.com/us/app/dexcom-share2/id834775275?mt=8) to be running on the same device. Internet-dependent access via Share servers is also supported. -
2. Pump must have a remote ID added in the [Remote Options](https://www.medtronicdiabetes.com/sites/default/files/library/download-library/workbooks/x22_menu_map.pdf) menu. -
3. Early firmware (US <= 2.4A, AU/EUR <= 2.6A) is required for using Closed Loop and Bolus features. -
4. It's not impossible, but comms-heavy and there's [some work to be done](https://github.com/LoopKit/Loop/issues/100). - -### Mac and Xcode - -To build Loop you will need a Mac, and have Xcode 8 installed on it. You can build Loop without an Apple Developer Account, but any apps built this way will expire after a week, so signing up for the $99 developer account is recommended. - -### iOS Phone - -Loop will run on on any iPhone that is compatible with iOS 10. - -### RileyLink - -Bluetooth LE communication with Minimed pumps is enabled by the [RileyLink](https://github.com/ps2/rileylink), a compact BLE-to-916MHz bridge device designed by [@ps2](https://github.com/ps2). Please visit the [repository](https://github.com/ps2/rileylink) and the [gitter room](https://gitter.im/ps2/rileylink) for more information. - -### Carthage - -[Carthage](https://github.com/carthage/carthage) is used to manage framework dependencies. It will need to be [installed on your Mac](https://github.com/carthage/carthage#installing-carthage) to build and run the app, but most users won't have a need to explicitly rebuild any dependencies. - -# Getting Started - -[Sign up for the Loop Users announcement list](https://groups.google.com/forum/#!forum/loop-ios-users) to stay informed of critical issues that may arise. - -[Please visit the Wiki for a "Guide to Loop" setup, installation, FAQs, and use.](https://github.com/LoopKit/Loop/wiki) +For FAQs, how to contribute to open source aspects of Loop and other tips, refer to the [Wiki](https://github.com/LoopKit/Loop/wiki) (Note: there is also a tab for the Wiki at the top of this page) diff --git a/Scripts/apply-info-customizations.sh b/Scripts/apply-info-customizations.sh new file mode 100755 index 0000000000..8cb9a49d08 --- /dev/null +++ b/Scripts/apply-info-customizations.sh @@ -0,0 +1,61 @@ +#!/bin/sh -e + +# apply-info-customizations.sh +# Loop +# +# Created by Pete Schwamb on 4/25/23. +# Copyright © 2023 LoopKit Authors. All rights reserved. + + +SCRIPT="$(basename "${0}")" +SCRIPT_DIRECTORY="$(dirname "${0}")" + +error() { + echo "ERROR: ${*}" >&2 + echo "Usage: ${SCRIPT} [-i|--info-plist-path info-plist-path]" >&2 + echo "Parameters:" >&2 + echo " -i|--info-plist-path path to the Info.plist file to modify; optional, defaults to \${BUILT_PRODUCTS_DIR}/\${INFOPLIST_PATH}" >&2 + exit 1 +} + +warn() { + echo "WARN: ${*}" >&2 +} + +info() { + echo "INFO: ${*}" >&2 +} + +info_plist_path="${BUILT_PRODUCTS_DIR}/${CONTENTS_FOLDER_PATH}/BuildDetails.plist" +while [[ $# -gt 0 ]] +do + case $1 in + -i|--info-plist-path) + info_plist_path="${2}" + shift 2 + ;; + esac +done + +if [ ${#} -ne 0 ]; then + error "Unexpected arguments: ${*}" +fi + +if [ "${info_plist_path}" == "/" -o ! -e "${info_plist_path}" ]; then + error "Must provide valid --info-plist-path, or have valid \${BUILT_PRODUCTS_DIR} and \${INFOPLIST_PATH} set." +fi + +info "Applying info.plist customizations from ../InfoCustomizations.txt" + +while read -r -a words; do # iterate over lines of input + set -- "${words[@]}" # update positional parameters + for word; do + if [[ $word = *"="* ]]; then # if a word contains an "="... + key=${word%%=*} + value=${word#*=} + echo "Key = $key" + echo "Value = $value" + plutil -replace $key -string "${value}" "${info_plist_path}" + fi + done +done <"../InfoCustomizations.txt" diff --git a/Scripts/build-derived-assets.sh b/Scripts/build-derived-assets.sh new file mode 100755 index 0000000000..18f6bf93f8 --- /dev/null +++ b/Scripts/build-derived-assets.sh @@ -0,0 +1,63 @@ +#!/bin/sh -eu + +# +# build-derived-assets.sh +# Loop +# +# Copyright © 2019 LoopKit Authors. All rights reserved. +# + +SCRIPT="$(basename "${0}")" + +error() { + echo "ERROR: ${*}" >&2 + echo "Usage: ${SCRIPT} " >&2 + echo "Parameters:" >&2 + echo " directory with derived assets" >&2 + exit 1 +} + +info() { + echo "INFO: ${*}" >&2 +} + +if [ ${#} -lt 1 ]; then + error "Missing arguments" +fi + +DIRECTORY="${1}" +shift 1 + +if [ ${#} -ne 0 ]; then + error "Unexpected arguments: ${*}" +fi + +if [ ! -d "${DIRECTORY}" ]; then + error "Directory '${DIRECTORY}' does not exist" +fi + +DERIVED_ASSETS="${DIRECTORY}/DerivedAssets.xcassets" +DERIVED_ASSETS_BASE="${DIRECTORY}/DerivedAssetsBase.xcassets" + +# Assets can be overridden by a DerivedAssetsOverride.xcassets in ${DIRECTORY}, or +# By a file named ${DIRECTORY}/../../OverrideAssets${EXECUTABLE_NAME}.xcassets + +DERIVED_ASSETS_OVERRIDE="${DIRECTORY}/DerivedAssetsOverride.xcassets" +if [ ! -e "${DERIVED_ASSETS_OVERRIDE}" ]; then + DERIVED_ASSETS_OVERRIDE="${DIRECTORY}/../../OverrideAssets${EXECUTABLE_NAME}.xcassets" +fi + +info "Building derived assets for ${DIRECTORY}..." +rm -rf "${DERIVED_ASSETS}" + +info "Copying derived assets base to derived assets..." +cp -av "${DERIVED_ASSETS_BASE}" "${DERIVED_ASSETS}" + +if [ -e "${DERIVED_ASSETS_OVERRIDE}" ]; then + info "Copying derived assets override to derived assets..." + for ASSET_PATH in "${DERIVED_ASSETS_OVERRIDE}"/*; do + ASSET_FILE="$(basename "${ASSET_PATH}")" + rm -rf "${DERIVED_ASSETS}/${ASSET_FILE}" + cp -av "${ASSET_PATH}" "${DERIVED_ASSETS}/${ASSET_FILE}" + done +fi diff --git a/Scripts/capture-build-details.sh b/Scripts/capture-build-details.sh new file mode 100755 index 0000000000..07aab04a17 --- /dev/null +++ b/Scripts/capture-build-details.sh @@ -0,0 +1,102 @@ +#!/bin/sh -e + +# capture-build-details.sh +# Loop +# +# Copyright © 2019 LoopKit Authors. All rights reserved. + +SCRIPT="$(basename "${0}")" +SCRIPT_DIRECTORY="$(dirname "${0}")" + +error() { + echo "ERROR: ${*}" >&2 + echo "Usage: ${SCRIPT} [-r|--git-source-root git-source-root] [-p|--provisioning-profile-path provisioning-profile-path]" >&2 + echo "Parameters:" >&2 + echo " -p|--provisioning-profile-path path to the .mobileprovision provisioning profile file to check for expiration; optional, defaults to \${HOME}/Library/MobileDevice/Provisioning Profiles/\${EXPANDED_PROVISIONING_PROFILE}.mobileprovision" >&2 + exit 1 +} + +warn() { + echo "WARN: ${*}" >&2 +} + +info() { + echo "INFO: ${*}" >&2 +} + +info_plist_path="${BUILT_PRODUCTS_DIR}/${CONTENTS_FOLDER_PATH}/BuildDetails.plist" +xcode_build_version=${XCODE_PRODUCT_BUILD_VERSION:-$(xcodebuild -version | grep version | cut -d ' ' -f 3)} + +while [[ $# -gt 0 ]] +do + case $1 in + -i|--info-plist-path) + info_plist_path="${2}" + shift 2 + ;; + -p|--provisioning-profile-path) + provisioning_profile_path="${2}" + shift 2 + ;; + esac +done + +if [ ${#} -ne 0 ]; then + error "Unexpected arguments: ${*}" +fi + +if [ "${info_plist_path}" == "/" -o ! -e "${info_plist_path}" ]; then + error "File does not exist: ${info_plist_path}" +fi + +info "Gathering build details in ${PWD}" + +if [ -e .git ]; then + rev=$(git rev-parse HEAD) + plutil -replace com-loopkit-Loop-git-revision -string ${rev:0:7} "${info_plist_path}" + branch=$(git branch --show-current) + if [ -n "$branch" ]; then + plutil -replace com-loopkit-Loop-git-branch -string "${branch}" "${info_plist_path}" + else + warn "No git branch found, not setting com-loopkit-Loop-git-branch" + fi +fi + +plutil -replace com-loopkit-Loop-srcroot -string "${PWD}" "${info_plist_path}" +plutil -replace com-loopkit-Loop-build-date -string "$(date)" "${info_plist_path}" +plutil -replace com-loopkit-Loop-xcode-version -string "${xcode_build_version}" "${info_plist_path}" + +# Determine the provisioning profile path +if [ -z "${provisioning_profile_path}" ]; then + if [ -e "${HOME}/Library/MobileDevice/Provisioning Profiles/${EXPANDED_PROVISIONING_PROFILE}.mobileprovision" ]; then + provisioning_profile_path="${HOME}/Library/MobileDevice/Provisioning Profiles/${EXPANDED_PROVISIONING_PROFILE}.mobileprovision" + elif [ -e "${HOME}/Library/Developer/Xcode/UserData/Provisioning Profiles/${EXPANDED_PROVISIONING_PROFILE}.mobileprovision" ]; then + provisioning_profile_path="${HOME}/Library/Developer/Xcode/UserData/Provisioning Profiles/${EXPANDED_PROVISIONING_PROFILE}.mobileprovision" + else + warn "Provisioning profile not found in expected locations" + fi +fi + +if [ -e "${provisioning_profile_path}" ]; then + profile_expire_date=$(security cms -D -i "${provisioning_profile_path}" | plutil -p - | grep ExpirationDate | cut -b 23-) + # Convert to plutil format + profile_expire_date=$(date -j -f "%Y-%m-%d %H:%M:%S" "${profile_expire_date}" +"%Y-%m-%dT%H:%M:%SZ") + plutil -replace com-loopkit-Loop-profile-expiration -date "${profile_expire_date}" "${info_plist_path}" +else + warn "Invalid provisioning profile path ${provisioning_profile_path}" +fi + +# determine if this is a workspace build +# if so, fill out the git revision and branch +if [ -e ../.git ] +then + pushd . > /dev/null + cd .. + rev=$(git rev-parse HEAD) + plutil -replace com-loopkit-LoopWorkspace-git-revision -string "${rev:0:7}" "${info_plist_path}" + branch=$(git branch --show-current) + if [ -n "$branch" ]; then + plutil -replace com-loopkit-LoopWorkspace-git-branch -string "${branch}" "${info_plist_path}" + fi + popd . > /dev/null +fi diff --git a/Scripts/copy-plugins.sh b/Scripts/copy-plugins.sh new file mode 100755 index 0000000000..a81e221569 --- /dev/null +++ b/Scripts/copy-plugins.sh @@ -0,0 +1,41 @@ +#!/bin/sh -e + +# copy-plugins.sh +# Loop +# +# Copyright © 2019 LoopKit Authors. All rights reserved. + + +shopt -s nullglob + +# Copy device plugins +function copy_plugins { + echo "Looking for plugins in $1" + for f in "$1"/*.loopplugin; do + plugin=$(basename "$f") + echo Copying plugin: $plugin to frameworks directory in app + plugin_path="$(readlink -f "$f" || echo "$f")" + plugin_as_framework_path="${BUILT_PRODUCTS_DIR}/${FRAMEWORKS_FOLDER_PATH}/${plugin%.*}.framework" + rsync -va --exclude=Frameworks "$plugin_path/." "${plugin_as_framework_path}" + # Rename .plugin to .framework + if [ "$EXPANDED_CODE_SIGN_IDENTITY" != "-" ] && [ "$EXPANDED_CODE_SIGN_IDENTITY" != "" ]; then + export CODESIGN_ALLOCATE=${DT_TOOLCHAIN_DIR}/usr/bin/codesign_allocate + echo "Signing ${plugin} with ${EXPANDED_CODE_SIGN_IDENTITY_NAME}" + /usr/bin/codesign --force --sign ${EXPANDED_CODE_SIGN_IDENTITY} --timestamp=none --preserve-metadata=identifier,entitlements,flags "$plugin_as_framework_path" + else + echo "Skipping signing, no identity set" + fi + for framework_path in "${f}"/Frameworks/*.framework; do + framework=$(basename "$framework_path") + echo "Copying plugin's framework $framework_path to ${BUILT_PRODUCTS_DIR}/${FRAMEWORKS_FOLDER_PATH}/." + cp -avf "$framework_path" "${BUILT_PRODUCTS_DIR}/${FRAMEWORKS_FOLDER_PATH}/." + plugin_path="$(readlink -f "$f" || echo "$f")" + if [ "$EXPANDED_CODE_SIGN_IDENTITY" != "-" ] && [ "$EXPANDED_CODE_SIGN_IDENTITY" != "" ]; then + echo "Signing $framework for $plugin with $EXPANDED_CODE_SIGN_IDENTITY_NAME" + /usr/bin/codesign --force --sign ${EXPANDED_CODE_SIGN_IDENTITY} --timestamp=none --preserve-metadata=identifier,entitlements,flags "${BUILT_PRODUCTS_DIR}/${FRAMEWORKS_FOLDER_PATH}/${framework}" + fi + done + done +} + +copy_plugins "$BUILT_PRODUCTS_DIR" diff --git a/Scripts/install-scenarios.sh b/Scripts/install-scenarios.sh new file mode 100755 index 0000000000..36440d6441 --- /dev/null +++ b/Scripts/install-scenarios.sh @@ -0,0 +1,18 @@ +#!/bin/sh + +# install-scenarios.sh +# Loop +# +# Created by Pete Schwamb on 12/19/22. +# Copyright © 2022 LoopKit Authors. All rights reserved. + +SCENARIOS_DIR="$WORKSPACE_ROOT"/Scenarios + +if [ -d "$SCENARIOS_DIR" ] +then + echo "$SCENARIOS_DIR exists. Installing scenarios." + echo cp -a "$SCENARIOS_DIR" "${BUILT_PRODUCTS_DIR}/Scenarios" + cp -a "$SCENARIOS_DIR" "${BUILT_PRODUCTS_DIR}/${WRAPPER_NAME}/Scenarios" +else + echo "Scenarios missing or not configured... Not installing." +fi diff --git a/Scripts/make_scenario.py b/Scripts/make_scenario.py new file mode 100644 index 0000000000..0d7949b78f --- /dev/null +++ b/Scripts/make_scenario.py @@ -0,0 +1,134 @@ +import json +from math import sin, pi + + +NAME = 'Sine Curve.json' + + +class Scenario: + def __init__(self, glucose_values, basal_doses, bolus_doses, carb_entries): + self.glucose_values = glucose_values + self.basal_doses = basal_doses + self.bolus_doses = bolus_doses + self.carb_entries = carb_entries + + def json(self): + return { + 'glucoseValues': [glucose.json() for glucose in self.glucose_values], + 'basalDoses': [basal.json() for basal in self.basal_doses], + 'bolusDoses': [bolus.json() for bolus in self.bolus_doses], + 'carbEntries': [entry.json() for entry in self.carb_entries] + } + + +class GlucoseValue: + def __init__(self, mgdl, date_offset): + self.mgdl = mgdl + self.date_offset = date_offset + + def json(self): + return { + 'mgdlValue': self.mgdl, + 'dateOffset': self.date_offset + } + + +class BasalDose: + def __init__(self, units_per_hour, date_offset, duration): + self.units_per_hour = units_per_hour + self.date_offset = date_offset + self.duration = duration + + def json(self): + return { + 'unitsPerHourValue': self.units_per_hour, + 'dateOffset': self.date_offset, + 'duration': self.duration + } + + +class BolusDose: + def __init__(self, units, date_offset, delivery_duration): + self.units = units + self.date_offset = date_offset + self.delivery_duration = delivery_duration + + def json(self): + return { + 'unitsValue': self.units, + 'dateOffset': self.date_offset, + 'deliveryDuration': self.delivery_duration + } + + +class CarbEntry: + def __init__(self, grams, date_offset, + absorption_time, entered_at_offset=None): + self.grams = grams + self.date_offset = date_offset + self.absorption_time = absorption_time + self.entered_at_offset = entered_at_offset + + def json(self): + d = { + 'gramValue': self.grams, + 'dateOffset': self.date_offset, + 'absorptionTime': self.absorption_time + } + + if self.entered_at_offset is not None: + d['enteredAtOffset'] = self.entered_at_offset + + return d + + +def minutes(count): + return 60 * count + + +def hours(count): + return 60 * minutes(count) + + +def make_scenario(): + return Scenario( + make_glucose_values(), + make_basal_doses(), + make_bolus_doses(), + make_carb_entries() + ) + + +def make_glucose_values(): + amplitude = 40 + base = 110 + period = hours(3) + offsets = [minutes(t * 5) for t in range(-120, 120)] + values = [base + amplitude * sin(2 * pi / period * t) for t in offsets] + return [GlucoseValue(value, offset) for value, offset in zip(values, offsets)] + + +def make_basal_doses(): + return [ + BasalDose(1.2, hours(-1.5), hours(0.5)), + BasalDose(0.9, hours(-1.0), hours(0.5)), + BasalDose(0.8, hours(-0.5), hours(0.5)) + ] + + +def make_bolus_doses(): + return [ + BolusDose(3.0, minutes(-15), minutes(2)), + ] + + +def make_carb_entries(): + return [ + CarbEntry(30, minutes(-5), hours(3)), + CarbEntry(15, minutes(15), hours(2), entered_at_offset=minutes(-15)), + ] + + +if __name__ == '__main__': + with open(NAME, 'w') as outfile: + json.dump(make_scenario().json(), outfile) diff --git a/Shortcuts/Cancel Override.shortcut b/Shortcuts/Cancel Override.shortcut new file mode 100644 index 0000000000..2cd57e69f7 Binary files /dev/null and b/Shortcuts/Cancel Override.shortcut differ diff --git a/Shortcuts/Loop Remote Overrides.shortcut b/Shortcuts/Loop Remote Overrides.shortcut new file mode 100644 index 0000000000..f543c80851 Binary files /dev/null and b/Shortcuts/Loop Remote Overrides.shortcut differ diff --git a/Shortcuts/Loop.shortcut b/Shortcuts/Loop.shortcut new file mode 100644 index 0000000000..be2aa9be73 Binary files /dev/null and b/Shortcuts/Loop.shortcut differ diff --git a/Version.xcconfig b/Version.xcconfig new file mode 100644 index 0000000000..c5da25416b --- /dev/null +++ b/Version.xcconfig @@ -0,0 +1,16 @@ +// +// Version.xcconfig +// Loop +// +// Created by Darin Krauss on 1/30/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +// Version +// [for DIY Loop] configure the version number in LoopWorkspace +LOOP_MARKETING_VERSION = 1.0 +CURRENT_PROJECT_VERSION = 1 + +// Optional override (enables override of version) +#include? "../VersionOverride.xcconfig" +#include? "VersionOverride.xcconfig" diff --git a/WatchApp Extension/Assets.xcassets/Complication.complicationset/Circular.imageset/Contents.json b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Circular.imageset/Contents.json new file mode 100644 index 0000000000..79979e083e --- /dev/null +++ b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Circular.imageset/Contents.json @@ -0,0 +1,30 @@ +{ + "images" : [ + { + "idiom" : "watch", + "filename" : "Icon-Complication-16x16@2x.png", + "screen-width" : "<=145", + "scale" : "2x" + }, + { + "idiom" : "watch", + "filename" : "Icon-Complication-18x18@2x.png", + "screen-width" : ">161", + "scale" : "2x" + }, + { + "idiom" : "watch", + "scale" : "2x", + "screen-width" : ">145" + }, + { + "idiom" : "watch", + "scale" : "2x", + "screen-width" : ">183" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/WatchApp Extension/Assets.xcassets/Complication.complicationset/Circular.imageset/Icon-Complication-16x16@2x.png b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Circular.imageset/Icon-Complication-16x16@2x.png new file mode 100644 index 0000000000..ff4721e7b3 Binary files /dev/null and b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Circular.imageset/Icon-Complication-16x16@2x.png differ diff --git a/WatchApp Extension/Assets.xcassets/Complication.complicationset/Circular.imageset/Icon-Complication-18x18@2x.png b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Circular.imageset/Icon-Complication-18x18@2x.png new file mode 100644 index 0000000000..9f97913e0d Binary files /dev/null and b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Circular.imageset/Icon-Complication-18x18@2x.png differ diff --git a/WatchApp Extension/Assets.xcassets/Complication.complicationset/Contents.json b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Contents.json new file mode 100644 index 0000000000..e8b3252e30 --- /dev/null +++ b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Contents.json @@ -0,0 +1,53 @@ +{ + "assets" : [ + { + "filename" : "Circular.imageset", + "idiom" : "watch", + "role" : "circular" + }, + { + "filename" : "Extra Large.imageset", + "idiom" : "watch", + "role" : "extra-large" + }, + { + "filename" : "Graphic Bezel.imageset", + "idiom" : "watch", + "role" : "graphic-bezel" + }, + { + "filename" : "Graphic Circular.imageset", + "idiom" : "watch", + "role" : "graphic-circular" + }, + { + "filename" : "Graphic Corner.imageset", + "idiom" : "watch", + "role" : "graphic-corner" + }, + { + "filename" : "Graphic Extra Large.imageset", + "idiom" : "watch", + "role" : "graphic-extra-large" + }, + { + "filename" : "Graphic Large Rectangular.imageset", + "idiom" : "watch", + "role" : "graphic-large-rectangular" + }, + { + "filename" : "Modular.imageset", + "idiom" : "watch", + "role" : "modular" + }, + { + "filename" : "Utilitarian.imageset", + "idiom" : "watch", + "role" : "utilitarian" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WatchApp Extension/Assets.xcassets/Complication.complicationset/Extra Large.imageset/Contents.json b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Extra Large.imageset/Contents.json new file mode 100644 index 0000000000..aefef2914e --- /dev/null +++ b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Extra Large.imageset/Contents.json @@ -0,0 +1,28 @@ +{ + "images" : [ + { + "idiom" : "watch", + "scale" : "2x", + "screen-width" : "<=145" + }, + { + "idiom" : "watch", + "scale" : "2x", + "screen-width" : ">161" + }, + { + "idiom" : "watch", + "scale" : "2x", + "screen-width" : ">145" + }, + { + "idiom" : "watch", + "scale" : "2x", + "screen-width" : ">183" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/WatchApp Extension/Assets.xcassets/Complication.complicationset/Graphic Bezel.imageset/Contents.json b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Graphic Bezel.imageset/Contents.json new file mode 100644 index 0000000000..fe9e93e854 --- /dev/null +++ b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Graphic Bezel.imageset/Contents.json @@ -0,0 +1,30 @@ +{ + "images" : [ + { + "idiom" : "watch", + "scale" : "2x", + "screen-width" : "<=145" + }, + { + "idiom" : "watch", + "filename" : "Icon-AppleWatch-42x42@2x.png", + "screen-width" : ">161", + "scale" : "2x" + }, + { + "idiom" : "watch", + "scale" : "2x", + "screen-width" : ">145" + }, + { + "idiom" : "watch", + "filename" : "Icon-AppleWatch-47x47@2x.png", + "screen-width" : ">183", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/WatchApp Extension/Assets.xcassets/Complication.complicationset/Graphic Bezel.imageset/Icon-AppleWatch-42x42@2x.png b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Graphic Bezel.imageset/Icon-AppleWatch-42x42@2x.png new file mode 100644 index 0000000000..815478c10a Binary files /dev/null and b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Graphic Bezel.imageset/Icon-AppleWatch-42x42@2x.png differ diff --git a/WatchApp Extension/Assets.xcassets/Complication.complicationset/Graphic Bezel.imageset/Icon-AppleWatch-47x47@2x.png b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Graphic Bezel.imageset/Icon-AppleWatch-47x47@2x.png new file mode 100644 index 0000000000..28afdee569 Binary files /dev/null and b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Graphic Bezel.imageset/Icon-AppleWatch-47x47@2x.png differ diff --git a/WatchApp Extension/Assets.xcassets/Complication.complicationset/Graphic Circular.imageset/Contents.json b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Graphic Circular.imageset/Contents.json new file mode 100644 index 0000000000..fe9e93e854 --- /dev/null +++ b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Graphic Circular.imageset/Contents.json @@ -0,0 +1,30 @@ +{ + "images" : [ + { + "idiom" : "watch", + "scale" : "2x", + "screen-width" : "<=145" + }, + { + "idiom" : "watch", + "filename" : "Icon-AppleWatch-42x42@2x.png", + "screen-width" : ">161", + "scale" : "2x" + }, + { + "idiom" : "watch", + "scale" : "2x", + "screen-width" : ">145" + }, + { + "idiom" : "watch", + "filename" : "Icon-AppleWatch-47x47@2x.png", + "screen-width" : ">183", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/WatchApp Extension/Assets.xcassets/Complication.complicationset/Graphic Circular.imageset/Icon-AppleWatch-42x42@2x.png b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Graphic Circular.imageset/Icon-AppleWatch-42x42@2x.png new file mode 100644 index 0000000000..815478c10a Binary files /dev/null and b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Graphic Circular.imageset/Icon-AppleWatch-42x42@2x.png differ diff --git a/WatchApp Extension/Assets.xcassets/Complication.complicationset/Graphic Circular.imageset/Icon-AppleWatch-47x47@2x.png b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Graphic Circular.imageset/Icon-AppleWatch-47x47@2x.png new file mode 100644 index 0000000000..28afdee569 Binary files /dev/null and b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Graphic Circular.imageset/Icon-AppleWatch-47x47@2x.png differ diff --git a/WatchApp Extension/Assets.xcassets/Complication.complicationset/Graphic Corner.imageset/Contents.json b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Graphic Corner.imageset/Contents.json new file mode 100644 index 0000000000..637251bc8a --- /dev/null +++ b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Graphic Corner.imageset/Contents.json @@ -0,0 +1,30 @@ +{ + "images" : [ + { + "idiom" : "watch", + "scale" : "2x", + "screen-width" : "<=145" + }, + { + "idiom" : "watch", + "filename" : "Icon-Complication-20x20@2x.png", + "screen-width" : ">161", + "scale" : "2x" + }, + { + "idiom" : "watch", + "scale" : "2x", + "screen-width" : ">145" + }, + { + "idiom" : "watch", + "filename" : "Icon-Complication-22x22@2x.png", + "screen-width" : ">183", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/WatchApp Extension/Assets.xcassets/Complication.complicationset/Graphic Corner.imageset/Icon-Complication-20x20@2x.png b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Graphic Corner.imageset/Icon-Complication-20x20@2x.png new file mode 100644 index 0000000000..3e64959cdc Binary files /dev/null and b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Graphic Corner.imageset/Icon-Complication-20x20@2x.png differ diff --git a/WatchApp Extension/Assets.xcassets/Complication.complicationset/Graphic Corner.imageset/Icon-Complication-22x22@2x.png b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Graphic Corner.imageset/Icon-Complication-22x22@2x.png new file mode 100644 index 0000000000..1593033216 Binary files /dev/null and b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Graphic Corner.imageset/Icon-Complication-22x22@2x.png differ diff --git a/WatchApp Extension/Assets.xcassets/Complication.complicationset/Graphic Extra Large.imageset/Contents.json b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Graphic Extra Large.imageset/Contents.json new file mode 100644 index 0000000000..ed7de25e57 --- /dev/null +++ b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Graphic Extra Large.imageset/Contents.json @@ -0,0 +1,28 @@ +{ + "images" : [ + { + "idiom" : "watch", + "scale" : "2x", + "screen-width" : "<=145" + }, + { + "idiom" : "watch", + "scale" : "2x", + "screen-width" : ">161" + }, + { + "idiom" : "watch", + "scale" : "2x", + "screen-width" : ">145" + }, + { + "idiom" : "watch", + "scale" : "2x", + "screen-width" : ">183" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WatchApp Extension/Assets.xcassets/Complication.complicationset/Graphic Large Rectangular.imageset/Contents.json b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Graphic Large Rectangular.imageset/Contents.json new file mode 100644 index 0000000000..e011e32711 --- /dev/null +++ b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Graphic Large Rectangular.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "idiom" : "watch", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/WatchApp Extension/Assets.xcassets/Complication.complicationset/Modular.imageset/Contents.json b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Modular.imageset/Contents.json new file mode 100644 index 0000000000..df4e8550a6 --- /dev/null +++ b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Modular.imageset/Contents.json @@ -0,0 +1,31 @@ +{ + "images" : [ + { + "idiom" : "watch", + "filename" : "Icon-Complication-26x26@2x.png", + "screen-width" : "<=145", + "scale" : "2x" + }, + { + "idiom" : "watch", + "filename" : "Icon-Complication-29x29@2x.png", + "screen-width" : ">161", + "scale" : "2x" + }, + { + "idiom" : "watch", + "scale" : "2x", + "screen-width" : ">145" + }, + { + "idiom" : "watch", + "filename" : "Icon-Complication-32x32@2x.png", + "screen-width" : ">183", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/WatchApp Extension/Assets.xcassets/Complication.complicationset/Modular.imageset/Icon-Complication-26x26@2x.png b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Modular.imageset/Icon-Complication-26x26@2x.png new file mode 100644 index 0000000000..48e6ea7a29 Binary files /dev/null and b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Modular.imageset/Icon-Complication-26x26@2x.png differ diff --git a/WatchApp Extension/Assets.xcassets/Complication.complicationset/Modular.imageset/Icon-Complication-29x29@2x.png b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Modular.imageset/Icon-Complication-29x29@2x.png new file mode 100644 index 0000000000..2a0fc8ffc0 Binary files /dev/null and b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Modular.imageset/Icon-Complication-29x29@2x.png differ diff --git a/WatchApp Extension/Assets.xcassets/Complication.complicationset/Modular.imageset/Icon-Complication-32x32@2x.png b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Modular.imageset/Icon-Complication-32x32@2x.png new file mode 100644 index 0000000000..d929b5acc8 Binary files /dev/null and b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Modular.imageset/Icon-Complication-32x32@2x.png differ diff --git a/WatchApp Extension/Assets.xcassets/Complication.complicationset/Utilitarian.imageset/Contents.json b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Utilitarian.imageset/Contents.json new file mode 100644 index 0000000000..be2006d7ea --- /dev/null +++ b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Utilitarian.imageset/Contents.json @@ -0,0 +1,31 @@ +{ + "images" : [ + { + "idiom" : "watch", + "filename" : "Icon-Complication-20x20@2x.png", + "screen-width" : "<=145", + "scale" : "2x" + }, + { + "idiom" : "watch", + "filename" : "Icon-Complication-22x22@2x.png", + "screen-width" : ">161", + "scale" : "2x" + }, + { + "idiom" : "watch", + "scale" : "2x", + "screen-width" : ">145" + }, + { + "idiom" : "watch", + "filename" : "Icon-Complication-25x25@2x.png", + "screen-width" : ">183", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/WatchApp Extension/Assets.xcassets/Complication.complicationset/Utilitarian.imageset/Icon-Complication-20x20@2x.png b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Utilitarian.imageset/Icon-Complication-20x20@2x.png new file mode 100644 index 0000000000..3e64959cdc Binary files /dev/null and b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Utilitarian.imageset/Icon-Complication-20x20@2x.png differ diff --git a/WatchApp Extension/Assets.xcassets/Complication.complicationset/Utilitarian.imageset/Icon-Complication-22x22@2x.png b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Utilitarian.imageset/Icon-Complication-22x22@2x.png new file mode 100644 index 0000000000..1593033216 Binary files /dev/null and b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Utilitarian.imageset/Icon-Complication-22x22@2x.png differ diff --git a/WatchApp Extension/Assets.xcassets/Complication.complicationset/Utilitarian.imageset/Icon-Complication-25x25@2x.png b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Utilitarian.imageset/Icon-Complication-25x25@2x.png new file mode 100644 index 0000000000..2e5645d885 Binary files /dev/null and b/WatchApp Extension/Assets.xcassets/Complication.complicationset/Utilitarian.imageset/Icon-Complication-25x25@2x.png differ diff --git a/WatchApp Extension/Assets.xcassets/separator.colorset/Contents.json b/WatchApp Extension/Assets.xcassets/separator.colorset/Contents.json new file mode 100644 index 0000000000..5fa0de2011 --- /dev/null +++ b/WatchApp Extension/Assets.xcassets/separator.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + }, + "colors" : [ + { + "idiom" : "universal", + "color" : { + "color-space" : "srgb", + "components" : { + "red" : "0.722", + "alpha" : "0.500", + "blue" : "0.722", + "green" : "0.722" + } + } + } + ] +} \ No newline at end of file diff --git a/WatchApp Extension/Base.lproj/InfoPlist.strings b/WatchApp Extension/Base.lproj/InfoPlist.strings new file mode 100644 index 0000000000..06077199aa --- /dev/null +++ b/WatchApp Extension/Base.lproj/InfoPlist.strings @@ -0,0 +1,6 @@ +/* (No Comment) */ +"CFBundleDisplayName" = "WatchApp Extension"; + +/* (No Comment) */ +"CFBundleName" = "$(PRODUCT_NAME)"; + diff --git a/WatchApp Extension/Base.lproj/ckcomplication.strings b/WatchApp Extension/Base.lproj/ckcomplication.strings deleted file mode 100644 index 63987e6900..0000000000 --- a/WatchApp Extension/Base.lproj/ckcomplication.strings +++ /dev/null @@ -1,10 +0,0 @@ -/* - ckcomplication.strings - Loop - - Created by Nate Racklyeft on 9/18/16. - Copyright © 2016 Nathan Racklyeft. All rights reserved. -*/ - -/* The complication template example unit string */ -"mg/dL" = "mg/dL" diff --git a/WatchApp Extension/ComplicationController.swift b/WatchApp Extension/ComplicationController.swift index 63884fbbf0..9f79aad280 100644 --- a/WatchApp Extension/ComplicationController.swift +++ b/WatchApp Extension/ComplicationController.swift @@ -8,10 +8,13 @@ import ClockKit import WatchKit - +import LoopCore +import os.log final class ComplicationController: NSObject, CLKComplicationDataSource { + private let log = OSLog(category: "ComplicationController") + // MARK: - Timeline Configuration func getSupportedTimeTravelDirections(for complication: CLKComplication, withHandler handler: @escaping (CLKComplicationTimeTravelDirections) -> Void) { @@ -19,16 +22,16 @@ final class ComplicationController: NSObject, CLKComplicationDataSource { } func getTimelineStartDate(for complication: CLKComplication, withHandler handler: @escaping (Date?) -> Void) { - if let date = ExtensionDelegate.shared().lastContext?.glucoseDate { - handler(date as Date) + if let date = ExtensionDelegate.shared().loopManager.activeContext?.glucoseDate { + handler(date) } else { handler(nil) } } func getTimelineEndDate(for complication: CLKComplication, withHandler handler: @escaping (Date?) -> Void) { - if let date = ExtensionDelegate.shared().lastContext?.glucoseDate { - handler(date as Date) + if let date = ExtensionDelegate.shared().loopManager.activeContext?.glucoseDate { + handler(date) } else { handler(nil) } @@ -40,61 +43,186 @@ final class ComplicationController: NSObject, CLKComplicationDataSource { // MARK: - Timeline Population - private lazy var formatter = NumberFormatter() + private let chartManager = ComplicationChartManager() - func getCurrentTimelineEntry(for complication: CLKComplication, withHandler handler: (@escaping (CLKComplicationTimelineEntry?) -> Void)) { + private func updateChartManagerIfNeeded(completion: @escaping () -> Void) { + guard + #available(watchOSApplicationExtension 5.0, *), + let activeComplications = CLKComplicationServer.sharedInstance().activeComplications, + activeComplications.contains(where: { $0.family == .graphicRectangular }) + else { + completion() + return + } - switch complication.family { - case .modularSmall: - if let context = ExtensionDelegate.shared().lastContext, - let glucose = context.glucose, - let unit = context.preferredGlucoseUnit, - let glucoseString = formatter.string(from: NSNumber(value: glucose.doubleValue(for: unit))), - let date = context.glucoseDate, date.timeIntervalSinceNow.minutes >= -15, - let template = CLKComplicationTemplateModularSmallStackText(line1: glucoseString, date: date) + ExtensionDelegate.shared().loopManager.generateChartData { chartData in + self.chartManager.data = chartData + completion() + } + } + + func makeChart() -> UIImage? { + // c.f. https://developer.apple.com/design/human-interface-guidelines/watchos/icons-and-images/complication-images/ + let size: CGSize = { + switch WKInterfaceDevice.current().screenBounds.width { + case let x where x > 180: // 44mm + return CGSize(width: 171.0, height: 54.0) + default: // 40mm + return CGSize(width: 150.0, height: 47.0) + } + }() + + let scale = WKInterfaceDevice.current().screenScale + return chartManager.renderChartImage(size: size, scale: scale) + } + + func getCurrentTimelineEntry(for complication: CLKComplication, withHandler handler: (@escaping (CLKComplicationTimelineEntry?) -> Void)) { + updateChartManagerIfNeeded(completion: { + let entry: CLKComplicationTimelineEntry? + + let timelineDate = Date() + + self.log.default("Updating current complication timeline entry") + + if let context = ExtensionDelegate.shared().loopManager.activeContext, + let template = CLKComplicationTemplate.templateForFamily(complication.family, + from: context, + at: timelineDate, + recencyInterval: LoopCoreConstants.inputDataRecencyInterval, + chartGenerator: self.makeChart) { - handler(CLKComplicationTimelineEntry(date: date, complicationTemplate: template)) + switch complication.family { + case .graphicRectangular: + break + default: + template.tintColor = .tintColor + } + entry = CLKComplicationTimelineEntry(date: timelineDate, complicationTemplate: template) } else { - handler(nil) + entry = nil } - default: - handler(nil) - } - } - - func getTimelineEntries(for complication: CLKComplication, before date: Date, limit: Int, withHandler handler: (@escaping ([CLKComplicationTimelineEntry]?) -> Void)) { - // Call the handler with the timeline entries prior to the given date - handler(nil) + + handler(entry) + }) } func getTimelineEntries(for complication: CLKComplication, after date: Date, limit: Int, withHandler handler: (@escaping ([CLKComplicationTimelineEntry]?) -> Void)) { - // Call the handler with the timeline entries after to the given date - if let context = ExtensionDelegate.shared().lastContext, - let glucose = context.glucose, - let unit = context.preferredGlucoseUnit, - let glucoseString = formatter.string(from: NSNumber(value: glucose.doubleValue(for: unit))), - let glucoseDate = context.glucoseDate, glucoseDate.timeIntervalSince(date) > 0, - let template = CLKComplicationTemplateModularSmallStackText(line1: glucoseString, date: glucoseDate) - { - handler([CLKComplicationTimelineEntry(date: glucoseDate, complicationTemplate: template)]) - } else { - handler(nil) + updateChartManagerIfNeeded { + let entries: [CLKComplicationTimelineEntry]? + + guard let context = ExtensionDelegate.shared().loopManager.activeContext, + let glucoseDate = context.glucoseDate else + { + handler(nil) + return + } + + var futureChangeDates: [Date] = [ + // Stale glucose date: just a second after glucose expires + glucoseDate + LoopCoreConstants.inputDataRecencyInterval + 1, + ] + + if let loopLastRunDate = context.loopLastRunDate { + let freshnessCategories = [ + LoopCompletionFreshness.fresh, + LoopCompletionFreshness.aging, + LoopCompletionFreshness.stale + ].compactMap( { $0.maxAge }) + futureChangeDates.append(contentsOf: freshnessCategories.map { loopLastRunDate + $0 + 1}) + } + + entries = futureChangeDates.filter { $0 > date }.compactMap({ (futureChangeDate) -> CLKComplicationTimelineEntry? in + if let template = CLKComplicationTemplate.templateForFamily(complication.family, + from: context, + at: futureChangeDate, + recencyInterval: LoopCoreConstants.inputDataRecencyInterval, + chartGenerator: self.makeChart) + { + template.tintColor = UIColor.tintColor + self.log.default("Adding complication timeline entry for date %{public}@", String(describing: futureChangeDate)) + return CLKComplicationTimelineEntry(date: futureChangeDate, complicationTemplate: template) + } else { + return nil + } + }) + + handler(entries) } } // MARK: - Placeholder Templates func getLocalizableSampleTemplate(for complication: CLKComplication, withHandler handler: @escaping (CLKComplicationTemplate?) -> Void) { - switch complication.family { - case .modularSmall: - let template = CLKComplicationTemplateModularSmallStackText() + let template = getLocalizableSampleTemplate(for: complication.family) + handler(template) + } - template.line1TextProvider = CLKSimpleTextProvider(text: "--", shortText: "--", accessibilityLabel: "No glucose value available") - template.line2TextProvider = CLKSimpleTextProvider.localizableTextProvider(withStringsFileTextKey: "mg/dL") + func getLocalizableSampleTemplate(for family: CLKComplicationFamily) -> CLKComplicationTemplate? { + let glucoseAndTrendText = CLKSimpleTextProvider.localizableTextProvider(withStringsFileTextKey: "120↘︎") + let glucoseText = CLKSimpleTextProvider.localizableTextProvider(withStringsFileTextKey: "120") + let timeText = CLKSimpleTextProvider.localizableTextProvider(withStringsFileTextKey: "3MIN") - handler(template) - default: - handler(nil) + switch family { + case .modularSmall: + return CLKComplicationTemplateModularSmallStackText(line1TextProvider: glucoseAndTrendText, line2TextProvider: timeText) + case .modularLarge: + return CLKComplicationTemplateModularLargeTallBody(headerTextProvider: timeText, bodyTextProvider: glucoseAndTrendText) + case .circularSmall: + return CLKComplicationTemplateCircularSmallSimpleText(textProvider: glucoseAndTrendText) + case .extraLarge: + return CLKComplicationTemplateExtraLargeStackText(line1TextProvider: glucoseAndTrendText, line2TextProvider: timeText) + case .utilitarianSmall, .utilitarianSmallFlat: + return CLKComplicationTemplateUtilitarianSmallFlat(textProvider: glucoseAndTrendText) + case .utilitarianLarge: + let eventualGlucoseText = CLKSimpleTextProvider.localizableTextProvider(withStringsFileTextKey: "75") + return CLKComplicationTemplateUtilitarianLargeFlat(textProvider: CLKSimpleTextProvider.localizableTextProvider(withStringsFileFormatKey: "UtilitarianLargeFlat", textProviders: [glucoseAndTrendText, eventualGlucoseText, CLKTimeTextProvider(date: Date())])) + case .graphicCorner: + if #available(watchOSApplicationExtension 5.0, *) { + let template = CLKComplicationTemplateGraphicCornerStackText(innerTextProvider: timeText, outerTextProvider: glucoseAndTrendText) + timeText.tintColor = .tintColor + return template + } else { + return nil + } + case .graphicCircular: + if #available(watchOSApplicationExtension 5.0, *) { + return CLKComplicationTemplateGraphicCircularOpenGaugeSimpleText( + gaugeProvider: CLKSimpleGaugeProvider(style: .fill, gaugeColor: .tintColor, fillFraction: 1), + bottomTextProvider: glucoseText, + centerTextProvider: CLKSimpleTextProvider(text: "↘︎") + ) + } else { + return nil + } + case .graphicBezel: + if #available(watchOSApplicationExtension 5.0, *) { + guard let circularTemplate = getLocalizableSampleTemplate(for: .graphicCircular) as? CLKComplicationTemplateGraphicCircular else { + fatalError("\(#function) invoked with .graphicCircular must return a subclass of CLKComplicationTemplateGraphicCircular") + } + return CLKComplicationTemplateGraphicBezelCircularText(circularTemplate: circularTemplate, textProvider: timeText) + } else { + return nil + } + case .graphicRectangular: + if #available(watchOSApplicationExtension 5.0, *) { + // TODO: Better placeholder image here + return CLKComplicationTemplateGraphicRectangularLargeImage(textProvider: glucoseAndTrendText, imageProvider: CLKFullColorImageProvider(fullColorImage: UIImage())) + + } else { + return nil + } + case .graphicExtraLarge: + if #available(watchOSApplicationExtension 5.0, *) { + return CLKComplicationTemplateGraphicExtraLargeCircularOpenGaugeSimpleText( + gaugeProvider: CLKSimpleGaugeProvider(style: .fill, gaugeColor: .tintColor, fillFraction: 1), + bottomTextProvider: glucoseText, + centerTextProvider: CLKSimpleTextProvider(text: "↘︎") + ) + } else { + return nil + } + @unknown default: + return nil } } } diff --git a/WatchApp Extension/Controllers/ActionHUDController.swift b/WatchApp Extension/Controllers/ActionHUDController.swift new file mode 100644 index 0000000000..dce285b9d9 --- /dev/null +++ b/WatchApp Extension/Controllers/ActionHUDController.swift @@ -0,0 +1,298 @@ +// +// ActionHUDController.swift +// Loop +// +// Created by Nathan Racklyeft on 5/29/16. +// Copyright © 2016 Nathan Racklyeft. All rights reserved. +// + +import WatchKit +import WatchConnectivity +import HealthKit +import LoopKit +import LoopCore +import SwiftUI + + +final class ActionHUDController: HUDInterfaceController { + @IBOutlet var preMealButton: WKInterfaceButton! + @IBOutlet var preMealButtonImage: WKInterfaceImage! + @IBOutlet var preMealButtonBackground: WKInterfaceGroup! + @IBOutlet var overrideButton: WKInterfaceButton! + @IBOutlet var overrideButtonImage: WKInterfaceImage! + @IBOutlet var overrideButtonBackground: WKInterfaceGroup! + @IBOutlet var carbsButton: WKInterfaceButton! + @IBOutlet var carbsButtonImage: WKInterfaceImage! + @IBOutlet var carbsButtonBackground: WKInterfaceGroup! + @IBOutlet var bolusButton: WKInterfaceButton! + @IBOutlet var bolusButtonImage: WKInterfaceImage! + @IBOutlet var bolusButtonBackground: WKInterfaceGroup! + + private lazy var preMealButtonGroup = ButtonGroup(button: preMealButton, image: preMealButtonImage, background: preMealButtonBackground, onBackgroundColor: .carbsColor, offBackgroundColor: .darkCarbsColor, onIconColor: .darkCarbsColor, offIconColor: .carbsColor) + + private lazy var overrideButtonGroup = ButtonGroup(button: overrideButton, image: overrideButtonImage, background: overrideButtonBackground, onBackgroundColor: .overrideColor, offBackgroundColor: .darkOverrideColor, onIconColor: .darkOverrideColor, offIconColor: .overrideColor) + + private lazy var carbsButtonGroup = ButtonGroup(button: carbsButton, image: carbsButtonImage, background: carbsButtonBackground, onBackgroundColor: .carbsColor, offBackgroundColor: .darkCarbsColor, onIconColor: .darkCarbsColor, offIconColor: .carbsColor) + + private lazy var bolusButtonGroup = ButtonGroup(button: bolusButton, image: bolusButtonImage, background: bolusButtonBackground, onBackgroundColor: .insulin, offBackgroundColor: .darkInsulin, onIconColor: .darkInsulin, offIconColor: .insulin) + + @IBOutlet var overrideButtonLabel: WKInterfaceLabel? + + override func willActivate() { + super.willActivate() + + // Update the override button description based on the feature flag; this cannot be done earlier than `-willActivate` (e.g. didSet on the IBOutlet is too soon) + if FeatureFlags.sensitivityOverridesEnabled { + overrideButtonLabel?.setText(NSLocalizedString("Preset", comment: "The text for the Watch button for enabling a custom preset")) + } else { + overrideButtonLabel?.setText(NSLocalizedString("Workout", comment: "The text for the Watch button for enabling workout mode")) + } + + let userActivity = NSUserActivity.forViewLoopStatus() + if #available(watchOSApplicationExtension 5.0, *) { + update(userActivity) + } else { + updateUserActivity(userActivity.activityType, userInfo: userActivity.userInfo, webpageURL: nil) + } + } + + override func update() { + super.update() + + let activeOverrideContext: TemporaryScheduleOverride.Context? + if let override = loopManager.settings.scheduleOverride, override.isActive() { + activeOverrideContext = override.context + } else { + activeOverrideContext = nil + } + + updateForPreMeal(enabled: loopManager.settings.preMealOverride?.isActive() == true) + updateForOverrideContext(activeOverrideContext) + + let isClosedLoop = loopManager.activeContext?.isClosedLoop ?? false + + if !isClosedLoop && FeatureFlags.simpleBolusCalculatorEnabled { + preMealButtonGroup.state = .disabled + overrideButtonGroup.state = .disabled + carbsButtonGroup.state = .disabled + bolusButtonGroup.state = .disabled + } else { + carbsButtonGroup.state = .off + bolusButtonGroup.state = .off + + if loopManager.settings.preMealTargetRange == nil { + preMealButtonGroup.state = .disabled + } else if preMealButtonGroup.state == .disabled { + preMealButtonGroup.state = .off + } + + if !canEnableOverride { + overrideButtonGroup.state = .disabled + } else if overrideButtonGroup.state == .disabled { + overrideButtonGroup.state = .off + } + } + + glucoseFormatter.updateUnit(to: loopManager.displayGlucoseUnit) + } + + private var canEnableOverride: Bool { + if FeatureFlags.sensitivityOverridesEnabled { + return !loopManager.settings.overridePresets.isEmpty + } else { + return loopManager.settings.legacyWorkoutTargetRange != nil + } + } + + private func updateForPreMeal(enabled: Bool) { + if enabled { + preMealButtonGroup.state = .on + } else { + preMealButtonGroup.turnOff() + } + } + + private func updateForOverrideContext(_ context: TemporaryScheduleOverride.Context?) { + switch context { + case nil: + overrideButtonGroup.turnOff() + case .preset?, .custom?: + overrideButtonGroup.state = .on + case .legacyWorkout?: + preMealButtonGroup.turnOff() + overrideButtonGroup.state = .on + case .preMeal?: + assertionFailure() + } + } + + // MARK: - Menu Items + + private var pendingMessageResponses = 0 + + private let glucoseFormatter = QuantityFormatter(for: .milligramsPerDeciliter) + + @IBAction func togglePreMealMode() { + guard let range = loopManager.settings.preMealTargetRange else { + return + } + + let buttonToSelect = loopManager.settings.preMealOverride?.isActive() == true ? SelectedButton.on : SelectedButton.off + let viewModel = OnOffSelectionViewModel( + title: NSLocalizedString("Pre-Meal", comment: "Title for sheet to enable/disable pre-meal on watch"), + message: formattedGlucoseRangeString(from: range), + onSelection: setPreMealEnabled, + selectedButton: buttonToSelect, + selectedButtonTint: .carbsColor) + + presentController(withName: OnOffSelectionController.className, context: viewModel) + } + + func setPreMealEnabled(_ isPreMealEnabled: Bool) { + updateForPreMeal(enabled: isPreMealEnabled) + pendingMessageResponses += 1 + + var settings = loopManager.settings + let overrideContext = settings.scheduleOverride?.context + if isPreMealEnabled { + settings.enablePreMealOverride(for: .hours(1)) + + if !FeatureFlags.sensitivityOverridesEnabled { + settings.clearOverride(matching: .legacyWorkout) + updateForOverrideContext(nil) + } + } else { + settings.clearOverride(matching: .preMeal) + } + + let userInfo = LoopSettingsUserInfo(settings: settings) + do { + try WCSession.default.sendSettingsUpdateMessage(userInfo, completionHandler: { (result) in + DispatchQueue.main.async { + self.pendingMessageResponses -= 1 + + switch result { + case .success(let context): + if self.pendingMessageResponses == 0 { + self.loopManager.settings.preMealOverride = settings.preMealOverride + self.loopManager.settings.scheduleOverride = settings.scheduleOverride + } + + ExtensionDelegate.shared().loopManager.updateContext(context) + case .failure(let error): + if self.pendingMessageResponses == 0 { + ExtensionDelegate.shared().present(error) + self.updateForPreMeal(enabled: isPreMealEnabled) + self.updateForOverrideContext(overrideContext) + } + } + } + }) + } catch { + pendingMessageResponses -= 1 + if pendingMessageResponses == 0 { + updateForPreMeal(enabled: isPreMealEnabled) + updateForOverrideContext(overrideContext) + presentAlert( + withTitle: NSLocalizedString("Send Failed", comment: "The title of the alert controller displayed after a glucose range override send attempt fails"), + message: NSLocalizedString("Make sure your iPhone is nearby and try again", comment: "The recovery message displayed after a glucose range override send attempt fails"), + preferredStyle: .alert, + actions: [.dismissAction()] + ) + } + } + } + + @IBAction func toggleOverride() { + if FeatureFlags.sensitivityOverridesEnabled { + overrideButtonGroup.state == .on + ? sendOverride(nil) + : presentController(withName: OverrideSelectionController.className, context: self as OverrideSelectionControllerDelegate) + } else if let range = loopManager.settings.legacyWorkoutTargetRange { + let buttonToSelect = loopManager.settings.nonPreMealOverrideEnabled() == true ? SelectedButton.on : SelectedButton.off + + let viewModel = OnOffSelectionViewModel( + title: NSLocalizedString("Workout", comment: "Title for sheet to enable/disable workout mode on watch"), + message: formattedGlucoseRangeString(from: range), + onSelection: { isWorkoutEnabled in + let override = isWorkoutEnabled ? self.loopManager.settings.legacyWorkoutOverride(for: .infinity) : nil + self.sendOverride(override) + }, + selectedButton: buttonToSelect, + selectedButtonTint: .glucose + ) + presentController(withName: OnOffSelectionController.className, context: viewModel) + } + } + + private func formattedGlucoseRangeString(from range: ClosedRange) -> String { + let unit = loopManager.displayGlucoseUnit + glucoseFormatter.updateUnit(to: unit) + let rangeDouble = range.doubleRange(for: unit) + return String( + format: NSLocalizedString( + "%1$@ – %2$@ %3$@", + comment: "Format string for glucose range (1: lower bound)(2: upper bound)(3: unit)" + ), + glucoseFormatter.numberFormatter.string(from: rangeDouble.minValue) ?? String(rangeDouble.minValue), + glucoseFormatter.numberFormatter.string(from: rangeDouble.maxValue) ?? String(rangeDouble.maxValue), + glucoseFormatter.localizedUnitStringWithPlurality() + ) + } + + private func sendOverride(_ override: TemporaryScheduleOverride?) { + updateForOverrideContext(override?.context) + pendingMessageResponses += 1 + + var settings = loopManager.settings + let isPreMealEnabled = settings.preMealOverride?.isActive() == true + if override?.context == .legacyWorkout { + settings.preMealOverride = nil + } + settings.scheduleOverride = override + + let userInfo = LoopSettingsUserInfo(settings: settings) + do { + try WCSession.default.sendSettingsUpdateMessage(userInfo, completionHandler: { (result) in + DispatchQueue.main.async { + self.pendingMessageResponses -= 1 + + switch result { + case .success(let context): + if self.pendingMessageResponses == 0 { + self.loopManager.settings.scheduleOverride = override + self.loopManager.settings.preMealOverride = settings.preMealOverride + } + + ExtensionDelegate.shared().loopManager.updateContext(context) + case .failure(let error): + if self.pendingMessageResponses == 0 { + ExtensionDelegate.shared().present(error) + self.updateForOverrideContext(override?.context) + self.updateForPreMeal(enabled: isPreMealEnabled) + } + } + } + }) + } catch { + pendingMessageResponses -= 1 + if pendingMessageResponses == 0 { + updateForOverrideContext(override?.context) + updateForPreMeal(enabled: isPreMealEnabled) + presentAlert( + withTitle: NSLocalizedString("Send Failed", comment: "The title of the alert controller displayed after a glucose range override send attempt fails"), + message: NSLocalizedString("Make sure your iPhone is nearby and try again", comment: "The recovery message displayed after a glucose range override send attempt fails"), + preferredStyle: .alert, + actions: [.dismissAction()] + ) + } + } + } +} + +extension ActionHUDController: OverrideSelectionControllerDelegate { + func overrideSelectionController(_ controller: OverrideSelectionController, didSelectPreset preset: TemporaryScheduleOverridePreset) { + let override = preset.createOverride(enactTrigger: .local) + sendOverride(override) + } +} diff --git a/WatchApp Extension/Controllers/AddCarbsInterfaceController.swift b/WatchApp Extension/Controllers/AddCarbsInterfaceController.swift deleted file mode 100644 index 1ac3c89faa..0000000000 --- a/WatchApp Extension/Controllers/AddCarbsInterfaceController.swift +++ /dev/null @@ -1,140 +0,0 @@ -// -// AddCarbsInterfaceController.swift -// Naterade -// -// Created by Nathan Racklyeft on 1/23/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import WatchKit -import WatchConnectivity - - -final class AddCarbsInterfaceController: WKInterfaceController, IdentifiableClass { - - fileprivate var carbValue: Int = 15 { - didSet { - guard carbValue >= 0 else { - carbValue = 0 - return - } - - guard carbValue <= 100 else { - carbValue = 100 - return - } - - valueLabel.setText(String(carbValue)) - } - } - - private var absorptionTime = AbsorptionTimeType.medium { - didSet { - absorptionButtonA.setBackgroundColor(UIColor.darkTintColor) - absorptionButtonB.setBackgroundColor(UIColor.darkTintColor) - absorptionButtonC.setBackgroundColor(UIColor.darkTintColor) - - switch absorptionTime { - case .fast: - absorptionButtonA.setBackgroundColor(UIColor.tintColor) - case .medium: - absorptionButtonB.setBackgroundColor(UIColor.tintColor) - case .slow: - absorptionButtonC.setBackgroundColor(UIColor.tintColor) - } - } - } - - @IBOutlet weak var valueLabel: WKInterfaceLabel! - - @IBOutlet weak var absorptionButtonA: WKInterfaceButton! - - @IBOutlet weak var absorptionButtonB: WKInterfaceButton! - - @IBOutlet weak var absorptionButtonC: WKInterfaceButton! - - override func awake(withContext context: Any?) { - super.awake(withContext: context) - - // Configure interface objects here. - crownSequencer.delegate = self - - absorptionTime = .medium - } - - override func willActivate() { - // This method is called when watch view controller is about to be visible to user - super.willActivate() - - crownSequencer.focus() - } - - override func didDeactivate() { - // This method is called when watch view controller is no longer visible - super.didDeactivate() - } - - // MARK: - Actions - - @IBAction func decrement() { - carbValue -= 5 - } - - @IBAction func increment() { - carbValue += 5 - } - - @IBAction func setAbsorptionTimeFast() { - absorptionTime = .fast - } - - @IBAction func setAbsorptionTimeMedium() { - absorptionTime = .medium - } - - @IBAction func setAbsorptionTimeSlow() { - absorptionTime = .slow - } - - @IBAction func save() { - if carbValue > 0 { - let entry = CarbEntryUserInfo(value: Double(carbValue), absorptionTimeType: absorptionTime, startDate: Date()) - - do { - try WCSession.default().sendCarbEntryMessage(entry, - replyHandler: { (suggestion) in - WKExtension.shared().rootInterfaceController?.presentController(withName: BolusInterfaceController.className, context: suggestion) - }, - errorHandler: { (error) in - ExtensionDelegate.shared().present(error) - } - ) - } catch { - presentAlert(withTitle: NSLocalizedString("Send Failed", comment: "The title of the alert controller displayed after a carb entry send attempt fails"), - message: NSLocalizedString("Make sure your iPhone is nearby and try again", comment: "The recovery message displayed after a carb entry send attempt fails"), - preferredStyle: .alert, - actions: [WKAlertAction.dismissAction()] - ) - return - } - } - - dismiss() - } - - // MARK: - Crown Sequencer - - fileprivate var accumulatedRotation: Double = 0 -} - -fileprivate let rotationsPerCarb: Double = 1/24 - -extension AddCarbsInterfaceController: WKCrownDelegate { - func crownDidRotate(_ crownSequencer: WKCrownSequencer?, rotationalDelta: Double) { - accumulatedRotation += rotationalDelta - - let remainder = accumulatedRotation.truncatingRemainder(dividingBy: rotationsPerCarb) - carbValue += Int((accumulatedRotation - remainder).divided(by: rotationsPerCarb)) - accumulatedRotation = remainder - } -} diff --git a/WatchApp Extension/Controllers/BolusInterfaceController.swift b/WatchApp Extension/Controllers/BolusInterfaceController.swift deleted file mode 100644 index 0dfd6a2444..0000000000 --- a/WatchApp Extension/Controllers/BolusInterfaceController.swift +++ /dev/null @@ -1,171 +0,0 @@ -// -// BolusInterfaceController.swift -// Naterade -// -// Created by Nathan Racklyeft on 3/20/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import WatchKit -import Foundation -import WatchConnectivity - - -final class BolusInterfaceController: WKInterfaceController, IdentifiableClass { - - fileprivate var pickerValue: Int = 0 { - didSet { - guard pickerValue >= 0 else { - pickerValue = 0 - return - } - - guard pickerValue <= maxPickerValue else { - pickerValue = maxPickerValue - return - } - - let bolusValue = bolusValueFromPickerValue(pickerValue) - - switch bolusValue { - case let x where x < 1: - formatter.minimumFractionDigits = 3 - case let x where x < 10: - formatter.minimumFractionDigits = 2 - default: - formatter.minimumFractionDigits = 1 - } - - valueLabel.setText(formatter.string(from: NSNumber(value: bolusValue)) ?? "--") - } - } - - private func pickerValueFromBolusValue(_ bolusValue: Double) -> Int { - switch bolusValue { - case let bolus where bolus > 10: - return Int((bolus - 10.0) * 10) + pickerValueFromBolusValue(10) - case let bolus where bolus > 1: - return Int((bolus - 1.0) * 20) + pickerValueFromBolusValue(1) - default: - return Int(bolusValue * 40) - } - } - - private func bolusValueFromPickerValue(_ pickerValue: Int) -> Double { - switch pickerValue { - case let picker where picker > 220: - return Double(picker - 220) / 10.0 + bolusValueFromPickerValue(220) - case let picker where picker > 40: - return Double(picker - 40) / 20.0 + bolusValueFromPickerValue(40) - default: - return Double(pickerValue) / 40.0 - } - } - - private lazy var formatter: NumberFormatter = { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - formatter.minimumIntegerDigits = 1 - - return formatter - }() - - private var maxPickerValue = 0 - - /// 1.25 - @IBOutlet weak var valueLabel: WKInterfaceLabel! - - /// REC: 2.25 U - @IBOutlet weak var recommendedValueLabel: WKInterfaceLabel! - - override func awake(withContext context: Any?) { - super.awake(withContext: context) - - var maxBolusValue: Double = 15 - var pickerValue = 0 - - if let context = context as? BolusSuggestionUserInfo { - let recommendedBolus = context.recommendedBolus - - if let maxBolus = context.maxBolus { - maxBolusValue = maxBolus - } else if recommendedBolus > 0 { - maxBolusValue = recommendedBolus - } - - let recommendedPickerValue = pickerValueFromBolusValue(recommendedBolus) - pickerValue = Int(Double(recommendedPickerValue) * 0.75) - - if let valueString = formatter.string(from: NSNumber(value: recommendedBolus)) { - recommendedValueLabel.setText(String(format: NSLocalizedString("Rec: %@ U", comment: "The label and value showing the recommended bolus"), valueString).localizedUppercase) - } - } - - self.maxPickerValue = pickerValueFromBolusValue(maxBolusValue) - self.pickerValue = pickerValue - - crownSequencer.delegate = self - } - - override func willActivate() { - // This method is called when watch view controller is about to be visible to user - super.willActivate() - - crownSequencer.focus() - } - - override func didDeactivate() { - // This method is called when watch view controller is no longer visible - super.didDeactivate() - } - - // MARK: - Actions - - @IBAction func decrement() { - pickerValue -= 10 - } - - @IBAction func increment() { - pickerValue += 10 - } - - @IBAction func deliver() { - let bolusValue = bolusValueFromPickerValue(pickerValue) - - if bolusValue > 0 { - let bolus = SetBolusUserInfo(value: bolusValue, startDate: Date()) - - do { - try WCSession.default().sendBolusMessage(bolus) { (error) in - ExtensionDelegate.shared().present(error) - } - } catch { - presentAlert( - withTitle: NSLocalizedString("Bolus Failed", comment: "The title of the alert controller displayed after a bolus attempt fails"), - message: NSLocalizedString("Make sure your iPhone is nearby and try again", comment: "The recovery message displayed after a bolus attempt fails"), - preferredStyle: .alert, - actions: [WKAlertAction.dismissAction()] - ) - return - } - } - - dismiss() - } - - // MARK: - Crown Sequencer - - fileprivate var accumulatedRotation: Double = 0 -} - -fileprivate let rotationsPerValue: Double = 1/24 - -extension BolusInterfaceController: WKCrownDelegate { - func crownDidRotate(_ crownSequencer: WKCrownSequencer?, rotationalDelta: Double) { - accumulatedRotation += rotationalDelta - - let remainder = accumulatedRotation.truncatingRemainder(dividingBy: rotationsPerValue) - pickerValue += Int((accumulatedRotation - remainder).divided(by: rotationsPerValue)) - accumulatedRotation = remainder - } -} diff --git a/WatchApp Extension/Controllers/CarbAndBolusFlowController.swift b/WatchApp Extension/Controllers/CarbAndBolusFlowController.swift new file mode 100644 index 0000000000..102bc98de8 --- /dev/null +++ b/WatchApp Extension/Controllers/CarbAndBolusFlowController.swift @@ -0,0 +1,73 @@ +// +// CarbAndBolusFlowController.swift +// WatchApp Extension +// +// Created by Michael Pangburn on 4/7/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import WatchKit +import SwiftUI +import HealthKit +import LoopCore +import LoopKit + + +final class CarbAndBolusFlowController: WKHostingController, IdentifiableClass { + private lazy var viewModel = { + CarbAndBolusFlowViewModel( + configuration: configuration, + dismiss: { [weak self] in + guard let self = self else { return } + self.willDeactivateObserver = nil + self.dismiss() + } + ) + }() + + private var configuration: CarbAndBolusFlow.Configuration = .carbEntry(nil) + + override var body: CarbAndBolusFlow { + CarbAndBolusFlow(viewModel: viewModel) + } + + private var willDeactivateObserver: AnyObject? { + didSet { + if let oldValue = oldValue { + NotificationCenter.default.removeObserver(oldValue) + } + } + } + + override func awake(withContext context: Any?) { + if let configuration = context as? CarbAndBolusFlow.Configuration { + self.configuration = configuration + } + } + + override func didAppear() { + super.didAppear() + + updateNewCarbEntryUserActivity() + + // If the screen turns off, the screen should be dismissed for safety reasons + willDeactivateObserver = NotificationCenter.default.addObserver(forName: ExtensionDelegate.willResignActiveNotification, object: ExtensionDelegate.shared(), queue: nil, using: { [weak self] (_) in + if let self = self { + WKInterfaceDevice.current().play(.failure) + self.dismiss() + } + }) + } + + override func didDeactivate() { + super.didDeactivate() + + willDeactivateObserver = nil + } +} + +extension CarbAndBolusFlowController: NSUserActivityDelegate { + func updateNewCarbEntryUserActivity() { + update(.forDidAddCarbEntryOnWatch()) + } +} diff --git a/WatchApp Extension/Controllers/CarbEntryListController.swift b/WatchApp Extension/Controllers/CarbEntryListController.swift new file mode 100644 index 0000000000..a704a942cd --- /dev/null +++ b/WatchApp Extension/Controllers/CarbEntryListController.swift @@ -0,0 +1,122 @@ +// +// CarbEntryListController.swift +// WatchApp Extension +// +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import HealthKit +import LoopCore +import LoopKit +import os.log +import WatchKit + +class CarbEntryListController: WKInterfaceController, IdentifiableClass { + @IBOutlet private var table: WKInterfaceTable! + + @IBOutlet private var cobLabel: WKInterfaceLabel! + + @IBOutlet var totalLabel: WKInterfaceLabel! + + @IBOutlet var headerGroup: WKInterfaceGroup! + + private let log = OSLog(category: "CarbEntryListController") + + private lazy var loopManager = ExtensionDelegate.shared().loopManager + + private lazy var carbFormatter: QuantityFormatter = { + let formatter = QuantityFormatter(for: .gram()) + formatter.numberFormatter.numberStyle = .none + return formatter + }() + + private var observers: [Any] = [] { + didSet { + for observer in oldValue { + NotificationCenter.default.removeObserver(observer) + } + } + } + + override func awake(withContext context: Any?) { + table.setNumberOfRows(0, withRowType: TextRowController.className) + loopManager.requestCarbBackfill() + reloadCarbEntries() + updateActiveCarbs() + } + + override func willActivate() { + observers = [ + NotificationCenter.default.addObserver(forName: CarbStore.carbEntriesDidChange, object: loopManager.carbStore, queue: nil) { [weak self] (note) in + self?.log.default("Received carbEntriesDidChange notification: %{public}@. Updating list", String(describing: note.userInfo ?? [:])) + + DispatchQueue.main.async { + self?.reloadCarbEntries() + } + }, + NotificationCenter.default.addObserver(forName: LoopDataManager.didUpdateContextNotification, object: loopManager, queue: nil) { [weak self] (note) in + DispatchQueue.main.async { + self?.updateActiveCarbs() + self?.loopManager.requestCarbBackfill() + } + } + ] + } + + override func didDeactivate() { + observers = [] + } +} + + +extension CarbEntryListController { + private func updateActiveCarbs() { + guard let activeCarbohydrates = loopManager.activeContext?.activeCarbohydrates else { + return + } + + cobLabel.setText(carbFormatter.string(from: activeCarbohydrates)) + } + + private func reloadCarbEntries() { + let start = min(Calendar.current.startOfDay(for: Date()), Date(timeIntervalSinceNow: -loopManager.carbStore.maximumAbsorptionTimeInterval)) + + loopManager.carbStore.getCarbEntries(start: start) { (result) in + switch result { + case .success(let entries): + DispatchQueue.main.async { + self.setCarbEntries(entries) + } + case .failure(let error): + self.log.error("Failed to fetch carb entries: %{public}@", String(describing: error)) + } + } + } + + private func setCarbEntries(_ entries: [StoredCarbEntry]) { + dispatchPrecondition(condition: .onQueue(.main)) + + table.setNumberOfRows(entries.count, withRowType: TextRowController.className) + + var total = 0.0 + + let timeFormatter = DateFormatter() + timeFormatter.dateStyle = .none + timeFormatter.timeStyle = .short + + let unit = loopManager.carbStore.preferredUnit ?? .gram() + + for (index, entry) in entries.reversed().enumerated() { + guard let row = table.rowController(at: index) as? TextRowController else { + continue + } + + total += entry.quantity.doubleValue(for: unit) + + row.textLabel.setText(timeFormatter.string(from: entry.startDate)) + row.detailTextLabel.setText(carbFormatter.string(from: entry.quantity)) + } + + totalLabel.setText(carbFormatter.string(from: HKQuantity(unit: unit, doubleValue: total))) + } +} diff --git a/WatchApp Extension/Controllers/ChartHUDController.swift b/WatchApp Extension/Controllers/ChartHUDController.swift new file mode 100644 index 0000000000..f7aa0b0231 --- /dev/null +++ b/WatchApp Extension/Controllers/ChartHUDController.swift @@ -0,0 +1,212 @@ +// +// ChartHUDController.swift +// Loop +// +// Created by Bharat Mediratta on 6/26/18. +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +import WatchKit +import WatchConnectivity +import LoopKit +import HealthKit +import SpriteKit +import os.log +import LoopCore + +final class ChartHUDController: HUDInterfaceController, WKCrownDelegate { + private enum TableRow: Int, CaseIterable { + case iob + case cob + case netBasal + case reservoirVolume + + var title: String { + switch self { + case .iob: + return NSLocalizedString("Active Insulin", comment: "HUD row title for IOB") + case .cob: + return NSLocalizedString("Active Carbs", comment: "HUD row title for COB") + case .netBasal: + return NSLocalizedString("Net Basal Rate", comment: "HUD row title for Net Basal Rate") + case .reservoirVolume: + return NSLocalizedString("Reservoir Volume", comment: "HUD row title for remaining reservoir volume") + } + } + + var isLast: Bool { + return self == TableRow.allCases.last + } + } + + @IBOutlet private weak var table: WKInterfaceTable! + + @IBOutlet private weak var glucoseScene: WKInterfaceSKScene! + private let scene = GlucoseChartScene() + private var timer: Timer? { + didSet { + oldValue?.invalidate() + } + } + private let log = OSLog(category: "ChartHUDController") + private var hasInitialActivation = false + + private var observers: [Any] = [] { + didSet { + for observer in oldValue { + NotificationCenter.default.removeObserver(observer) + } + } + } + + override init() { + super.init() + + glucoseScene.presentScene(scene) + } + + override func awake(withContext context: Any?) { + super.awake(withContext: context) + + table.setNumberOfRows(TableRow.allCases.count, withRowType: HUDRowController.className) + } + + override func didAppear() { + super.didAppear() + + if glucoseScene.isPaused { + log.default("didAppear() unpausing") + glucoseScene.isPaused = false + } else { + log.default("didAppear() not paused") + glucoseScene.isPaused = false + } + + // Force an update when our pixels need to move + let pixelsWide = scene.size.width * WKInterfaceDevice.current().screenScale + let pixelInterval = scene.visibleDuration / TimeInterval(pixelsWide) + + timer = Timer.scheduledTimer(withTimeInterval: pixelInterval, repeats: true) { [weak self] _ in + self?.log.default("Timer fired, triggering update") + self?.scene.setNeedsUpdate() + } + + // These margins are only available after we appear (sadly) + + scene.textInsets.left = max(scene.textInsets.left, systemMinimumLayoutMargins.leading) + scene.textInsets.right = max(scene.textInsets.right, systemMinimumLayoutMargins.trailing) + + for row in TableRow.allCases { + let cell = table.rowController(at: row.rawValue) as! HUDRowController + cell.setContentInset(systemMinimumLayoutMargins) + } + } + + override func willDisappear() { + super.willDisappear() + + log.default("willDisappear") + + timer = nil + } + + override func willActivate() { + super.willActivate() + + observers = [ + NotificationCenter.default.addObserver(forName: GlucoseStore.glucoseSamplesDidChange, object: loopManager.glucoseStore, queue: nil) { [weak self] (note) in + self?.log.default("Received GlucoseSamplesDidChange notification: %{public}@. Updating chart", String(describing: note.userInfo ?? [:])) + + DispatchQueue.main.async { + self?.updateGlucoseChart() + } + } + ] + + if glucoseScene.isPaused { + log.default("willActivate() unpausing") + glucoseScene.isPaused = false + } else { + log.default("willActivate()") + } + + if !hasInitialActivation && UserDefaults.standard.startOnChartPage { + log.default("Switching to startOnChartPage") + becomeCurrentPage() + } + + hasInitialActivation = true + + loopManager.requestGlucoseBackfillIfNecessary() + } + + override func didDeactivate() { + super.didDeactivate() + + observers = [] + + log.default("didDeactivate() pausing") + glucoseScene.isPaused = true + } + + override func update() { + super.update() + + guard let activeContext = loopManager.activeContext else { + return + } + + for row in TableRow.allCases { + let cell = table.rowController(at: row.rawValue) as! HUDRowController + cell.setTitle(row.title) + cell.setIsLastRow(row.isLast) + cell.setContentInset(systemMinimumLayoutMargins) + + let isActiveContextStale = Date().timeIntervalSince(activeContext.creationDate) > LoopCoreConstants.inputDataRecencyInterval + + switch row { + case .iob: + cell.setActiveInsulin(isActiveContextStale ? nil : activeContext.activeInsulin) + case .cob: + cell.setActiveCarbohydrates(isActiveContextStale ? nil : activeContext.activeCarbohydrates) + case .netBasal: + cell.setNetTempBasalDose(isActiveContextStale ? nil : activeContext.lastNetTempBasalDose) + case .reservoirVolume: + cell.setReservoirVolume(isActiveContextStale ? nil : activeContext.reservoirVolume) + } + } + + if glucoseScene.isPaused { + log.default("update() unpausing") + glucoseScene.isPaused = false + } + + updateGlucoseChart() + } + + private func updateGlucoseChart() { + loopManager.generateChartData { chartData in + DispatchQueue.main.async { + self.scene.data = chartData + self.scene.setNeedsUpdate() + } + } + } + + override func table(_ table: WKInterfaceTable, didSelectRowAt rowIndex: Int) { + guard table == self.table, case .cob? = TableRow(rawValue: rowIndex) else { + return + } + + presentController(withName: CarbEntryListController.className, context: nil) + } + + @IBAction func didTapOnChart(_ sender: Any) { + scene.decreaseVisibleDuration() + } + + @IBAction func didDoubleTapOnChart(_ sender: Any) { + scene.increaseVisibleDuration() + } + +} diff --git a/WatchApp Extension/Controllers/ContextUpdatable.swift b/WatchApp Extension/Controllers/ContextUpdatable.swift deleted file mode 100644 index 00cc2100d7..0000000000 --- a/WatchApp Extension/Controllers/ContextUpdatable.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// ContextUpdatable.swift -// Loop -// -// Created by Nate Racklyeft on 9/19/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import Foundation - - -protocol ContextUpdatable { - func update(with context: WatchContext?) -} diff --git a/WatchApp Extension/Controllers/HUDInterfaceController.swift b/WatchApp Extension/Controllers/HUDInterfaceController.swift new file mode 100644 index 0000000000..b23dc56680 --- /dev/null +++ b/WatchApp Extension/Controllers/HUDInterfaceController.swift @@ -0,0 +1,111 @@ +// +// HUDInterfaceController.swift +// WatchApp Extension +// +// Created by Bharat Mediratta on 6/29/18. +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +import WatchKit +import LoopCore +import LoopKit + +class HUDInterfaceController: WKInterfaceController { + private var activeContextObserver: NSObjectProtocol? + + @IBOutlet weak var loopHUDImage: WKInterfaceImage! + @IBOutlet weak var glucoseLabel: WKInterfaceLabel! + @IBOutlet weak var eventualGlucoseLabel: WKInterfaceLabel! + + var loopManager = ExtensionDelegate.shared().loopManager + + override func willActivate() { + super.willActivate() + + update() + + if activeContextObserver == nil { + activeContextObserver = NotificationCenter.default.addObserver(forName: LoopDataManager.didUpdateContextNotification, object: loopManager, queue: nil) { [weak self] _ in + DispatchQueue.main.async { + self?.update() + } + } + } + + loopManager.requestContextUpdate(completion: { + self.loopManager.requestGlucoseBackfillIfNecessary() + }) + } + + override func didDeactivate() { + super.didDeactivate() + + if let observer = activeContextObserver { + NotificationCenter.default.removeObserver(observer) + } + activeContextObserver = nil + } + + func update() { + guard let activeContext = loopManager.activeContext else { + loopHUDImage.setHidden(true) + return + } + loopHUDImage.setHidden(false) + + let date = activeContext.loopLastRunDate + let isClosedLoop = activeContext.isClosedLoop ?? false + loopHUDImage.setLoopImage(isClosedLoop: isClosedLoop, { + if let date = date { + switch date.timeIntervalSinceNow { + case let t where t > .minutes(-6): + return .fresh + case let t where t > .minutes(-20): + return .aging + default: + return .stale + } + } else { + return .unknown + } + }()) + + if date != nil { + glucoseLabel.setText(NSLocalizedString("– – –", comment: "No glucose value representation (3 dashes for mg/dL)")) + glucoseLabel.setHidden(false) + + let showEventualGlucose = FeatureFlags.showEventualBloodGlucoseOnWatchEnabled + if showEventualGlucose { + eventualGlucoseLabel.setHidden(true) + } + + if let glucose = activeContext.glucose, let glucoseDate = activeContext.glucoseDate, let unit = activeContext.displayGlucoseUnit, glucoseDate.timeIntervalSinceNow > -LoopCoreConstants.inputDataRecencyInterval { + let formatter = NumberFormatter.glucoseFormatter(for: unit) + + if let glucoseValue = formatter.string(from: glucose.doubleValue(for: unit)) { + let trend = activeContext.glucoseTrend?.symbol ?? "" + glucoseLabel.setText(glucoseValue + trend) + } + + if showEventualGlucose, let eventualGlucose = activeContext.eventualGlucose, let eventualGlucoseValue = formatter.string(from: eventualGlucose.doubleValue(for: unit)) { + eventualGlucoseLabel.setText(eventualGlucoseValue) + eventualGlucoseLabel.setHidden(false) + } + } + } + + } + + @IBAction func addCarbs() { + presentController(withName: CarbAndBolusFlowController.className, context: CarbAndBolusFlow.Configuration.carbEntry(nil)) + } + + func addCarbs(initialEntry: NewCarbEntry) { + presentController(withName: CarbAndBolusFlowController.className, context: CarbAndBolusFlow.Configuration.carbEntry(initialEntry)) + } + + @IBAction func setBolus() { + presentController(withName: CarbAndBolusFlowController.className, context: CarbAndBolusFlow.Configuration.manualBolus) + } + +} diff --git a/WatchApp Extension/Controllers/HUDRowController.swift b/WatchApp Extension/Controllers/HUDRowController.swift new file mode 100644 index 0000000000..d1c8ee5cca --- /dev/null +++ b/WatchApp Extension/Controllers/HUDRowController.swift @@ -0,0 +1,126 @@ +// +// HUDRowController.swift +// WatchApp Extension +// +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import HealthKit +import LoopCore +import LoopKit +import WatchKit + +class HUDRowController: NSObject, IdentifiableClass { + @IBOutlet private var textLabel: WKInterfaceLabel! + @IBOutlet private var detailTextLabel: WKInterfaceLabel! + @IBOutlet private var outerGroup: WKInterfaceGroup! + @IBOutlet private var bottomSeparator: WKInterfaceSeparator! +} + +extension HUDRowController { + func setTitle(_ title: String) { + textLabel.setText(title.localizedUppercase) + } + + func setDetail(_ detail: String?) { + detailTextLabel.setText(detail ?? "–") + } + + func setContentInset(_ inset: NSDirectionalEdgeInsets) { + outerGroup.setContentInset(inset.deviceInsets) + } + + func setIsLastRow(_ isLastRow: Bool) { + bottomSeparator.setHidden(isLastRow) + } +} + +extension HUDRowController { + func setActiveInsulin(_ activeInsulin: HKQuantity?) { + guard let activeInsulin = activeInsulin else { + setDetail(nil) + return + } + + let insulinFormatter: QuantityFormatter = { + let insulinFormatter = QuantityFormatter(for: .internationalUnit()) + insulinFormatter.numberFormatter.minimumFractionDigits = 1 + insulinFormatter.numberFormatter.maximumFractionDigits = 1 + + return insulinFormatter + }() + + setDetail(insulinFormatter.string(from: activeInsulin)) + } + + func setActiveCarbohydrates(_ activeCarbohydrates: HKQuantity?) { + guard let activeCarbohydrates = activeCarbohydrates else { + setDetail(nil) + return + } + + let carbFormatter = QuantityFormatter(for: .gram()) + carbFormatter.numberFormatter.maximumFractionDigits = 0 + + setDetail(carbFormatter.string(from: activeCarbohydrates)) + } + + func setNetTempBasalDose(_ tempBasal: Double?) { + guard let tempBasal = tempBasal else { + setDetail(nil) + return + } + + let basalFormatter = NumberFormatter() + basalFormatter.numberStyle = .decimal + basalFormatter.minimumFractionDigits = 1 + basalFormatter.maximumFractionDigits = 3 + basalFormatter.positivePrefix = basalFormatter.plusSign + + let unit = NSLocalizedString( + "U/hr", + comment: "The short unit display string for international units of insulin delivery per hour" + ) + + setDetail(basalFormatter.string(from: tempBasal, unit: unit)) + } + + func setReservoirVolume(_ reservoirVolume: HKQuantity?) { + guard let reservoirVolume = reservoirVolume else { + setDetail(nil) + return + } + + let insulinFormatter: QuantityFormatter = { + let insulinFormatter = QuantityFormatter(for: .internationalUnit()) + insulinFormatter.unitStyle = .long + insulinFormatter.numberFormatter.minimumFractionDigits = 0 + insulinFormatter.numberFormatter.maximumFractionDigits = 0 + + return insulinFormatter + }() + + setDetail(insulinFormatter.string(from: reservoirVolume)) + } +} + + +fileprivate extension NSDirectionalEdgeInsets { + var deviceInsets: UIEdgeInsets { + let left: CGFloat + let right: CGFloat + + switch WKInterfaceDevice.current().layoutDirection { + case .rightToLeft: + right = leading + left = trailing + case .leftToRight: + fallthrough + @unknown default: + left = leading + right = trailing + } + + return UIEdgeInsets(top: top, left: left, bottom: bottom, right: right) + } +} diff --git a/WatchApp Extension/Controllers/NotificationController.swift b/WatchApp Extension/Controllers/NotificationController.swift index 0cc9b0011e..9b2aaa71da 100644 --- a/WatchApp Extension/Controllers/NotificationController.swift +++ b/WatchApp Extension/Controllers/NotificationController.swift @@ -14,19 +14,14 @@ import UserNotifications final class NotificationController: WKUserNotificationInterfaceController { override init() { - // Initialize variables here. super.init() - - // Configure interface objects here. } override func willActivate() { - // This method is called when watch view controller is about to be visible to user super.willActivate() } override func didDeactivate() { - // This method is called when watch view controller is no longer visible super.didDeactivate() } diff --git a/WatchApp Extension/Controllers/OnOffSelectionController.swift b/WatchApp Extension/Controllers/OnOffSelectionController.swift new file mode 100644 index 0000000000..40d5881079 --- /dev/null +++ b/WatchApp Extension/Controllers/OnOffSelectionController.swift @@ -0,0 +1,29 @@ +// +// OnOffSelectionController.swift +// WatchApp Extension +// +// Created by Anna Quinlan on 8/20/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import LoopCore + + +final class OnOffSelectionController: WKHostingController, IdentifiableClass { + + private var viewModel: OnOffSelectionViewModel = OnOffSelectionViewModel(title: "", message: "", onSelection: {_ in }) + + override func awake(withContext context: Any?) { + guard let model = context as? OnOffSelectionViewModel else { + fatalError("OnOffSelectionController invoked without proper context") + } + + model.dismiss = { self.dismiss() } + self.viewModel = model + } + + override var body: OnOffSelectionView { + OnOffSelectionView(viewModel: viewModel) + } +} diff --git a/WatchApp Extension/Controllers/OverrideSelectionController.swift b/WatchApp Extension/Controllers/OverrideSelectionController.swift new file mode 100644 index 0000000000..ba79776138 --- /dev/null +++ b/WatchApp Extension/Controllers/OverrideSelectionController.swift @@ -0,0 +1,66 @@ +// +// OverrideSelectionController.swift +// WatchApp Extension +// +// Created by Michael Pangburn on 1/31/19. +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import Foundation +import WatchKit +import LoopKit +import LoopCore +import WatchConnectivity + + +protocol OverrideSelectionControllerDelegate: AnyObject { + func overrideSelectionController(_ controller: OverrideSelectionController, didSelectPreset preset: TemporaryScheduleOverridePreset) +} + + +final class OverrideSelectionController: WKInterfaceController, IdentifiableClass { + + @IBOutlet private var table: WKInterfaceTable! + + private let loopManager = ExtensionDelegate.shared().loopManager + private lazy var presets = loopManager.settings.overridePresets + + weak var delegate: OverrideSelectionControllerDelegate? + + override func awake(withContext context: Any?) { + super.awake(withContext: context) + delegate = context as? OverrideSelectionControllerDelegate + + guard !presets.isEmpty else { + assertionFailure("Instantiating override selection controller without configured presets") + return + } + + configureTable() + } + + private func configureTable() { + table.setRowTypes([OverridePresetRow.className]) + table.setNumberOfRows(presets.count, withRowType: OverridePresetRow.className) + for index in presets.indices { + let row = table.rowController(at: index) as! OverridePresetRow + let preset = presets[index] + row.symbolLabel.setText(preset.symbol) + row.nameLabel.setText(preset.name) + } + } + + override func willActivate() { + super.willActivate() + } + + override func didDeactivate() { + super.didDeactivate() + } + + override func table(_ table: WKInterfaceTable, didSelectRowAt rowIndex: Int) { + let preset = presets[rowIndex] + delegate?.overrideSelectionController(self, didSelectPreset: preset) + dismiss() + } +} diff --git a/WatchApp Extension/Controllers/StatusInterfaceController.swift b/WatchApp Extension/Controllers/StatusInterfaceController.swift deleted file mode 100644 index 2da4b29324..0000000000 --- a/WatchApp Extension/Controllers/StatusInterfaceController.swift +++ /dev/null @@ -1,85 +0,0 @@ -// -// StatusInterfaceController.swift -// Loop -// -// Created by Nathan Racklyeft on 5/29/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import WatchKit -import Foundation - - -final class StatusInterfaceController: WKInterfaceController, ContextUpdatable { - - @IBOutlet weak var graphImage: WKInterfaceImage! - @IBOutlet weak var loopHUDImage: WKInterfaceImage! - @IBOutlet weak var loopTimer: WKInterfaceTimer! - @IBOutlet weak var glucoseLabel: WKInterfaceLabel! - @IBOutlet weak var eventualGlucoseLabel: WKInterfaceLabel! - @IBOutlet weak var statusLabel: WKInterfaceLabel! - - private var lastContext: WatchContext? - - func update(with context: WatchContext?) { - lastContext = context - - if let date = context?.loopLastRunDate { - self.loopTimer.setDate(date as Date) - self.loopTimer.setHidden(false) - self.loopTimer.start() - - let loopImage: LoopImage - - switch date.timeIntervalSinceNow { - case let t where t.minutes <= 5: - loopImage = .Fresh - case let t where t.minutes <= 15: - loopImage = .Aging - default: - loopImage = .Stale - } - - self.loopHUDImage.setLoopImage(loopImage) - } else { - loopTimer.setHidden(true) - loopHUDImage.setLoopImage(.Unknown) - } - - let numberFormatter = NumberFormatter() - - if let glucose = context?.glucose, let unit = context?.preferredGlucoseUnit { - let glucoseValue = glucose.doubleValue(for: unit) - let trend = context?.glucoseTrend?.symbol ?? "" - - self.glucoseLabel.setText((numberFormatter.string(from: NSNumber(value: glucoseValue)) ?? "") + trend) - self.glucoseLabel.setHidden(false) - } else { - glucoseLabel.setHidden(true) - } - - if let eventualGlucose = context?.eventualGlucose, let unit = context?.preferredGlucoseUnit { - let glucoseValue = eventualGlucose.doubleValue(for: unit) - - self.eventualGlucoseLabel.setText(numberFormatter.string(from: NSNumber(value: glucoseValue))) - self.eventualGlucoseLabel.setHidden(false) - } else { - eventualGlucoseLabel.setHidden(true) - } - - // TODO: Other elements - statusLabel.setHidden(true) - graphImage.setHidden(true) - } - - // MARK: - Menu Items - - @IBAction func addCarbs() { - presentController(withName: AddCarbsInterfaceController.className, context: nil) - } - - @IBAction func setBolus() { - presentController(withName: BolusInterfaceController.className, context: lastContext?.bolusSuggestion) - } - -} diff --git a/WatchApp Extension/Controllers/TextRowController.swift b/WatchApp Extension/Controllers/TextRowController.swift new file mode 100644 index 0000000000..db48a009bb --- /dev/null +++ b/WatchApp Extension/Controllers/TextRowController.swift @@ -0,0 +1,14 @@ +// +// TextRowController.swift +// WatchApp Extension +// +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import LoopCore +import WatchKit + +class TextRowController: NSObject, IdentifiableClass { + @IBOutlet private(set) var textLabel: WKInterfaceLabel! + @IBOutlet private(set) var detailTextLabel: WKInterfaceLabel! +} diff --git a/WatchApp Extension/ExtensionDelegate.swift b/WatchApp Extension/ExtensionDelegate.swift index 0e00462b73..1ef1d13d75 100644 --- a/WatchApp Extension/ExtensionDelegate.swift +++ b/WatchApp Extension/ExtensionDelegate.swift @@ -8,10 +8,21 @@ import WatchConnectivity import WatchKit +import HealthKit +import Intents import os +import os.log +import UserNotifications +import LoopKit final class ExtensionDelegate: NSObject, WKExtensionDelegate { + private(set) lazy var loopManager = LoopDataManager() + + private let log = OSLog(category: "ExtensionDelegate") + + private var observers: [NSKeyValueObservation] = [] + private var notifications: [NSObjectProtocol] = [] static func shared() -> ExtensionDelegate { return WKExtension.shared().extensionDelegate @@ -20,78 +31,86 @@ final class ExtensionDelegate: NSObject, WKExtensionDelegate { override init() { super.init() - let session = WCSession.default() + let session = WCSession.default session.delegate = self // It seems, according to [this sample code](https://developer.apple.com/library/prerelease/content/samplecode/QuickSwitch/Listings/QuickSwitch_WatchKit_Extension_ExtensionDelegate_swift.html#//apple_ref/doc/uid/TP40016647-QuickSwitch_WatchKit_Extension_ExtensionDelegate_swift-DontLinkElementID_8) // that WCSession activation and delegation and WKWatchConnectivityRefreshBackgroundTask don't have any determinism, // and that KVO is the "recommended" way to deal with it. - session.addObserver(self, forKeyPath: "activationState", options: [], context: nil) - session.addObserver(self, forKeyPath: "hasContentPending", options: [], context: nil) + observers.append(session.observe(\WCSession.activationState) { [weak self] (session, change) in + self?.log.default("WCSession.applicationState did change to %d", session.activationState.rawValue) + + DispatchQueue.main.async { + self?.completePendingConnectivityTasksIfNeeded() + } + }) + observers.append(session.observe(\WCSession.hasContentPending) { [weak self] (session, change) in + self?.log.default("WCSession.hasContentPending did change to %d", session.hasContentPending) + + DispatchQueue.main.async { + self?.loopManager.sendDidUpdateContextNotificationIfNecessary() + self?.completePendingConnectivityTasksIfNeeded() + } + }) + + notifications.append(NotificationCenter.default.addObserver(forName: LoopDataManager.didUpdateContextNotification, object: loopManager, queue: nil) { [weak self] (_) in + DispatchQueue.main.async { + self?.loopManagerDidUpdateContext() + } + }) session.activate() } - override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { - DispatchQueue.main.async { - self.completePendingConnectivityTasksIfNeeded() + deinit { + for notification in notifications { + NotificationCenter.default.removeObserver(notification) } } func applicationDidFinishLaunching() { - // Perform any final initialization of your application. + UNUserNotificationCenter.current().delegate = self + if #available(watchOSApplicationExtension 5.0, *) { + INRelevantShortcutStore.default.registerShortcuts() + } } func applicationDidBecomeActive() { - // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. - - if WCSession.default().activationState != .activated { - WCSession.default().activate() + if WCSession.default.activationState != .activated { + WCSession.default.activate() } + + NotificationCenter.default.post(name: type(of: self).didBecomeActiveNotification, object: self) } func applicationWillResignActive() { - // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. - // Use this method to pause ongoing tasks, disable timers, etc. - } + UserDefaults.standard.startOnChartPage = (WKExtension.shared().visibleInterfaceController as? ChartHUDController) != nil - func handleUserActivity(_ userInfo: [AnyHashable : Any]?) { - // Use it to respond to Handoff–related activity. WatchKit calls this method when your app is launched as a result of a Handoff action. Use the information in the provided userInfo dictionary to determine how you want to respond to the action. For example, you might decide to display a specific interface controller. + NotificationCenter.default.post(name: type(of: self).willResignActiveNotification, object: self) } + // Presumably the main thread? func handle(_ backgroundTasks: Set) { + loopManager.requestGlucoseBackfillIfNecessary() + for task in backgroundTasks { switch task { case is WKApplicationRefreshBackgroundTask: - os_log("Processing WKApplicationRefreshBackgroundTask") - // Use the WKApplicationRefreshBackgroundTask class to update your app’s state in the background. - // You often use a background app refresh task to drive other tasks. For example, you could use a background app refresh task to start an URLSession background transfer, or to schedule a background snapshot refresh task. - // Your app must schedule background app refresh tasks by calling your extension’s scheduleBackgroundRefresh(withPreferredDate:userInfo:scheduledCompletion:) method. The system never schedules these tasks. - // WKExtension.shared().scheduleBackgroundRefresh(withPreferredDate:userInfo: scheduledCompletion:) - // For more information, see [WKApplicationRefreshBackgroundTask] https://developer.apple.com/reference/watchkit/wkapplicationrefreshbackgroundtask - // Background app refresh tasks are budgeted. In general, the system performs approximately one task per hour for each app in the dock (including the most recently used app). This budget is shared among all apps on the dock. The system performs multiple tasks an hour for each app with a complication on the active watch face. This budget is shared among all complications on the watch face. After you exhaust the budget, the system delays your requests until more time becomes available. + log.default("Processing WKApplicationRefreshBackgroundTask") break case let task as WKSnapshotRefreshBackgroundTask: - os_log("Processing WKSnapshotRefreshBackgroundTask") - // Use the WKSnapshotRefreshBackgroundTask class to update your app’s user interface. You can push, pop, or present other interface controllers, and then update the content of the desired interface controller. The system automatically takes a snapshot of your user interface as soon as this task completes. - // Your app can invalidate its current snapshot and schedule a background snapshot refresh tasks by calling your extension’s scheduleSnapshotRefresh(withPreferredDate:userInfo:scheduledCompletion:) method. The system will also schedule background snapshot refresh tasks to periodically update your snapshot. - // For more information, see WKSnapshotRefreshBackgroundTask. - // For more information about snapshots, see Snapshots. - + log.default("Processing WKSnapshotRefreshBackgroundTask") task.setTaskCompleted(restoredDefaultState: false, estimatedSnapshotExpiration: Date(timeIntervalSinceNow: TimeInterval(minutes: 5)), userInfo: nil) return // Don't call the standard setTaskCompleted handler case is WKURLSessionRefreshBackgroundTask: - // Use the WKURLSessionRefreshBackgroundTask class to respond to URLSession background transfers. break case let task as WKWatchConnectivityRefreshBackgroundTask: - os_log("Processing WKWatchConnectivityRefreshBackgroundTask") - // Use the WKWatchConnectivityRefreshBackgroundTask class to receive background updates from the WatchConnectivity framework. - // For more information, see WKWatchConnectivityRefreshBackgroundTask. + log.default("Processing WKWatchConnectivityRefreshBackgroundTask") pendingConnectivityTasks.append(task) - if WCSession.default().activationState != .activated { - WCSession.default().activate() + if WCSession.default.activationState != .activated { + WCSession.default.activate() } completePendingConnectivityTasksIfNeeded() @@ -100,82 +119,183 @@ final class ExtensionDelegate: NSObject, WKExtensionDelegate { break } - task.setTaskCompleted() + if #available(watchOSApplicationExtension 4.0, *) { + task.setTaskCompletedWithSnapshot(false) + } else { + task.setTaskCompleted() + } } } private var pendingConnectivityTasks: [WKWatchConnectivityRefreshBackgroundTask] = [] private func completePendingConnectivityTasksIfNeeded() { - if WCSession.default().activationState == .activated && !WCSession.default().hasContentPending { - pendingConnectivityTasks.forEach { $0.setTaskCompleted() } + if WCSession.default.activationState == .activated && !WCSession.default.hasContentPending { + pendingConnectivityTasks.forEach { (task) in + self.log.default("Completing WKWatchConnectivityRefreshBackgroundTask %{public}@", String(describing: task)) + if #available(watchOSApplicationExtension 4.0, *) { + task.setTaskCompletedWithSnapshot(false) + } else { + task.setTaskCompleted() + } + } pendingConnectivityTasks.removeAll() } } - // Main queue only - private(set) var lastContext: WatchContext? { - didSet { - WKExtension.shared().rootUpdatableInterfaceController?.update(with: lastContext) - - if WKExtension.shared().applicationState != .active { - WKExtension.shared().scheduleSnapshotRefresh(withPreferredDate: Date(), userInfo: nil) { (_) in } + func handle(_ userActivity: NSUserActivity) { + if #available(watchOSApplicationExtension 5.0, *) { + switch userActivity.activityType { + case NSUserActivity.newCarbEntryActivityType, NSUserActivity.didAddCarbEntryOnWatchActivityType: + if let statusController = WKExtension.shared().visibleInterfaceController as? HUDInterfaceController { + statusController.addCarbs() + } + default: + break } + } + } - // Update complication data if needed - let server = CLKComplicationServer.sharedInstance() - for complication in server.activeComplications ?? [] { - // In watchOS 2, we forced a timeline reload every 8 hours because attempting to extend it indefinitely seemed to lead to the complication "freezing". - if UserDefaults.standard.complicationDataLastRefreshed.timeIntervalSinceNow < TimeInterval(hours: -8) { - UserDefaults.standard.complicationDataLastRefreshed = Date() - os_log("Reloading complication timeline") - server.reloadTimeline(for: complication) - } else { - os_log("Extending complication timeline") - // TODO: Switch this back to extendTimeline if things are working correctly. - // Time Travel appears to be disabled by default in watchOS 3 anyway - server.reloadTimeline(for: complication) + private func updateContext(_ data: [String: Any]) { + guard let context = WatchContext(rawValue: data) else { + log.error("Could not decode WatchContext: %{public}@", data) + return + } + + if context.displayGlucoseUnit == nil { + let type = HKQuantityType.quantityType(forIdentifier: .bloodGlucose)! + loopManager.healthStore.preferredUnits(for: [type]) { (units, error) in + context.displayGlucoseUnit = units[type] + + DispatchQueue.main.async { + self.loopManager.updateContext(context) } } + } else { + DispatchQueue.main.async { + self.loopManager.updateContext(context) + } } } - fileprivate func updateContext(_ data: [String: Any]) { - if let context = WatchContext(rawValue: data as WatchContext.RawValue) { - DispatchQueue.main.async { - self.lastContext = context + private func loopManagerDidUpdateContext() { + dispatchPrecondition(condition: .onQueue(.main)) + + if WKExtension.shared().applicationState != .active { + WKExtension.shared().scheduleSnapshotRefresh(withPreferredDate: Date(), userInfo: nil) { (error) in + if let error = error { + self.log.error("scheduleSnapshotRefresh error: %{public}@", String(describing: error)) + } } } + + // Update complication data if needed + let server = CLKComplicationServer.sharedInstance() + for complication in server.activeComplications ?? [] { + log.default("Reloading complication timeline") + server.reloadTimeline(for: complication) + } } } extension ExtensionDelegate: WCSessionDelegate { func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { - if activationState == .activated && lastContext == nil { + if activationState == .activated { updateContext(session.receivedApplicationContext) } } func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String : Any]) { + log.default("didReceiveApplicationContext") updateContext(applicationContext) } + // This method is called on a background thread of your app func session(_ session: WCSession, didReceiveUserInfo userInfo: [String : Any] = [:]) { - // WatchContext is the only userInfo type without a "name" key. This isn't a great heuristic. - if !(userInfo["name"] is String) { + let name = userInfo["name"] as? String ?? "WatchContext" + + log.default("didReceiveUserInfo: %{public}@", name) + + switch name { + case LoopSettingsUserInfo.name: + if let settings = LoopSettingsUserInfo(rawValue: userInfo)?.settings { + DispatchQueue.main.async { + self.loopManager.settings = settings + } + } else { + log.error("Could not decode LoopSettingsUserInfo: %{public}@", userInfo) + } + case SupportedBolusVolumesUserInfo.name: + guard let volumes = SupportedBolusVolumesUserInfo(rawValue: userInfo)?.supportedBolusVolumes else { + log.error("Could not decode SupportedBolusVolumesUserInfo: %{public}@", userInfo) + return + } + + DispatchQueue.main.async { + self.loopManager.supportedBolusVolumes = volumes + } + case "WatchContext": + // WatchContext is the only userInfo type without a "name" key. This isn't a great heuristic. updateContext(userInfo) + default: + break } } } +extension ExtensionDelegate: UNUserNotificationCenterDelegate { + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { + switch response.actionIdentifier { + case UNNotificationDefaultActionIdentifier: + guard + response.notification.request.identifier == LoopNotificationCategory.missedMeal.rawValue, + let statusController = WKExtension.shared().visibleInterfaceController as? HUDInterfaceController + else { + break + } + + let userInfo = response.notification.request.content.userInfo + // If we have info about a meal, the carb entry UI should reflect it + if + let mealTime = userInfo[LoopNotificationUserInfoKey.missedMealTime.rawValue] as? Date, + let carbAmount = userInfo[LoopNotificationUserInfoKey.missedMealCarbAmount.rawValue] as? Double + { + let missedEntry = NewCarbEntry(quantity: HKQuantity(unit: .gram(), + doubleValue: carbAmount), + startDate: mealTime, + foodType: nil, + absorptionTime: nil) + statusController.addCarbs(initialEntry: missedEntry) + // Otherwise, just provide the ability to add carbs + } else { + statusController.addCarbs() + } + default: + break + } + + completionHandler() + } + + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + completionHandler([.badge, .sound, .list, .banner]) + } +} + + extension ExtensionDelegate { + static let didBecomeActiveNotification = Notification.Name("com.loopkit.Loop.LoopWatch.didBecomeActive") + + static let willResignActiveNotification = Notification.Name("com.loopkit.Loop.LoopWatch.willResignActive") /// Global shortcut to present an alert for a specific error out-of-context with a specific interface controller. /// /// - parameter error: The error whose contents to display func present(_ error: Error) { + dispatchPrecondition(condition: .onQueue(.main)) + WKExtension.shared().rootInterfaceController?.presentAlert(withTitle: error.localizedDescription, message: (error as NSError).localizedRecoverySuggestion ?? (error as NSError).localizedFailureReason, preferredStyle: .alert, actions: [WKAlertAction.dismissAction()]) } } @@ -185,8 +305,4 @@ fileprivate extension WKExtension { var extensionDelegate: ExtensionDelegate! { return delegate as? ExtensionDelegate } - - var rootUpdatableInterfaceController: ContextUpdatable? { - return rootInterfaceController as? ContextUpdatable - } } diff --git a/WatchApp Extension/Extensions/ButtonGroup.swift b/WatchApp Extension/Extensions/ButtonGroup.swift new file mode 100644 index 0000000000..744b6c055d --- /dev/null +++ b/WatchApp Extension/Extensions/ButtonGroup.swift @@ -0,0 +1,73 @@ +// +// ButtonGroup.swift +// WatchApp Extension +// +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +import WatchKit + + +class ButtonGroup { + private let button: WKInterfaceButton + private let image: WKInterfaceImage + private let background: WKInterfaceGroup + private let onBackgroundColor: UIColor + private let offBackgroundColor: UIColor + private let onIconColor: UIColor + private let offIconColor: UIColor + + enum State { + case on + case off + case disabled + } + + var state: State = .off { + didSet { + let imageTintColor: UIColor + let backgroundColor: UIColor + switch state { + case .on: + imageTintColor = onIconColor + backgroundColor = onBackgroundColor + case .off: + imageTintColor = offIconColor + backgroundColor = offBackgroundColor + case .disabled: + imageTintColor = .disabledButtonColor + backgroundColor = .darkDisabledButtonColor + } + + button.setEnabled(state != .disabled) + image.setTintColor(imageTintColor) + background.setBackgroundColor(backgroundColor) + } + } + + init(button: WKInterfaceButton, + image: WKInterfaceImage, + background: WKInterfaceGroup, + onBackgroundColor: UIColor, + offBackgroundColor: UIColor, + onIconColor: UIColor, + offIconColor: UIColor) + { + self.button = button + self.image = image + self.background = background + self.onBackgroundColor = onBackgroundColor + self.offBackgroundColor = offBackgroundColor + self.onIconColor = onIconColor + self.offIconColor = offIconColor + } + + func turnOff() { + switch state { + case .on: + state = .off + case .off, .disabled: + break + } + } +} diff --git a/WatchApp Extension/Extensions/CGRect.swift b/WatchApp Extension/Extensions/CGRect.swift new file mode 100644 index 0000000000..56d6a82fba --- /dev/null +++ b/WatchApp Extension/Extensions/CGRect.swift @@ -0,0 +1,23 @@ +// +// CGRect.swift +// WatchApp Extension +// +// Created by Michael Pangburn on 10/17/18. +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +import CoreGraphics + + +extension CGRect { + func alignedToScreenScale(_ screenScale: CGFloat) -> CGRect { + let factor = 1 / screenScale + + return CGRect( + x: origin.x.floored(to: factor), + y: origin.y.floored(to: factor), + width: size.width.ceiled(to: factor), + height: size.height.ceiled(to: factor) + ) + } +} diff --git a/WatchApp Extension/Extensions/CLKComplicationTemplate.swift b/WatchApp Extension/Extensions/CLKComplicationTemplate.swift index cd30289801..f49a9f2db0 100644 --- a/WatchApp Extension/Extensions/CLKComplicationTemplate.swift +++ b/WatchApp Extension/Extensions/CLKComplicationTemplate.swift @@ -7,20 +7,194 @@ // import ClockKit +import HealthKit +import LoopKit import Foundation +import LoopCore +extension CLKComplicationTemplate { -extension CLKComplicationTemplateModularSmallStackText { + static func templateForFamily( + _ family: CLKComplicationFamily, + from context: WatchContext, + at date: Date, + recencyInterval: TimeInterval, + chartGenerator makeChart: () -> UIImage? + ) -> CLKComplicationTemplate? { + guard let glucose = context.glucose, let unit = context.displayGlucoseUnit else { + return nil + } + + return templateForFamily(family, + glucose: glucose, + unit: unit, + glucoseDate: context.glucoseDate, + trend: context.glucoseTrend, + eventualGlucose: context.eventualGlucose, + at: date, + loopLastRunDate: context.loopLastRunDate, + recencyInterval: recencyInterval, + chartGenerator: makeChart) + } - convenience init?(line1: String?, date: Date?) { - guard let line1 = line1, let date = date else { + static func templateForFamily( + _ family: CLKComplicationFamily, + glucose: HKQuantity, + unit: HKUnit, + glucoseDate: Date?, + trend: GlucoseTrend?, + eventualGlucose: HKQuantity?, + at date: Date, + loopLastRunDate: Date?, + recencyInterval: TimeInterval, + chartGenerator makeChart: () -> UIImage? + ) -> CLKComplicationTemplate? { + + let formatter = NumberFormatter.glucoseFormatter(for: unit) + + guard let glucoseDate = glucoseDate else { return nil } + + let glucoseString: String + let trendString: String + + let isGlucoseStale = date.timeIntervalSince(glucoseDate) > recencyInterval - self.init() + if isGlucoseStale { + glucoseString = NSLocalizedString("---", comment: "No glucose value representation (3 dashes for mg/dL; no spaces as this will get truncated in the watch complication)") + trendString = "" + } else { + guard let formattedGlucose = formatter.string(from: glucose.doubleValue(for: unit)) else { + return nil + } + glucoseString = formattedGlucose + trendString = trend?.symbol ?? " " + } + + let loopCompletionFreshness = LoopCompletionFreshness(lastCompletion: loopLastRunDate, at: date) + + let tintColor: UIColor + + switch loopCompletionFreshness { + case .fresh: + tintColor = .tintColor + case .aging: + tintColor = .agingColor + case .stale: + tintColor = .staleColor + } - line1TextProvider = CLKSimpleTextProvider(text: line1) - line2TextProvider = CLKTimeTextProvider(date: date) - } + let glucoseAndTrend = "\(glucoseString)\(trendString)" + var accessibilityStrings = [glucoseString] + + if let trend = trend { + accessibilityStrings.append(trend.localizedDescription) + } + let glucoseAndTrendText = CLKSimpleTextProvider(text: glucoseAndTrend, shortText: glucoseString, accessibilityLabel: accessibilityStrings.joined(separator: ", ")) + + let timeText: CLKTextProvider + + if let loopLastRunDate = loopLastRunDate { + timeText = CLKRelativeDateTextProvider(date: loopLastRunDate, style: .natural, units: [.minute, .hour, .day]) + } else { + timeText = CLKTextProvider(format: "") + } + timeText.tintColor = tintColor + + let timeFormatter = DateFormatter() + timeFormatter.dateStyle = .none + timeFormatter.timeStyle = .short + + switch family { + case .modularSmall: + let template = CLKComplicationTemplateModularSmallStackText(line1TextProvider: glucoseAndTrendText, line2TextProvider: timeText) + template.highlightLine2 = true + return template + case .modularLarge: + return CLKComplicationTemplateModularLargeTallBody(headerTextProvider: timeText, bodyTextProvider: glucoseAndTrendText) + case .circularSmall: + return CLKComplicationTemplateCircularSmallSimpleText(textProvider: CLKSimpleTextProvider(text: glucoseString)) + case .extraLarge: + return CLKComplicationTemplateExtraLargeStackText(line1TextProvider: glucoseAndTrendText, line2TextProvider: timeText) + case .utilitarianSmall, .utilitarianSmallFlat: + return CLKComplicationTemplateUtilitarianSmallFlat(textProvider: CLKSimpleTextProvider(text: glucoseString)) + case .utilitarianLarge: + var eventualGlucoseText = "" + if let eventualGlucose = eventualGlucose, + let eventualGlucoseString = formatter.string(from: eventualGlucose.doubleValue(for: unit)) + { + eventualGlucoseText = eventualGlucoseString + } + + let format = NSLocalizedString("UtilitarianLargeFlat", tableName: "ckcomplication", comment: "Utilitarian large flat format string (1: Glucose & Trend symbol) (2: Eventual Glucose) (3: Time)") + + return CLKComplicationTemplateUtilitarianLargeFlat( + textProvider: CLKSimpleTextProvider(text: String(format: format, arguments: [ + glucoseAndTrend, + eventualGlucoseText, + timeFormatter.string(from: glucoseDate) + ] + ))) + case .graphicCorner: + if #available(watchOSApplicationExtension 5.0, *) { + return CLKComplicationTemplateGraphicCornerStackText(innerTextProvider: timeText, outerTextProvider: glucoseAndTrendText) + } else { + return nil + } + case .graphicCircular: + if #available(watchOSApplicationExtension 5.0, *) { + return CLKComplicationTemplateGraphicCircularOpenGaugeSimpleText( + gaugeProvider: CLKSimpleGaugeProvider(style: .fill, gaugeColor: tintColor, fillFraction: 1), + bottomTextProvider: CLKSimpleTextProvider(text: trendString), + centerTextProvider: CLKSimpleTextProvider(text: glucoseString) + ) + } else { + return nil + } + case .graphicBezel: + if #available(watchOSApplicationExtension 5.0, *) { + guard + let circularTemplate = templateForFamily(.graphicCircular, + glucose: glucose, + unit: unit, + glucoseDate: glucoseDate, + trend: trend, + eventualGlucose: eventualGlucose, + at: date, + loopLastRunDate: loopLastRunDate, + recencyInterval: recencyInterval, + chartGenerator: makeChart + ) as? CLKComplicationTemplateGraphicCircular + else { + fatalError("\(#function) invoked with .graphicCircular must return a subclass of CLKComplicationTemplateGraphicCircular") + } + return CLKComplicationTemplateGraphicBezelCircularText(circularTemplate: circularTemplate, textProvider: timeText) + } else { + return nil + } + case .graphicRectangular: + if #available(watchOSApplicationExtension 5.0, *) { + return CLKComplicationTemplateGraphicRectangularLargeImage( + textProvider: CLKTextProvider(byJoining: [glucoseAndTrendText, timeText], separator: " "), + imageProvider: CLKFullColorImageProvider(fullColorImage: makeChart() ?? UIImage()) + ) + } else { + return nil + } + case .graphicExtraLarge: + if #available(watchOSApplicationExtension 5.0, *) { + return CLKComplicationTemplateGraphicExtraLargeCircularOpenGaugeSimpleText( + gaugeProvider: CLKSimpleGaugeProvider(style: .fill, gaugeColor: tintColor, fillFraction: 1), + bottomTextProvider: CLKSimpleTextProvider(text: trendString), + centerTextProvider: CLKSimpleTextProvider(text: glucoseString) + ) + } else { + return nil + } + @unknown default: + return nil + } + } } diff --git a/WatchApp Extension/Extensions/CLKTextProvider+Compound.h b/WatchApp Extension/Extensions/CLKTextProvider+Compound.h new file mode 100644 index 0000000000..194f99584e --- /dev/null +++ b/WatchApp Extension/Extensions/CLKTextProvider+Compound.h @@ -0,0 +1,21 @@ +// +// CLKTextProvider+Compound.h +// Loop +// +// Created by Michael Pangburn on 10/27/18. +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +#define CLKTextProvider_Compound_h + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface CLKTextProvider (Compound) + ++ (CLKTextProvider *)textProviderByJoiningTextProviders: (NSArray *)textProviders separator:(NSString *) separator; + +@end + +NS_ASSUME_NONNULL_END diff --git a/WatchApp Extension/Extensions/CLKTextProvider+Compound.m b/WatchApp Extension/Extensions/CLKTextProvider+Compound.m new file mode 100644 index 0000000000..5271796dff --- /dev/null +++ b/WatchApp Extension/Extensions/CLKTextProvider+Compound.m @@ -0,0 +1,37 @@ +// +// CLKTextProvider+Compound.m +// WatchApp Extension +// +// Created by Michael Pangburn on 10/27/18. +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +#import "CLKTextProvider+Compound.h" + +NS_ASSUME_NONNULL_BEGIN + +// CLKTextProvider.textProviderWithFormat (compound text provider creation) is unavailable in Swift. +// c.f. https://crunchybagel.com/using-multicolour-clktextprovider-in-swift-in-watchos-5/ +@implementation CLKTextProvider (Compound) + ++ (CLKTextProvider *)textProviderByJoiningTextProviders: (NSArray *)textProviders separator:(NSString *) separator { + + NSString *formatString = @"%@%@"; + + if (separator.length > 0) { + formatString = [NSString stringWithFormat:@"%@%@%@", @"%@", separator, @"%@"]; + } + + CLKTextProvider *firstItem = textProviders.firstObject; + + for (NSUInteger index = 1; index < textProviders.count; index++) { + CLKTextProvider *secondItem = [textProviders objectAtIndex: index]; + firstItem = [CLKTextProvider textProviderWithFormat:formatString, firstItem, secondItem]; + } + + return firstItem; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/WatchApp Extension/Extensions/Collection.swift b/WatchApp Extension/Extensions/Collection.swift new file mode 100644 index 0000000000..26a7200cf4 --- /dev/null +++ b/WatchApp Extension/Extensions/Collection.swift @@ -0,0 +1,14 @@ +// +// Collection.swift +// WatchApp Extension +// +// Created by Michael Pangburn on 6/30/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +extension Collection { + /// Returns a sequence containing adjacent pairs of elements in the ordered collection. + func adjacentPairs() -> Zip2Sequence { + return zip(self, dropFirst()) + } +} diff --git a/WatchApp Extension/Extensions/Comparable.swift b/WatchApp Extension/Extensions/Comparable.swift new file mode 100644 index 0000000000..aae6846520 --- /dev/null +++ b/WatchApp Extension/Extensions/Comparable.swift @@ -0,0 +1,23 @@ +// +// Comparable.swift +// WatchApp Extension +// +// Created by Michael Pangburn on 3/27/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +extension Comparable { + func clamped(to range: ClosedRange) -> Self { + if self < range.lowerBound { + return range.lowerBound + } else if self > range.upperBound { + return range.upperBound + } else { + return self + } + } + + mutating func clamp(to range: ClosedRange) { + self = clamped(to: range) + } +} diff --git a/WatchApp Extension/Extensions/Date.swift b/WatchApp Extension/Extensions/Date.swift new file mode 100644 index 0000000000..0106bba973 --- /dev/null +++ b/WatchApp Extension/Extensions/Date.swift @@ -0,0 +1,20 @@ +// +// Date.swift +// WatchApp Extension +// +// Created by Bharat Mediratta on 6/26/18. +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +import Foundation + + +extension Date { + static var earliestGlucoseCutoff: Date { + return Date(timeIntervalSinceNow: .hours(-6)) + } + + static var staleGlucoseCutoff: Date { + return Date(timeIntervalSinceNow: .minutes(-5)) + } +} diff --git a/WatchApp Extension/Extensions/INRelevantShortcutStore+Loop.swift b/WatchApp Extension/Extensions/INRelevantShortcutStore+Loop.swift new file mode 100644 index 0000000000..97c2a79e4e --- /dev/null +++ b/WatchApp Extension/Extensions/INRelevantShortcutStore+Loop.swift @@ -0,0 +1,30 @@ +// +// INRelevantShortcutStore+Loop.swift +// Loop +// +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +import Intents +import os.log + + +@available(watchOSApplicationExtension 5.0, *) +extension INRelevantShortcutStore { + func registerShortcuts() { + var shortcuts = [INRelevantShortcut]() + + let shortcut = INShortcut(userActivity: .forDidAddCarbEntryOnWatch()) + let relevance = INRelevantShortcut(shortcut: shortcut) + relevance.shortcutRole = .action + relevance.relevanceProviders = [] + + shortcuts.append(relevance) + + setRelevantShortcuts(shortcuts) { (error) in + if let error = error { + os_log(.error, "Error specifying shortcuts: %{public}@", String(describing: error)) + } + } + } +} diff --git a/WatchApp Extension/Extensions/IdentifiableClass.swift b/WatchApp Extension/Extensions/IdentifiableClass.swift deleted file mode 100644 index 91ce24e219..0000000000 --- a/WatchApp Extension/Extensions/IdentifiableClass.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// IdentifiableClass.swift -// Naterade -// -// Created by Nathan Racklyeft on 2/9/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import Foundation - - -protocol IdentifiableClass: class { - static var className: String { get } -} - - -extension IdentifiableClass { - static var className: String { - return NSStringFromClass(self).components(separatedBy: ".").last! - } -} diff --git a/WatchApp Extension/Extensions/NSUserDefaults+WatchApp.swift b/WatchApp Extension/Extensions/NSUserDefaults+WatchApp.swift new file mode 100644 index 0000000000..84e311ef88 --- /dev/null +++ b/WatchApp Extension/Extensions/NSUserDefaults+WatchApp.swift @@ -0,0 +1,52 @@ +// +// NSUserDefaults.swift +// Naterade +// +// Created by Nathan Racklyeft on 3/29/16. +// Copyright © 2016 Nathan Racklyeft. All rights reserved. +// + +import Foundation + + +extension UserDefaults { + private enum Key: String { + case StartOnChartPage = "com.loudnate.Naterade.StartOnChartPage" + case VisibleDuration = "com.loudnate.Naterade.VisibleDuration" + case SupportedBolusVolumes = "com.loopkit.Loop.SupportedBolusVolumes" + } + + var startOnChartPage: Bool { + get { + return object(forKey: Key.StartOnChartPage.rawValue) as? Bool ?? false + } + set { + set(newValue, forKey: Key.StartOnChartPage.rawValue) + } + } + + var visibleDuration: TimeInterval { + get { + if let value = object(forKey: Key.VisibleDuration.rawValue) as? TimeInterval { + return value + } + return TimeInterval(hours: 2) + } + set { + set(newValue.rawValue, forKey: Key.VisibleDuration.rawValue) + } + } + + var supportedBolusVolumes: [Double]? { + get { + array(forKey: Key.SupportedBolusVolumes.rawValue) as? [Double] + } + set { + guard let newValue = newValue else { + removeObject(forKey: Key.SupportedBolusVolumes.rawValue) + return + } + set(newValue, forKey: Key.SupportedBolusVolumes.rawValue) + } + } +} diff --git a/WatchApp Extension/Extensions/NSUserDefaults.swift b/WatchApp Extension/Extensions/NSUserDefaults.swift deleted file mode 100644 index b45ba55feb..0000000000 --- a/WatchApp Extension/Extensions/NSUserDefaults.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// NSUserDefaults.swift -// Naterade -// -// Created by Nathan Racklyeft on 3/29/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import Foundation - - -extension UserDefaults { - private enum Key: String { - case ComplicationDataLastRefreshed = "com.loudnate.Naterade.ComplicationDataLastRefreshed" - } - - var complicationDataLastRefreshed: Date { - get { - return object(forKey: Key.ComplicationDataLastRefreshed.rawValue) as? Date ?? Date.distantPast - } - set { - set(newValue, forKey: Key.ComplicationDataLastRefreshed.rawValue) - } - } -} diff --git a/WatchApp Extension/Extensions/UIColor.swift b/WatchApp Extension/Extensions/UIColor.swift index 8f21edbcee..8805741357 100644 --- a/WatchApp Extension/Extensions/UIColor.swift +++ b/WatchApp Extension/Extensions/UIColor.swift @@ -10,10 +10,41 @@ import UIKit extension UIColor { - @nonobjc static let tintColor = UIColor.HIGOrangeColor() + static let tintColor = UIColor(named: "tint")! - @nonobjc static let darkTintColor = UIColor.HIGOrangeColorDark() + static let carbsColor = UIColor(named: "carbs")! + // Equivalent to carbsColor with alpha 0.14 on a black background + static let darkCarbsColor = UIColor(named: "carbs-dark")! + + static let glucose = UIColor(named: "glucose")! + + // Equivalent to glucoseColor with alpha 0.14 on a black background + static let darkGlucose = UIColor(named: "glucose-dark")! + + static let insulin = UIColor(named: "insulin")! + + static let darkInsulin = UIColor(named: "insulin-dark")! + + static let overrideColor = UIColor(named: "workout")! + + // Equivalent to workoutColor with alpha 0.14 on a black background + static let darkOverrideColor = UIColor(named: "workout-dark")! + + static let disabledButtonColor = UIColor.gray + + static let darkDisabledButtonColor = UIColor.disabledButtonColor.withAlphaComponent(0.14) + + static let chartLabel = HIGWhiteColor() + + static let chartNowLine = HIGWhiteColor().withAlphaComponent(0.2) + + static let chartPlatter = HIGWhiteColorDark() + + static let agingColor = UIColor(named: "warning") ?? HIGYellowColor() + + static let staleColor = HIGRedColor() + // MARK: - HIG colors // See: https://developer.apple.com/watch/human-interface-guidelines/visual-design/#color @@ -56,4 +87,13 @@ extension UIColor { private static func HIGGreenColorDark() -> UIColor { return HIGGreenColor().withAlphaComponent(0.14) } + + private static func HIGWhiteColor() -> UIColor { + return UIColor(red: 242 / 255, green: 244 / 255, blue: 1, alpha: 1) + } + + private static func HIGWhiteColorDark() -> UIColor { + // Equivalent to HIGWhiteColor().withAlphaComponent(0.14) on black + return UIColor(red: 33 / 255, green: 34 / 255, blue: 35 / 255, alpha: 1) + } } diff --git a/WatchApp Extension/Extensions/WCSession.swift b/WatchApp Extension/Extensions/WCSession.swift index 346b7ced61..246eff2b2c 100644 --- a/WatchApp Extension/Extensions/WCSession.swift +++ b/WatchApp Extension/Extensions/WCSession.swift @@ -6,53 +6,176 @@ // Copyright © 2016 Nathan Racklyeft. All rights reserved. // +import LoopCore import WatchConnectivity +import os.log enum MessageError: Error { - case activationError - case decodingError - case reachabilityError + case activation + case decoding + case reachability + case send(Error) } +enum WCSessionMessageResult { + case success(T) + case failure(MessageError) +} + +private let log = OSLog(category: "WCSession Extension") extension WCSession { - func sendCarbEntryMessage(_ carbEntry: CarbEntryUserInfo, replyHandler: @escaping (BolusSuggestionUserInfo) -> Void, errorHandler: @escaping (Error) -> Void) throws { + func sendPotentialCarbEntryMessage(_ carbEntry: PotentialCarbEntryUserInfo, replyHandler: @escaping (WatchContext) -> Void, errorHandler: @escaping (Error) -> Void) throws { guard activationState == .activated else { - throw MessageError.activationError + throw MessageError.activation } guard isReachable else { - transferUserInfo(carbEntry.rawValue) + log.default("sendPotentialCarbEntryMessage: Phone is unreachable, taking no action") return } sendMessage(carbEntry.rawValue, - replyHandler: { (reply) in - guard let suggestion = BolusSuggestionUserInfo(rawValue: reply as BolusSuggestionUserInfo.RawValue) else { - errorHandler(MessageError.decodingError) + replyHandler: { reply in + guard let context = WatchContext(rawValue: reply as WatchContext.RawValue) else { + log.error("sendPotentialCarbEntryMessage: could not decode reply: %{public}@", reply) + errorHandler(MessageError.decoding) return } - replyHandler(suggestion) + replyHandler(context) }, - errorHandler: errorHandler + errorHandler: { error in + log.error("sendPotentialCarbEntryMessage: message send failed with error: %{public}@", String(describing: error)) + errorHandler(error) + } + ) + } + + func sendBolusMessage(_ userInfo: SetBolusUserInfo, completionHandler: @escaping (Error?) -> Void) throws { + guard activationState == .activated else { + throw MessageError.activation + } + + guard isReachable else { + throw MessageError.reachability + } + + sendMessage(userInfo.rawValue, + replyHandler: { reply in + completionHandler(nil) + }, + errorHandler: { error in + log.info("sendBolusMessage failure: %{public}@", error.localizedDescription) + completionHandler(error) + } + ) + } + + func sendSettingsUpdateMessage(_ userInfo: LoopSettingsUserInfo, completionHandler: @escaping (Result) -> Void) throws { + guard activationState == .activated else { + throw MessageError.activation + } + + guard isReachable else { + throw MessageError.reachability + } + + sendMessage(userInfo.rawValue, replyHandler: { (reply) in + if let context = WatchContext(rawValue: reply) { + completionHandler(.success(context)) + } else { + completionHandler(.failure(MessageError.decoding)) + } + }, errorHandler: { (error) in + completionHandler(.failure(error)) + }) + } + + func sendCarbBackfillRequestMessage(_ userInfo: CarbBackfillRequestUserInfo, completionHandler: @escaping (WCSessionMessageResult) -> Void) { + log.default("sendCarbBackfillRequestMessage: since %{public}@", String(describing: userInfo.startDate)) + + // Backfill is optional so we ignore any errors + guard activationState == .activated else { + log.error("sendCarbBackfillRequestMessage failed: not activated") + completionHandler(.failure(.activation)) + return + } + + guard isReachable else { + log.error("sendCarbBackfillRequestMessage failed: not reachable") + completionHandler(.failure(.reachability)) + return + } + + sendMessage(userInfo.rawValue, + replyHandler: { reply in + if let context = WatchHistoricalCarbs(rawValue: reply as WatchHistoricalCarbs.RawValue) { + log.default("sendCarbBackfillRequestMessage succeeded with %d samples", context.objects.count) + completionHandler(.success(context)) + } else { + log.error("sendCarbBackfillRequestMessage failed: could not decode reply %{public}@", reply) + completionHandler(.failure(.decoding)) + } + }, + errorHandler: { error in + log.error("sendCarbBackfillRequestMessage error: %{public}@", String(describing: error)) + completionHandler(.failure(.send(error))) + } ) } - func sendBolusMessage(_ userInfo: SetBolusUserInfo, errorHandler: @escaping (Error) -> Void) throws { + func sendGlucoseBackfillRequestMessage(_ userInfo: GlucoseBackfillRequestUserInfo, completionHandler: @escaping (WCSessionMessageResult) -> Void) { + log.default("sendGlucoseBackfillRequestMessage: since %{public}@", String(describing: userInfo.startDate)) + + // Backfill is optional so we ignore any errors guard activationState == .activated else { - throw MessageError.activationError + log.error("sendGlucoseBackfillRequestMessage failed: not activated") + completionHandler(.failure(.activation)) + return } guard isReachable else { - throw MessageError.reachabilityError + log.error("sendGlucoseBackfillRequestMessage failed: not reachable") + completionHandler(.failure(.reachability)) + return } sendMessage(userInfo.rawValue, - replyHandler: { (reply) in + replyHandler: { reply in + if let context = WatchHistoricalGlucose(rawValue: reply as WatchHistoricalGlucose.RawValue) { + log.default("sendGlucoseBackfillRequestMessage succeeded with %d samples", context.samples.count) + completionHandler(.success(context)) + } else { + log.error("sendGlucoseBackfillRequestMessage failed: could not decode reply %{public}@", reply) + completionHandler(.failure(.decoding)) + } }, - errorHandler: errorHandler + errorHandler: { error in + log.error("sendGlucoseBackfillRequestMessage error: %{public}@", String(describing: error)) + completionHandler(.failure(.send(error))) + } ) } + + func sendContextRequestMessage(_ userInfo: WatchContextRequestUserInfo, completionHandler: @escaping (Result) -> Void) throws { + guard activationState == .activated else { + throw MessageError.activation + } + + guard isReachable else { + throw MessageError.reachability + } + + sendMessage(userInfo.rawValue, replyHandler: { (reply) in + if let context = WatchContext(rawValue: reply) { + completionHandler(.success(context)) + } else { + completionHandler(.failure(MessageError.decoding)) + } + }, errorHandler: { (error) in + completionHandler(.failure(error)) + }) + } } diff --git a/WatchApp Extension/Extensions/WKInterfaceImage.swift b/WatchApp Extension/Extensions/WKInterfaceImage.swift index c17655b63a..024514a004 100644 --- a/WatchApp Extension/Extensions/WKInterfaceImage.swift +++ b/WatchApp Extension/Extensions/WKInterfaceImage.swift @@ -9,15 +9,20 @@ import WatchKit enum LoopImage: String { - case Fresh - case Aging - case Stale - case Unknown + case fresh + case aging + case stale + case unknown + + func imageName(isClosedLoop: Bool) -> String { + let suffix = isClosedLoop ? "closed" : "open" + return "loop_\(rawValue)_\(suffix)" + } } extension WKInterfaceImage { - func setLoopImage(_ loopImage: LoopImage) { - setImageNamed("loop_\(loopImage.rawValue.lowercased())") + func setLoopImage(isClosedLoop: Bool, _ loopImage: LoopImage) { + setImageNamed(loopImage.imageName(isClosedLoop: isClosedLoop)) } } diff --git a/WatchApp Extension/Extensions/WKInterfaceLabel.swift b/WatchApp Extension/Extensions/WKInterfaceLabel.swift new file mode 100644 index 0000000000..5dde1640f7 --- /dev/null +++ b/WatchApp Extension/Extensions/WKInterfaceLabel.swift @@ -0,0 +1,35 @@ +// +// WKInterfaceLabel.swift +// WatchApp Extension +// +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import WatchKit + +extension WKInterfaceLabel { + func setRoundedText(_ text: String?, style: UIFont.TextStyle, traits: UIFontDescriptor.SymbolicTraits = []) { + guard let text = text else { + setText(nil) + return + } + + if let descriptor = UIFontDescriptor.rounded(style: style, traits: traits) + { + setAttributedText(NSAttributedString(string: text, attributes: [NSAttributedString.Key.font: UIFont(descriptor: descriptor, size: 0)])) + } else { + setText(text) + } + } + + func setLargeBoldRoundedText(_ text: String?) { + setRoundedText(text, style: .largeTitle, traits: .traitBold) + } +} + +extension UIFontDescriptor { + class func rounded(style: UIFont.TextStyle, traits: UIFontDescriptor.SymbolicTraits) -> UIFontDescriptor? { + let descriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: style) + return descriptor.withDesign(.rounded)?.withSymbolicTraits(.traitBold) + } +} diff --git a/WatchApp Extension/Extensions/WatchApp Extension-Bridging-Header.h b/WatchApp Extension/Extensions/WatchApp Extension-Bridging-Header.h new file mode 100644 index 0000000000..35a90b80f3 --- /dev/null +++ b/WatchApp Extension/Extensions/WatchApp Extension-Bridging-Header.h @@ -0,0 +1,5 @@ +// +// Use this file to import your target's public headers that you would like to expose to Swift. +// + +#import "CLKTextProvider+Compound.h" diff --git a/WatchApp Extension/Extensions/WatchContext+WatchApp.swift b/WatchApp Extension/Extensions/WatchContext+WatchApp.swift index aff2dc566e..6c77afd7c2 100644 --- a/WatchApp Extension/Extensions/WatchContext+WatchApp.swift +++ b/WatchApp Extension/Extensions/WatchContext+WatchApp.swift @@ -7,15 +7,31 @@ // import Foundation +import HealthKit +import LoopKit extension WatchContext { - var glucoseTrend: GlucoseTrend? { - get { - if let glucoseTrendRawValue = glucoseTrendRawValue { - return GlucoseTrend(rawValue: glucoseTrendRawValue) - } else { - return nil - } + var activeInsulin: HKQuantity? { + guard let value = iob else { + return nil } + + return HKQuantity(unit: .internationalUnit(), doubleValue: value) + } + + var activeCarbohydrates: HKQuantity? { + guard let value = cob else { + return nil + } + + return HKQuantity(unit: .gram(), doubleValue: value) + } + + var reservoirVolume: HKQuantity? { + guard let value = reservoir else { + return nil + } + + return HKQuantity(unit: .internationalUnit(), doubleValue: value) } } diff --git a/WatchApp Extension/Info.plist b/WatchApp Extension/Info.plist index 50f23199d8..e0a8a9a98f 100644 --- a/WatchApp Extension/Info.plist +++ b/WatchApp Extension/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType XPC! CFBundleShortVersionString - 1.2.0 + $(LOOP_MARKETING_VERSION) CFBundleSignature ???? CFBundleVersion @@ -26,18 +26,33 @@ $(PRODUCT_MODULE_NAME).ComplicationController CLKComplicationSupportedFamilies + CLKComplicationFamilyCircularSmall + CLKComplicationFamilyExtraLarge + CLKComplicationFamilyGraphicBezel + CLKComplicationFamilyGraphicCircular + CLKComplicationFamilyGraphicCorner + CLKComplicationFamilyGraphicExtraLarge + CLKComplicationFamilyGraphicRectangular + CLKComplicationFamilyModularLarge CLKComplicationFamilyModularSmall + CLKComplicationFamilyUtilitarianLarge + CLKComplicationFamilyUtilitarianSmall + CLKComplicationFamilyUtilitarianSmallFlat NSExtension NSExtensionAttributes WKAppBundleIdentifier - $(MAIN_APP_BUNDLE_IDENTIFIER).watchkitapp + $(MAIN_APP_BUNDLE_IDENTIFIER).LoopWatch NSExtensionPointIdentifier com.apple.watchkit + NSHealthShareUsageDescription + Meal data from the Health database is used to determine glucose effects. Glucose data from the Health database is used for graphing and momentum calculation. Sleep data from the Health database is used to optimize delivery of Apple Watch complication updates during the time you are awake. + NSHealthUpdateUsageDescription + Carbohydrate meal data entered in the app and on the watch is stored in the Health database. Glucose data retrieved from the CGM is stored securely in HealthKit. RemoteInterfacePrincipalClass $(PRODUCT_MODULE_NAME).StatusInterfaceController WKExtensionDelegateClassName diff --git a/WatchApp Extension/InfoPlist.xcstrings b/WatchApp Extension/InfoPlist.xcstrings new file mode 100644 index 0000000000..19974c32f6 --- /dev/null +++ b/WatchApp Extension/InfoPlist.xcstrings @@ -0,0 +1,450 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "CFBundleDisplayName" : { + "comment" : "Bundle display name", + "extractionState" : "extracted_with_value", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "WatchApp-udvidelse" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "WatchApp-Erweiterung" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "WatchApp Extension" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Extensión de WatchApp" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "WatchApp-laajennus" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "WatchApp Extension" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estensione WatchApp" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "WatchApp-utvidelse" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "WatchApp Extensie" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "WatchApp Extension" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Extensie WatchApp" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Расширение WatchApp" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "WatchApp Extension" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "WatchApp Uzantısı" + } + } + } + }, + "CFBundleName" : { + "comment" : "Bundle name", + "extractionState" : "extracted_with_value", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "WatchApp-udvidelse" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "WatchApp-Erweiterung" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "WatchApp Extension" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Extensión de WatchApp" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "WatchApp-laajennus" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "WatchApp Extension" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estensione WatchApp" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "WatchApp-utvidelse" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "WatchApp Extensie" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "WatchApp Extension" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Extensie WatchApp" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Расширение WatchApp" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "WatchApp Extension" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "WatchApp Uzantısı" + } + } + } + }, + "NSHealthShareUsageDescription" : { + "comment" : "Privacy - Health Share Usage Description", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يتم استخدام بيانات الوجبات من قواعد بيانات تطبيق صحتي لتحديد تأثيرات سكر الدم. يتم استخدام بيانات سكر الدم منقواعد بيانات تطبيق صحتي للرسم البياني والتحليل. تُستخدم بيانات النوم من قواعد بيانات تطبيق صحتي لتحسين توصيل تحديثات تعقيدات ساعة أبل أثناء فترة استيقاظك." + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mad-data fra Health-databasen bruges til at bestemme blodsukkereffekten. Blodsukkerdata fra Health-databasen bruges til graftegning og momentumberegning. Søvndata fra sundhedsdatabasen bruges til at optimere leveringen af opdateringer om komplikationer af Apple Watch i den tid, du er vågen." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mahlzeitendaten aus der Health Datenbank werden verwendet, um die Glukoseeffekte zu bestimmen. Glukosedaten aus der Health Datenbank werden zur grafischen Darstellung und Impulsberechnung verwendet. Schlafdaten aus der Health-Datenbank werden verwendet, um die Bereitstellung von Apple Watch-Komplikationsupdates während Deiner Wachzeit zu optimieren." + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Meal data from the Health database is used to determine glucose effects. Glucose data from the Health database is used for graphing and momentum calculation. Sleep data from the Health database is used to optimize delivery of Apple Watch complication updates during the time you are awake." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Los datos de alimentos de la base de datos de Salud se utilizan para determinar los efectos en el nivel de glucosa. Los datos de glucosa de la base de datos de Salud se utilizan para graficar y determinar cálculos de momento. Los datos de Sueño de la base de datos de Salud se utilizan para optimizar la entrega de actualizaciones de las complicaciones del Apple Watch durante el tiempo que está despierto." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Terveys-sovelluksen ateriatietoja käytetään glukoosivaikutusten määrittämiseen. Terveys-sovelluksen glukoositietoja käytetään graafeissa ja laskelmissa. Unitietoja käytetään Apple Watch -komplikaation toiminnan optimointiin hereillä olon aikana." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les données sur les repas provenant de la base de données Santé sont utilisées pour déterminer les effets du glucose. Les données relatives au glucose provenant de la base de données Santé sont utilisées pour la création de graphiques et le calcul de l'élan. Les données relatives au sommeil provenant de la base de données Santé sont utilisées pour optimiser l'envoi des mises à jour des complications de l'Apple Watch pendant la période où vous êtes éveillé(e)." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "נתוני ארוחות ממאגר Health משמשים לקביעת השפעות הגלוקוז. נתוני הגלוקוז ממסד Health הבריאות משמשים לגרפים ולחישוב מגמה. נתוני שינה ממסד Health משפרים את הנראות ב-Apple Watch." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "I dati sui pasti del database Salute vengono utilizzati per determinare gli effetti del glucosio. I dati sul glucosio del database Salute vengono utilizzati per la rappresentazione grafica e il calcolo del momento. I dati sul sonno del database Salute vengono utilizzati per ottimizzare la consegna degli aggiornamenti delle complicazioni di Apple Watch durante il periodo di veglia." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アプリに入力された炭水化物の食事データは、健康データベースに保存されます。 グルコースデータはHealthKitに安全に保存されます" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Måltidsdata fra helsedatabasen brukes til å bestemme glukoseeffekter. Glukosedata fra helsedatabasen brukes til grafer og momentumberegning. Søvndata fra helsedatabasen brukes til å optimalisere leveringen av Apple Watch-komplikasjonsoppdateringer når du er våken." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maaltijdgegevens uit de database Gezondheid worden gebruikt om glucose-effecenten te bepalen. Glucosegegevens uit de database Gezondheid worden gebruikt voor grafieken en het berekenen van trendlijnen. Slaapgegevens uit de database Gezondheid worden gebruikt om de Apple Watch complicatie bij te werken wanneer je wakker bent." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dane posiłków z bazy danych aplikacji Zdrowie służą do określania wpływu glukozy. Dane dotyczące glukozy z bazy danych aplikacji Zdrowie są wykorzystywane do tworzenia wykresów i wyznaczania trendu. Dane dotyczące snu z bazy danych aplikacji Zdrowie służą do optymalizacji dostarczania aktualizacji komplikacji Apple Watch w czasie, gdy nie śpisz." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Os dados de refeições do banco de dados de saúde são utilizados para definir os efeitos da glicose para a representação gráfica e cálculo da aceleração." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Datele mesei din baza de date din aplicația Sănătate sunt folosite pentru a determina efectele glicemice. Datele despre glicemie din baza de date Sănătate sunt folosite pentru construirea graficelor și calcularea influențelor glicemice. Datele de somn din baza de date Sănătate sunt folosite pentru a optimiza livrarea de actualizări de date pentru ceasul Apple pe perioada când sunteți treaz." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Данные о приеме пищи из базы данных Health используются для определения влияния глюкозы. Данные о глюкозе из базы данных Health используются для построения графиков и расчетов. Данные о сне из базы данных Health используются для оптимизации доставки обновлений усложнений Apple Watch во время вашего бодрствования." + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Údaje o jedle z databázy Health sa používajú na určenie účinkov glukózy. Údaje o glukóze z databázy Health sa používajú na vytváranie grafov a výpočet hybnosti. Údaje o spánku z databázy Health sa používajú na optimalizáciu doručovania aktualizácií komplikácií Apple Watch v čase, keď ste hore." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kolhydratdata från Apple Health-databasen används för att avgöra blodsockereffekt. Blodsockervärden från Apple Health-databasen används i diagram och för beräkning av förändring." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sağlık veri tabanından alınan yemek verileri, KŞ etkilerini belirlemek için kullanılır. Sağlık veri tabanından alınan KŞ verileri, grafik ve momentum hesaplaması için kullanılır. Sağlık veritabanındaki uyku verileri, uyanık olduğunuz süre boyunca Apple Watch komplikasyon güncellemelerinin teslimini optimize etmek için kullanılır." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thông số bữa ăn từ app Health được sử dụng để xác định tác động của glucose. Thông số glucose từ app Health được sử dụng cho các tính toán vẽ đồ thị và chuyển động của đường huyết." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "数据库中的膳食数据用于确定葡萄糖影响。健康数据库中的葡萄糖数据用于绘图和动量计算。" + } + } + } + }, + "NSHealthUpdateUsageDescription" : { + "comment" : "Privacy - Health Update Usage Description", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "بيانات كربوهيدرات الوجبة المدخلة للتطبيق و الساعة محفوظة في قواعد بيانات تطبيق صحتي. يتم تخزين بيانات سكر الدم المستردة من نظام متابعة سكر الدم المستمرة بشكل آمن في تطبيق صحتي." + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Data om kulhydratmåltider, der indtastes i appen og på uret, gemmes i Apples Health-database. Glukosedata, der hentes fra CGM'en, gemmes sikkert i HealthKit." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "In der App und auf der Uhr eingegebene Daten zu Kohlenhydratmahlzeiten werden in der HealthKit-Datenbank gespeichert. Vom CGM abgerufene Glukosewerte werden sicher in HealthKit-Datenbank gespeichert." + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Carbohydrate meal data entered in the app and on the watch is stored in the Health database. Glucose data retrieved from the CGM is stored securely in HealthKit." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Datos de alimentos ingresados en la aplicación y en el reloj son almacenados en la base de datos de Salud. Los datos de glucosa extraídos del monitor continuo de glucosa se almacenan de manera segura en HealthKit." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sovelluksen ja kellon kautta tallennetut hiilihydraattitiedot tallennetaan Terveys-sovellukseen. Glukoosiseurannan kautta saadut glukoositiedot tallennetaan turvallisesti HealthKitiin." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les données de glucides des repas entrées dans l'application ou la montre sont enregistrées dans la base de donnée Santé. Les données de taux de glucose provenant du CGM sont enregistrées de manière sécurisée dans HealthKit." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carbohydrate meal data entered in the app and on the watch is stored in the Health database. Glucose data retrieved from the CGM is stored securely in HealthKit." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "I dati sui carboidrati dei pasti inseriti nell'app e sull'orologio sono trasferiti nel database di Salute. I dati recuperati dal sensore CGM sono storati nel database di HealthKit." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "健康データベースからの食事データは、グルコース効果を決定するために使用される。 グルコースデータはグラフ作成と解析のためにHealthKitから検索されます" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Karbohydratmåltidsdata som legges inn i appen og på klokken lagres i Helsedatabasen. Glukosedata hentet fra CGM lagres sikkert i HealthKit." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maaltijdkoolhydraten die worden ingevoerd in de app en met de watch worden opgeslagen in de database Gezondheid. Ontvangen glucosegegevens van de CGM worden veilig opgeslagen in HealthKit." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Posiłek węglowodanowy wprowadzony w aplikacji i na zegarku oraz dane o poziomie cukru pobrane z ciągłego monitoringu glukozy są bezpiecznie przechowywane w aplikacji Zdrowie." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dados de carboidratos inseridos no aplicativo e no Apple Watch são armazenados no banco de dados de saúde. Dados de glicemia recebidos do CGM são armazenados de modo seguro no HealthKit." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carbohidrații introduși în aplicație și pe ceas sunt stocați în baza de date Sănătate. Glicemiile din GCM sunt stocate în siguranță în HealthKit." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Данные об углеводах в приложении и на смарт-часах хранятся в базе данных Здоровье. Данные о гликемии полученные от систем непрерывного мониторинга хранятся в безопасности в Комплексе Здоровья HealthKit " + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Údaje o uhlohydrátoch zadané v aplikácii a na hodinkách sú uložené v databáze Health. Údaje o glukóze získané z CGM sú bezpečne uložené v HealthKit." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kolhydratvärden inmatade i appen i klockan lagras i Apple Health-databasen. Glukosvärden mottagna från CGM lagras krypterat i HealthKit." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uygulamaya ve saate girilen öğün karbonhidrat verileri Sağlık veritabanında saklanır. CGM'den alınan KŞ verileri, HealthKit'te güvenli bir şekilde saklanır." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dữ liệu Carbohydrate của bữa ăn được nhập trên phần mềm và trên đồng hồ thông minh sẽ được lưu trữ tại app Health. Các thông số glucose được lấy từ thiết bị theo dõi đường huyết liên tục/CGM sẽ được lưu trữ an toàn trong HealthKit." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在应用程序和手表中输入的碳水化合物膳食数据存储在健康数据库中。从CGM检索的葡萄糖数据安全地存储在HealthKit中。" + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/WatchApp Extension/Localizable.xcstrings b/WatchApp Extension/Localizable.xcstrings new file mode 100644 index 0000000000..6eea2af596 --- /dev/null +++ b/WatchApp Extension/Localizable.xcstrings @@ -0,0 +1,4384 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "---" : { + "comment" : "No glucose value representation (3 dashes for mg/dL; no spaces as this will get truncated in the watch complication)", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "---" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "---" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "---" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "---" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "---" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "---" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "---" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "--" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "---" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "---" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "---" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "---" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "---" + } + } + } + }, + "– – –" : { + "comment" : "No glucose value representation (3 dashes for mg/dL)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "---" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "– – –" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "– – –" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "– – –" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "– – –" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "– – –" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "– – –" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "---" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "– – –" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "---" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "---" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "– – –" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "– –" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "– – –" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "---" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "– – –" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "– – –" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "– – –" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "– – –" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "– – –" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "---" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "– – –" + } + } + } + }, + "%@" : { + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + } + } + }, + "%1$@ – %2$@ %3$@" : { + "comment" : "Format string for glucose range (1: lower bound)(2: upper bound)(3: unit)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ – %2$@ %3$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ – %2$@ %3$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ – %2$@ %3$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ – %2$@ %3$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ – %2$@ %3$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ – %2$@ %3$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ – %2$@ %3$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ – %2$@ %3$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ – %2$@ %3$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ – %2$@ %3$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ - %2$@ %3$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ – %2$@ %3$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ – %2$@ %3$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ – %2$@ %3$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ – %2$@ %3$@" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ – %2$@ %3$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ – %2$@ %3$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ – %2$@ %3$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ – %2$@ %3$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ – %2$@ %3$@" + } + } + } + }, + "Active Carbs" : { + "comment" : "HUD row title for COB", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "كارب النشط" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktive kulhydrater" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktive KH" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carbohidratos Activos" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Akt. hiilari" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glucides actifs" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Active Carbs" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carboidrati attivi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "残存糖質" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktive karbohydrater" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actieve Koolhydraten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktywne węglowodany" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carboidratos Ativos" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carbohidrați activi" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Активные углеводы" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktívne sacharidy" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktiva kolhydrater" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktif Karb." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lượng Carbs còn hoạt động" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "活性碳水" + } + } + } + }, + "Active Insulin" : { + "comment" : "HUD row title for IOB", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "أنسولين نشط" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktivt insulin" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktives Insulin" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulina activa" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Akt. insuliini" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insuline active" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Active Insulin" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulina attiva" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "残存インスリン" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktivt insulin" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actieve Insuline" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktywna insulina" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulina Ativa" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Insulină activă" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Активный инсулин" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktívny inzulín" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktivt insulin" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktif İnsülin" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lượng Insulin còn hoạt động" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "活性胰岛素" + } + } + } + }, + "Add Carb Entry" : { + "comment" : "Title of the user activity for adding carbs", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add Carb Entry" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilføj kulhydrater" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kohlenhydrate hinzufügen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agregar Entrada de Carb" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lisää hiilihydraatteja" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajouter des glucides" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add Carb Entry" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aggiungi inserimento carboidrati" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "糖質の記入を追加" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Legg til karbohydrater" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kh. Inv. Toevoegen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wprowadź węglowodany" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adicionar Carb" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adaugă carbohidrați" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить запись углеводов" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zadať sacharidy" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lägg till kolhydrater" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Karb Girişi Ekle" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Khai báo Carb" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "添加碳水化合物" + } + } + } + }, + "Bolus" : { + "comment" : "Button text to confirm manual bolus on Apple Watch", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolo" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolo" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ボーラス" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Болюс" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus" + } + } + } + }, + "Bolus Failed" : { + "comment" : "The title of the alert controller displayed after a bolus attempt fails", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus Failed" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus fejlede" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus fehlgeschlagen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolo Falló" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus epäonnistui" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Échec du bolus" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus Failed" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolo fallito" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ボーラス不成功" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus mislyktes" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus Mislukt" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus nie podany" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus Falhou" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus eșuat" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Болюс не состоялся" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus misslyckades" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus Başarısız" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus lỗi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "大剂量输注失败" + } + } + } + }, + "Bolus Recommendation Updated" : { + "comment" : "Alert title for updated bolus recommendation on Apple Watch", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolusanbefaling opdateret" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktualisierte Bolusempfehlung" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recomendación de bolo fue actualicada" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolussuositus päivitetty" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recommandation de Bolus modifiée" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "המלצת בולוס התעדכנה" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Raccomandazione bolo aggiornata" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolusanbefaling oppdatert" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aanbevolen Bolus Bijgewerkt" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zaktualizowano rekomendowanego Bolus" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actualizare recomandare de bolus" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Рекомендации по болюсу обновлены" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Det finns en ny bolusrekommendation" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bolus Önerisi Güncellendi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "大剂量推荐值已更新" + } + } + } + }, + "Continue" : { + "comment" : "Button text to continue from carb entry to bolus entry on Apple Watch", + "localizations" : { + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pokračovat" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fortsæt" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Weiter" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Continuar" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jatka" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Continuer" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Continue" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "जारी" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Continua" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "次へ" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fortsett" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ga Verder" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kontynuuj" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Continuar" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Continuă" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Продолжить" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pokračovať" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fortsätt" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Devam et" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tiếp tục" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "继续" + } + } + } + }, + "dB" : { + "comment" : "The short unit display string for decibles", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "дБ" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "dB" + } + } + } + }, + "Dismiss" : { + "comment" : "The action button title to dismiss an error message", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تجاهل" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afvis" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schließen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ignorar" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ohita" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fermer" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dismiss" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "閉じる" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avvis" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sluiten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rozumiem" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dispensar" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renunță" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отклонить" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avfärda" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reddet" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Từ bỏ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "忽略" + } + } + } + }, + "g" : { + "comment" : "Short unit label for gram measurement\nThe short unit display string for grams", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "г" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "gr" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "g" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "克" + } + } + } + }, + "Make sure your iPhone is nearby and try again" : { + "comment" : "The recovery message displayed after a glucose range override send attempt fails", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تأكد من أن الآيفون الخاص بك قريب ثم حاول مرة أخرى" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sørg for, at din iPhone er i nærheden og prøv igen" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stelle sicher, dass Dein iPhone in der Nähe ist, und versuche es erneut." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Asegúrate que tu iPhone se encuentre cerca e inténtalo de nuevo" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Varmista, että iPhone on riittävän lähellä ja yritä uudelleen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Assurez-vous que votre iPhone est à proximité et réessayez" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Make sure your iPhone is nearby and try again" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Assicurati che il tuo iPhone sia vicino e riprova" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "iPhone が近くにあることを確認して、再実行してください" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pass på at iPhone er i nærheten, og prøv igjen" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zorg dat je iPhone in de buurt is en probeer opnieuw" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Upewnij się, że Twój iPhone jest w pobliżu i spróbuj ponownie" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verifique se o iPhone está próximo e tente novamente" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Asigurați-vă că iPhone-ul este în apropiere, după care încercați din nou" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Убедитесь, что ваш iPhone поблизости и повторите попытку" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Säkerställ att telefonen är inom räckhåll och försök igen" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "iPhone'nunuzun yakında olduğundan emin olun ve tekrar deneyin" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đảm bảo rằng iPhone của bạn đang ở gần và thử lại" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请确保苹果手机接近设备并重试" + } + } + } + }, + "Make sure your iPhone is nearby and try again." : { + "comment" : "The recovery message displayed after a bolus attempt fails\nThe recovery message displayed after a potential carb entry send attempt fails", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sørg for, at din iPhone er tæt på og prøv igen." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stelle sicher, dass Dein iPhone in der Nähe ist, und versuche es erneut." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Asegúrate que tu iPhone se encuentre cerca e inténtalo de nuevo." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Varmista, että iPhone on riittävän lähellä ja yritä uudelleen." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Assurez-vous que votre iPhone est à proximité et réessayez." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Assicurati che il tuo iPhone sia vicino e riprova." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sjekk at din iPhone er i nærheten og prøv igjen." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zorg dat je iPhone in de buurt is en probeer het opnieuw." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Upewnij się, że Twój iPhone jest w pobliżu i spróbuj ponownie." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Asigurați-vă că iPhone-ul este în apropiere, după care încercați din nou." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Убедитесь, что ваш iPhone находится поблизости, и повторите попытку." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Säkerställ att din telefon är inom räckhåll och försök igen." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "iPhone'unuzun yakında olduğundan emin olun ve tekrar deneyin." + } + } + } + }, + "mg/dL" : { + "comment" : "The short unit display string for milligrams of glucose per decilter", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dl" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "мг/дл" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dl" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "mg/dL" + } + } + } + }, + "mmol/L" : { + "comment" : "The short unit display string for millimoles of glucose per liter", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ммоль/л" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "mmol/L" + } + } + } + }, + "Net Basal Rate" : { + "comment" : "HUD row title for Net Basal Rate", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "صافي الضخ المستمر" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Netto basalrate" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Netto Basalrate" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tasa basal neta" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Basaali netto" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Débit basal net" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Net Basal Rate" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Velocità basale netta" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "正味基礎インスリン" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Netto Basaldose" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Netto Basaalsnelheid" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dawka podstawowa netto" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Taxa Basal Líquida" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rată bazală netă" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Чистая базальная доза" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nettobasaldos" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Net Bazal Oran" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tỷ lệ liều basal ròng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "净基础率" + } + } + } + }, + "Off" : { + "comment" : "Label for off button", + "localizations" : { + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vypnuto" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Slukket" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aus" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apagado" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pois päältä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Désactivé" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "כבוי" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disattivato" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Av" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uit" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wyłącz" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oprit" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выключено" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vypnuté" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Av" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kapalı" + } + } + } + }, + "OK" : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "موافق" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ok" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tamam" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "好的" + } + } + } + }, + "On" : { + "comment" : "Label for on button", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "On" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zapnuto" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tændt" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "An" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Encendido" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Päällä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activé" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "דולק" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Attivato" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "オン" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "På" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aan" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Włącz" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ligado" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pornit" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включено" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zapnuté" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "På" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Açık" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "On" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "开" + } + } + } + }, + "Override" : { + "comment" : "The text for the Watch button for enabling a temporary override", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "Override" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Override" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voreinstellung" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Override" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sobreescritura" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tilapäisas." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajustement" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Override" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Override" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "オーバーライド" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Overstyr" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Override" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cel Tymczasowy" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sobrepor" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Înlocuire" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Override" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geçersiz kıl" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chồng lên" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "覆盖" + } + } + } + }, + "Please reconfirm the bolus amount." : { + "comment" : "Alert message for updated bolus recommendation on Apple Watch", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bekræft venligst bolus igen." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bitte Bolus erneut bestätigen." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Por favor, vuelva a confirmar la cantidad del bolo." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vahvista bolus uudelleen." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Veuillez reconfirmer le bolus." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Si prega di riconfermare la quantità di bolo." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vennligst bekreft bolus på nytt." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bevestig bolus opnieuw." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Potwierdź ponownie wielkość bolusa." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vă rugăm să reconfirmați valoarea bolusului." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пожалуйста, подтвердите болюс." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bekräfta bolusmängd." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lütfen bolus miktarını tekrar onaylayın." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请重新确认大剂量。" + } + } + } + }, + "Pre-Meal" : { + "comment" : "Title for sheet to enable/disable pre-meal on watch", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pre-Meal" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Før-måltid" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vor dem Essen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pre-Comida" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ennen ateriaa" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pré-Repas" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pre-Meal" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pre-pasto" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "食前" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pre-måltid" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pre-Meal" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Przed posiłkiem" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pré-Refeição" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Preprandial" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "До еды" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Före måltid" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yemek öncesi" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trước bữa ăn" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "餐前" + } + } + } + }, + "Preset" : { + "comment" : "The text for the Watch button for enabling a custom preset", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forudindstillet" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voreinstellung" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Preestablecido" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Esiasetus" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Préréglage" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Preset" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Overstyring" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Override" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ustawienie wstępne" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Presetare" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пресеты" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Förinställning" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "ön ayar" + } + } + } + }, + "QUANTITY_VALUE_AND_UNIT" : { + "comment" : "Format string for combining localized numeric value and unit. (1: numeric value)(2: unit)", + "extractionState" : "extracted_with_value", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ %2$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@%2$@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ %2$@" + } + } + } + }, + "Rec: %@ U" : { + "comment" : "The label and value showing the recommended bolus", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "التوصية: %@ U" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anb: %@ E" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Empfohlen: %@ IE" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rec: %@ U" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rec: %@ U" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suosit: %@ U" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rec: %@ U" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rec: %@ U" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rac: %@ U" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "推奨: %@ U" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rec: %@ E" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voorgesteld: %@ E" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rekomendowane: %@ J" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rec: %@ U" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rec: %@ U" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Рекомендовано: %@ ед" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rek: %@ E" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Önrln: %@ Ü" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Khuyến nghị: %@ U" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "推荐: %@ 单位" + } + } + } + }, + "REC: %@ U" : { + "comment" : "Recommended bolus amount label on Apple Watch", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rec: %@ U" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Empfohlen: %@ IE" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "REC: %@ U" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "SUOSIT: %@ U" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "REC : %@ U" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "RAC: %@ U" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "REC: %@ E" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "AANBEVOLEN: %@ E" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rek: %@ J" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "REC: %@ U" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Рекомендовано: %@ ед" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rek: %@ E" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Önerilen: %@ Ü" + } + } + } + }, + "REC: Calculating..." : { + "comment" : "Indicator that recommended bolus computation is in progress on Apple Watch", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "REC: Beregner..." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Empfohlen: berechne…" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "REC: Calculando..." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "SUOSIT: Lasketaan..." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "REC: Calcul en cours..." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "RAC: Calcolo in corso..." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beregner anbefalt bolus..." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "AANBEVOLEN: Berekenen..." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rek: Obliczanie..." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "REC: Se calculează..." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Рекомендовано: Расчет..." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rek: Beräknar..." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Önerilen: Hesaplanıyor..." + } + } + } + }, + "Reservoir Volume" : { + "comment" : "HUD row title for remaining reservoir volume", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "حجم الخزان" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reservoimængde" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reservoir Inhalt" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Volumen de Reservorio" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Säiliön määrä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Volume du réservoir" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reservoir Volume" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Volume serbatoio" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "リザーバ残量" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reservoarstørrelse" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reservoirinhoud" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Objętość w zbiorniku" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Volume do Reservatório" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Volum rezervor" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Остаток резервуара" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reservoarvolym" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rezervuar Hacmi" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Khối lượng ngăn chứa insulin" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "储药器容量" + } + } + } + }, + "Save" : { + "comment" : "Button text to confirm carb entry without bolusing on Apple Watch", + "localizations" : { + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uložit" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gem" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Speichern" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agregar" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tallenna" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sauvegarder" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Save" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Salva" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "保存" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lagre" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opslaan" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zapisz" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Salvar" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Salvează" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сохранить" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uložiť" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spara" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kaydet" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lưu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "保存" + } + } + } + }, + "Save & Bolus" : { + "comment" : "Button text to confirm carb entry and bolus on Apple Watch", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gem & bolus" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Speichern & Bolus abgeben" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Guardar & administrar bolo" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tallenna & Bolus" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sauvegarder & envoyer Bolus" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Salva & Bolo" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lagre & bolus" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opslaan & Bolussen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zapisz i bolus" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Salvează & Bolus" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сохранить и ввести болюс" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Spara & Bolus" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kaydet & Bolus" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "保存并推注大剂量" + } + } + } + }, + "Send Failed" : { + "comment" : "The title of the alert controller displayed after a glucose range override send attempt fails", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "فشل الإرسال" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Send mislykkedes" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Senden fehlgeschlagen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Envío Falló" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lähetys epäonnistui" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Echec de l'envoi" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Send Failed" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Invio fallito" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "送信に失敗" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Send mislyktes" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Versturen Mislukt" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wysyłanie nie powiodło się" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Envio falhou" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transmitere eșuată" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ошибка отправки" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sändning misslyckades" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gönderme Başarısız" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chuyển bị lỗi" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "发送失败" + } + } + } + }, + "Turn Digital Crown\nto bolus" : { + "comment" : "Help text for bolus confirmation on Apple Watch", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Drej det digitale hjul for bolus" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Drehe die digitale \nKrone für einen Bolus" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gire el Digital Crown \npara administrar bolo" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vahvista pyörittämällä Digital Crownia" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tourner la Digital Crown pour envoyer le bolus" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ruota la corona digitale\nper il bolo" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Snu \"Digital Crown\" for å gi bolus" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Turn Digital Crown\nom te bolussen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Przekręć Digital Crown, aby podać bolus" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rotiți pentru a confirma bolusul" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Покрутите колесико\nдля болюса" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vrid på krona för att ge bolus" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Digital Crown'u bolusa\nçevirin" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "旋转数字表冠\n以推注大剂量" + } + } + } + }, + "U" : { + "comment" : "The short unit display string for international units of insulin", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "وحدة" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "E" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "IE" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "U" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "U" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "U" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "U" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "U" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "U" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "E" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "E" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "J" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "U" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "U" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ед" + } + }, + "sk" : { + "stringUnit" : { + "state" : "translated", + "value" : "j" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "E" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ü" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "U" + } + } + } + }, + "U/hr" : { + "comment" : "The short unit display string for international units of insulin delivery per hour", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "وحدة لكل ساعة" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "E/t" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "IE/h" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "U/hra" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "U/h" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "U/h" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "U/hr" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "U/ora" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "U/時" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "E/t" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "E/uur" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "J/h" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "U/hr" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "U/oră" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ед/ч" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "E/timme" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ü/sa" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "U/giờ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "U/小时" + } + } + } + }, + "Unable to Reach iPhone" : { + "comment" : "The title of the alert controller displayed after a potential carb entry send attempt fails", + "localizations" : { + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "iPhone er uden for urets rækkevidde" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dein iPhone kann nicht erreicht werden" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No se puede contactar al iPhone" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "iPhoneen ei saada yhteyttä" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossible d’atteindre l’iPhone" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impossibile contattare iPhone" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kan ikke nå iPhone" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kan iPhone niet bereiken" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nie można połączyć się z iPhone'em" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nu se poate accesa iPhone-ul" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удается подключиться к iPhone" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Det går inte att nå iPhone" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "iPhone'a Ulaşılamıyor" + } + } + } + }, + "Workout" : { + "comment" : "The text for the Watch button for enabling workout mode\nTitle for sheet to enable/disable workout mode on watch", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "التمارين" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Motion" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Training" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ejercicio" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Liikunta" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exercice" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "Workout" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Allenamento" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "運動" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trening" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Training" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wysiłek fizyczny" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exercício" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activitate sportivă" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Физическая нагрузка" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Träning" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Egzersiz" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Workout" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "运动" + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/WatchApp Extension/Managers/ComplicationChartManager.swift b/WatchApp Extension/Managers/ComplicationChartManager.swift new file mode 100644 index 0000000000..bfca19ea24 --- /dev/null +++ b/WatchApp Extension/Managers/ComplicationChartManager.swift @@ -0,0 +1,190 @@ +// +// ComplicationChartManager.swift +// WatchApp Extension +// +// Created by Michael Pangburn on 10/17/18. +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +import Foundation +import UIKit +import HealthKit +import WatchKit +import LoopKit + +private let textInsets = UIEdgeInsets(top: 2, left: 2, bottom: 2, right: 2) + +extension CGSize { + fileprivate static let glucosePoint = CGSize(width: 2, height: 2) +} + +extension NSAttributedString { + fileprivate class func forGlucoseLabel(string: String) -> NSAttributedString { + return NSAttributedString(string: string, attributes: [ + .font: UIFont(name: "HelveticaNeue", size: 10)!, + .foregroundColor: UIColor.chartLabel + ]) + } +} + +extension CGFloat { + fileprivate static let predictionDashPhase: CGFloat = 11 +} + +private let predictionDashLengths: [CGFloat] = [5, 3] + + +final class ComplicationChartManager { + private enum GlucoseLabelPosition { + case high + case low + } + + var data: GlucoseChartData? + private var lastRenderDate: Date? + private var renderedChartImage: UIImage? + private var visibleInterval: TimeInterval = .hours(4) + + private var unit: HKUnit { + return data?.unit ?? .milligramsPerDeciliter + } + + func renderChartImage(size: CGSize, scale: CGFloat) -> UIImage? { + guard let data = data else { + renderedChartImage = nil + return nil + } + + UIGraphicsBeginImageContextWithOptions(size, false, 0.0) + defer { UIGraphicsEndImageContext() } + + guard let context = UIGraphicsGetCurrentContext() else { + return nil + } + + drawChart(in: context, data: data, size: size) + + guard let cgImage = context.makeImage() else { + renderedChartImage = nil + return nil + } + + let image = UIImage(cgImage: cgImage, scale: scale, orientation: .up) + renderedChartImage = image + return image + } + + private func drawChart(in context: CGContext, data: GlucoseChartData, size: CGSize) { + let now = Date() + lastRenderDate = now + let spannedInterval = DateInterval(start: now - visibleInterval / 2, duration: visibleInterval) + let glucoseRange = data.chartableGlucoseRange(from: spannedInterval) + let scaler = GlucoseChartScaler(size: size, dateInterval: spannedInterval, glucoseRange: glucoseRange, unit: unit) + + let drawingSteps = [drawTargetRange, drawOverridesIfNeeded, drawHistoricalGlucose, drawPredictedGlucose, drawGlucoseLabels] + drawingSteps.forEach { drawIn in drawIn(context, scaler) } + } + + private func drawGlucoseLabels(in context: CGContext, using scaler: GlucoseChartScaler) { + let formatter = NumberFormatter.glucoseFormatter(for: unit) + drawGlucoseLabelText(formatter.string(from: scaler.glucoseMax)!, position: .high, scaler: scaler) + drawGlucoseLabelText(formatter.string(from: scaler.glucoseMin)!, position: .low, scaler: scaler) + } + + private func drawGlucoseLabelText(_ text: String, position: GlucoseLabelPosition, scaler: GlucoseChartScaler) { + let attributedText = NSAttributedString.forGlucoseLabel(string: text) + let size = attributedText.size() + let x = scaler.xCoordinate(for: scaler.dates.end) - size.width - textInsets.right + let y: CGFloat = { + switch position { + case .high: + return scaler.yCoordinate(for: scaler.glucoseMax) + textInsets.top + case .low: + return scaler.yCoordinate(for: scaler.glucoseMin) - size.height - textInsets.bottom + } + }() + let rect = CGRect(origin: CGPoint(x: x, y: y), size: size).alignedToScreenScale(WKInterfaceDevice.current().screenScale) + attributedText.draw(with: rect, options: .usesLineFragmentOrigin, context: nil) + } + + private func drawTargetRange(in context: CGContext, using scaler: GlucoseChartScaler) { + let activeOverride = data?.activeScheduleOverride + let targetRangeAlpha: CGFloat = activeOverride != nil ? 0.2 : 0.3 + context.setFillColor(UIColor.glucose.withAlphaComponent(targetRangeAlpha).cgColor) + data?.correctionRange?.quantityBetween(start: scaler.dates.start, end: scaler.dates.end).forEach { range in + let rangeRect = scaler.rect(for: range, unit: unit) + context.fill(rangeRect) + } + } + + private func drawOverridesIfNeeded(in context: CGContext, using scaler: GlucoseChartScaler) { + let overrideColor = UIColor.glucose.withAlphaComponent(0.4).cgColor + let extendedOverrideColor = UIColor.glucose.withAlphaComponent(0.25).cgColor + let spannedInterval = scaler.dates + + func drawOverride( + _ override: TemporaryScheduleOverride, + pushingStartTo startDate: Date? = nil, + extendingToChartEnd shouldExtendToChartEnd: Bool + ) { + var override = override + if let startDate = startDate { + guard startDate < override.scheduledEndDate else { + return + } + + override.scheduledInterval = DateInterval(start: startDate, end: override.scheduledEndDate) + } + + guard let overrideHashable = TemporaryScheduleOverrideHashable(override) else { + return + } + + context.setFillColor(overrideColor) + let overrideRect = scaler.rect(for: overrideHashable, unit: unit) + context.fill(overrideRect) + + if spannedInterval.end > override.scheduledEndDate, shouldExtendToChartEnd { + var extendedOverride = override + extendedOverride.duration = .finite(spannedInterval.end.timeIntervalSince(override.startDate)) + // Target range already known to be non-nil + let extendedOverrideHashable = TemporaryScheduleOverrideHashable(extendedOverride)! + let extendedOverrideRect = scaler.rect(for: extendedOverrideHashable, unit: unit) + context.setFillColor(extendedOverrideColor) + context.fill(extendedOverrideRect) + } + } + + if let preMealOverride = data?.activePreMealOverride { + drawOverride(preMealOverride, extendingToChartEnd: true) + } + + if let override = data?.activeScheduleOverride { + drawOverride(override, pushingStartTo: data?.activePreMealOverride?.scheduledEndDate, extendingToChartEnd: data?.activePreMealOverride == nil) + } + } + + private func drawHistoricalGlucose(in context: CGContext, using scaler: GlucoseChartScaler) { + context.setFillColor(UIColor.glucose.cgColor) + data?.historicalGlucose?.lazy.filter { + scaler.dates.contains($0.startDate) + }.forEach { glucose in + let origin = scaler.point(for: glucose, unit: unit) + let glucoseRect = CGRect(origin: origin, size: .glucosePoint).alignedToScreenScale(WKInterfaceDevice.current().screenScale) + context.fill(glucoseRect) + } + } + + private func drawPredictedGlucose(in context: CGContext, using scaler: GlucoseChartScaler) { + guard let predictedGlucose = data?.predictedGlucose, predictedGlucose.count > 2 else { + return + } + let predictedPath = CGMutablePath() + let glucosePoints = predictedGlucose.map { scaler.point(for: $0, unit: unit) } + predictedPath.addLines(between: glucosePoints) + let dashedPath = predictedPath.copy(dashingWithPhase: .predictionDashPhase, lengths: predictionDashLengths) + context.setStrokeColor(UIColor.white.cgColor) + context.addPath(dashedPath) + context.strokePath() + } +} diff --git a/WatchApp Extension/Managers/LoopDataManager.swift b/WatchApp Extension/Managers/LoopDataManager.swift new file mode 100644 index 0000000000..579b6a2148 --- /dev/null +++ b/WatchApp Extension/Managers/LoopDataManager.swift @@ -0,0 +1,219 @@ +// +// LoopDataManager.swift +// WatchApp Extension +// +// Created by Bharat Mediratta on 6/21/18. +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +import Foundation +import HealthKit +import LoopKit +import LoopCore +import WatchConnectivity +import os.log + + +class LoopDataManager { + let carbStore: CarbStore + + let glucoseStore: GlucoseStore + + @PersistedProperty(key: "Settings") + private var rawSettings: LoopSettings.RawValue? + + // Main queue only + var settings: LoopSettings { + didSet { + needsDidUpdateContextNotification = true + sendDidUpdateContextNotificationIfNecessary() + rawSettings = settings.rawValue + } + } + + // Main queue only + var supportedBolusVolumes = UserDefaults.standard.supportedBolusVolumes { + didSet { + UserDefaults.standard.supportedBolusVolumes = supportedBolusVolumes + needsDidUpdateContextNotification = true + sendDidUpdateContextNotificationIfNecessary() + } + } + + private let log = OSLog(category: "LoopDataManager") + + // Main queue only + private(set) var activeContext: WatchContext? { + didSet { + needsDidUpdateContextNotification = true + sendDidUpdateContextNotificationIfNecessary() + } + } + + private var needsDidUpdateContextNotification: Bool = false + + /// The last attempt to backfill glucose. We use a date because the message timeout is longer + /// than our desired retry interval, so we allow multiple messages in-flight + /// Main queue only + private var lastGlucoseBackfill = Date.distantPast + + public let healthStore: HKHealthStore + + init() { + healthStore = HKHealthStore() + let cacheStore = PersistenceController.controllerInLocalDirectory() + + carbStore = CarbStore( + cacheStore: cacheStore, + cacheLength: .hours(24), // Require 24 hours to store recent carbs "since midnight" for CarbEntryListController + defaultAbsorptionTimes: LoopCoreConstants.defaultCarbAbsorptionTimes, + syncVersion: 0, + provenanceIdentifier: HKSource.default().bundleIdentifier + ) + glucoseStore = GlucoseStore( + cacheStore: cacheStore, + cacheLength: .hours(4), + provenanceIdentifier: HKSource.default().bundleIdentifier + ) + + settings = LoopSettings() + + if let rawSettings = rawSettings, let storedSettings = LoopSettings(rawValue: rawSettings) { + self.settings = storedSettings + } + } +} + +extension LoopDataManager { + static let didUpdateContextNotification = Notification.Name(rawValue: "com.loopkit.notification.ContextUpdated") +} + +extension LoopDataManager { + func updateContext(_ context: WatchContext) { + dispatchPrecondition(condition: .onQueue(.main)) + + if activeContext == nil || context.shouldReplace(activeContext!) { + if let newGlucoseSample = context.newGlucoseSample { + self.glucoseStore.addGlucoseSamples([newGlucoseSample]) { (_) in } + } + activeContext = context + } + } + + func sendDidUpdateContextNotificationIfNecessary() { + dispatchPrecondition(condition: .onQueue(.main)) + + if needsDidUpdateContextNotification && !WCSession.default.hasContentPending { + needsDidUpdateContextNotification = false + NotificationCenter.default.post(name: LoopDataManager.didUpdateContextNotification, object: self) + } + } + + func requestCarbBackfill() { + dispatchPrecondition(condition: .onQueue(.main)) + + let start = min(Calendar.current.startOfDay(for: Date()), Date(timeIntervalSinceNow: -carbStore.maximumAbsorptionTimeInterval)) + let userInfo = CarbBackfillRequestUserInfo(startDate: start) + WCSession.default.sendCarbBackfillRequestMessage(userInfo) { (result) in + switch result { + case .success(let context): + self.carbStore.setSyncCarbObjects(context.objects) { (error) in + if let error = error { + self.log.error("Failure setting sync carb objects: %{public}@", String(describing: error)) + } + } + case .failure: + // Already logged + break + } + } + } + + @discardableResult + func requestGlucoseBackfillIfNecessary() -> Bool { + dispatchPrecondition(condition: .onQueue(.main)) + + guard lastGlucoseBackfill < .staleGlucoseCutoff else { + log.default("Skipping glucose backfill request because our latest attempt was %{public}@", String(describing: lastGlucoseBackfill)) + return false + } + + // Loop doesn't read data from HealthKit anymore, and its local watch data is truly ephemeral + // to power the chart. Fetch enough data to populate the display of the chart. + let latestDate = max(lastGlucoseBackfill, .earliestGlucoseCutoff) + guard latestDate < .staleGlucoseCutoff else { + self.log.default("Skipping glucose backfill request because our latest sample date is %{public}@", String(describing: latestDate)) + return false + } + + lastGlucoseBackfill = Date() + let userInfo = GlucoseBackfillRequestUserInfo(startDate: latestDate) + WCSession.default.sendGlucoseBackfillRequestMessage(userInfo) { (result) in + switch result { + case .success(let context): + self.glucoseStore.setSyncGlucoseSamples(context.samples) { (error) in + if let error = error { + self.log.error("Failure setting sync glucose samples: %{public}@", String(describing: error)) + } + } + case .failure: + // Already logged + // Reset our last date to immediately retry + DispatchQueue.main.async { + self.lastGlucoseBackfill = .earliestGlucoseCutoff + } + } + } + + return true + } + + func requestContextUpdate(completion: @escaping () -> Void = { }) { + try? WCSession.default.sendContextRequestMessage(WatchContextRequestUserInfo(), completionHandler: { (result) in + DispatchQueue.main.async { + switch result { + case .success(let context): + self.updateContext(context) + case .failure: + break + } + completion() + } + }) + } +} + +extension LoopDataManager { + var displayGlucoseUnit: HKUnit { + activeContext?.displayGlucoseUnit ?? .milligramsPerDeciliter + } +} + +extension LoopDataManager { + func generateChartData(completion: @escaping (GlucoseChartData?) -> Void) { + guard let activeContext = activeContext else { + completion(nil) + return + } + + glucoseStore.getGlucoseSamples(start: .earliestGlucoseCutoff) { result in + var historicalGlucose: [StoredGlucoseSample]? + switch result { + case .failure(let error): + self.log.error("Failure getting glucose samples: %{public}@", String(describing: error)) + historicalGlucose = nil + case .success(let samples): + historicalGlucose = samples + } + let chartData = GlucoseChartData( + unit: activeContext.displayGlucoseUnit, + correctionRange: self.settings.glucoseTargetRangeSchedule, + preMealOverride: self.settings.preMealOverride, + scheduleOverride: self.settings.scheduleOverride, + historicalGlucose: historicalGlucose, + predictedGlucose: (activeContext.isClosedLoop ?? false) ? activeContext.predictedGlucose?.values : nil + ) + completion(chartData) + } + } +} diff --git a/WatchApp Extension/Models/GlucoseChartData.swift b/WatchApp Extension/Models/GlucoseChartData.swift new file mode 100644 index 0000000000..4ed6bd7ee8 --- /dev/null +++ b/WatchApp Extension/Models/GlucoseChartData.swift @@ -0,0 +1,128 @@ +// +// GlucoseChartData.swift +// WatchApp Extension +// +// Created by Michael Pangburn on 10/17/18. +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +import Foundation +import HealthKit +import LoopKit + + +struct GlucoseChartData { + var unit: HKUnit? + + var correctionRange: GlucoseRangeSchedule? + + var preMealOverride: TemporaryScheduleOverride? + + var scheduleOverride: TemporaryScheduleOverride? + + var historicalGlucose: [SampleValue]? { + didSet { + historicalGlucoseRange = historicalGlucose?.quantityRange + } + } + + private(set) var historicalGlucoseRange: ClosedRange? + + var predictedGlucose: [SampleValue]? { + didSet { + predictedGlucoseRange = predictedGlucose?.quantityRange + } + } + + private(set) var predictedGlucoseRange: ClosedRange? + + init(unit: HKUnit?, correctionRange: GlucoseRangeSchedule?, preMealOverride: TemporaryScheduleOverride?, scheduleOverride: TemporaryScheduleOverride?, historicalGlucose: [SampleValue]?, predictedGlucose: [SampleValue]?) { + self.unit = unit + self.correctionRange = correctionRange + self.preMealOverride = preMealOverride + self.scheduleOverride = scheduleOverride + self.historicalGlucose = historicalGlucose + self.historicalGlucoseRange = historicalGlucose?.quantityRange + self.predictedGlucose = predictedGlucose + self.predictedGlucoseRange = predictedGlucose?.quantityRange + } + + func chartableGlucoseRange(from interval: DateInterval) -> ClosedRange { + let unit = self.unit ?? .milligramsPerDeciliter + + // Defaults + var min = unit.lowWatermark + var max = unit.highWatermark + + for correction in correctionRange?.quantityBetween(start: interval.start, end: interval.end) ?? [] { + min = Swift.min(min, correction.value.lowerBound.doubleValue(for: unit)) + max = Swift.max(max, correction.value.upperBound.doubleValue(for: unit)) + } + + if let override = activePreMealOverride?.settings.targetRange { + min = Swift.min(min, override.lowerBound.doubleValue(for: unit)) + max = Swift.max(max, override.upperBound.doubleValue(for: unit)) + } + + if let override = activeScheduleOverride?.settings.targetRange { + min = Swift.min(min, override.lowerBound.doubleValue(for: unit)) + max = Swift.max(max, override.upperBound.doubleValue(for: unit)) + } + + if let historicalGlucoseRange = historicalGlucoseRange { + min = Swift.min(min, historicalGlucoseRange.lowerBound.doubleValue(for: unit)) + max = Swift.max(max, historicalGlucoseRange.upperBound.doubleValue(for: unit)) + } + + if let predictedGlucoseRange = predictedGlucoseRange { + min = Swift.min(min, predictedGlucoseRange.lowerBound.doubleValue(for: unit)) + max = Swift.max(max, predictedGlucoseRange.upperBound.doubleValue(for: unit)) + } + + // Predicted glucose values can be below a concentration of 0, + // but we want to let those fall off the graph since it's technically impossible + min = Swift.max(0, min.floored(to: unit.axisIncrement)) + max = max.ceiled(to: unit.axisIncrement) + + let lowerBound = HKQuantity(unit: unit, doubleValue: min) + let upperBound = HKQuantity(unit: unit, doubleValue: max) + + return lowerBound...upperBound + } + + var activeScheduleOverride: TemporaryScheduleOverride? { + guard let override = scheduleOverride, override.isActive() else { + return nil + } + return override + } + + var activePreMealOverride: TemporaryScheduleOverride? { + guard let override = preMealOverride, override.isActive() else { + return nil + } + return override + } +} + +private extension HKUnit { + var axisIncrement: Double { + return chartableIncrement * 25 + } + + var highWatermark: Double { + if self == .milligramsPerDeciliter { + return 150 + } else { + return 8 + } + } + + var lowWatermark: Double { + if self == .milligramsPerDeciliter { + return 75 + } else { + return 4 + } + } +} diff --git a/WatchApp Extension/Models/GlucoseChartScaler.swift b/WatchApp Extension/Models/GlucoseChartScaler.swift new file mode 100644 index 0000000000..cb03f8380b --- /dev/null +++ b/WatchApp Extension/Models/GlucoseChartScaler.swift @@ -0,0 +1,96 @@ +// +// GlucoseChartScaler.swift +// WatchApp Extension +// +// Created by Michael Pangburn on 10/17/18. +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +import Foundation +import CoreGraphics +import HealthKit +import LoopKit +import WatchKit + + +enum CoordinateSystem { + /// The graphics coordinate system in which the origin is the top left corner. + /// Use in working with UIKit and CoreGraphics. + case standard + + /// The graphics coordinate system in which the origin is the bottom left corner. + /// Use in working with SpriteKit. + case inverted +} + +struct GlucoseChartScaler { + let dates: DateInterval + let glucoseMin: Double + let glucoseMax: Double + let xScale: CGFloat + let yScale: CGFloat + let coordinateSystem: CoordinateSystem + + func xCoordinate(for date: Date) -> CGFloat { + return CGFloat(date.timeIntervalSince(dates.start)) * xScale + } + + func yCoordinate(for glucose: Double) -> CGFloat { + switch coordinateSystem { + case .standard: + return CGFloat(glucoseMax - glucose) * yScale + case .inverted: + return CGFloat(glucose - glucoseMin) * yScale + } + } + + func point(_ date: Date, _ glucose: Double) -> CGPoint { + return CGPoint(x: xCoordinate(for: date), y: yCoordinate(for: glucose)) + } + + func point(for glucose: SampleValue, unit: HKUnit) -> CGPoint { + return point(glucose.startDate, glucose.quantity.doubleValue(for: unit)) + } + + // By default enforce a minimum height so that the range is visible + func rect( + for range: GlucoseChartValueHashable, + unit: HKUnit, + minHeight: CGFloat = 2, + alignedToScreenScale screenScale: CGFloat = WKInterfaceDevice.current().screenScale + ) -> CGRect { + + let minY = range.min.doubleValue(for: unit) + let maxY = range.max.doubleValue(for: unit) + + switch coordinateSystem { + case .standard: + let topLeft = point(max(dates.start, range.start), maxY) + let bottomRight = point(min(dates.end, range.end), minY) + let size = CGSize(width: bottomRight.x - topLeft.x, height: max(bottomRight.y - topLeft.y, minHeight)) + return CGRect(origin: topLeft, size: size).alignedToScreenScale(screenScale) + case .inverted: + let bottomLeft = point(max(dates.start, range.start), minY) + let topRight = point(min(dates.end, range.end), maxY) + let size = CGSize(width: topRight.x - bottomLeft.x, height: max(topRight.y - bottomLeft.y, minHeight)) + return CGRect(origin: bottomLeft, size: size).alignedToScreenScale(screenScale) + } + } +} + +extension GlucoseChartScaler { + init(size: CGSize, dateInterval: DateInterval, glucoseRange: ClosedRange, unit: HKUnit, coordinateSystem: CoordinateSystem = .standard) { + self.dates = dateInterval + self.glucoseMin = glucoseRange.lowerBound.doubleValue(for: unit) + self.glucoseMax = glucoseRange.upperBound.doubleValue(for: unit) + self.xScale = size.width / CGFloat(dateInterval.duration) + self.yScale = size.height / CGFloat(glucoseRange.span(with: unit)) + self.coordinateSystem = coordinateSystem + } +} + +extension ClosedRange where Bound == HKQuantity { + fileprivate func span(with unit: HKUnit) -> Double { + return upperBound.doubleValue(for: unit) - lowerBound.doubleValue(for: unit) + } +} diff --git a/WatchApp Extension/Models/OverridePresetRow.swift b/WatchApp Extension/Models/OverridePresetRow.swift new file mode 100644 index 0000000000..f28fa7ec89 --- /dev/null +++ b/WatchApp Extension/Models/OverridePresetRow.swift @@ -0,0 +1,18 @@ +// +// OverridePresetRow.swift +// WatchApp Extension +// +// Created by Michael Pangburn on 1/31/19. +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import Foundation +import WatchKit +import LoopKit +import LoopCore + + +final class OverridePresetRow: NSObject, IdentifiableClass { + @IBOutlet var symbolLabel: WKInterfaceLabel! + @IBOutlet var nameLabel: WKInterfaceLabel! +} diff --git a/WatchApp Extension/PushNotificationPayload.apns b/WatchApp Extension/PushNotificationPayload.apns index e793a02b3c..edffb57568 100644 --- a/WatchApp Extension/PushNotificationPayload.apns +++ b/WatchApp Extension/PushNotificationPayload.apns @@ -1,16 +1,17 @@ { "aps": { "alert": { - "body": "Test message", - "title": "Optional title" + "body": "RileyLink timed out. Check your pump before retrying.", + "title": "Bolus", + "subtitle": "3.5 U bolus failed" }, - "category": "myCategory" + "category": "bolusFailure" }, "WatchKit Simulator Actions": [ { - "title": "First Button", - "identifier": "firstButtonAction" + "title": "Retry", + "identifier": "retryBolus" } ], diff --git a/WatchApp Extension/Scenes/GlucoseChartScene.swift b/WatchApp Extension/Scenes/GlucoseChartScene.swift new file mode 100644 index 0000000000..8f69ff5e1d --- /dev/null +++ b/WatchApp Extension/Scenes/GlucoseChartScene.swift @@ -0,0 +1,347 @@ +// +// GlucoseChartScene.swift +// WatchApp Extension +// +// Created by Bharat Mediratta on 7/16/18. +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +import Foundation +import SpriteKit +import HealthKit +import LoopKit +import WatchKit +import os.log + +private extension TimeInterval { + static let moveAnimationDuration: TimeInterval = 0.25 + static let fadeAnimationDuration: TimeInterval = 0.75 +} + +private enum NodePlane: Int { + case lines = 0 + case ranges + case overrideRanges + case values + case labels + + var zPosition: CGFloat { + return CGFloat(rawValue) + } +} + +private extension SKLabelNode { + static func basic(at position: CGPoint) -> SKLabelNode { + let basic = SKLabelNode(text: "--") + basic.fontSize = UIFont.preferredFont(forTextStyle: .caption2).pointSize + basic.fontName = "HelveticaNeue" + basic.fontColor = .chartLabel + basic.alpha = 0.8 + basic.verticalAlignmentMode = .top + basic.horizontalAlignmentMode = .left + basic.position = position + basic.zPosition = NodePlane.labels.zPosition + return basic + } + + func move(to position: CGPoint) { + guard !self.position.equalTo(position) else { + return + } + + self.position = position + } +} + +private extension SKSpriteNode { + func move(to rect: CGRect, animated: Bool) { + guard !size.equalTo(rect.size) || !position.equalTo(rect.origin) else { + return + } + + if parent == nil || !animated { + size = rect.size + position = rect.origin + + if parent != nil { + alpha = 0 + run(.sequence([ + .wait(forDuration: .moveAnimationDuration), + .fadeIn(withDuration: .fadeAnimationDuration) + ])) + } + } else { + run(.group([ + .move(to: rect.origin, duration: .moveAnimationDuration), + .resize(toWidth: rect.size.width, duration: .moveAnimationDuration), + .resize(toHeight: rect.size.height, duration: .moveAnimationDuration) + ])) + } + } +} + +class GlucoseChartScene: SKScene { + let logger = Logger(category: "GlucoseChartScene") + + var textInsets = UIEdgeInsets(top: 5, left: 5, bottom: 5, right: 5) { + didSet { + setNeedsUpdate() + } + } + + var data: GlucoseChartData? { + didSet { + if let firstNewValue = data?.predictedGlucose?.first { + if oldValue?.predictedGlucose?.first == nil || oldValue?.predictedGlucose?.first!.startDate != firstNewValue.startDate { + shouldAnimatePredictionPath = true + } + } + } + } + + private(set) var visibleDuration = UserDefaults.standard.visibleDuration { + didSet { + logger.log("New visible duration: \(self.visibleDuration.hours)h") + setNeedsUpdate() + UserDefaults.standard.visibleDuration = visibleDuration + } + } + + private var hoursLabel: SKLabelNode! + private var maxBGLabel: SKLabelNode! + private var minBGLabel: SKLabelNode! + private var nodes: [Int: SKSpriteNode] = [:] + private var predictedPathNode: SKShapeNode? + + private var needsUpdate = true + private var shouldAnimatePredictionPath = false + + private lazy var dateFormatter: DateComponentsFormatter = { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.hour] + formatter.unitsStyle = .abbreviated + return formatter + }() + + override init() { + // Use the fixed sizes specified in the storyboard, based on our guess of the model size + let screen = WKInterfaceDevice.current().screenBounds + let height: CGFloat + + switch screen.width { + case let x where x < 150: // 38mm + height = 73 + case let x where x > 180: // 44mm + height = 111 + default: + height = 90 + } + + super.init(size: CGSize(width: screen.width, height: height)) + } + + override func sceneDidLoad() { + super.sceneDidLoad() + + anchorPoint = CGPoint(x: 0, y: 0) + scaleMode = .resizeFill + backgroundColor = .chartPlatter + + let dashedPath = CGPath(rect: CGRect(origin: CGPoint(x: size.width / 2, y: 0), size: CGSize(width: 0, height: size.height)), transform: nil).copy(dashingWithPhase: 0, lengths: [4.0, 3.0]) + let now = SKShapeNode(path: dashedPath) + now.strokeColor = .chartNowLine + now.zPosition = NodePlane.lines.zPosition + addChild(now) + + hoursLabel = SKLabelNode.basic(at: CGPoint(x: textInsets.left, y: size.height - textInsets.top)) + addChild(hoursLabel) + + maxBGLabel = SKLabelNode.basic(at: CGPoint(x: size.width - textInsets.right, y: size.height - textInsets.top)) + maxBGLabel.horizontalAlignmentMode = .right + addChild(maxBGLabel) + + minBGLabel = SKLabelNode.basic(at: CGPoint(x: size.width - textInsets.right, y: textInsets.bottom)) + minBGLabel.horizontalAlignmentMode = .right + minBGLabel.verticalAlignmentMode = .bottom + addChild(minBGLabel) + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + } + + override func update(_ currentTime: TimeInterval) { + logger.debug("update(_:)") + + if needsUpdate { + needsUpdate = false + performUpdate(animated: true) + } + } + + private var childNodesHaveActions: Bool { + return children.contains(where: { $0.hasActions() }) + } + + override func didFinishUpdate() { + let isPaused = self.isPaused + let childNodesHaveActions = self.childNodesHaveActions + logger.debug("didFinishUpdate() needsUpdate: \(self.needsUpdate) isPaused: \(isPaused) childNodesHaveActions: \(childNodesHaveActions)") + + super.didFinishUpdate() + + if !needsUpdate && !isPaused && !childNodesHaveActions { + logger.log("didFinishUpdate() pausing") + self.isPaused = true + } + } + + private func getSprite(forHash hashValue: Int) -> (sprite: SKSpriteNode, created: Bool) { + if let existingNode = nodes[hashValue] { + return (sprite: existingNode, created: false) + } else { + let newNode = SKSpriteNode(color: .clear, size: CGSize(width: 0, height: 0)) + newNode.anchorPoint = CGPoint(x: 0, y: 0) + nodes[hashValue] = newNode + addChild(newNode) + return (sprite: newNode, created: true) + } + } + + func setNeedsUpdate() { + dispatchPrecondition(condition: .onQueue(.main)) + needsUpdate = true + + if isPaused { + logger.log("setNeedsUpdate() unpausing") + isPaused = false + } + } + + private func performUpdate(animated: Bool) { + guard let data = data, let unit = data.unit else { + return + } + + let spannedInterval = DateInterval(start: Date() - visibleDuration / 2, duration: visibleDuration) + let glucoseRange = data.chartableGlucoseRange(from: spannedInterval) + let scaler = GlucoseChartScaler(size: size, dateInterval: spannedInterval, glucoseRange: glucoseRange, unit: unit, coordinateSystem: .inverted) + + let numberFormatter = NumberFormatter.glucoseFormatter(for: unit) + minBGLabel.text = numberFormatter.string(from: glucoseRange.lowerBound.doubleValue(for: unit)) + minBGLabel.move(to: CGPoint(x: size.width - textInsets.right, y: textInsets.bottom)) + maxBGLabel.text = numberFormatter.string(from: glucoseRange.upperBound.doubleValue(for: unit)) + maxBGLabel.move(to: CGPoint(x: size.width - textInsets.right, y: size.height - textInsets.top)) + hoursLabel.text = dateFormatter.string(from: visibleDuration) + hoursLabel.move(to: CGPoint(x: textInsets.left, y: size.height - textInsets.top)) + + // Keep track of the nodes we started this pass with so we can expire obsolete nodes at the end + var inactiveNodes = nodes + + let isOverrideActive = data.activePreMealOverride != nil || data.activeScheduleOverride != nil + data.correctionRange?.quantityBetween(start: spannedInterval.start, end: spannedInterval.end).forEach { range in + let (sprite, created) = getSprite(forHash: range.chartHashValue) + sprite.color = UIColor.glucose.withAlphaComponent(isOverrideActive ? 0.2 : 0.3) + sprite.zPosition = NodePlane.ranges.zPosition + sprite.move(to: scaler.rect(for: range, unit: unit), animated: !created) + inactiveNodes.removeValue(forKey: range.chartHashValue) + } + + // Make temporary overrides visually match what we do in the Loop app. This means that we have + // one darker box which represents the duration of the override, but we have a second lighter box which + // extends to the end of the visible window. + func plotOverride( + _ override: TemporaryScheduleOverride, + pushingStartTo startDate: Date? = nil, + extendingToChartEnd shouldExtendToChartEnd: Bool + ) { + var override = override + if let startDate = startDate { + guard startDate < override.scheduledEndDate else { + return + } + + override.scheduledInterval = DateInterval(start: startDate, end: override.scheduledEndDate) + } + + guard let overrideHashable = TemporaryScheduleOverrideHashable(override) else { + return + } + + let (sprite1, created) = getSprite(forHash: overrideHashable.chartHashValue) + sprite1.color = UIColor.glucose.withAlphaComponent(0.4) + sprite1.zPosition = NodePlane.overrideRanges.zPosition + sprite1.move(to: scaler.rect(for: overrideHashable, unit: unit), animated: !created) + inactiveNodes.removeValue(forKey: overrideHashable.chartHashValue) + + if override.scheduledEndDate < spannedInterval.end, shouldExtendToChartEnd { + var extendedOverride = override + extendedOverride.duration = .finite(spannedInterval.end.timeIntervalSince(overrideHashable.start)) + // Target range already known to be non-nil + let extendedOverrideHashable = TemporaryScheduleOverrideHashable(extendedOverride)! + let (sprite2, created) = getSprite(forHash: extendedOverrideHashable.chartHashValue) + sprite2.color = UIColor.glucose.withAlphaComponent(0.25) + sprite2.zPosition = NodePlane.overrideRanges.zPosition + sprite2.move(to: scaler.rect(for: extendedOverrideHashable, unit: unit), animated: !created) + inactiveNodes.removeValue(forKey: extendedOverrideHashable.chartHashValue) + } + } + + if let preMealOverride = data.activePreMealOverride { + plotOverride(preMealOverride, extendingToChartEnd: true) + } + + if let override = data.activeScheduleOverride { + plotOverride(override, pushingStartTo: data.activePreMealOverride?.scheduledEndDate, extendingToChartEnd: data.activePreMealOverride == nil) + } + + data.historicalGlucose?.filter { scaler.dates.contains($0.startDate) }.forEach { + let center = scaler.point($0.startDate, $0.quantity.doubleValue(for: unit)) + let size = CGSize(width: 2, height: 2) + let origin = CGPoint(x: center.x - size.width / 2, y: center.y - size.height / 2) + let (sprite, created) = getSprite(forHash: $0.chartHashValue) + sprite.color = .glucose + sprite.zPosition = NodePlane.values.zPosition + sprite.move(to: CGRect(origin: origin, size: size).alignedToScreenScale(WKInterfaceDevice.current().screenScale), animated: !created) + inactiveNodes.removeValue(forKey: $0.chartHashValue) + } + + predictedPathNode?.removeFromParent() + if let predictedGlucose = data.predictedGlucose, predictedGlucose.count > 2 { + let predictedPath = CGMutablePath() + predictedPath.addLines(between: predictedGlucose.map { + scaler.point($0.startDate, $0.quantity.doubleValue(for: unit)) + }) + + predictedPathNode = SKShapeNode(path: predictedPath.copy(dashingWithPhase: 11, lengths: [5, 3])) + predictedPathNode?.zPosition = NodePlane.values.zPosition + addChild(predictedPathNode!) + + if shouldAnimatePredictionPath { + shouldAnimatePredictionPath = false + // SKShapeNode paths cannot be easily animated. Make it vanish, then fade in at the new location. + predictedPathNode!.alpha = 0 + predictedPathNode!.run(.sequence([ + .wait(forDuration: .moveAnimationDuration), + .fadeIn(withDuration: .fadeAnimationDuration) + ]), + withKey: "move" + ) + } + } + + // Any inactive nodes can be safely removed + inactiveNodes.forEach { hash, node in + node.removeFromParent() + nodes.removeValue(forKey: hash) + } + } + + func decreaseVisibleDuration(by decrement: TimeInterval = .hours(1)) { + visibleDuration = max(.hours(2), visibleDuration - decrement) + } + + func increaseVisibleDuration(by increment: TimeInterval = .hours(1)) { + visibleDuration = min(.hours(12), visibleDuration + increment) + } +} diff --git a/WatchApp Extension/Scenes/GlucoseChartValueHashable.swift b/WatchApp Extension/Scenes/GlucoseChartValueHashable.swift new file mode 100644 index 0000000000..4737a2336f --- /dev/null +++ b/WatchApp Extension/Scenes/GlucoseChartValueHashable.swift @@ -0,0 +1,104 @@ +// +// GlucoseChartValueHashable.swift +// WatchApp Extension +// +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +import LoopKit +import HealthKit + + +protocol GlucoseChartValueHashable { + var start: Date { get } + var end: Date { get } + var min: HKQuantity { get } + var max: HKQuantity { get } + + var chartHashValue: Int { get } +} + +extension GlucoseChartValueHashable { + var chartHashValue: Int { + var hashValue = start.timeIntervalSinceReferenceDate.hashValue + hashValue ^= end.timeIntervalSince(start).hashValue + // HKQuantity.hashValue returns 0, so we need to convert + hashValue ^= min.doubleValue(for: .milligramsPerDeciliter).hashValue + if min != max { + hashValue ^= max.doubleValue(for: .milligramsPerDeciliter).hashValue + } + return hashValue + } +} + + +extension SampleValue { + var start: Date { + return startDate + } + + var end: Date { + return endDate + } + + var min: Double { + return quantity.doubleValue(for: .milligramsPerDeciliter) + } + + var max: Double { + return quantity.doubleValue(for: .milligramsPerDeciliter) + } + + var chartHashValue: Int { + var hashValue = start.timeIntervalSinceReferenceDate.hashValue + hashValue ^= end.timeIntervalSince(start).hashValue + hashValue ^= min.hashValue + return hashValue + } +} + + +extension AbsoluteScheduleValue: GlucoseChartValueHashable where T == ClosedRange { + var start: Date { + return startDate + } + + var end: Date { + return endDate + } + + var min: HKQuantity { + return value.lowerBound + } + + var max: HKQuantity { + return value.upperBound + } +} + +struct TemporaryScheduleOverrideHashable: GlucoseChartValueHashable { + let override: TemporaryScheduleOverride + + init?(_ override: TemporaryScheduleOverride) { + guard override.settings.targetRange != nil else { + return nil + } + self.override = override + } + + var start: Date { + return override.activeInterval.start + } + + var end: Date { + return override.activeInterval.end + } + + var min: HKQuantity { + return override.settings.targetRange!.lowerBound + } + + var max: HKQuantity { + return override.settings.targetRange!.upperBound + } +} diff --git a/WatchApp Extension/View Models/CarbAndBolusFlowViewModel.swift b/WatchApp Extension/View Models/CarbAndBolusFlowViewModel.swift new file mode 100644 index 0000000000..0cc21ca554 --- /dev/null +++ b/WatchApp Extension/View Models/CarbAndBolusFlowViewModel.swift @@ -0,0 +1,260 @@ +// +// CarbAndBolusFlowViewModel.swift +// WatchApp Extension +// +// Created by Michael Pangburn on 3/31/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import Foundation +import Combine +import HealthKit +import WatchKit +import WatchConnectivity +import LoopKit +import LoopCore + + +final class CarbAndBolusFlowViewModel: ObservableObject { + enum Error: Swift.Error { + case potentialCarbEntryMessageSendFailure + case bolusMessageSendFailure + } + + // MARK: - Published state + @Published var isComputingRecommendedBolus = false + @Published var recommendedBolusAmount: Double? + @Published var bolusPickerValues: BolusPickerValues + @Published var error: Error? + + // MARK: - Other state + let interactionStartDate = Date() + private var carbEntryUnderConsideration: NewCarbEntry? + private var contextUpdateObservation: AnyObject? + private var hasSentConfirmationMessage = false + private var contextDate: Date? + + // MARK: - Constants + private static let defaultSupportedBolusVolumes = (0...600).map { 0.05 * Double($0) } // U + private static let defaultMaxBolus: Double = 10 // U + + // MARK: - Initialization + let configuration: CarbAndBolusFlow.Configuration + private let dismiss: () -> Void + + init( + configuration: CarbAndBolusFlow.Configuration, + dismiss: @escaping () -> Void + ) { + let loopManager = ExtensionDelegate.shared().loopManager + switch configuration { + case .carbEntry: + break + case .manualBolus: + let activeContext = loopManager.activeContext + self.contextDate = activeContext?.creationDate + self._recommendedBolusAmount = Published(initialValue: activeContext?.recommendedBolusDose) + } + + self._bolusPickerValues = Published( + initialValue: BolusPickerValues( + supportedVolumes: loopManager.supportedBolusVolumes ?? Self.defaultSupportedBolusVolumes, + maxBolus: loopManager.settings.maximumBolus ?? Self.defaultMaxBolus + ) + ) + + self.configuration = configuration + self.dismiss = dismiss + + contextUpdateObservation = NotificationCenter.default.addObserver( + forName: LoopDataManager.didUpdateContextNotification, + object: loopManager, + queue: nil + ) { [weak self] _ in + guard + let self = self, + !self.hasSentConfirmationMessage + else { + return + } + + self.bolusPickerValues = BolusPickerValues( + supportedVolumes: loopManager.supportedBolusVolumes ?? Self.defaultSupportedBolusVolumes, + maxBolus: loopManager.settings.maximumBolus ?? Self.defaultMaxBolus + ) + + switch self.configuration { + case .carbEntry: + // If this new context wasn't generated in response to a potential carb entry message, + // recompute the recommended bolus for the carb entry under consideration. + let wasContextGeneratedFromPotentialCarbEntryMessage = loopManager.activeContext?.potentialCarbEntry != nil + if !wasContextGeneratedFromPotentialCarbEntryMessage, let entry = self.carbEntryUnderConsideration { + self.recommendBolus(for: entry) + } + case .manualBolus: + let activeContext = loopManager.activeContext + self.contextDate = activeContext?.creationDate + if self.recommendedBolusAmount != activeContext?.recommendedBolusDose { + self.recommendedBolusAmount = activeContext?.recommendedBolusDose + } + } + } + } + + deinit { + if let observation = contextUpdateObservation { + NotificationCenter.default.removeObserver(observation) + } + } + + func discardCarbEntryUnderConsideration() { + carbEntryUnderConsideration = nil + recommendedBolusAmount = nil + } + + func recommendBolus(forGrams grams: Int, eatenAt carbEntryDate: Date, absorptionTime carbAbsorptionTime: CarbAbsorptionTime, lastEntryDate: Date) { + let entry = NewCarbEntry( + date: lastEntryDate, + quantity: HKQuantity(unit: .gram(), doubleValue: Double(grams)), + startDate: carbEntryDate, + foodType: carbAbsorptionTime.emoji, + absorptionTime: absorptionTime(for: carbAbsorptionTime) + ) + + guard entry.quantity.doubleValue(for: .gram()) > 0 else { + return + } + + carbEntryUnderConsideration = entry + recommendBolus(for: entry) + } + + private func recommendBolus(for entry: NewCarbEntry) { + let potentialEntry = PotentialCarbEntryUserInfo(carbEntry: entry) + do { + isComputingRecommendedBolus = true + try WCSession.default.sendPotentialCarbEntryMessage(potentialEntry, + replyHandler: { [weak self] context in + DispatchQueue.main.async { + let loopManager = ExtensionDelegate.shared().loopManager + loopManager.updateContext(context) + + guard let self = self else { + return + } + + // Only update if this recommendation corresponds to the current carb entry under consideration. + guard context.potentialCarbEntry == self.carbEntryUnderConsideration else { + return + } + + defer { + self.isComputingRecommendedBolus = false + } + + self.contextDate = context.creationDate + + // Don't publish a new value if the recommendation has not changed. + guard self.recommendedBolusAmount != context.recommendedBolusDose else { + return + } + + self.recommendedBolusAmount = context.recommendedBolusDose + } + }, + errorHandler: { error in + DispatchQueue.main.async { [weak self] in + self?.isComputingRecommendedBolus = false + WKInterfaceDevice.current().play(.failure) + ExtensionDelegate.shared().present(error) + } + } + ) + } catch { + isComputingRecommendedBolus = false + self.error = .potentialCarbEntryMessageSendFailure + } + } + + private func absorptionTime(for carbAbsorptionTime: CarbAbsorptionTime) -> TimeInterval { + let defaultTimes = LoopCoreConstants.defaultCarbAbsorptionTimes + + switch carbAbsorptionTime { + case .fast: + return defaultTimes.fast + case .medium: + return defaultTimes.medium + case .slow: + return defaultTimes.slow + } + } + + func addCarbsWithoutBolusing() { + guard let carbEntry = carbEntryUnderConsideration else { + assertionFailure("Attempting to add carbs without a carb entry") + return + } + + sendSetBolusUserInfo(carbEntry: carbEntry, bolus: 0) + } + + func addCarbsAndDeliverBolus(_ bolusAmount: Double) { + sendSetBolusUserInfo(carbEntry: carbEntryUnderConsideration, bolus: bolusAmount) + } + + private func sendSetBolusUserInfo(carbEntry: NewCarbEntry?, bolus: Double) { + guard !hasSentConfirmationMessage else { + return + } + self.hasSentConfirmationMessage = true + + let bolus = SetBolusUserInfo(value: bolus, startDate: Date(), contextDate: self.contextDate, carbEntry: carbEntry, activationType: .activationTypeFor(recommendedAmount: recommendedBolusAmount, bolusAmount: bolus)) + do { + try WCSession.default.sendBolusMessage(bolus) { [weak self] (error) in + DispatchQueue.main.async { + if let error = error { + ExtensionDelegate.shared().present(error) + self?.hasSentConfirmationMessage = false + } else { + if bolus.carbEntry != nil { + if bolus.value == 0 { + // Notify for a successful carb entry (sans bolus) + WKInterfaceDevice.current().play(.success) + } + } + } + } + } + + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { + self.dismiss() + } + } catch { + self.error = .bolusMessageSendFailure + } + } +} + +extension CarbAndBolusFlowViewModel.Error: LocalizedError { + var failureReason: String? { + switch self { + case .potentialCarbEntryMessageSendFailure: + return NSLocalizedString("Unable to Reach iPhone", comment: "The title of the alert controller displayed after a potential carb entry send attempt fails") + case .bolusMessageSendFailure: + return NSLocalizedString("Bolus Failed", comment: "The title of the alert controller displayed after a bolus attempt fails") + } + } + + var recoverySuggestion: String? { + switch self { + case .potentialCarbEntryMessageSendFailure: + return NSLocalizedString("Make sure your iPhone is nearby and try again.", comment: "The recovery message displayed after a potential carb entry send attempt fails") + case .bolusMessageSendFailure: + return NSLocalizedString("Make sure your iPhone is nearby and try again.", comment: "The recovery message displayed after a bolus attempt fails") + } + } +} + +extension CarbAndBolusFlowViewModel.Error: Identifiable { + var id: Self { self } +} diff --git a/WatchApp Extension/View Models/OnOffSelectionViewModel.swift b/WatchApp Extension/View Models/OnOffSelectionViewModel.swift new file mode 100644 index 0000000000..c188e59492 --- /dev/null +++ b/WatchApp Extension/View Models/OnOffSelectionViewModel.swift @@ -0,0 +1,39 @@ +// +// OnOffSelectionViewModel.swift +// WatchApp Extension +// +// Created by Anna Quinlan on 8/20/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +enum SelectedButton { + case on + case off +} + +class OnOffSelectionViewModel: ObservableObject { + var title: String + var message: String + var onSelection: (Bool) -> Void + var dismiss: (() -> Void)? + var selectedButton: SelectedButton + var selectedButtonTint: UIColor + + init( + title: String, + message: String, + onSelection: @escaping (Bool) -> Void, + dismiss: (() -> Void)? = nil, + selectedButton: SelectedButton = .off, + selectedButtonTint: UIColor = .tintColor + ) { + self.title = title + self.message = message + self.onSelection = onSelection + self.dismiss = dismiss + self.selectedButton = selectedButton + self.selectedButtonTint = selectedButtonTint + } +} diff --git a/WatchApp Extension/Views/ActionButton.swift b/WatchApp Extension/Views/ActionButton.swift new file mode 100644 index 0000000000..e3b2e67bd9 --- /dev/null +++ b/WatchApp Extension/Views/ActionButton.swift @@ -0,0 +1,50 @@ +// +// ActionButton.swift +// WatchApp Extension +// +// Created by Michael Pangburn on 3/24/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import SwiftUI + + +struct ActionButton: View { + var title: Text + var color: Color + var action: () -> Void + + var body: some View { + Button(action: action, label: { + title + .fontWeight(.semibold) + .animation(nil) + }) + .buttonStyle(ActionButtonStyle(color: color)) + .animation(.default) + .frame(height: 40) + } +} + +private struct ActionButtonStyle: ButtonStyle { + var color: Color + @Environment(\.sizeClass) private var sizeClass + + func makeBody(configuration: Configuration) -> some View { + backgroundShape + .padding(.horizontal, sizeClass.hasRoundedCorners ? 4 : 0) + .overlay(configuration.label) + .padding(configuration.isPressed ? 1 : 0) + .overlay(Color.black.opacity(configuration.isPressed ? 0.35 : 0)) + } + + private var backgroundShape: some View { + Group { + if sizeClass.hasRoundedCorners { + Capsule().fill(color) + } else { + RoundedRectangle(cornerRadius: 6).fill(color) + } + } + } +} diff --git a/WatchApp Extension/Views/Carb Entry & Bolus/AbsorptionTimeSelection.swift b/WatchApp Extension/Views/Carb Entry & Bolus/AbsorptionTimeSelection.swift new file mode 100644 index 0000000000..6ecadaa53d --- /dev/null +++ b/WatchApp Extension/Views/Carb Entry & Bolus/AbsorptionTimeSelection.swift @@ -0,0 +1,94 @@ +// +// AbsorptionTimeSelection.swift +// WatchApp Extension +// +// Created by Michael Pangburn on 3/24/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import SwiftUI + + +struct AbsorptionTimeSelection: View { + @Binding var lastEntryDate: Date + @Binding var selectedAbsorptionTime: CarbAbsorptionTime + @Binding var expanded: Bool + var amount: Int + + var body: some View { + HStack(spacing: 0) { + ForEach(CarbAbsorptionTime.allCases, id: \.self) { absorptionTime in + Group { + if self.expanded || absorptionTime == self.selectedAbsorptionTime { + self.button(for: absorptionTime) + } + } + } + }.frame(height: 40) + } + + private func button(for absorptionTime: CarbAbsorptionTime) -> some View { + Button( + action: { + if self.expanded { + self.lastEntryDate = Date() + self.selectedAbsorptionTime = absorptionTime + } else { + withAnimation { + self.expanded = true + } + } + }, + label: { + self.label(for: absorptionTime) + } + ) + .buttonStyle(AbsorptionButtonStyle(backgroundColor: self.backgroundColor(for: absorptionTime))) + .zIndex(absorptionTime == selectedAbsorptionTime ? 1 : 0) + .frame(maxWidth: 90) + .transition(self.transition(for: absorptionTime)) + } + + private func label(for absorptionTime: CarbAbsorptionTime) -> some View { + HStack(spacing: 6) { + if !expanded && absorptionTime == selectedAbsorptionTime { + quantityLabel + } + + Text(absorptionTime.emoji) + .font(.system(size: 25)) + } + } + + private var quantityLabel: some View { + HStack(alignment: .firstTextBaseline, spacing: 0) { + CarbAmountLabel(amount: amount, scale: .small) + GramLabel(scale: .small) + } + } + + private func transition(for absorptionTime: CarbAbsorptionTime) -> AnyTransition { + let edgeTowardSelectedButton: Edge = absorptionTime.rawValue < selectedAbsorptionTime.rawValue ? .trailing : .leading + return .moveAndFade(to: edgeTowardSelectedButton) + } + + private func backgroundColor(for absorptionTime: CarbAbsorptionTime) -> Color { + if absorptionTime == selectedAbsorptionTime { + return expanded ? .carbs : .defaultWatchButtonGray + } else { + return .darkCarbs + } + } +} + +private struct AbsorptionButtonStyle: ButtonStyle { + var backgroundColor: Color + + func makeBody(configuration: Configuration) -> some View { + RoundedRectangle(cornerRadius: 8) + .fill(backgroundColor) + .overlay(configuration.label) + .padding(configuration.isPressed ? 1 : 0) + .overlay(Color.black.opacity(configuration.isPressed ? 0.35 : 0)) + } +} diff --git a/WatchApp Extension/Views/Carb Entry & Bolus/BolusArrow.swift b/WatchApp Extension/Views/Carb Entry & Bolus/BolusArrow.swift new file mode 100644 index 0000000000..7a8d47657d --- /dev/null +++ b/WatchApp Extension/Views/Carb Entry & Bolus/BolusArrow.swift @@ -0,0 +1,53 @@ +// +// BolusArrow.swift +// WatchApp Extension +// +// Created by Michael Pangburn on 3/30/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import SwiftUI + + +struct BolusArrow: View { + var progress: Double + @Environment(\.sizeClass) private var sizeClass + + private var isFinished: Bool { progress >= 1.0 } + + private let triangleSize = CGSize(width: 31, height: 25) + private let triangleOffsetY: CGFloat = 17 + + var body: some View { + ZStack { + arrow + .alignmentGuide(VerticalAlignment.center) { dimensions in + dimensions[VerticalAlignment.center] + - self.triangleOffsetY + + CGFloat(self.progress) * self.triangleOffsetY + } + arrow + } + .padding(.top, 4) + // Animate the arrow down off-screen once finished + .offset(y: isFinished ? sizeClass.screenSize.height : 0) + .animation(Animation.default.speed(isFinished ? 0.35 : 1.0)) + } + + private var arrow: some View { + Arrow(fillOpacity: progress) + .frame(width: triangleSize.width, height: triangleSize.height) + } +} + +private struct Arrow: View { + var fillOpacity: Double + + var body: some View { + ZStack { + TopDownTriangle().fill(Color.black) + TopDownTriangle().fill(Color.insulin.opacity(fillOpacity)) + TopDownTriangle().stroke(Color.insulin, lineWidth: 3) + } + } +} diff --git a/WatchApp Extension/Views/Carb Entry & Bolus/BolusConfirmationView.swift b/WatchApp Extension/Views/Carb Entry & Bolus/BolusConfirmationView.swift new file mode 100644 index 0000000000..3ccdc8bc64 --- /dev/null +++ b/WatchApp Extension/Views/Carb Entry & Bolus/BolusConfirmationView.swift @@ -0,0 +1,76 @@ +// +// BolusConfirmationView.swift +// WatchApp Extension +// +// Created by Michael Pangburn on 3/27/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import Combine +import SwiftUI + + +struct BolusConfirmationView: View { + // Strictly for storage. Use `progress` to access the underlying value. + @Binding private var progressStorage: Double + + private let completion: () -> Void + private let resetProgress = PeriodicPublisher(interval: 0.25) + + private var progress: Binding { + Binding( + get: { self.progressStorage.clamped(to: -1...1) }, + set: { newValue in + // Prevent further state changes after completion. + guard abs(self.progressStorage) < 1.0 else { + return + } + + withAnimation { + self.progressStorage = newValue + } + + self.resetProgress.acknowledge() + if abs(newValue) >= 1.0 { + WKInterfaceDevice.current().play(.success) + self.completion() + } + } + ) + } + + init(progress: Binding, onConfirmation completion: @escaping () -> Void) { + self._progressStorage = progress + self.completion = completion + } + + var body: some View { + VStack(spacing: 8) { + BolusConfirmationVisual(progress: abs(progress.wrappedValue)) + helpText + } + .focusable() + // By experimentation, it seems that 0...1 with low rotational sensitivity requires only 1/4 of one rotation. + // Scale accordingly, allowing negative values such that the crown can be rotated in either direction. + .digitalCrownRotation( + progress, + over: -1...1, + sensitivity: .low, + scalingRotationBy: 4 + ) + .onReceive(resetProgress) { + self.progress.wrappedValue = 0 + } + } + + private var isFinished: Bool { abs(progress.wrappedValue) >= 1.0 } + + private var helpText: some View { + Text("Turn Digital Crown\nto bolus", comment: "Help text for bolus confirmation on Apple Watch") + .font(.footnote) + .multilineTextAlignment(.center) + .foregroundColor(Color(.lightGray)) + .fixedSize(horizontal: false, vertical: true) + .opacity(isFinished ? 0 : 1) + } +} diff --git a/WatchApp Extension/Views/Carb Entry & Bolus/BolusConfirmationVisual.swift b/WatchApp Extension/Views/Carb Entry & Bolus/BolusConfirmationVisual.swift new file mode 100644 index 0000000000..4483d4c38b --- /dev/null +++ b/WatchApp Extension/Views/Carb Entry & Bolus/BolusConfirmationVisual.swift @@ -0,0 +1,38 @@ +// +// BolusConfirmationVisual.swift +// WatchApp Extension +// +// Created by Michael Pangburn on 3/30/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import SwiftUI + + +struct BolusConfirmationVisual: View { + var progress: Double + + private var isFinished: Bool { progress >= 1.0 } + + var body: some View { + ZStack { + Circle() + .fill(Color.darkInsulin) + .opacity(isFinished ? 0 : 1) + .animation(Animation.default.speed(0.5)) + .overlay(BolusArrow(progress: progress)) + + if isFinished { + CompletionCheckmark(checkmarkColor: .white, circleStrokeColor: .insulin) + .padding() + .transition(.opacity) + } + }.frame(height: 68) + } +} + +struct BolusConfirmationVisual_Previews: PreviewProvider { + static var previews: some View { + BolusConfirmationVisual(progress: 0) + } +} diff --git a/WatchApp Extension/Views/Carb Entry & Bolus/BolusInput.swift b/WatchApp Extension/Views/Carb Entry & Bolus/BolusInput.swift new file mode 100644 index 0000000000..4c01e5c4be --- /dev/null +++ b/WatchApp Extension/Views/Carb Entry & Bolus/BolusInput.swift @@ -0,0 +1,109 @@ +// +// BolusInput.swift +// WatchApp Extension +// +// Created by Michael Pangburn on 3/24/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import Foundation +import SwiftUI +import LoopKit + + +struct BolusInput: View { + @Binding var amount: Double + var isComputingRecommendedAmount: Bool + var recommendedAmount: Double? + var pickerValues: BolusPickerValues + var isEditable: Bool + + private var pickerValue: Binding { + Binding( + get: { self.pickerValues.index(of: self.amount) }, + set: { self.amount = self.pickerValues[$0] } + ) + } + + private static let amountFormatter: NumberFormatter = { + let formatter = QuantityFormatter(for: .internationalUnit()) + return formatter.numberFormatter + }() + + private static let recommendedAmountFormatter: NumberFormatter = { + let formatter = QuantityFormatter(for: .internationalUnit()) + return formatter.numberFormatter + }() + + var body: some View { + VStack(spacing: 0) { + DoseVolumeInput( + volume: amount, + isEditable: isEditable, + increment: { self.amount = self.pickerValues[self.pickerValues.index(of: self.amount+0.5)] }, + decrement: { self.amount = self.pickerValues[self.pickerValues.index(of: self.amount-0.5)] }, + formatVolume: formatVolume + ) + .focusable(isEditable) + .digitalCrownRotation( + pickerValue, + over: ClosedRange(pickerValues.indices), + rotationsPerIncrement: 1/24 + ) + + if isEditable { + recommendedAmountLabel + } + } + } + + private var recommendedAmountLabel: some View { + recommendedAmountLabelText + .font(Font.footnote) + .foregroundColor(.insulin) + .transition(.opacity) + } + + private var recommendedAmountLabelText: Text { + if isComputingRecommendedAmount { + return Text("REC: Calculating...", comment: "Indicator that recommended bolus computation is in progress on Apple Watch") + } else { + let valueString = recommendedAmount.map { value in Self.recommendedAmountFormatter.string(from: value) ?? String(value) } ?? "–" + return Text("REC: \(valueString) U", comment: "Recommended bolus amount label on Apple Watch") + } + } + + private func formatVolume(_ volume: Double) -> String { + // Look at surrounding bolus volumes to determine precision + let previous = pickerValues.decrementing(volume, by: 1) + let next = pickerValues.incrementing(volume, by: 1) + let maxPrecision = 3 + let requiredPrecision = [previous, volume, next] + .map { Decimal($0) } + .deltaScale(boundedBy: maxPrecision) + Self.amountFormatter.minimumFractionDigits = requiredPrecision + return Self.amountFormatter.string(from: volume) ?? String(volume) + } +} + +fileprivate extension Decimal { + func rounded(toPlaces scale: Int, roundingMode: NSDecimalNumber.RoundingMode = .plain) -> Decimal { + var result = Decimal() + var localCopy = self + NSDecimalRound(&result, &localCopy, scale, roundingMode) + return result + } +} + +fileprivate extension Collection where Element == Decimal { + /// Returns the maximum number of decimal places necessary to meaningfully distinguish between adjacent values. + /// - Precondition: The collection is sorted in ascending order. + func deltaScale(boundedBy maxScale: Int) -> Int { + let roundedToMaxScale = lazy.map { $0.rounded(toPlaces: maxScale) } + guard let minDelta = roundedToMaxScale.adjacentPairs().map(-).map(abs).min() else { + return 0 + } + + return abs(Swift.min(minDelta.exponent, 0)) + } +} diff --git a/WatchApp Extension/Views/Carb Entry & Bolus/CarbAmountInput.swift b/WatchApp Extension/Views/Carb Entry & Bolus/CarbAmountInput.swift new file mode 100644 index 0000000000..c6cd81fa2b --- /dev/null +++ b/WatchApp Extension/Views/Carb Entry & Bolus/CarbAmountInput.swift @@ -0,0 +1,47 @@ +// +// CarbAmountInput.swift +// WatchApp Extension +// +// Created by Michael Pangburn on 3/30/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import SwiftUI + + +struct CarbAmountInput: View { + @Binding var amount: Int + var increment: () -> Void + var decrement: () -> Void + + var body: some View { + HStack { + decrementButton + Spacer() + HStack(alignment: .firstTextBaseline, spacing: 0) { + CarbAmountLabel(amount: amount, scale: .large) + GramLabel(scale: .large) + } + Spacer() + incrementButton + } + } + + private var decrementButton: some View { + Button(action: decrement, label: { + Text(verbatim: "−") + .font(.system(.body, design: .rounded)) + .bold() + }) + .buttonStyle(CircularAccessoryButtonStyle(color: .carbs)) + } + + private var incrementButton: some View { + Button(action: increment, label: { + Text(verbatim: "+") + .font(.system(.body, design: .rounded)) + .bold() + }) + .buttonStyle(CircularAccessoryButtonStyle(color: .carbs)) + } +} diff --git a/WatchApp Extension/Views/Carb Entry & Bolus/CarbAmountLabel.swift b/WatchApp Extension/Views/Carb Entry & Bolus/CarbAmountLabel.swift new file mode 100644 index 0000000000..42445fbf27 --- /dev/null +++ b/WatchApp Extension/Views/Carb Entry & Bolus/CarbAmountLabel.swift @@ -0,0 +1,28 @@ +// +// CarbAmountLabel.swift +// WatchApp Extension +// +// Created by Michael Pangburn on 4/6/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import SwiftUI + + +struct CarbAmountLabel: View { + var amount: Int + var origin: Anchor? + var scale: PositionedTextScale + + var body: some View { + ScalablePositionedText( + text: Text(verbatim: "\(amount)"), + scale: scale, + origin: origin, + smallTextStyle: .body, + largeTextStyle: .title1, + design: .rounded, + weight: .bold + ) + } +} diff --git a/WatchApp Extension/Views/Carb Entry & Bolus/CarbAndBolusFlow.swift b/WatchApp Extension/Views/Carb Entry & Bolus/CarbAndBolusFlow.swift new file mode 100644 index 0000000000..0c27958fe9 --- /dev/null +++ b/WatchApp Extension/Views/Carb Entry & Bolus/CarbAndBolusFlow.swift @@ -0,0 +1,372 @@ +// +// CarbAndBolusFlow.swift +// WatchApp Extension +// +// Created by Michael Pangburn on 3/23/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import SwiftUI +import HealthKit +import LoopKit + + +struct CarbAndBolusFlow: View { + enum Configuration: Equatable { + case carbEntry(NewCarbEntry?) + case manualBolus + } + + private enum FlowState { + case carbEntry + case bolusEntry + case bolusConfirmation + } + + fileprivate enum AlertState { + case bolusRecommendationChanged + case communicationError(CarbAndBolusFlowViewModel.Error) + } + + // MARK: - State + @State private var flowState: FlowState + @ObservedObject private var viewModel: CarbAndBolusFlowViewModel + @Environment(\.sizeClass) private var sizeClass + + // MARK: - State: Carb Entry + // Date the user last changed the carb entry with the UI + @State private var carbLastEntryDate = Date() + @State private var carbAmount = 15 + // Date of the carb entry + @State private var carbEntryDate = Date() + @State private var carbAbsorptionTime: CarbAbsorptionTime = .medium + @State private var inputMode: CarbEntryInputMode = .carbs + + // MARK: - State: Bolus Entry + @State private var bolusAmount: Double = 0 + @State private var receivedInitialBolusRecommendation = false + @State private var activeAlert: AlertState? + + // MARK: - State: Bolus Confirmation + @State private var bolusConfirmationProgress: Double = 0 + + // MARK: - Initialization + + private var configuration: Configuration { viewModel.configuration } + + init(viewModel: CarbAndBolusFlowViewModel) { + switch viewModel.configuration { + case .carbEntry(let entry): + _flowState = State(initialValue: .carbEntry) + + if let entry = entry { + _carbEntryDate = State(initialValue: entry.startDate) + + let initialCarbAmount = entry.quantity.doubleValue(for: .gram()) + _carbAmount = State(initialValue: Int(initialCarbAmount)) + } + case .manualBolus: + _flowState = State(initialValue: .bolusEntry) + } + + self.viewModel = viewModel + } + + // MARK: - View Tree + + var body: some View { + VStack(spacing: 2) { + inputViews + Spacer() + actionView + } + // Position the carb labels via preference keys propagated up from lower in the view tree. + .overlayPreferenceValue(CarbAmountPositionKey.self, positionedCarbAmountLabel) + .overlayPreferenceValue(GramLabelPositionKey.self, positionedGramLabel) + + // Handle incoming bolus recommendations. + .onReceive(viewModel.$recommendedBolusAmount, perform: handleNewBolusRecommendation) + + // Handle error states. + .onReceive(viewModel.$error) { self.activeAlert = $0.map(AlertState.communicationError) } + .alert(item: $activeAlert, content: alert(for:)) + } +} + +// MARK: - Input views + +extension CarbAndBolusFlow { + private var inputViews: some View { + VStack(spacing: 4) { + if flowState == .carbEntry { + CarbAndDateInput( + lastEntryDate: $carbLastEntryDate, + amount: $carbAmount, + date: $carbEntryDate, + initialDate: viewModel.interactionStartDate, + inputMode: $inputMode + ) + .transition(.shrinkDownAndFade) + } else { + BolusInput( + amount: $bolusAmount, + isComputingRecommendedAmount: viewModel.isComputingRecommendedBolus, + recommendedAmount: viewModel.recommendedBolusAmount, + pickerValues: viewModel.bolusPickerValues, + isEditable: flowState == .bolusEntry + ) + } + + if configuration != .manualBolus && flowState != .bolusConfirmation { + AbsorptionTimeSelection( + lastEntryDate: $carbLastEntryDate, + selectedAbsorptionTime: $carbAbsorptionTime, + expanded: absorptionButtonsExpanded, + amount: carbAmount + ) + } + } + .padding(.top, topPaddingToPositionInputViews) + } + + private var absorptionButtonsExpanded: Binding { + Binding( + get: { self.flowState == .carbEntry }, + set: { isExpanded in isExpanded ? self.returnToCarbEntry() : self.transitionToBolusEntry() } + ) + } + + private func returnToCarbEntry() { + withAnimation { + flowState = .carbEntry + } + receivedInitialBolusRecommendation = false + viewModel.discardCarbEntryUnderConsideration() + + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(300)) { + self.bolusAmount = 0 + } + } + + private func transitionToBolusEntry() { + viewModel.recommendBolus(forGrams: carbAmount, eatenAt: carbEntryDate, absorptionTime: carbAbsorptionTime, lastEntryDate: carbLastEntryDate) + withAnimation { + flowState = .bolusEntry + inputMode = .carbs + } + } + + private var topPaddingToPositionInputViews: CGFloat { + guard flowState == .bolusConfirmation else { + return 0 + } + + // Derived via experimentation to hold the bolus amount label in place in transition to bolus confirmation. + switch sizeClass { + case .size38mm: + return 2 + case .size42mm: + return 0 + case .size40mm, .size41mm: + if case .carbEntry = configuration { + return 7 + } else { + return 19 + } + case .size44mm, .size45mm: + return 5 + } + } +} + +// MARK: - Action views + +extension CarbAndBolusFlow { + private var actionView: some View { + Group { + if flowState == .carbEntry { + continueToBolusEntryButton + } + + if flowState == .bolusEntry { + saveCarbsAndBolusButton + } + + if flowState == .bolusConfirmation { + bolusConfirmationView + } + } + } + + private var continueToBolusEntryButton: some View { + ActionButton( + title: Text("Continue", comment: "Button text to continue from carb entry to bolus entry on Apple Watch"), + color: .carbs + ) { + self.transitionToBolusEntry() + } + .offset(y: actionButtonOffsetY) + .transition(.fadeIn(after: 0.175)) + } + + private var saveCarbsAndBolusButton: some View { + ActionButton( + title: saveButtonText, + color: bolusAmount > 0 || configuration == .manualBolus ? .insulin : .blue + ) { + if self.bolusAmount > 0 { + withAnimation { + self.flowState = .bolusConfirmation + } + } else if case .carbEntry = self.configuration { + self.viewModel.addCarbsWithoutBolusing() + } + } + .offset(y: actionButtonOffsetY) + .transition(.fadeIn(after: 0.35, removal: .identity)) + } + + private var saveButtonText: Text { + switch configuration { + case .carbEntry: + return bolusAmount > 0 + ? Text("Save & Bolus", comment: "Button text to confirm carb entry and bolus on Apple Watch") + : Text("Save", comment: "Button text to confirm carb entry without bolusing on Apple Watch") + case .manualBolus: + return Text("Bolus", comment: "Button text to confirm manual bolus on Apple Watch") + } + } + + private var actionButtonOffsetY: CGFloat { + switch sizeClass { + case .size38mm, .size42mm: + return 0 + case .size40mm, .size41mm: + return 20 + case .size44mm, .size45mm: + return 27 + } + } + + private var bolusConfirmationView: some View { + BolusConfirmationView(progress: $bolusConfirmationProgress, onConfirmation: { + self.viewModel.addCarbsAndDeliverBolus(self.bolusAmount) + }) + .padding(.bottom, bolusConfirmationPadding) + .transition(.fadeIn(after: 0.35)) + } + + private var bolusConfirmationPadding: CGFloat { + switch sizeClass { + case .size42mm: + return 12 + default: + return 0 + } + } +} + +// MARK: - Carb label layout + +extension CarbAndBolusFlow { + private var carbLabelScale: PositionedTextScale { + flowState == .carbEntry ? .large : .small + } + + private func positionedCarbAmountLabel(_ origin: Anchor?) -> some View { + origin.map { origin in + carbLabelStyle(CarbAmountLabel(amount: carbAmount, origin: origin, scale: carbLabelScale)) + } + } + + private func positionedGramLabel(_ origin: Anchor?) -> some View { + origin.map { origin in + carbLabelStyle(GramLabel(origin: origin, scale: carbLabelScale)) + } + } + + private func carbLabelStyle(_ content: Content) -> some View { + let color: Color + if flowState == .carbEntry { + color = inputMode == .carbs ? .carbs : Color(.lightGray) + } else { + color = .white + } + + return content + .foregroundColor(color) + .onTapGesture { + if self.flowState == .carbEntry { + self.inputMode.toggle() + } else { + self.returnToCarbEntry() + } + } + } +} + +// MARK: - Handling incoming data + +extension CarbAndBolusFlow { + private func handleNewBolusRecommendation(_ recommendedBolus: Double?) { + guard flowState != .carbEntry else { + return + } + + if !receivedInitialBolusRecommendation { + receivedInitialBolusRecommendation = true + + // If the user hasn't started to dial a bolus amount, update to the recommended amount. + if flowState == .bolusEntry, bolusAmount == 0, let recommendedBolus = recommendedBolus { + bolusAmount = recommendedBolus + } + } else { + // Boot the user out of bolus confirmation to acknowledge the updated recommendation. + if flowState == .bolusConfirmation { + withAnimation { + flowState = .bolusEntry + } + } + + bolusAmount = recommendedBolus ?? 0 + activeAlert = .bolusRecommendationChanged + } + } + + private func alert(for activeAlert: AlertState) -> SwiftUI.Alert { + switch activeAlert { + case .bolusRecommendationChanged: + return recommendedBolusUpdatedAlert + case .communicationError(let error): + return communicationErrorAlert(for: error) + } + } + + private var recommendedBolusUpdatedAlert: SwiftUI.Alert { + SwiftUI.Alert( + title: Text("Bolus Recommendation Updated", comment: "Alert title for updated bolus recommendation on Apple Watch"), + message: Text("Please reconfirm the bolus amount.", comment: "Alert message for updated bolus recommendation on Apple Watch"), + dismissButton: .default(Text("OK")) + ) + } + + private func communicationErrorAlert(for error: CarbAndBolusFlowViewModel.Error) -> SwiftUI.Alert { + let dismissAction: () -> Void + switch error { + case .potentialCarbEntryMessageSendFailure: + dismissAction = {} + case .bolusMessageSendFailure: + dismissAction = { self.bolusConfirmationProgress = 0 } + } + + return SwiftUI.Alert( + title: Text(error.failureReason!), + message: Text(error.recoverySuggestion!), + dismissButton: .default(Text("OK"), action: dismissAction) + ) + } +} + +extension CarbAndBolusFlow.AlertState: Hashable, Identifiable { + var id: Self { self } +} diff --git a/WatchApp Extension/Views/Carb Entry & Bolus/CarbAndDateInput.swift b/WatchApp Extension/Views/Carb Entry & Bolus/CarbAndDateInput.swift new file mode 100644 index 0000000000..6618b87191 --- /dev/null +++ b/WatchApp Extension/Views/Carb Entry & Bolus/CarbAndDateInput.swift @@ -0,0 +1,111 @@ +// +// CarbAndDateInput.swift +// WatchApp Extension +// +// Created by Michael Pangburn on 3/27/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import SwiftUI + + +struct CarbAndDateInput: View { + @Binding var lastEntryDate: Date + @Binding var amount: Int + @Binding var date: Date + var initialDate: Date + @Binding var inputMode: CarbEntryInputMode + + private let carbIncrement = 5 + private let validCarbAmountRange = 0...100 + private let dateIncrement = TimeInterval(minutes: 15) + private let validDateDeltaRange = TimeInterval(hours: -8)...TimeInterval(hours: 4) + private var validDateRange: ClosedRange { + initialDate.addingTimeInterval(validDateDeltaRange.lowerBound)...initialDate.addingTimeInterval(validDateDeltaRange.upperBound) + } + + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .none + formatter.timeStyle = .short + return formatter + }() + + private var digitalCrownRotation: Binding { + switch inputMode { + case .carbs: + return Binding( + get: { self.amount }, + set: { + if $0 != self.amount { + self.lastEntryDate = Date() + self.amount = $0 + } + } + ) + case .date: + return Binding( + get: { Int(self.date.timeIntervalSince(self.initialDate).minutes) }, + set: { + let date = self.initialDate.addingTimeInterval(.minutes(Double($0))) + if date != self.date { + self.lastEntryDate = Date() + self.date = date + } + } + ) + } + } + + var body: some View { + VStack(spacing: 0) { + CarbAmountInput(amount: $amount, increment: increment, decrement: decrement) + dateLabel + }.onTapGesture { + self.inputMode.toggle() + } + .focusable() + .digitalCrownRotation(digitalCrownRotation, over: digitalCrownRange, rotationsPerIncrement: 1/24) + } + + var digitalCrownRange: ClosedRange { + switch inputMode { + case .carbs: + return validCarbAmountRange + case .date: + return Int(validDateDeltaRange.lowerBound.minutes)...Int(validDateDeltaRange.upperBound.minutes) + } + } + + var dateLabel: some View { + Text("\(date, formatter: Self.dateFormatter)") + .font(Font.footnote) + .foregroundColor(inputMode == .date ? .carbs : Color(.lightGray)) + } + + private func increment() { + self.lastEntryDate = Date() + + switch self.inputMode { + case .carbs: + self.amount = (self.amount + carbIncrement).clamped(to: validCarbAmountRange) + case .date: + self.date = self.date.addingTimeInterval(dateIncrement).clamped(to: validDateRange) + } + + WKInterfaceDevice.current().play(.directionUp) + } + + private func decrement() { + self.lastEntryDate = Date() + + switch self.inputMode { + case .carbs: + self.amount = (self.amount - carbIncrement).clamped(to: validCarbAmountRange) + case .date: + self.date = self.date.addingTimeInterval(-dateIncrement).clamped(to: validDateRange) + } + + WKInterfaceDevice.current().play(.directionDown) + } +} diff --git a/WatchApp Extension/Views/Carb Entry & Bolus/DoseVolumeInput.swift b/WatchApp Extension/Views/Carb Entry & Bolus/DoseVolumeInput.swift new file mode 100644 index 0000000000..b0443e12f4 --- /dev/null +++ b/WatchApp Extension/Views/Carb Entry & Bolus/DoseVolumeInput.swift @@ -0,0 +1,82 @@ +// +// DoseVolumeInput.swift +// WatchApp Extension +// +// Created by Michael Pangburn on 3/30/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import SwiftUI + + +struct DoseVolumeInput: View { + var volume: Double + var unit = Text("U") + var isEditable: Bool + var increment: () -> Void + var decrement: () -> Void + var formatVolume: (_ volume: Double) -> String + + var body: some View { + // Negative spacing draws the increment buttons close enough + // to fit a unit label in the width of a 38mm watch. + HStack(spacing: -4) { + if isEditable { + decrementButton + } + Spacer() + + HStack(alignment: .firstTextBaseline, spacing: 0) { + numericLabel + unitLabel + } + + Spacer() + if isEditable { + incrementButton + } + } + } + + private var numericLabel: some View { + Text(formatVolume(volume)) + .font(.system(.title, design: .rounded)) + .bold() + .foregroundColor(.insulin) + .fixedSize() + } + + private var unitLabel: some View { + unit + .font(.system(.callout, design: .rounded)) + .bold() + .foregroundColor(.insulin) + .fixedSize() + } + + private var decrementButton: some View { + Button(action: { + self.decrement() + WKInterfaceDevice.current().play(.directionDown) + }, label: { + Text(verbatim: "−") + .font(.system(.body, design: .rounded)) + .bold() + }) + .buttonStyle(CircularAccessoryButtonStyle(color: .insulin)) + .transition(.opacity) + } + + private var incrementButton: some View { + Button(action: { + self.increment() + WKInterfaceDevice.current().play(.directionUp) + }, label: { + Text(verbatim: "+") + .font(.system(.body, design: .rounded)) + .bold() + }) + .buttonStyle(CircularAccessoryButtonStyle(color: .insulin)) + .transition(.opacity) + } +} diff --git a/WatchApp Extension/Views/Carb Entry & Bolus/GramLabel.swift b/WatchApp Extension/Views/Carb Entry & Bolus/GramLabel.swift new file mode 100644 index 0000000000..b3c2d53d90 --- /dev/null +++ b/WatchApp Extension/Views/Carb Entry & Bolus/GramLabel.swift @@ -0,0 +1,27 @@ +// +// GramLabel.swift +// WatchApp Extension +// +// Created by Michael Pangburn on 4/6/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import SwiftUI + + +struct GramLabel: View { + var origin: Anchor? + var scale: PositionedTextScale + + var body: some View { + ScalablePositionedText( + text: Text("g", comment: "Short unit label for gram measurement"), + scale: scale, + origin: origin, + smallTextStyle: .footnote, + largeTextStyle: .callout, + design: .rounded, + weight: .bold + ) + } +} diff --git a/WatchApp Extension/Views/Carb Entry & Bolus/Models/BolusPickerValues.swift b/WatchApp Extension/Views/Carb Entry & Bolus/Models/BolusPickerValues.swift new file mode 100644 index 0000000000..8d310f2bbf --- /dev/null +++ b/WatchApp Extension/Views/Carb Entry & Bolus/Models/BolusPickerValues.swift @@ -0,0 +1,55 @@ +// +// BolusPickerValues.swift +// WatchApp Extension +// +// Created by Michael Pangburn on 4/1/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +struct BolusPickerValues: RandomAccessCollection { + private var supportedVolumes: [Double] + + init(supportedVolumes: [Double], maxBolus: Double) { + self.supportedVolumes = Array(supportedVolumes.prefix(while: { $0 <= maxBolus })) + if self.supportedVolumes.first != 0 { + self.supportedVolumes.insert(0, at: supportedVolumes.startIndex) + } + } + + var startIndex: Int { supportedVolumes.startIndex } + var endIndex: Int { supportedVolumes.endIndex } + + func index(after i: Int) -> Int { supportedVolumes.index(after: i) } + func index(before i: Int) -> Int { supportedVolumes.index(before: i) } + + func index(_ i: Int, offsetBy distance: Int, limitedBy limit: Int) -> Int? { + supportedVolumes.index(i, offsetBy: distance, limitedBy: limit) + } + + subscript(pickerValue: Int) -> Double { + supportedVolumes[pickerValue] + } +} + +extension BolusPickerValues { + func index(of bolusValue: Double) -> Int { + let indexAfter = supportedVolumes.firstIndex(where: { $0 > bolusValue }) ?? supportedVolumes.endIndex + guard indexAfter > 0 else { return 0 } + return supportedVolumes.index(before: indexAfter) + } + + func incrementing(_ bolusValue: Double, by pickerIncrement: Int) -> Double { + assert(pickerIncrement >= 0) + let thisIndex = index(of: bolusValue) + let targetIndex = index(thisIndex, offsetBy: pickerIncrement, limitedBy: endIndex - 1) ?? endIndex - 1 + return self[targetIndex] + } + + func decrementing(_ bolusValue: Double, by pickerDecrement: Int) -> Double { + assert(pickerDecrement >= 0) + let thisIndex = index(of: bolusValue) + let targetIndex = index(thisIndex, offsetBy: -pickerDecrement, limitedBy: startIndex) ?? startIndex + return self[targetIndex] + } +} + diff --git a/WatchApp Extension/Views/Carb Entry & Bolus/Models/CarbEntryInputMode.swift b/WatchApp Extension/Views/Carb Entry & Bolus/Models/CarbEntryInputMode.swift new file mode 100644 index 0000000000..0bcd2d3716 --- /dev/null +++ b/WatchApp Extension/Views/Carb Entry & Bolus/Models/CarbEntryInputMode.swift @@ -0,0 +1,21 @@ +// +// CarbEntryInputMode.swift +// WatchApp Extension +// +// Created by Michael Pangburn on 4/1/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +enum CarbEntryInputMode { + case carbs + case date + + mutating func toggle() { + switch self { + case .carbs: + self = .date + case .date: + self = .carbs + } + } +} diff --git a/WatchApp Extension/Views/Carb Entry & Bolus/Preference Keys/CarbAmountPositionKey.swift b/WatchApp Extension/Views/Carb Entry & Bolus/Preference Keys/CarbAmountPositionKey.swift new file mode 100644 index 0000000000..9bcd0b2894 --- /dev/null +++ b/WatchApp Extension/Views/Carb Entry & Bolus/Preference Keys/CarbAmountPositionKey.swift @@ -0,0 +1,18 @@ +// +// CarbAmountPositionKey.swift +// WatchApp Extension +// +// Created by Michael Pangburn on 3/27/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import SwiftUI + + +struct CarbAmountPositionKey: PreferenceKey { + static var defaultValue: Anchor? { nil } + + static func reduce(value: inout Anchor?, nextValue: () -> Anchor?) { + value = value ?? nextValue() + } +} diff --git a/WatchApp Extension/Views/Carb Entry & Bolus/Preference Keys/GramLabelPositionKey.swift b/WatchApp Extension/Views/Carb Entry & Bolus/Preference Keys/GramLabelPositionKey.swift new file mode 100644 index 0000000000..62e39b5949 --- /dev/null +++ b/WatchApp Extension/Views/Carb Entry & Bolus/Preference Keys/GramLabelPositionKey.swift @@ -0,0 +1,18 @@ +// +// GramLabelPositionKey.swift +// WatchApp Extension +// +// Created by Michael Pangburn on 3/27/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import SwiftUI + + +struct GramLabelPositionKey: PreferenceKey { + static var defaultValue: Anchor? { nil } + + static func reduce(value: inout Anchor?, nextValue: () -> Anchor?) { + value = value ?? nextValue() + } +} diff --git a/WatchApp Extension/Views/Checkmark.swift b/WatchApp Extension/Views/Checkmark.swift new file mode 100644 index 0000000000..ab7a60e5ec --- /dev/null +++ b/WatchApp Extension/Views/Checkmark.swift @@ -0,0 +1,22 @@ +// +// Checkmark.swift +// WatchApp Extension +// +// Created by Michael Pangburn on 3/30/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import SwiftUI + + +/// A checkmark based on unit ratios of +/// {0, 20} -> {17, 0} -> {45, 43} +struct Checkmark: Shape { + func path(in rect: CGRect) -> Path { + Path { path in + path.move(to: CGPoint(x: rect.minX, y: rect.maxY - 20 / 43 * rect.height)) + path.addLine(to: CGPoint(x: rect.minX + 17 / 45 * rect.width, y: rect.maxY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY)) + } + } +} diff --git a/WatchApp Extension/Views/CircularAccessoryButtonStyle.swift b/WatchApp Extension/Views/CircularAccessoryButtonStyle.swift new file mode 100644 index 0000000000..f837a92205 --- /dev/null +++ b/WatchApp Extension/Views/CircularAccessoryButtonStyle.swift @@ -0,0 +1,23 @@ +// +// CircularAccessoryButtonStyle.swift +// WatchApp Extension +// +// Created by Michael Pangburn on 3/24/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import SwiftUI + + +struct CircularAccessoryButtonStyle: ButtonStyle { + var color: Color + + func makeBody(configuration: Configuration) -> some View { + Circle() + .fill(color.opacity(0.14)) + .overlay(configuration.label.foregroundColor(color)) + .padding(configuration.isPressed ? 1 : 0) + .frame(width: 22, height: 22) + .overlay(Color.black.opacity(configuration.isPressed ? 0.35 : 0)) + } +} diff --git a/WatchApp Extension/Views/CompletionCheckmark.swift b/WatchApp Extension/Views/CompletionCheckmark.swift new file mode 100644 index 0000000000..1fe3ee4c61 --- /dev/null +++ b/WatchApp Extension/Views/CompletionCheckmark.swift @@ -0,0 +1,45 @@ +// +// CompletionCheckmark.swift +// WatchApp Extension +// +// Created by Michael Pangburn on 3/30/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import SwiftUI + + +struct CompletionCheckmark: View { + var checkmarkColor: Color + var checkmarkLineWidth: CGFloat = 5 + var circleStrokeColor: Color + var circleLineWidth: CGFloat = 3 + + @State private var appeared = false + + private let checkmarkScale: CGFloat = 0.35 + + var body: some View { + strokedCircle + .overlay(checkmark) + .onAppear { + withAnimation(Animation.default.speed(0.65).delay(0.3)) { + self.appeared = true + } + } + } + + private var strokedCircle: some View { + Circle() + .rotation(.degrees(-90)) // Start the animation from 12 o'clock + .trim(from: 0, to: appeared ? 1 : 0) + .stroke(circleStrokeColor, style: StrokeStyle(lineWidth: circleLineWidth, lineCap: .round)) + } + + private var checkmark: some View { + Checkmark() + .stroke(checkmarkColor, style: StrokeStyle(lineWidth: checkmarkLineWidth / checkmarkScale, lineCap: .round, lineJoin: .round)) + .aspectRatio(22.5 / 21.5, contentMode: .fit) + .scaleEffect(checkmarkScale) + } +} diff --git a/WatchApp Extension/Views/Extensions/AnyTransition.swift b/WatchApp Extension/Views/Extensions/AnyTransition.swift new file mode 100644 index 0000000000..14e1ff234e --- /dev/null +++ b/WatchApp Extension/Views/Extensions/AnyTransition.swift @@ -0,0 +1,32 @@ +// +// AnyTransition.swift +// WatchApp Extension +// +// Created by Michael Pangburn on 3/27/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import SwiftUI + + +extension AnyTransition { + static var shrinkDownAndFade: AnyTransition { + AnyTransition + .move(edge: .bottom) + .combined(with: .scale(scale: 0, anchor: .bottom)) + .combined(with: .opacity) + } + + static func moveAndFade(to edge: Edge) -> AnyTransition { + AnyTransition + .move(edge: edge) + .combined(with: .opacity) + } + + static func fadeIn(after delay: Double, removal: AnyTransition = .opacity) -> AnyTransition { + .asymmetric( + insertion: AnyTransition.opacity.animation(Animation.default.delay(delay)), + removal: removal + ) + } +} diff --git a/WatchApp Extension/Views/Extensions/Color.swift b/WatchApp Extension/Views/Extensions/Color.swift new file mode 100644 index 0000000000..4eb24bc4ff --- /dev/null +++ b/WatchApp Extension/Views/Extensions/Color.swift @@ -0,0 +1,20 @@ +// +// Color.swift +// WatchApp Extension +// +// Created by Michael Pangburn on 3/24/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import SwiftUI + + +extension Color { + static let carbs = Color(.carbsColor) + static let darkCarbs = Color(.darkCarbsColor) + + static let insulin = Color(.insulin) + static let darkInsulin = Color(.darkInsulin) + + static let defaultWatchButtonGray = Color(white: 35 / 255) +} diff --git a/WatchApp Extension/Views/Extensions/DigitalCrownRotation.swift b/WatchApp Extension/Views/Extensions/DigitalCrownRotation.swift new file mode 100644 index 0000000000..01323e8b45 --- /dev/null +++ b/WatchApp Extension/Views/Extensions/DigitalCrownRotation.swift @@ -0,0 +1,52 @@ +// +// DigitalCrownRotation.swift +// WatchApp Extension +// +// Created by Michael Pangburn on 4/1/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import SwiftUI + + +extension View { + func digitalCrownRotation( + _ binding: Binding, + over bounds: ClosedRange, + rotationsPerIncrement: Double + ) -> some View { + precondition(rotationsPerIncrement > 0) + + let scaledBounds = (Double(bounds.lowerBound) / rotationsPerIncrement)...(Double(bounds.upperBound) / rotationsPerIncrement) + let scaledBinding = Binding( + get: { Double(binding.wrappedValue) / rotationsPerIncrement }, + set: { binding.wrappedValue = I(($0 * rotationsPerIncrement).rounded()).clamped(to: bounds) } + ) + return digitalCrownRotation( + scaledBinding, + from: scaledBounds.lowerBound, + through: scaledBounds.upperBound + ) + } + + func digitalCrownRotation( + _ binding: Binding, + over bounds: ClosedRange, + sensitivity: DigitalCrownRotationalSensitivity = .high, + scalingRotationBy scaleFactor: V + ) -> some View where V.Stride: BinaryFloatingPoint { + precondition(scaleFactor > 0) + + let scaledBounds = (bounds.lowerBound * scaleFactor)...(bounds.upperBound * scaleFactor) + let scaledBinding = Binding( + get: { binding.wrappedValue * scaleFactor }, + set: { binding.wrappedValue = $0 / scaleFactor } + ) + return digitalCrownRotation( + scaledBinding, + from: scaledBounds.lowerBound, + through: scaledBounds.upperBound, + sensitivity: sensitivity + ) + } +} diff --git a/WatchApp Extension/Views/Extensions/Environment+SizeClass.swift b/WatchApp Extension/Views/Extensions/Environment+SizeClass.swift new file mode 100644 index 0000000000..1ee3331718 --- /dev/null +++ b/WatchApp Extension/Views/Extensions/Environment+SizeClass.swift @@ -0,0 +1,85 @@ +// +// Environment.swift +// WatchApp Extension +// +// Created by Michael Pangburn on 4/6/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import SwiftUI + + +extension EnvironmentValues { + var sizeClass: WKInterfaceDevice.SizeClass { + get { self[SizeClassKey.self] } + set { self[SizeClassKey.self] = newValue } + } +} + + +private struct SizeClassKey: EnvironmentKey { + static let defaultValue = WKInterfaceDevice.current().sizeClass +} + + +extension WKInterfaceDevice { + enum SizeClass: CaseIterable { + // Apple Watch Series 3 and earlier + case size38mm + case size42mm + + // Apple Watch Series 4 - 6 + case size40mm + case size44mm + + // Apple Watch Series 7 + case size41mm + case size45mm + } + + var sizeClass: SizeClass { + if let sizeClass = SizeClass(screenSize: screenBounds.size) { + return sizeClass + } else { + // Future sizes, if not explicitly supported, will use 40mm class. + return .size40mm + } + } +} + +extension WKInterfaceDevice.SizeClass { + init?(screenSize: CGSize) { + let sizeClassesWithSizes = WKInterfaceDevice.SizeClass.allCases.map { (sizeClass: $0, screenSize: $0.screenSize) } + guard let sizeClass = sizeClassesWithSizes.first(where: { $0.screenSize == screenSize })?.sizeClass else { + return nil + } + + self = sizeClass + } + + var screenSize: CGSize { + switch self { + case .size38mm: + return CGSize(width: 136, height: 170) + case .size42mm: + return CGSize(width: 156, height: 195) + case .size40mm: + return CGSize(width: 162, height: 197) + case .size41mm: + return CGSize(width: 176, height: 215) + case .size44mm: + return CGSize(width: 184, height: 224) + case .size45mm: + return CGSize(width: 198, height: 242) + } + } + + var hasRoundedCorners: Bool { + switch self { + case .size40mm, .size41mm, .size44mm, .size45mm: + return true + case .size38mm, .size42mm: + return false + } + } +} diff --git a/WatchApp Extension/Views/Extensions/PeriodicPublisher.swift b/WatchApp Extension/Views/Extensions/PeriodicPublisher.swift new file mode 100644 index 0000000000..da6641d079 --- /dev/null +++ b/WatchApp Extension/Views/Extensions/PeriodicPublisher.swift @@ -0,0 +1,41 @@ +// +// PeriodicPublisher.swift +// WatchApp Extension +// +// Created by Michael Pangburn on 3/30/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import Combine + + +/// A publisher which emits a value at a defined interval, which can be delayed via acknowledgment. +final class PeriodicPublisher { + private let didExecute: AnyPublisher + private let _acknowledge: () -> Void + + init(interval: TimeInterval, runLoop: RunLoop = .main, mode: RunLoop.Mode = .common) { + var lastAcknowledged = Date.distantPast + _acknowledge = { lastAcknowledged = Date() } + didExecute = Timer.publish(every: interval, on: runLoop, in: mode) + .autoconnect() + .filter { date in + date.timeIntervalSince(lastAcknowledged) >= interval + } + .map { _ in () } + .eraseToAnyPublisher() + } + + func acknowledge() { + _acknowledge() + } +} + +extension PeriodicPublisher: Publisher { + typealias Output = Void + typealias Failure = Never + + func receive(subscriber: S) where S.Failure == Failure, S.Input == Output { + didExecute.receive(subscriber: subscriber) + } +} diff --git a/WatchApp Extension/Views/Extensions/UIFont.swift b/WatchApp Extension/Views/Extensions/UIFont.swift new file mode 100644 index 0000000000..033123f4d2 --- /dev/null +++ b/WatchApp Extension/Views/Extensions/UIFont.swift @@ -0,0 +1,37 @@ +// +// UIFont.swift +// WatchApp Extension +// +// Created by Michael Pangburn on 3/27/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import SwiftUI + + +extension UIFont.TextStyle { + var swiftUIVariant: Font.TextStyle { + switch self { + case .largeTitle: + return .largeTitle + case .title1, .title2, .title3: + return .title + case .headline: + return .headline + case .body: + return .body + case .callout: + return .callout + case .subheadline: + return .subheadline + case .footnote: + return .footnote + case .caption1, .caption2: + return .caption + default: + assertionFailure("Unknown text style \(self)") + return .body + } + } +} + diff --git a/WatchApp Extension/Views/OnOffSelectionView.swift b/WatchApp Extension/Views/OnOffSelectionView.swift new file mode 100644 index 0000000000..fc44b51efc --- /dev/null +++ b/WatchApp Extension/Views/OnOffSelectionView.swift @@ -0,0 +1,90 @@ +// +// OnOffSelectionView.swift +// WatchApp Extension +// +// Created by Anna Quinlan on 8/20/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import SwiftUI + +struct OnOffSelectionView: View { + // MARK: - Initialization + var viewModel: OnOffSelectionViewModel + + // MARK: - View Tree + + var body: some View { + VStack { + Spacer() + titleStack + Spacer() + if viewModel.selectedButton == .on { + buttonStackWithOnSelected + } else if viewModel.selectedButton == .off { + buttonStackWithOffSelected + } + } + } + + var titleStack: some View { + VStack(spacing: 2) { + Text(viewModel.title) + Text(viewModel.message) + } + } + + var buttonStackWithOnSelected: some View { + VStack(spacing: 5) { + onButton + .background(Color(viewModel.selectedButtonTint).cornerRadius(20.0)) + offButton + } + } + + var buttonStackWithOffSelected: some View { + VStack(spacing: 5) { + onButton + offButton + .background(Color(viewModel.selectedButtonTint).cornerRadius(20.0)) + } + } + + var onButton: some View { + Button(action: { + self.viewModel.onSelection(true) + self.viewModel.dismiss?() + }) { + Text("On", comment: "Label for on button") + } + .cornerRadius(20) + } + + var offButton: some View { + Button(action: { + self.viewModel.onSelection(false) + self.viewModel.dismiss?() + }) { + Text("Off", comment: "Label for off button") + } + .cornerRadius(20) + } +} + +struct OnOffSelectionView_Previews: PreviewProvider { + static var previews: some View { + Group { + OnOffSelectionView(viewModel: OnOffSelectionViewModel(title: "Pre-Meal", message: "80-90 mg/dL", onSelection: {_ in print("hi")}, selectedButton: .on, selectedButtonTint: .carbsColor)) + .previewDevice(PreviewDevice(rawValue: "Apple Watch Series 2 - 38mm")) + + OnOffSelectionView(viewModel: OnOffSelectionViewModel(title: "Pre-Meal", message: "80-90 mg/dL", onSelection: {_ in print("hi")}, selectedButton: .off, selectedButtonTint: .carbsColor)) + .previewDevice(PreviewDevice(rawValue: "Apple Watch Series 2 - 42mm")) + + OnOffSelectionView(viewModel: OnOffSelectionViewModel(title: "Workout", message: "180-190 mg/dL", onSelection: {_ in print("hi")}, selectedButton: .on, selectedButtonTint: .glucose)) + .previewDevice(PreviewDevice(rawValue: "Apple Watch Series 4 - 44mm")) + + OnOffSelectionView(viewModel: OnOffSelectionViewModel(title: "Workout", message: "180-190 mg/dL", onSelection: {_ in print("hi")}, selectedButton: .off, selectedButtonTint: .glucose)) + .previewDevice(PreviewDevice(rawValue: "Apple Watch Series 4 - 40mm")) + } + } +} diff --git a/WatchApp Extension/Views/ScalablePositionedText.swift b/WatchApp Extension/Views/ScalablePositionedText.swift new file mode 100644 index 0000000000..6a3ea3f99c --- /dev/null +++ b/WatchApp Extension/Views/ScalablePositionedText.swift @@ -0,0 +1,63 @@ +// +// ScalablePositionedText.swift +// WatchApp Extension +// +// Created by Michael Pangburn on 4/6/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import SwiftUI + + +enum PositionedTextScale { + case small + case large +} + +/// Applies a scale effect between text styles to enable smoothly animated text resizing, +/// while managing positioning and propagation of position up the view hierarchy. +struct ScalablePositionedText: View where Key.Value == Anchor? { + var text: Text + var scale: PositionedTextScale + var origin: Anchor? + var smallTextStyle: UIFont.TextStyle + var largeTextStyle: UIFont.TextStyle + var design: Font.Design = .default + var weight: Font.Weight? + + var body: some View { + text + .font(.system(textStyle.swiftUIVariant, design: design)) + .fontWeight(weight) + .scaleEffect(scaleRatio, anchor: .topLeading) + .position(at: origin, orPropagateVia: Key.self) + } + + private var isLayoutOnly: Bool { origin == nil } + + private var textStyle: UIFont.TextStyle { + if isLayoutOnly { + switch scale { + case .small: + return smallTextStyle + case .large: + return largeTextStyle + } + } else { + return largeTextStyle + } + } + + private var isScaleEffectApplied: Bool { + !isLayoutOnly && scale == .small + } + + private var scaleRatio: CGFloat { + isScaleEffectApplied ? smallScaleRatio : 1 + } + + private var smallScaleRatio: CGFloat { + UIFont.preferredFont(forTextStyle: smallTextStyle).pointSize + / UIFont.preferredFont(forTextStyle: largeTextStyle).pointSize + } +} diff --git a/WatchApp Extension/Views/TopDownTriangle.swift b/WatchApp Extension/Views/TopDownTriangle.swift new file mode 100644 index 0000000000..5d9d62bd95 --- /dev/null +++ b/WatchApp Extension/Views/TopDownTriangle.swift @@ -0,0 +1,22 @@ +// +// TopDownTriangle.swift +// WatchApp Extension +// +// Created by Michael Pangburn on 3/30/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import SwiftUI + + +/// An isosceles triangle, pointed downward. +struct TopDownTriangle: Shape { + func path(in rect: CGRect) -> Path { + Path { path in + path.move(to: CGPoint(x: rect.minX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.midX, y: rect.maxY)) + path.closeSubpath() + } + } +} diff --git a/WatchApp Extension/Views/View+Position.swift b/WatchApp Extension/Views/View+Position.swift new file mode 100644 index 0000000000..6d3ca0eeca --- /dev/null +++ b/WatchApp Extension/Views/View+Position.swift @@ -0,0 +1,32 @@ +// +// View+Position.swift +// WatchApp Extension +// +// Created by Michael Pangburn on 4/6/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import SwiftUI + + +extension View { + /// Positions the view at the given anchor if non-nil; + /// otherwise propagates the view's origin via the given preference key. + func position( + at origin: Anchor?, + orPropagateVia _: Key.Type, + visibleWhenPropagatingBounds: Bool = false + ) -> some View where Key.Value == Anchor? { + Group { + if origin != nil { + GeometryReader { geometry in + self.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .offset(x: geometry[origin!].x, y: geometry[origin!].y) + } + } else { + anchorPreference(key: Key.self, value: .topLeading) { $0 } + .opacity(visibleWhenPropagatingBounds ? 1 : 0) + } + } + } +} diff --git a/WatchApp Extension/WatchApp Extension.entitlements b/WatchApp Extension/WatchApp Extension.entitlements index 0c67376eba..5bc8787030 100644 --- a/WatchApp Extension/WatchApp Extension.entitlements +++ b/WatchApp Extension/WatchApp Extension.entitlements @@ -1,5 +1,12 @@ - + + com.apple.developer.healthkit + + com.apple.developer.healthkit.access + + com.apple.developer.siri + + diff --git a/WatchApp Extension/ckcomplication.xcstrings b/WatchApp Extension/ckcomplication.xcstrings new file mode 100644 index 0000000000..bbde2258f7 --- /dev/null +++ b/WatchApp Extension/ckcomplication.xcstrings @@ -0,0 +1,341 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "3MIN" : { + "comment" : "The complication template example time string", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "3MIN" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "3MIN" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "3MIN" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "3MIN" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "3MIN" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "3MIN" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "3MIN" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "3MIN" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "3MIN" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "3 min" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "3MIN" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "3МИН" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "3DK" + } + } + } + }, + "120" : { + "comment" : "The complication template example glucose string", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "120" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "6,5" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "120" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "120" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "120" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "120" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "120" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "120" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "6,7" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "120" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "120" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "120" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "120" + } + } + } + }, + "120↘︎" : { + "comment" : "The complication template example glucose and trend string", + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "120↘︎" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "6,5↘︎" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "120↘︎" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "120↘︎" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "120↘︎" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "120↘︎" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "120↘︎" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "120↘︎" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "6,7 ↘︎" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "120↘︎" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "120↘︎" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "120↘︎" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "120↘︎" + } + } + } + }, + "UtilitarianLargeFlat" : { + "comment" : "Utilitarian large flat format string (1: Glucose & Trend symbol) (2: Eventual Glucose) (3: Time)", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@%@ %@" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@%@ %@" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@%@ %@" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@%@ %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ %@ %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@%@ %@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@%@ %@" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ %@ %@" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@%@%@" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@%@ %@" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ %@ %@" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@%@ %@" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@%@ %@" + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/WatchApp Extension/it.lproj/InfoPlist.strings b/WatchApp Extension/it.lproj/InfoPlist.strings deleted file mode 100644 index 95055d6bef..0000000000 --- a/WatchApp Extension/it.lproj/InfoPlist.strings +++ /dev/null @@ -1,3 +0,0 @@ -/* (No Commment) */ -"CFBundleDisplayName" = "Estensione WatchApp"; - diff --git a/WatchApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/WatchApp/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index fb7e4851fa..0000000000 --- a/WatchApp/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "images" : [ - { - "size" : "24x24", - "idiom" : "watch", - "filename" : "watch-24@2x.png", - "scale" : "2x", - "role" : "notificationCenter", - "subtype" : "38mm" - }, - { - "size" : "27.5x27.5", - "idiom" : "watch", - "filename" : "watch-27.5@2x.png", - "scale" : "2x", - "role" : "notificationCenter", - "subtype" : "42mm" - }, - { - "size" : "29x29", - "idiom" : "watch", - "filename" : "watch-29@2x.png", - "role" : "companionSettings", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "watch", - "filename" : "watch-29@3x.png", - "role" : "companionSettings", - "scale" : "3x" - }, - { - "size" : "40x40", - "idiom" : "watch", - "filename" : "watch-40@2x.png", - "scale" : "2x", - "role" : "appLauncher", - "subtype" : "38mm" - }, - { - "size" : "86x86", - "idiom" : "watch", - "filename" : "watch-86@2x.png", - "scale" : "2x", - "role" : "quickLook", - "subtype" : "38mm" - }, - { - "size" : "98x98", - "idiom" : "watch", - "filename" : "watch-98@2x.png", - "scale" : "2x", - "role" : "quickLook", - "subtype" : "42mm" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-24@2x.png b/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-24@2x.png deleted file mode 100644 index 69885337ae..0000000000 Binary files a/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-24@2x.png and /dev/null differ diff --git a/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-27.5@2x.png b/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-27.5@2x.png deleted file mode 100644 index a9660e4a04..0000000000 Binary files a/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-27.5@2x.png and /dev/null differ diff --git a/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-29@2x.png b/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-29@2x.png deleted file mode 100644 index a933fd9d42..0000000000 Binary files a/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-29@2x.png and /dev/null differ diff --git a/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-29@3x.png b/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-29@3x.png deleted file mode 100644 index 0e006e094d..0000000000 Binary files a/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-29@3x.png and /dev/null differ diff --git a/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-40@2x.png b/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-40@2x.png deleted file mode 100644 index 45b19baed7..0000000000 Binary files a/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-40@2x.png and /dev/null differ diff --git a/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-86@2x.png b/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-86@2x.png deleted file mode 100644 index f2f80b5460..0000000000 Binary files a/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-86@2x.png and /dev/null differ diff --git a/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-98@2x.png b/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-98@2x.png deleted file mode 100644 index 2064ccdf8c..0000000000 Binary files a/WatchApp/Assets.xcassets/AppIcon.appiconset/watch-98@2x.png and /dev/null differ diff --git a/WatchApp/Assets.xcassets/bolus.imageset/Contents.json b/WatchApp/Assets.xcassets/bolus.imageset/Contents.json deleted file mode 100644 index 72322c7055..0000000000 --- a/WatchApp/Assets.xcassets/bolus.imageset/Contents.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "images" : [ - { - "idiom" : "watch", - "scale" : "2x" - }, - { - "idiom" : "watch", - "filename" : "bolus_38mm.png", - "screen-width" : "<=145", - "scale" : "2x" - }, - { - "idiom" : "watch", - "filename" : "bolus_42mm.png", - "screen-width" : ">145", - "scale" : "2x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/WatchApp/Assets.xcassets/bolus.imageset/bolus_38mm.png b/WatchApp/Assets.xcassets/bolus.imageset/bolus_38mm.png deleted file mode 100644 index 39084fb101..0000000000 Binary files a/WatchApp/Assets.xcassets/bolus.imageset/bolus_38mm.png and /dev/null differ diff --git a/WatchApp/Assets.xcassets/bolus.imageset/bolus_42mm.png b/WatchApp/Assets.xcassets/bolus.imageset/bolus_42mm.png deleted file mode 100644 index 9593428950..0000000000 Binary files a/WatchApp/Assets.xcassets/bolus.imageset/bolus_42mm.png and /dev/null differ diff --git a/WatchApp/Assets.xcassets/fork.imageset/Contents.json b/WatchApp/Assets.xcassets/fork.imageset/Contents.json deleted file mode 100644 index e24ede6f2c..0000000000 --- a/WatchApp/Assets.xcassets/fork.imageset/Contents.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "images" : [ - { - "idiom" : "watch", - "scale" : "2x" - }, - { - "idiom" : "watch", - "filename" : "fork_38.png", - "screen-width" : "<=145", - "scale" : "2x" - }, - { - "idiom" : "watch", - "filename" : "fork_42.png", - "screen-width" : ">145", - "scale" : "2x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/WatchApp/Assets.xcassets/fork.imageset/fork_38.png b/WatchApp/Assets.xcassets/fork.imageset/fork_38.png deleted file mode 100644 index 8326635151..0000000000 Binary files a/WatchApp/Assets.xcassets/fork.imageset/fork_38.png and /dev/null differ diff --git a/WatchApp/Assets.xcassets/fork.imageset/fork_42.png b/WatchApp/Assets.xcassets/fork.imageset/fork_42.png deleted file mode 100644 index 2dd03dde86..0000000000 Binary files a/WatchApp/Assets.xcassets/fork.imageset/fork_42.png and /dev/null differ diff --git a/WatchApp/Assets.xcassets/loop/Contents.json b/WatchApp/Assets.xcassets/loop/Contents.json deleted file mode 100644 index da4a164c91..0000000000 --- a/WatchApp/Assets.xcassets/loop/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/WatchApp/Assets.xcassets/loop/loop_aging.imageset/Contents.json b/WatchApp/Assets.xcassets/loop/loop_aging.imageset/Contents.json deleted file mode 100644 index cb4811aff0..0000000000 --- a/WatchApp/Assets.xcassets/loop/loop_aging.imageset/Contents.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "images" : [ - { - "idiom" : "watch", - "scale" : "2x" - }, - { - "idiom" : "watch", - "filename" : "loop-aging@38mm.png", - "screen-width" : "<=145", - "scale" : "2x" - }, - { - "idiom" : "watch", - "filename" : "loop-aging@42mm.png", - "screen-width" : ">145", - "scale" : "2x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/WatchApp/Assets.xcassets/loop/loop_aging.imageset/loop-aging@38mm.png b/WatchApp/Assets.xcassets/loop/loop_aging.imageset/loop-aging@38mm.png deleted file mode 100644 index f670d5bc20..0000000000 Binary files a/WatchApp/Assets.xcassets/loop/loop_aging.imageset/loop-aging@38mm.png and /dev/null differ diff --git a/WatchApp/Assets.xcassets/loop/loop_aging.imageset/loop-aging@42mm.png b/WatchApp/Assets.xcassets/loop/loop_aging.imageset/loop-aging@42mm.png deleted file mode 100644 index c1ba3ad82b..0000000000 Binary files a/WatchApp/Assets.xcassets/loop/loop_aging.imageset/loop-aging@42mm.png and /dev/null differ diff --git a/WatchApp/Assets.xcassets/loop/loop_fresh.imageset/Contents.json b/WatchApp/Assets.xcassets/loop/loop_fresh.imageset/Contents.json deleted file mode 100644 index 585f56d7fa..0000000000 --- a/WatchApp/Assets.xcassets/loop/loop_fresh.imageset/Contents.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "images" : [ - { - "idiom" : "watch", - "scale" : "2x" - }, - { - "idiom" : "watch", - "filename" : "loop-fresh@38mm.png", - "screen-width" : "<=145", - "scale" : "2x" - }, - { - "idiom" : "watch", - "filename" : "loop-fresh@42mm.png", - "screen-width" : ">145", - "scale" : "2x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/WatchApp/Assets.xcassets/loop/loop_fresh.imageset/loop-fresh@38mm.png b/WatchApp/Assets.xcassets/loop/loop_fresh.imageset/loop-fresh@38mm.png deleted file mode 100644 index 576184a7e1..0000000000 Binary files a/WatchApp/Assets.xcassets/loop/loop_fresh.imageset/loop-fresh@38mm.png and /dev/null differ diff --git a/WatchApp/Assets.xcassets/loop/loop_fresh.imageset/loop-fresh@42mm.png b/WatchApp/Assets.xcassets/loop/loop_fresh.imageset/loop-fresh@42mm.png deleted file mode 100644 index dd33947526..0000000000 Binary files a/WatchApp/Assets.xcassets/loop/loop_fresh.imageset/loop-fresh@42mm.png and /dev/null differ diff --git a/WatchApp/Assets.xcassets/loop/loop_stale.imageset/Contents.json b/WatchApp/Assets.xcassets/loop/loop_stale.imageset/Contents.json deleted file mode 100644 index d849fa9344..0000000000 --- a/WatchApp/Assets.xcassets/loop/loop_stale.imageset/Contents.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "images" : [ - { - "idiom" : "watch", - "scale" : "2x" - }, - { - "idiom" : "watch", - "filename" : "loop-stale@38mm.png", - "screen-width" : "<=145", - "scale" : "2x" - }, - { - "idiom" : "watch", - "filename" : "loop-stale@42mm.png", - "screen-width" : ">145", - "scale" : "2x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/WatchApp/Assets.xcassets/loop/loop_stale.imageset/loop-stale@38mm.png b/WatchApp/Assets.xcassets/loop/loop_stale.imageset/loop-stale@38mm.png deleted file mode 100644 index 2e0644cd15..0000000000 Binary files a/WatchApp/Assets.xcassets/loop/loop_stale.imageset/loop-stale@38mm.png and /dev/null differ diff --git a/WatchApp/Assets.xcassets/loop/loop_stale.imageset/loop-stale@42mm.png b/WatchApp/Assets.xcassets/loop/loop_stale.imageset/loop-stale@42mm.png deleted file mode 100644 index ac18028dab..0000000000 Binary files a/WatchApp/Assets.xcassets/loop/loop_stale.imageset/loop-stale@42mm.png and /dev/null differ diff --git a/WatchApp/Assets.xcassets/loop/loop_unknown.imageset/Contents.json b/WatchApp/Assets.xcassets/loop/loop_unknown.imageset/Contents.json deleted file mode 100644 index 91ea68fbd0..0000000000 --- a/WatchApp/Assets.xcassets/loop/loop_unknown.imageset/Contents.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "images" : [ - { - "idiom" : "watch", - "scale" : "2x" - }, - { - "idiom" : "watch", - "filename" : "loop-unknown@38mm.png", - "screen-width" : "<=145", - "scale" : "2x" - }, - { - "idiom" : "watch", - "filename" : "loop-unknown@42mm.png", - "screen-width" : ">145", - "scale" : "2x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/WatchApp/Assets.xcassets/loop/loop_unknown.imageset/loop-unknown@38mm.png b/WatchApp/Assets.xcassets/loop/loop_unknown.imageset/loop-unknown@38mm.png deleted file mode 100644 index 3e30a9160f..0000000000 Binary files a/WatchApp/Assets.xcassets/loop/loop_unknown.imageset/loop-unknown@38mm.png and /dev/null differ diff --git a/WatchApp/Assets.xcassets/loop/loop_unknown.imageset/loop-unknown@42mm.png b/WatchApp/Assets.xcassets/loop/loop_unknown.imageset/loop-unknown@42mm.png deleted file mode 100644 index a74ccb086f..0000000000 Binary files a/WatchApp/Assets.xcassets/loop/loop_unknown.imageset/loop-unknown@42mm.png and /dev/null differ diff --git a/WatchApp/Base.lproj/Interface.storyboard b/WatchApp/Base.lproj/Interface.storyboard index f58c6910b6..03cdd19a1b 100644 --- a/WatchApp/Base.lproj/Interface.storyboard +++ b/WatchApp/Base.lproj/Interface.storyboard @@ -1,244 +1,445 @@ - + + - - + + + + - - + + - + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + - + - + - + - - + - + - - - - - + - - - + + + + + + + + + + + + - + + - - - - + + + + + + + + + + + + + + + + + - + - + - + - + - + - - + - + - + - - + + + + - - - - - - - - - - - - - - - + - - - - - - + + + + + - + - - + + - + - + - + - - - - - - - - -