diff --git a/FreeAPS.xcodeproj/project.pbxproj b/FreeAPS.xcodeproj/project.pbxproj index 1d7e1a9227..388ea37f19 100644 --- a/FreeAPS.xcodeproj/project.pbxproj +++ b/FreeAPS.xcodeproj/project.pbxproj @@ -445,6 +445,16 @@ E4984C5262A90469788754BB /* PreferencesEditorProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F8BA8533F56BC55748CA877 /* PreferencesEditorProvider.swift */; }; E97285ED9B814CD5253C6658 /* AddCarbsDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F48C3AC770D4CCD0EA2B0C2 /* AddCarbsDataFlow.swift */; }; E974172296125A5AE99E634C /* PumpConfigRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AD22C985B79A2F0D2EA3D9D /* PumpConfigRootView.swift */; }; + F2159A4A2BA60A6000A0B716 /* ContactTrickDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2159A492BA60A6000A0B716 /* ContactTrickDataFlow.swift */; }; + F2159A4C2BA60A8E00A0B716 /* ContactTrickRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2159A4B2BA60A8E00A0B716 /* ContactTrickRootView.swift */; }; + F2159A4E2BA60AC000A0B716 /* ContactTrickProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2159A4D2BA60AC000A0B716 /* ContactTrickProvider.swift */; }; + F2159A502BA60AE400A0B716 /* ContactTrickStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2159A4F2BA60AE400A0B716 /* ContactTrickStateModel.swift */; }; + F2159A522BA60F7A00A0B716 /* FontWeight.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2159A512BA60F7A00A0B716 /* FontWeight.swift */; }; + F2159A542BA6207F00A0B716 /* ContactTrickEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2159A532BA6207F00A0B716 /* ContactTrickEntry.swift */; }; + F2159A572BA6239F00A0B716 /* ContactTrickManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2159A562BA6239F00A0B716 /* ContactTrickManager.swift */; }; + F2159A592BA78B7400A0B716 /* ContactTrickState.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2159A582BA78B7400A0B716 /* ContactTrickState.swift */; }; + F2159A5B2BA7939C00A0B716 /* ContactPicture.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2159A5A2BA7939C00A0B716 /* ContactPicture.swift */; }; + F270F68D2BAE374C00F6D8DD /* FontTracking.swift in Sources */ = {isa = PBXBuildFile; fileRef = F270F68C2BAE374C00F6D8DD /* FontTracking.swift */; }; F5CA3DB1F9DC8B05792BBFAA /* CGMDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9B5C0607505A38F256BF99A /* CGMDataFlow.swift */; }; F5F7E6C1B7F098F59EB67EC5 /* TargetsEditorDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA49538D56989D8DA6FCF538 /* TargetsEditorDataFlow.swift */; }; F816825E28DB441200054060 /* HeartBeatManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F816825D28DB441200054060 /* HeartBeatManager.swift */; }; @@ -1031,6 +1041,16 @@ E625985B47742D498CB1681A /* NotificationsConfigProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationsConfigProvider.swift; sourceTree = ""; }; E68CDC1E5C438D1BEAD4CF24 /* LibreConfigStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LibreConfigStateModel.swift; sourceTree = ""; }; E9AAB83FB6C3B41EFD1846A0 /* AddTempTargetRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AddTempTargetRootView.swift; sourceTree = ""; }; + F2159A492BA60A6000A0B716 /* ContactTrickDataFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactTrickDataFlow.swift; sourceTree = ""; }; + F2159A4B2BA60A8E00A0B716 /* ContactTrickRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactTrickRootView.swift; sourceTree = ""; }; + F2159A4D2BA60AC000A0B716 /* ContactTrickProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactTrickProvider.swift; sourceTree = ""; }; + F2159A4F2BA60AE400A0B716 /* ContactTrickStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactTrickStateModel.swift; sourceTree = ""; }; + F2159A512BA60F7A00A0B716 /* FontWeight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FontWeight.swift; sourceTree = ""; }; + F2159A532BA6207F00A0B716 /* ContactTrickEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactTrickEntry.swift; sourceTree = ""; }; + F2159A562BA6239F00A0B716 /* ContactTrickManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactTrickManager.swift; sourceTree = ""; }; + F2159A582BA78B7400A0B716 /* ContactTrickState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactTrickState.swift; sourceTree = ""; }; + F2159A5A2BA7939C00A0B716 /* ContactPicture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactPicture.swift; sourceTree = ""; }; + F270F68C2BAE374C00F6D8DD /* FontTracking.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FontTracking.swift; sourceTree = ""; }; F816825D28DB441200054060 /* HeartBeatManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HeartBeatManager.swift; sourceTree = ""; }; F816825F28DB441800054060 /* BluetoothTransmitter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BluetoothTransmitter.swift; sourceTree = ""; }; F90692A9274B7AAE0037068D /* HealthKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitManager.swift; sourceTree = ""; }; @@ -1341,6 +1361,7 @@ 49CA5A152BDA3815001F0D3A /* KetoProtect */, 49CA5A012BD8E459001F0D3A /* B30 */, CE1F2B982B011C58002EDCA0 /* AutoISF */, + F2159A472BA60A0300A0B716 /* ContactTrick */, 195D80B22AF696EE00D25097 /* Dynamic */, BD7DA9A32AE06DBA00601B20 /* BolusCalculatorConfig */, 190EBCC229FF134900BA767D /* StatConfig */, @@ -1480,6 +1501,7 @@ 3811DE9125C9D88200A708ED /* Services */ = { isa = PBXGroup; children = ( + F2159A552BA6238D00A0B716 /* ContactTrick */, 6B1A8D2C2B156EC100E76752 /* LiveActivity */, CEB434E128B8F9BC00B70274 /* Bluetooth */, F90692A8274B7A980037068D /* HealthKit */, @@ -1815,6 +1837,9 @@ CC41E2992B1E1F460070974F /* HistoryLayout.swift */, 19B60B772B5E7E97002F4F74 /* Threshold.swift */, 192424CA2B7A64E70063CBF0 /* NIghtscoutExercise.swift */, + F2159A512BA60F7A00A0B716 /* FontWeight.swift */, + F2159A532BA6207F00A0B716 /* ContactTrickEntry.swift */, + F270F68C2BAE374C00F6D8DD /* FontTracking.swift */, ); path = Models; sourceTree = ""; @@ -2563,6 +2588,35 @@ path = View; sourceTree = ""; }; + F2159A472BA60A0300A0B716 /* ContactTrick */ = { + isa = PBXGroup; + children = ( + F2159A482BA60A1600A0B716 /* View */, + F2159A492BA60A6000A0B716 /* ContactTrickDataFlow.swift */, + F2159A4D2BA60AC000A0B716 /* ContactTrickProvider.swift */, + F2159A4F2BA60AE400A0B716 /* ContactTrickStateModel.swift */, + ); + path = ContactTrick; + sourceTree = ""; + }; + F2159A482BA60A1600A0B716 /* View */ = { + isa = PBXGroup; + children = ( + F2159A4B2BA60A8E00A0B716 /* ContactTrickRootView.swift */, + ); + path = View; + sourceTree = ""; + }; + F2159A552BA6238D00A0B716 /* ContactTrick */ = { + isa = PBXGroup; + children = ( + F2159A562BA6239F00A0B716 /* ContactTrickManager.swift */, + F2159A582BA78B7400A0B716 /* ContactTrickState.swift */, + F2159A5A2BA7939C00A0B716 /* ContactPicture.swift */, + ); + path = ContactTrick; + sourceTree = ""; + }; F5DE2E6D7B2133BBD3353DC7 /* View */ = { isa = PBXGroup; children = ( @@ -2965,6 +3019,7 @@ 388E595C25AD948C0019842D /* FreeAPSApp.swift in Sources */, 38FEF3FC2737E53800574A46 /* MainStateModel.swift in Sources */, CECCB4262BDBDCF7006E41C4 /* carbPresetResult.swift in Sources */, + F2159A542BA6207F00A0B716 /* ContactTrickEntry.swift in Sources */, 38569348270B5DFB0002C50D /* GlucoseSource.swift in Sources */, CEB434E328B8F9DB00B70274 /* BluetoothStateManager.swift in Sources */, 3811DE4225C9D4A100A708ED /* SettingsDataFlow.swift in Sources */, @@ -3072,14 +3127,17 @@ 495068BD2BDFF1B20048FF3B /* BaseIntentsRequest.swift in Sources */, 495068BC2BDFF1B20048FF3B /* AppShortcuts.swift in Sources */, 3811DEB025C9D88300A708ED /* BaseKeychain.swift in Sources */, + F2159A5B2BA7939C00A0B716 /* ContactPicture.swift in Sources */, 3811DE4325C9D4A100A708ED /* SettingsProvider.swift in Sources */, 45252C95D220E796FDB3B022 /* ConfigEditorDataFlow.swift in Sources */, 3871F38725ED661C0013ECB5 /* Suggestion.swift in Sources */, 38C4D33A25E9A1ED00D30B77 /* NSObject+AssociatedValues.swift in Sources */, + F2159A572BA6239F00A0B716 /* ContactTrickManager.swift in Sources */, 38DF179027733EAD00B3528F /* SnowScene.swift in Sources */, 38AAF8712600C1B0004AF583 /* MainChartView.swift in Sources */, 195F00482B5C267D00DAC71A /* DescriptionView.swift in Sources */, 19DC677F29CA675700FD9EC4 /* OverrideProfilesDataFlow.swift in Sources */, + F2159A522BA60F7A00A0B716 /* FontWeight.swift in Sources */, 1935364028496F7D001E0B16 /* Oref2_variables.swift in Sources */, CE2FAD3A297D93F0001A872C /* BloodGlucoseExtensions.swift in Sources */, 38E4453A274E411700EC9A94 /* Disk+[UIImage].swift in Sources */, @@ -3118,6 +3176,7 @@ F90692D1274B99B60037068D /* HealthKitProvider.swift in Sources */, 19F95FF729F10FEE00314DDC /* StatStateModel.swift in Sources */, 385CEAC125F2EA52002D6D5B /* Announcement.swift in Sources */, + F270F68D2BAE374C00F6D8DD /* FontTracking.swift in Sources */, 8B759CFCF47B392BB365C251 /* BasalProfileEditorDataFlow.swift in Sources */, 195D80B42AF6973A00D25097 /* DynamicRootView.swift in Sources */, 389442CB25F65F7100FA1F27 /* NightscoutTreatment.swift in Sources */, @@ -3173,6 +3232,7 @@ CE7CA3562A064973004BE681 /* StateIntentRequest.swift in Sources */, E4984C5262A90469788754BB /* PreferencesEditorProvider.swift in Sources */, DD399FB31EACB9343C944C4C /* PreferencesEditorStateModel.swift in Sources */, + F2159A4E2BA60AC000A0B716 /* ContactTrickProvider.swift in Sources */, 19E1F7EA29D082ED005C8D20 /* IconConfigProvider.swift in Sources */, 44190F0BBA464D74B857D1FB /* PreferencesEditorRootView.swift in Sources */, E97285ED9B814CD5253C6658 /* AddCarbsDataFlow.swift in Sources */, @@ -3192,9 +3252,11 @@ E0D4F80527513ECF00BDF1FE /* HealthKitSample.swift in Sources */, 919DBD08F13BAFB180DF6F47 /* AddTempTargetStateModel.swift in Sources */, 49CA5A1A2BDA3873001F0D3A /* KetoProtectDataFlow.swift in Sources */, + F2159A4A2BA60A6000A0B716 /* ContactTrickDataFlow.swift in Sources */, 8BC2F5A29AD1ED08AC0EE013 /* AddTempTargetRootView.swift in Sources */, 38A00B1F25FC00F7006BC0B0 /* Autotune.swift in Sources */, 38AAF85525FFF846004AF583 /* CurrentGlucoseView.swift in Sources */, + F2159A4C2BA60A8E00A0B716 /* ContactTrickRootView.swift in Sources */, 041D1E995A6AE92E9289DC49 /* BolusDataFlow.swift in Sources */, 23888883D4EA091C88480FF2 /* BolusProvider.swift in Sources */, 38E98A2D25F52DC400C0CED0 /* NSLocking+Extensions.swift in Sources */, @@ -3220,6 +3282,7 @@ 38E4453B274E411700EC9A94 /* Disk+VolumeInformation.swift in Sources */, 7BCFACB97C821041BA43A114 /* ManualTempBasalRootView.swift in Sources */, 38E44534274E411700EC9A94 /* Disk+InternalHelpers.swift in Sources */, + F2159A592BA78B7400A0B716 /* ContactTrickState.swift in Sources */, 38A00B2325FC2B55006BC0B0 /* LRUCache.swift in Sources */, 3083261C4B268E353F36CD0B /* AutotuneConfigDataFlow.swift in Sources */, 891DECF7BC20968D7F566161 /* AutotuneConfigProvider.swift in Sources */, @@ -3235,6 +3298,7 @@ 38569349270B5DFB0002C50D /* AppGroupSource.swift in Sources */, F5CA3DB1F9DC8B05792BBFAA /* CGMDataFlow.swift in Sources */, BA00D96F7B2FF169A06FB530 /* CGMStateModel.swift in Sources */, + F2159A502BA60AE400A0B716 /* ContactTrickStateModel.swift in Sources */, 61962FCAF8A2D222553AC5A3 /* LibreConfigDataFlow.swift in Sources */, BD7DA9A52AE06DFC00601B20 /* BolusCalculatorConfigDataFlow.swift in Sources */, 6EADD581738D64431902AC0A /* LibreConfigProvider.swift in Sources */, diff --git a/FreeAPS/Resources/Info.plist b/FreeAPS/Resources/Info.plist index 3bb17a29f7..9709c39a2e 100644 --- a/FreeAPS/Resources/Info.plist +++ b/FreeAPS/Resources/Info.plist @@ -115,6 +115,12 @@ UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown + NSCalendarsFullAccessUsageDescription + To create events with BG reading values, so that they can be viewed on Apple Watch and CarPlay + NSContactsUsageDescription + To update contacts with BG reading values (contact trick) + LSApplicationCategoryType + UISupportedInterfaceOrientations~ipad UIInterfaceOrientationPortrait diff --git a/FreeAPS/Resources/json/defaults/settings/contact_trick.json b/FreeAPS/Resources/json/defaults/settings/contact_trick.json new file mode 100644 index 0000000000..fe51488c70 --- /dev/null +++ b/FreeAPS/Resources/json/defaults/settings/contact_trick.json @@ -0,0 +1 @@ +[] diff --git a/FreeAPS/Sources/APS/OpenAPS/Constants.swift b/FreeAPS/Sources/APS/OpenAPS/Constants.swift index b4df9ab0fe..137c1cac30 100644 --- a/FreeAPS/Sources/APS/OpenAPS/Constants.swift +++ b/FreeAPS/Sources/APS/OpenAPS/Constants.swift @@ -39,6 +39,7 @@ extension OpenAPS { static let carbRatios = "settings/carb_ratios.json" static let tempTargets = "settings/temptargets.json" static let model = "settings/model.json" + static let contactTrick = "settings/contact_trick.json" } enum Monitor { diff --git a/FreeAPS/Sources/Assemblies/ServiceAssembly.swift b/FreeAPS/Sources/Assemblies/ServiceAssembly.swift index f5a49697b2..bef9d8d665 100644 --- a/FreeAPS/Sources/Assemblies/ServiceAssembly.swift +++ b/FreeAPS/Sources/Assemblies/ServiceAssembly.swift @@ -20,6 +20,7 @@ final class ServiceAssembly: Assembly { container.register(UserNotificationsManager.self) { r in BaseUserNotificationsManager(resolver: r) } container.register(WatchManager.self) { r in BaseWatchManager(resolver: r) } container.register(GarminManager.self) { r in BaseGarminManager(resolver: r) } + container.register(ContactTrickManager.self) { r in BaseContactTrickManager(resolver: r) } if #available(iOS 16.2, *) { container.register(LiveActivityBridge.self) { r in diff --git a/FreeAPS/Sources/Models/ContactTrickEntry.swift b/FreeAPS/Sources/Models/ContactTrickEntry.swift new file mode 100644 index 0000000000..c2e16925e2 --- /dev/null +++ b/FreeAPS/Sources/Models/ContactTrickEntry.swift @@ -0,0 +1,83 @@ + +struct ContactTrickEntry: JSON, Equatable { + var enabled: Bool = false + var layout: ContactTrickLayout = .single + var ring1: ContactTrickLargeRing = .none + var primary: ContactTrickValue = .glucose + var top: ContactTrickValue = .none + var bottom: ContactTrickValue = .none + var contactId: String? = nil + var displayName: String? = nil + var darkMode: Bool = true + var ringWidth: Int = 7 + var ringGap: Int = 2 + var fontSize: Int = 100 + var fontName: String = "Default Font" + var fontWeight: FontWeight = .medium + var fontTracking: FontTracking = .normal + + func isDefaultFont() -> Bool { + fontName == "Default Font" + } +} + +protocol ContactTrickObserver { + func basalProfileDidChange(_ entry: [ContactTrickEntry]) +} + +extension ContactTrickEntry { + private enum CodingKeys: String, CodingKey { + case enabled + case layout + case ring1 + case primary + case top + case bottom + case contactId + case displayName + case darkMode + case ringWidth + case ringGap + case fontSize + case fontName + case fontWeight + case fontTracking + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let enabled = try container.decodeIfPresent(Bool.self, forKey: .enabled) ?? false + let layout = try container.decodeIfPresent(ContactTrickLayout.self, forKey: .layout) ?? .single + let ring1 = try container.decodeIfPresent(ContactTrickLargeRing.self, forKey: .ring1) ?? .none + let primary = try container.decodeIfPresent(ContactTrickValue.self, forKey: .primary) ?? .glucose + let top = try container.decodeIfPresent(ContactTrickValue.self, forKey: .top) ?? .none + let bottom = try container.decodeIfPresent(ContactTrickValue.self, forKey: .bottom) ?? .none + let contactId = try container.decodeIfPresent(String.self, forKey: .contactId) + let displayName = try container.decodeIfPresent(String.self, forKey: .displayName) + let darkMode = try container.decodeIfPresent(Bool.self, forKey: .darkMode) ?? true + let ringWidth = try container.decodeIfPresent(Int.self, forKey: .ringWidth) ?? 7 + let ringGap = try container.decodeIfPresent(Int.self, forKey: .ringGap) ?? 2 + let fontSize = try container.decodeIfPresent(Int.self, forKey: .fontSize) ?? 100 + let fontName = try container.decodeIfPresent(String.self, forKey: .fontName) ?? "Default Font" + let fontWeight = try container.decodeIfPresent(FontWeight.self, forKey: .fontWeight) ?? .regular + let fontTracking = try container.decodeIfPresent(FontTracking.self, forKey: .fontTracking) ?? .normal + + self = ContactTrickEntry( + enabled: enabled, + layout: layout, + ring1: ring1, + primary: primary, + top: top, + bottom: bottom, + contactId: contactId, + displayName: displayName, + darkMode: darkMode, + ringWidth: ringWidth, + ringGap: ringGap, + fontSize: fontSize, + fontName: fontName, + fontWeight: fontWeight, + fontTracking: fontTracking + ) + } +} diff --git a/FreeAPS/Sources/Models/FontTracking.swift b/FreeAPS/Sources/Models/FontTracking.swift new file mode 100644 index 0000000000..99a50546d1 --- /dev/null +++ b/FreeAPS/Sources/Models/FontTracking.swift @@ -0,0 +1,32 @@ +import Foundation + +enum FontTracking: String, JSON, Identifiable, CaseIterable, Codable { + var id: String { rawValue } + + case tighter + case tight + case normal + case wide + + var displayName: String { + switch self { + case .tighter: + NSLocalizedString("Tighter", comment: "") + case .tight: + NSLocalizedString("Tight", comment: "") + case .normal: + NSLocalizedString("Normal", comment: "") + case .wide: + NSLocalizedString("Wide", comment: "") + } + } + + var value: Double { + switch self { + case .tighter: -0.05 + case .tight: -0.025 + case .normal: 0 + case .wide: 0.05 + } + } +} diff --git a/FreeAPS/Sources/Models/FontWeight.swift b/FreeAPS/Sources/Models/FontWeight.swift new file mode 100644 index 0000000000..671d7d92f3 --- /dev/null +++ b/FreeAPS/Sources/Models/FontWeight.swift @@ -0,0 +1,29 @@ +import Foundation + +enum FontWeight: String, JSON, Identifiable, CaseIterable, Codable { + var id: String { rawValue } + + case light + case regular + case medium + case semibold + case bold + case black + + var displayName: String { + switch self { + case .light: + return NSLocalizedString("Light", comment: "") + case .regular: + return NSLocalizedString("Regular", comment: "") + case .medium: + return NSLocalizedString("Medium", comment: "") + case .semibold: + return NSLocalizedString("Semibold", comment: "") + case .bold: + return NSLocalizedString("Bold", comment: "") + case .black: + return NSLocalizedString("Black", comment: "") + } + } +} diff --git a/FreeAPS/Sources/Modules/Base/BaseProvider.swift b/FreeAPS/Sources/Modules/Base/BaseProvider.swift index deb4bc5424..9f85cbb33c 100644 --- a/FreeAPS/Sources/Modules/Base/BaseProvider.swift +++ b/FreeAPS/Sources/Modules/Base/BaseProvider.swift @@ -11,6 +11,7 @@ class BaseProvider: Provider, Injectable { @Injected() var deviceManager: DeviceDataManager! @Injected() var storage: FileStorage! @Injected() var bluetoothProvider: BluetoothStateManager! + @Injected() var contactTrickManager: ContactTrickManager! required init(resolver: Resolver) { injectServices(resolver) diff --git a/FreeAPS/Sources/Modules/ContactTrick/ContactTrickDataFlow.swift b/FreeAPS/Sources/Modules/ContactTrick/ContactTrickDataFlow.swift new file mode 100644 index 0000000000..4c3cf67fb3 --- /dev/null +++ b/FreeAPS/Sources/Modules/ContactTrick/ContactTrickDataFlow.swift @@ -0,0 +1,30 @@ +import Combine +import Foundation + +enum ContactTrick { + enum Config {} + + class Item: Identifiable, Hashable, Equatable { + let id = UUID() + var index: Int = 0 + var entry: ContactTrickEntry + + init(index: Int, entry: ContactTrickEntry) { + self.index = index + self.entry = entry + } + + static func == (lhs: Item, rhs: Item) -> Bool { + lhs.index == rhs.index + } + + func hash(into hasher: inout Hasher) { + hasher.combine(index) + } + } +} + +protocol ContactTrickProvider: Provider { + var contacts: [ContactTrickEntry] { get } + func saveContacts(_ contacts: [ContactTrickEntry]) -> AnyPublisher +} diff --git a/FreeAPS/Sources/Modules/ContactTrick/ContactTrickProvider.swift b/FreeAPS/Sources/Modules/ContactTrick/ContactTrickProvider.swift new file mode 100644 index 0000000000..a9f08f6dde --- /dev/null +++ b/FreeAPS/Sources/Modules/ContactTrick/ContactTrickProvider.swift @@ -0,0 +1,28 @@ +import Combine +import Foundation + +extension ContactTrick { + final class Provider: BaseProvider, ContactTrickProvider { + private let processQueue = DispatchQueue(label: "ContactTrickProvider.processQueue") + + var contacts: [ContactTrickEntry] { + storage.retrieve(OpenAPS.Settings.contactTrick, as: [ContactTrickEntry].self) + ?? [ContactTrickEntry](from: OpenAPS.defaults(for: OpenAPS.Settings.contactTrick)) + ?? [] + } + + func saveContacts(_ contacts: [ContactTrickEntry]) -> AnyPublisher { + Future { promise in + self.storage.save(contacts, as: OpenAPS.Settings.contactTrick) + self.contactTrickManager.updateContacts(contacts: contacts) { result in + switch result { + case .success: + promise(.success(())) + case let .failure(error): + promise(.failure(error)) + } + } + }.eraseToAnyPublisher() + } + } +} diff --git a/FreeAPS/Sources/Modules/ContactTrick/ContactTrickStateModel.swift b/FreeAPS/Sources/Modules/ContactTrick/ContactTrickStateModel.swift new file mode 100644 index 0000000000..67d6039935 --- /dev/null +++ b/FreeAPS/Sources/Modules/ContactTrick/ContactTrickStateModel.swift @@ -0,0 +1,125 @@ +import ConnectIQ +import SwiftUI + +enum ContactTrickValue: String, JSON, CaseIterable, Identifiable, Codable { + var id: String { rawValue } + case none + case glucose + case eventualBG + case delta + case trend + case lastLoopDate + case cob + case iob + case ring + + var displayName: String { + switch self { + case .none: + return NSLocalizedString("None", comment: "") + case .glucose: + return NSLocalizedString("Glucose", comment: "") + case .eventualBG: + return NSLocalizedString("Eventual BG", comment: "") + case .delta: + return NSLocalizedString("Delta", comment: "") + case .trend: + return NSLocalizedString("Trend", comment: "") + case .lastLoopDate: + return NSLocalizedString("Last loop date", comment: "") + case .cob: + return NSLocalizedString("COB", comment: "") + case .iob: + return NSLocalizedString("IOB", comment: "") + case .ring: + return NSLocalizedString("Loop status", comment: "") + } + } +} + +enum ContactTrickLayout: String, JSON, CaseIterable, Identifiable, Codable { + var id: String { rawValue } + case single + case split + + var displayName: String { + switch self { + case .single: + return NSLocalizedString("Single", comment: "") + case .split: + return NSLocalizedString("Split", comment: "") + } + } +} + +enum ContactTrickLargeRing: String, JSON, CaseIterable, Identifiable, Codable { + var id: String { rawValue } + case none + case loop + case iob + case cob + case iobcob + + var displayName: String { + switch self { + case .none: + return NSLocalizedString("Don't show", comment: "") + case .loop: + return NSLocalizedString("Loop status", comment: "") + case .iob: + return NSLocalizedString("IOB", comment: "") + case .cob: + return NSLocalizedString("COB", comment: "") + case .iobcob: + return NSLocalizedString("IOB+COB", comment: "") + } + } +} + +extension ContactTrick { + final class StateModel: BaseStateModel { + @Published var syncInProgress = false + @Published var items: [Item] = [] + + override func subscribe() { + items = provider.contacts.enumerated().map { index, contact in + Item( + index: index, + entry: contact + ) + } + } + + func add() { + let newItem = Item( + index: items.count, + entry: ContactTrickEntry( + enabled: false, + layout: .single, + contactId: nil, + displayName: nil, + darkMode: true, + fontSize: 100, + fontName: "Default Font", + fontWeight: .medium + ) + ) + + items.append(newItem) + } + + func save() { + syncInProgress = true + let contacts = items.map { item -> ContactTrickEntry in + item.entry + } + provider.saveContacts(contacts) + .receive(on: DispatchQueue.main) + .sink { _ in + print("saved!") + self.syncInProgress = false + } receiveValue: {} + .store(in: &lifetime) + } + } +} diff --git a/FreeAPS/Sources/Modules/ContactTrick/View/ContactTrickRootView.swift b/FreeAPS/Sources/Modules/ContactTrick/View/ContactTrickRootView.swift new file mode 100644 index 0000000000..d8ce5483d5 --- /dev/null +++ b/FreeAPS/Sources/Modules/ContactTrick/View/ContactTrickRootView.swift @@ -0,0 +1,345 @@ +import Contacts +import ContactsUI +import SwiftUI +import Swinject + +extension ContactTrick { + struct RootView: BaseView { + let resolver: Resolver + @StateObject var state = StateModel() + + @State private var contactStore = CNContactStore() + @State private var authorization = CNContactStore.authorizationStatus(for: .contacts) + + var body: some View { + Form { + switch authorization { + case .authorized: + Section(header: Text("Contacts")) { + list + addButton + } + Section { + HStack { + if state.syncInProgress { + ProgressView().padding(.trailing, 10) + } + Button { state.save() } + label: { + Text(state.syncInProgress ? "Saving..." : "Save") + } + .disabled(state.syncInProgress || state.items.isEmpty) + } + } + + case .notDetermined: + Section { + Text( + "Need to ask for contacts access" + ) + } + Section { + Button(action: onRequestContactsAccess) { + Text("Grant access to contacts") + } + } + + case .denied: + Section { + Text( + "Contacts access denied" + ) + } + + case .restricted: + Section { + Text( + "Contacts access - restricted (parental control?)" + ) + } + + @unknown default: + Section { + Text( + "Contacts access - unknown" + ) + } + } + } + .dynamicTypeSize(...DynamicTypeSize.xxLarge) + .onAppear(perform: configureView) + .navigationTitle("Contact Trick") + .navigationBarTitleDisplayMode(.automatic) + .navigationBarItems( + trailing: EditButton() + ) + } + + private func contactSettings(for index: Int) -> some View { + EntryView(entry: $state.items[index].entry) + } + + private var list: some View { + List { + ForEach(state.items.indexed(), id: \.1.id) { index, _ in + NavigationLink(destination: contactSettings(for: index)) { + HStack { + Text( + state.items[index].entry.displayName ?? "Contact not selected" + ) + .font(.body) + .minimumScaleFactor(0.5) + .lineLimit(1) + + Spacer() + + Text( + state.items[index].entry.primary.displayName + ) + .foregroundColor(.accentColor) + } + } + .moveDisabled(true) + } + .onDelete(perform: onDelete) + } + } + + private var addButton: some View { + AnyView(Button(action: onAdd) { Text("Add") }) + } + + func onAdd() { + state.add() + } + + func onRequestContactsAccess() { + contactStore.requestAccess(for: .contacts) { _, _ in + DispatchQueue.main.async { + authorization = CNContactStore.authorizationStatus(for: .contacts) + } + } + } + + private func onDelete(offsets: IndexSet) { + state.items.remove(atOffsets: offsets) + } + } + + struct EntryView: View { + @Binding var entry: ContactTrickEntry + @State private var showContactPicker = false + @State private var availableFonts: [String]? = nil + + private let fontSizes: [Int] = [70, 80, 90, 100, 110, 120, 130, 140, 150] + private let ringWidths: [Int] = [5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] + private let ringGaps: [Int] = [0, 1, 2, 3, 4, 5] + + var body: some View { + Form { + Section { + if let displayName = entry.displayName { + Text(displayName) + } + Button(entry.contactId == nil ? "Select contact" : "Change contact") { + showContactPicker = true + } + } + Section { + Toggle("Enabled", isOn: $entry.enabled) + Picker( + selection: $entry.layout, + label: Text("Layout") + ) { + ForEach(ContactTrickLayout.allCases) { v in + Text(v.displayName).tag(v) + } + } + } + Section { + switch entry.layout { + case .single: + Picker( + selection: $entry.primary, + label: Text("Primary") + ) { + ForEach(ContactTrickValue.allCases) { v in + Text(v.displayName).tag(v) + } + } + Picker( + selection: $entry.top, + label: Text("Top") + ) { + ForEach(ContactTrickValue.allCases) { v in + Text(v.displayName).tag(v) + } + } + Picker( + selection: $entry.bottom, + label: Text("Bottom") + ) { + ForEach(ContactTrickValue.allCases) { v in + Text(v.displayName).tag(v) + } + } + case .split: + Picker( + selection: $entry.top, + label: Text("Top") + ) { + ForEach(ContactTrickValue.allCases) { v in + Text(v.displayName).tag(v) + } + } + Picker( + selection: $entry.bottom, + label: Text("Bottom") + ) { + ForEach(ContactTrickValue.allCases) { v in + Text(v.displayName).tag(v) + } + } + } + } + + Section(header: Text("Ring")) { + Picker( + selection: $entry.ring1, + label: Text("Outer") + ) { + ForEach(ContactTrickLargeRing.allCases) { v in + Text(v.displayName).tag(v) + } + } + Picker( + selection: $entry.ringWidth, + label: Text("Width") + ) { + ForEach(ringWidths, id: \.self) { s in + Text("\(s)").tag(s) + } + } + Picker( + selection: $entry.ringGap, + label: Text("Gap") + ) { + ForEach(ringGaps, id: \.self) { s in + Text("\(s)").tag(s) + } + } + } + + Section(header: Text("Font")) { + if availableFonts == nil { + HStack { + Spacer() + Button { + loadFonts() + } label: { + Text(entry.fontName) + } + } + } else { + Picker( + selection: $entry.fontName, + label: EmptyView() + ) { + ForEach(availableFonts!, id: \.self) { f in + Text(f).tag(f) + } + } + .pickerStyle(.navigationLink) + .labelsHidden() + } + Picker( + selection: $entry.fontSize, + label: Text("Size") + ) { + ForEach(fontSizes, id: \.self) { s in + Text("\(s)").tag(s) + } + } + Picker( + selection: $entry.fontTracking, + label: Text("Tracking") + ) { + ForEach(FontTracking.allCases) { w in + Text(w.displayName).tag(w) + } + } + if entry.isDefaultFont() { + Picker( + selection: $entry.fontWeight, + label: Text("Weight") + ) { + ForEach(FontWeight.allCases) { w in + Text(w.displayName).tag(w) + } + } + } + } + Section { + Toggle("Dark mode", isOn: $entry.darkMode) + } + } +// .navigationTitle(entry.displayName ?? "Contact not selected") + .fullScreenCover(isPresented: $showContactPicker) { + ContactPicker(entry: $entry) + } + } + + private func loadFonts() { + if availableFonts != nil { + return + } + var data = [String]() + + data.append("Default Font") + UIFont.familyNames.forEach { family in + UIFont.fontNames(forFamilyName: family).forEach { font in + data.append(font) + } + } + availableFonts = data + } + } + + struct ContactPicker: UIViewControllerRepresentable { + @Binding var entry: ContactTrickEntry + + func makeUIViewController(context: Context) -> CNContactPickerViewController { + let picker = CNContactPickerViewController() + picker.delegate = context.coordinator + return picker + } + + func updateUIViewController(_: CNContactPickerViewController, context _: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, CNContactPickerDelegate { + var parent: ContactPicker + + init(_ parent: ContactPicker) { + self.parent = parent + } + + func contactPicker(_: CNContactPickerViewController, didSelect contact: CNContact) { + parent.entry.contactId = contact.identifier + let display = if let emailAddress = contact.emailAddresses.first { + "\(emailAddress.value)" + } else { + "\(contact.familyName) \(contact.givenName))" + } + if display.isEmpty { + parent.entry.displayName = "Unnamed contact" + } else { + parent.entry.displayName = display + } + } + } + } +} diff --git a/FreeAPS/Sources/Modules/Settings/View/SettingsRootView.swift b/FreeAPS/Sources/Modules/Settings/View/SettingsRootView.swift index 2037a5b33e..4e5852b835 100644 --- a/FreeAPS/Sources/Modules/Settings/View/SettingsRootView.swift +++ b/FreeAPS/Sources/Modules/Settings/View/SettingsRootView.swift @@ -71,6 +71,7 @@ extension Settings { Text("Middleware") .navigationLink(to: .configEditor(file: OpenAPS.Middleware.determineBasal), from: self) Text("Notifications").navigationLink(to: .notificationsConfig, from: self) + Text("Contact trick").navigationLink(to: .contactTrick, from: self) Text("App Icons").navigationLink(to: .iconConfig, from: self) } header: { Text("Features") } diff --git a/FreeAPS/Sources/Router/Screen.swift b/FreeAPS/Sources/Router/Screen.swift index bd6f3b46b3..3c8fcba824 100644 --- a/FreeAPS/Sources/Router/Screen.swift +++ b/FreeAPS/Sources/Router/Screen.swift @@ -39,6 +39,7 @@ enum Screen: Identifiable, Hashable { case B30Conf case KetoConfig + case contactTrick var id: Int { String(reflecting: self).hashValue } } @@ -117,6 +118,8 @@ extension Screen { AIMIB30Conf.RootView(resolver: resolver) case .KetoConfig: KetoConf.RootView(resolver: resolver) + case .contactTrick: + ContactTrick.RootView(resolver: resolver) } } diff --git a/FreeAPS/Sources/Services/ContactTrick/ContactPicture.swift b/FreeAPS/Sources/Services/ContactTrick/ContactPicture.swift new file mode 100644 index 0000000000..1f988fa5b2 --- /dev/null +++ b/FreeAPS/Sources/Services/ContactTrick/ContactPicture.swift @@ -0,0 +1,906 @@ +import Foundation +import SwiftUI + +struct ContactPicture: View { + private enum Config { + static let lag: TimeInterval = 30 + } + + @Binding var contact: ContactTrickEntry + @Binding var state: ContactTrickState + + private static let formatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + return formatter + }() + + private static let numberFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.decimalSeparator = "." + return formatter + }() + + static func getImage( + contact: ContactTrickEntry, + state: ContactTrickState + ) -> UIImage { + let width = 256.0 + let height = 256.0 + var rect = CGRect(x: 0, y: 0, width: width, height: height) + let textColor: Color = contact.darkMode ? + Color(red: 250 / 256, green: 250 / 256, blue: 250 / 256) : + Color(red: 20 / 256, green: 20 / 256, blue: 20 / 256) + let secondaryTextColor: Color = contact.darkMode ? + Color(red: 220 / 256, green: 220 / 256, blue: 220 / 256) : + Color(red: 40 / 256, green: 40 / 256, blue: 40 / 256) + let fontWeight = contact.fontWeight.toUI() + + UIGraphicsBeginImageContext(rect.size) + + let ringWidth = Double(contact.ringWidth) / 100.0 + let ringGap = Double(contact.ringGap) / 100.0 + + rect = CGRect( + x: rect.minX + width * ringGap, + y: rect.minY + height * ringGap, + width: rect.width - width * ringGap * 2, + height: rect.height - height * ringGap * 2 + ) + + if contact.ring1 != .none { + rect = CGRect( + x: rect.minX + width * ringGap, + y: rect.minY + height * ringGap, + width: rect.width - width * ringGap * 2, + height: rect.height - height * ringGap * 2 + ) + + let ringRect = CGRect( + x: rect.minX + width * ringGap, + y: rect.minY + height * ringGap, + width: rect.width - width * ringGap * 2, + height: rect.height - width * ringGap * 2 + ) + drawRing(ring: contact.ring1, contact: contact, state: state, rect: ringRect, strokeWidth: width * ringWidth) + } + + if contact.ring1 != .none { + rect = CGRect( + x: rect.minX + width * (ringWidth + ringGap), + y: rect.minY + height * (ringWidth + ringGap), + width: rect.width - width * (ringWidth + ringGap) * 2, + height: rect.height - height * (ringWidth + ringGap) * 2 + ) + } + + switch contact.layout { + case .single: + let showTop = contact.top != .none + let showBottom = contact.bottom != .none + + let centerX = rect.minX + rect.width / 2 + let centerY = rect.minY + rect.height / 2 + let radius = min(rect.width, rect.height) / 2 + + let primaryHeight = radius * 0.8 + let topHeight = radius * 0.5 + var bottomHeight = radius * 0.5 + + var primaryY = centerY - primaryHeight / 2 + + if contact.bottom == .none, contact.top != .none { + primaryY += radius * 0.2 + } + if contact.bottom != .none, contact.top == .none { + primaryY -= radius * 0.2 + } + + let topY = primaryY - topHeight + let bottomY = primaryY + primaryHeight + + let primaryWidth = 2 * sqrt(radius * radius - (primaryHeight / 2) * (primaryHeight / 2)) + + let topWidth = 2 * + sqrt(radius * radius - (topHeight + primaryHeight / 2) * (topHeight + primaryHeight / 2)) + var bottomWidth = 2 * + sqrt(radius * radius - (bottomHeight + primaryHeight / 2) * (bottomHeight + primaryHeight / 2)) + + if contact.bottom != .none, contact.top == .none, + contact.ring1 == .iob || contact.ring1 == .cob || contact.ring1 == .iobcob + { + bottomWidth = bottomWidth + width * ringWidth * 2 + bottomHeight = bottomHeight + height * ringWidth * 2 + } + + let primaryRect = (showTop || showBottom) ? CGRect( + x: centerX - primaryWidth / 2, + y: primaryY, + width: primaryWidth, + height: primaryHeight + ) : rect + let topRect = CGRect( + x: centerX - topWidth / 2, + y: topY, + width: topWidth, + height: topHeight + ) + let bottomRect = CGRect( + x: centerX - bottomWidth / 2, + y: bottomY, + width: bottomWidth, + height: bottomHeight + ) + let secondaryFontSize = Int(Double(contact.fontSize) * 0.90) + + displayPiece( + value: contact.primary, + contact: contact, + state: state, + rect: primaryRect, + fitHeigh: false, + fontName: contact.fontName, + fontSize: contact.fontSize, + fontWeight: fontWeight, + fontTracking: contact.fontTracking, + color: textColor + ) + if showTop { + displayPiece( + value: contact.top, + contact: contact, + state: state, + rect: topRect, + fitHeigh: true, + fontName: contact.fontName, + fontSize: secondaryFontSize, + fontWeight: fontWeight, + fontTracking: contact.fontTracking, + color: secondaryTextColor + ) + } + if showBottom { + displayPiece( + value: contact.bottom, + contact: contact, + state: state, + rect: bottomRect, + fitHeigh: true, + fontName: contact.fontName, + fontSize: secondaryFontSize, + fontWeight: fontWeight, + fontTracking: contact.fontTracking, + color: secondaryTextColor + ) + } + + case .split: + let centerX = rect.origin.x + rect.size.width / 2 + let centerY = rect.origin.y + rect.size.height / 2 + let radius = min(rect.size.width, rect.size.height) / 2 + + let rectangleHeight = radius * sqrt(2) / 2 + let rectangleWidth = sqrt(2) * radius + + let topY = centerY - rectangleHeight + let bottomY = centerY + + let topRect = CGRect( + x: centerX - rectangleWidth / 2, + y: topY, + width: rectangleWidth, + height: rectangleHeight + ) + let bottomRect = CGRect( + x: centerX - rectangleWidth / 2, + y: bottomY, + width: rectangleWidth, + height: rectangleHeight + ) + let splitFontSize = Int(Double(contact.fontSize) * 0.80) + + displayPiece( + value: contact.top, + contact: contact, + state: state, + rect: topRect, + fitHeigh: true, + fontName: contact.fontName, + fontSize: splitFontSize, + fontWeight: fontWeight, + fontTracking: contact.fontTracking, + color: textColor + ) + displayPiece( + value: contact.bottom, + contact: contact, + state: state, + rect: bottomRect, + fitHeigh: true, + fontName: contact.fontName, + fontSize: splitFontSize, + fontWeight: fontWeight, + fontTracking: contact.fontTracking, + color: textColor + ) + } + let image = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + return image ?? UIImage() + } + + private static func displayPiece( + value: ContactTrickValue, + contact: ContactTrickEntry, + state: ContactTrickState, + rect: CGRect, + fitHeigh: Bool, + fontName: String?, + fontSize: Int, + fontWeight: UIFont.Weight, + fontTracking: FontTracking, + color: Color + ) { +// guard let context = UIGraphicsGetCurrentContext() else { +// return +// } +// +// // Set the fill color (optional) +// context.setFillColor(UIColor.red.cgColor) +// +// // Set the stroke color (optional) +// context.setStrokeColor(UIColor.black.cgColor) +// +// // Set the line width (optional) +// context.setLineWidth(2.0) +// +// // Create a rectangle +// let rectangle = CGRect(x: 50, y: 50, width: 100, height: 100) +// +// // Fill the rectangle (if fill color is set) +// context.fill(rect) +// +// // Stroke the rectangle (if stroke color is set) +// context.stroke(rect) + + guard value != .none else { return } + if value == .ring { + drawRing( + ring: .loop, + contact: contact, + state: state, + rect: CGRect( + x: rect.minX + rect.width * 0.10, + y: rect.minY + rect.height * 0.10, + width: rect.width * 0.80, + height: rect.height * 0.80 + ), + strokeWidth: 10.0 + ) + return + } + let text: String? = switch value { + case .glucose: state.glucose + case .eventualBG: state.eventualBG + case .delta: state.delta + case .trend: state.trend + case .lastLoopDate: state.lastLoopDate.map({ formatter.string(from: $0) }) + case .cob: state.cobText + case .iob: state.iobText + default: nil + } + + if let text = text { + drawText( + text: text, + rect: rect, + fitHeigh: fitHeigh, + fontName: fontName, + fontSize: fontSize, + fontWeight: fontWeight, + fontTracking: fontTracking, + color: color + ) + } + } + + private static func drawText( + text: String, + rect: CGRect, + fitHeigh: Bool, + fontName: String?, + fontSize: Int, + fontWeight: UIFont.Weight, + fontTracking: FontTracking, + color: Color + ) { + var theFontSize = fontSize + + func makeAttributes(_ size: Int) -> [NSAttributedString.Key: Any] { + let font = if let fontName { + UIFont(name: fontName, size: CGFloat(size)) ?? UIFont.systemFont(ofSize: CGFloat(size)) + } else { + UIFont.systemFont(ofSize: CGFloat(size), weight: fontWeight) + } + return [ + .font: font, + .foregroundColor: UIColor(color), + .tracking: fontTracking.value + ] + } + + var attributes: [NSAttributedString.Key: Any] = makeAttributes(theFontSize) + + var stringSize = text.size(withAttributes: attributes) + while stringSize.width > rect.width * 0.90 || fitHeigh && (stringSize.height > rect.height * 0.95), theFontSize > 50 { + theFontSize = theFontSize - 10 + attributes = makeAttributes(theFontSize) + stringSize = text.size(withAttributes: attributes) + } + + text.draw( + in: CGRectMake( + rect.minX + (rect.width - stringSize.width) / 2, + rect.minY + (rect.height - stringSize.height) / 2, + rect.minX + stringSize.width, + rect.minY + stringSize.height + ), + withAttributes: attributes + ) + } + + private static func drawRing( + ring: ContactTrickLargeRing, + contact: ContactTrickEntry, + state: ContactTrickState, + rect: CGRect, + strokeWidth: Double + ) { + guard let context = UIGraphicsGetCurrentContext() else { + return + } + switch ring { + case .loop: + let color = ringColor(contact: contact, state: state) + + let strokeWidth = strokeWidth + let center = CGPoint(x: rect.midX, y: rect.midY) + let radius = min(rect.width, rect.height) / 2 - strokeWidth / 2 + + context.setLineWidth(strokeWidth) + context.setStrokeColor(UIColor(color).cgColor) + + context.addArc(center: center, radius: radius, startAngle: 0, endAngle: 2 * .pi, clockwise: false) + + context.strokePath() + case .iob: + if let iob = state.iob { + drawProgressBar( + rect: rect, + progress: Double(iob) / Double(state.maxIOB), + colors: [contact.darkMode ? .blue : .blue, contact.darkMode ? .pink : .red], + strokeWidth: strokeWidth + ) + } + case .cob: + if let cob = state.cob { + drawProgressBar( + rect: rect, + progress: Double(cob) / Double(state.maxCOB), + colors: [contact.darkMode ? .green : .green, contact.darkMode ? .pink : .red], + strokeWidth: strokeWidth + ) + } + case .iobcob: + drawDoubleProgressBar( + rect: rect, + progress1: state.iob.map { Double($0) / Double(state.maxIOB) }, + progress2: state.cob.map { Double($0) / Double(state.maxCOB) }, + colors1: [contact.darkMode ? .blue : .blue, contact.darkMode ? .pink : .red], + colors2: [contact.darkMode ? .green : .green, contact.darkMode ? .pink : .red], + strokeWidth: strokeWidth + ) + default: + break + } + } + + private static func drawProgressBar( + rect: CGRect, + progress: Double, + colors: [Color], + strokeWidth: Double + ) { + let startAngle: CGFloat = -(.pi + .pi / 4.0) + let endAngle: CGFloat = .pi / 4.0 + + drawGradientArc( + rect: rect, + progress: progress, + colors: colors, + strokeWidth: strokeWidth, + startAngle: startAngle, + endAngle: endAngle, + gradientDirection: .leftToRight + ) + } + + private static func drawDoubleProgressBar( + rect: CGRect, + progress1: Double?, + progress2: Double?, + colors1: [Color], + colors2: [Color], + strokeWidth: Double + ) { + if let progress1 = progress1 { + let startAngle1: CGFloat = .pi / 2 + .pi / 5 + let endAngle1: CGFloat = 3 * .pi / 2 - .pi / 5 + drawGradientArc( + rect: rect, + progress: progress1, + colors: colors1, + strokeWidth: strokeWidth, + startAngle: startAngle1, + endAngle: endAngle1, + gradientDirection: .bottomToTop + ) + } + if let progress2 = progress2 { + let startAngle2: CGFloat = .pi / 2 - .pi / 5 + let endAngle2: CGFloat = -.pi / 2 + .pi / 5 + drawGradientArc( + rect: rect, + progress: progress2, + colors: colors2, + strokeWidth: strokeWidth, + startAngle: startAngle2, + endAngle: endAngle2, + gradientDirection: .bottomToTop + ) + } + } + + private static func drawGradientArc( + rect: CGRect, + progress: Double, + colors: [Color], + strokeWidth: Double, + startAngle: Double, + endAngle: Double, + gradientDirection: GradientDirection + ) { + guard let context = UIGraphicsGetCurrentContext() else { + return + } + + let colors = colors.map { c in UIColor(c).cgColor } + let locations: [CGFloat] = [0.0, 1.0] + guard let gradient = CGGradient( + colorsSpace: CGColorSpaceCreateDeviceRGB(), + colors: colors as CFArray, + locations: locations + ) else { + return + } + + context.saveGState() + + let center = CGPoint(x: rect.midX, y: rect.midY) + let radius = min(rect.width, rect.height) / 2 - strokeWidth / 2 + + // angle - The angle to the starting point of the arc, measured in radians from the positive x-axis. + + context.setLineWidth(strokeWidth) + context.setLineCap(.round) + + let circumference = 2 * .pi * radius + let offsetAngle = (strokeWidth / circumference * 1.1) * 2 * .pi + + let (start, middle, end) = if startAngle > endAngle { + ( + endAngle, + startAngle - (startAngle - endAngle) * max(min(progress, 1.0), 0.0), + startAngle + ) + } else { + ( + startAngle, + startAngle + (endAngle - startAngle) * max(min(progress, 1.0), 0.0), + endAngle + ) + } + + if start < middle - offsetAngle { + let arcPath1 = UIBezierPath() + arcPath1.addArc( + withCenter: center, + radius: radius, + startAngle: start, + endAngle: middle - offsetAngle, + clockwise: true + ) + context.addPath(arcPath1.cgPath) + } + + if middle + offsetAngle < end { + let arcPath2 = UIBezierPath() + arcPath2.addArc( + withCenter: center, + radius: radius, + startAngle: middle + offsetAngle, + endAngle: end, + clockwise: true + ) + context.addPath(arcPath2.cgPath) + } + + context.replacePathWithStrokedPath() + context.clip() + + switch gradientDirection { + case .bottomToTop: + context.drawLinearGradient( + gradient, + start: CGPoint(x: rect.midX, y: rect.maxY), + end: CGPoint(x: rect.midX, y: rect.minY), + options: [] + ) + + case .leftToRight: + context.drawLinearGradient( + gradient, + start: CGPoint(x: rect.minX, y: rect.midY), + end: CGPoint(x: rect.maxX, y: rect.midY), + options: [] + ) + } + context.resetClip() + + let circleCenter = CGPoint( + x: center.x + radius * cos(middle), + y: center.y + radius * sin(middle) + ) + + context.setLineWidth(strokeWidth * 0.7) + context.setStrokeColor(UIColor.white.cgColor) + context.addArc( + center: circleCenter, + radius: 0, + startAngle: 0, + endAngle: .pi * 2, + clockwise: true + ) + context.strokePath() + + context.restoreGState() + } + + private static func ringColor( + contact _: ContactTrickEntry, + state: ContactTrickState + ) -> Color { + guard let lastLoopDate = state.lastLoopDate else { + return .loopGray + } + let delta = Date().timeIntervalSince(lastLoopDate) - Config.lag + + if delta <= 5.minutes.timeInterval { + return .loopGreen + } else if delta <= 10.minutes.timeInterval { + return .loopYellow + } else { + return .loopRed + } + } + + var uiImage: UIImage { + ContactPicture.getImage(contact: contact, state: state) + } + + var body: some View { + Image(uiImage: uiImage) + .frame(width: 256, height: 256) + } +} + +extension FontWeight { + func toUI() -> UIFont.Weight { + switch self { + case .light: + UIFont.Weight.light + case .regular: + UIFont.Weight.regular + case .medium: + UIFont.Weight.medium + case .semibold: + UIFont.Weight.semibold + case .bold: + UIFont.Weight.bold + case .black: + UIFont.Weight.black + } + } +} + +enum GradientDirection: Int { + case leftToRight + case bottomToTop +} + +struct ContactPicturePreview: View { + @Binding var contact: ContactTrickEntry + @Binding var state: ContactTrickState + + var body: some View { + ZStack { + ContactPicture(contact: $contact, state: $state) + Circle() + .stroke(lineWidth: 20) + .foregroundColor(.white) + } + .frame(width: 256, height: 256) + .clipShape(Circle()) + .preferredColorScheme($contact.wrappedValue.darkMode ? .dark : .light) + } +} + +struct ContactPicture_Previews: PreviewProvider { + struct Preview: View { + @State var rangeIndicator: Bool = true + @State var darkMode: Bool = true + @State var fontSize: Int = 130 + @State var fontWeight: UIFont.Weight = .bold + @State var fontName: String? = "AmericanTypewriter" + + var body: some View { + ContactPicturePreview( + contact: .constant( + ContactTrickEntry( + primary: .glucose, + top: .delta, + bottom: .trend, + fontSize: 100, + fontWeight: .medium + ) + ), + state: .constant(ContactTrickState( + glucose: "6.8", + trend: "↗︎", + delta: "+0.2", + cob: 25, + cobText: "25" + )) + + ).previewDisplayName("bg + trend + delta") + + ContactPicturePreview( + contact: .constant( + ContactTrickEntry( + ring1: .iob, + primary: .glucose, + bottom: .trend, + fontSize: 100, + fontWeight: .medium + ) + ), + state: .constant(ContactTrickState( + glucose: "6.8", + trend: "↗︎", + iob: 6.1, + iobText: "6.1", + maxIOB: 8.0 + )) + ).previewDisplayName("bg + trend + iob ring") + + ContactPicturePreview( + contact: .constant( + ContactTrickEntry( + primary: .glucose, + top: .ring, + bottom: .trend, + fontSize: 100, + fontWeight: .medium + ) + ), + state: .constant(ContactTrickState( + glucose: "6.8", + trend: "↗︎", + lastLoopDate: .now + )) + + ).previewDisplayName("bg + trend + ring") + + ContactPicturePreview( + contact: .constant( + ContactTrickEntry( + ring1: .loop, + primary: .glucose, + top: .none, + bottom: .trend, + fontSize: 100, + fontWeight: .medium + ) + ), + state: .constant(ContactTrickState( + glucose: "8.8", + trend: "→", + lastLoopDate: .now + )) + + ).previewDisplayName("bg + trend + ring1") + + ContactPicturePreview( + contact: .constant( + ContactTrickEntry( + ring1: .loop, + primary: .glucose, + top: .none, + bottom: .eventualBG, + fontSize: 100, + fontWeight: .medium + ) + ), + state: .constant(ContactTrickState( + glucose: "6.8", + lastLoopDate: .now - 7.minutes, + eventualBG: "6.2" + )) + + ).previewDisplayName("bg + eventual + ring1") + + ContactPicturePreview( + contact: .constant( + ContactTrickEntry( + ring1: .loop, + primary: .lastLoopDate, + top: .none, + bottom: .none, + fontSize: 100, + fontWeight: .medium + ) + ), + state: .constant(ContactTrickState( + glucose: "6.8", + trend: "↗︎", + lastLoopDate: .now - 2.minutes + )) + + ).previewDisplayName("lastLoopDate + ring1") + + ContactPicturePreview( + contact: .constant( + ContactTrickEntry( + ring1: .loop, + primary: .glucose, + top: .none, + bottom: .none, + fontSize: 100, + fontWeight: .medium + ) + ), + state: .constant(ContactTrickState( + glucose: "6.8", + lastLoopDate: .now, + iob: 6.1, + iobText: "6.1", + maxIOB: 8.0 + )) + + ).previewDisplayName("bg + ring1 + ring2") + + ContactPicturePreview( + contact: .constant( + ContactTrickEntry( + layout: .split, + top: .iob, + bottom: .cob, + fontSize: 100, + fontWeight: .medium + ) + ), + state: .constant(ContactTrickState( + iob: 1.5, + iobText: "1.5", + cob: 25, + cobText: "25" + )) + + ).previewDisplayName("iob + cob") + + ContactPicturePreview( + contact: .constant( + ContactTrickEntry( + layout: .single, + ring1: .iobcob, + primary: .none, + ringWidth: 8, + ringGap: 3, + fontSize: 100, + fontWeight: .medium + ) + ), + state: .constant(ContactTrickState( + iob: 1, + iobText: "5.5", + cob: 25, + cobText: "25", + maxIOB: 10, + maxCOB: 120 + )) + + ).previewDisplayName("iobcob ring") + + ContactPicturePreview( + contact: .constant( + ContactTrickEntry( + layout: .single, + ring1: .iobcob, + primary: .none, + fontSize: 100, + fontWeight: .medium + ) + ), + state: .constant(ContactTrickState( + iob: -0.2, + iobText: "0.0", + cob: 0, + cobText: "0", + maxIOB: 10, + maxCOB: 120 + )) + + ).previewDisplayName("iobcob ring (0/0)") + + ContactPicturePreview( + contact: .constant( + ContactTrickEntry( + layout: .single, + ring1: .iobcob, + primary: .none, + fontSize: 100, + fontWeight: .medium + ) + ), + state: .constant(ContactTrickState( + iob: 10, + iobText: "0.0", + cob: 120, + cobText: "0", + maxIOB: 10, + maxCOB: 120 + )) + + ).previewDisplayName("iobcob ring (max/max)") + + ContactPicturePreview( + contact: .constant( + ContactTrickEntry( + layout: .single, + ring1: .iobcob, + primary: .glucose, + bottom: .trend, + fontSize: 100, + fontWeight: .medium + ) + ), + state: .constant(ContactTrickState( + glucose: "6.8", + trend: "↗︎", + iob: 5.5, + iobText: "5.5", + cob: 25, + cobText: "25", + maxIOB: 10, + maxCOB: 120 + )) + + ).previewDisplayName("bg + trend + iobcob ring") + } + } + + static var previews: some View { + Preview() + } +} diff --git a/FreeAPS/Sources/Services/ContactTrick/ContactTrickManager.swift b/FreeAPS/Sources/Services/ContactTrick/ContactTrickManager.swift new file mode 100644 index 0000000000..c424bd3a11 --- /dev/null +++ b/FreeAPS/Sources/Services/ContactTrick/ContactTrickManager.swift @@ -0,0 +1,225 @@ +import Algorithms +import Combine +import Contacts +import Foundation +import Swinject + +protocol ContactTrickManager { + func updateContacts(contacts: [ContactTrickEntry], completion: @escaping (Result) -> Void) +} + +final class BaseContactTrickManager: NSObject, ContactTrickManager, Injectable { + private let processQueue = DispatchQueue(label: "BaseContactTrickManager.processQueue") + private let contactStore = CNContactStore() + private var workItem: DispatchWorkItem? + + @Injected() private var broadcaster: Broadcaster! + @Injected() private var settingsManager: SettingsManager! + @Injected() private var storage: FileStorage! + + private var contacts: [ContactTrickEntry] = [] + + private let coreDataStorage = CoreDataStorage() + + init(resolver: Resolver) { + super.init() + injectServices(resolver) + + broadcaster.register(SuggestionObserver.self, observer: self) + broadcaster.register(SettingsObserver.self, observer: self) + + contacts = storage.retrieve(OpenAPS.Settings.contactTrick, as: [ContactTrickEntry].self) + ?? [ContactTrickEntry](from: OpenAPS.defaults(for: OpenAPS.Settings.contactTrick)) + ?? [] + + processQueue.async { + self.renderContacts() + } + } + + func updateContacts(contacts: [ContactTrickEntry], completion: @escaping (Result) -> Void) { + self.contacts = contacts + + processQueue.async { + self.renderContacts() + completion(.success(())) + } + } + + private func renderContacts() { + if let workItem = workItem, !workItem.isCancelled { + workItem.cancel() + } + + let readings = coreDataStorage.fetchGlucose(interval: DateFilter().twoHours) + let glucoseValues = glucoseText(readings) + + let suggestion: Suggestion? = storage.retrieve(OpenAPS.Enact.suggested, as: Suggestion.self) + + let state = ContactTrickState( + glucose: glucoseValues.glucose, + trend: glucoseValues.trend, + delta: glucoseValues.delta, + lastLoopDate: suggestion?.timestamp, + iob: suggestion?.iob, + iobText: suggestion?.iob.map { iob in + iobFormatter.string(from: iob as NSNumber)! + }, + cob: suggestion?.cob, + cobText: suggestion?.cob.map { cob in + cobFormatter.string(from: cob as NSNumber)! + }, + eventualBG: eventualBGString(suggestion), + maxIOB: settingsManager.preferences.maxIOB, + maxCOB: settingsManager.preferences.maxCOB + ) + + contacts.forEach { renderContact($0, state) } + + workItem = DispatchWorkItem(block: { + print("in updateContact, no updates received for more than 5 minutes") + self.renderContacts() + }) + DispatchQueue.main.asyncAfter(deadline: .now() + 5 * 60 + 15, execute: workItem!) + } + + private func renderContact(_ entry: ContactTrickEntry, _ state: ContactTrickState) { + guard let contactId = entry.contactId, entry.enabled else { + return + } + + let keysToFetch = [CNContactImageDataKey] as [CNKeyDescriptor] + + let contact: CNContact + do { + contact = try contactStore.unifiedContact(withIdentifier: contactId, keysToFetch: keysToFetch) + } catch { + print("in updateContact, an error has been thrown while fetching the selected contact") + return + } + + guard let mutableContact = contact.mutableCopy() as? CNMutableContact else { + return + } + + mutableContact.imageData = ContactPicture.getImage( + contact: entry, + state: state + ).pngData() + + saveUpdatedContact(mutableContact) + } + + private func saveUpdatedContact(_ mutableContact: CNMutableContact) { + let saveRequest = CNSaveRequest() + saveRequest.update(mutableContact) + do { + try contactStore.execute(saveRequest) + } catch let error as NSError { + var details: String? + if error.domain == CNErrorDomain { + switch error.code { + case CNError.authorizationDenied.rawValue: + details = "Authorization denied" + case CNError.communicationError.rawValue: + details = "Communication error" + case CNError.insertedRecordAlreadyExists.rawValue: + details = "Record already exists" + case CNError.dataAccessError.rawValue: + details = "Data access error" + default: + details = "Code \(error.code)" + } + } + print("in updateContact, failed to update the contact - \(details ?? "no details"): \(error.localizedDescription)") + + } catch { + print("in updateContact, failed to update the contact: \(error.localizedDescription)") + } + } + + private func glucoseText(_ glucose: [Readings]) -> (glucose: String, trend: String, delta: String) { + let glucoseValue = glucose.first?.glucose ?? 0 + + guard !glucose.isEmpty else { return ("--", "--", "--") } + + let delta = glucose.count >= 2 ? glucoseValue - glucose[1].glucose : nil + + let units = settingsManager.settings.units + let glucoseText = glucoseFormatter + .string(from: Double( + units == .mmolL ? Decimal(glucoseValue).asMmolL : Decimal(glucoseValue) + ) as NSNumber)! + + let directionText = glucose.first?.direction ?? "↔︎" + let deltaText = delta + .map { + self.deltaFormatter + .string(from: Double( + units == .mmolL ? Decimal($0).asMmolL : Decimal($0) + ) as NSNumber)! + } ?? "--" + + return (glucoseText, directionText, deltaText) + } + + private func eventualBGString(_ suggestion: Suggestion?) -> String? { + guard let eventualBG = suggestion?.eventualBG else { + return nil + } + let units = settingsManager.settings.units + return glucoseFormatter.string( + from: (units == .mmolL ? eventualBG.asMmolL : Decimal(eventualBG)) as NSNumber + )! + } + + private var glucoseFormatter: NumberFormatter { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.maximumFractionDigits = 0 + if settingsManager.settings.units == .mmolL { + formatter.minimumFractionDigits = 1 + formatter.maximumFractionDigits = 1 + } + formatter.roundingMode = .halfUp + return formatter + } + + private var iobFormatter: NumberFormatter { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.minimumFractionDigits = 1 + formatter.maximumFractionDigits = 1 + formatter.roundingMode = .halfUp + return formatter + } + + private var cobFormatter: NumberFormatter { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.maximumFractionDigits = 0 + formatter.roundingMode = .halfUp + return formatter + } + + private var deltaFormatter: NumberFormatter { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.maximumFractionDigits = 1 + formatter.positivePrefix = "+" + return formatter + } +} + +extension BaseContactTrickManager: + SuggestionObserver, + SettingsObserver +{ + func suggestionDidUpdate(_: Suggestion) { + renderContacts() + } + + func settingsDidChange(_: FreeAPSSettings) { + renderContacts() + } +} diff --git a/FreeAPS/Sources/Services/ContactTrick/ContactTrickState.swift b/FreeAPS/Sources/Services/ContactTrick/ContactTrickState.swift new file mode 100644 index 0000000000..0b61059865 --- /dev/null +++ b/FreeAPS/Sources/Services/ContactTrick/ContactTrickState.swift @@ -0,0 +1,15 @@ +import Foundation + +struct ContactTrickState: Codable { + var glucose: String? + var trend: String? + var delta: String? + var lastLoopDate: Date? + var iob: Decimal? + var iobText: String? + var cob: Decimal? + var cobText: String? + var eventualBG: String? + var maxIOB: Decimal = 10.0 + var maxCOB: Decimal = 120.0 +}