فهرست منبع

Merge remote-tracking branch 'origin/dev' into estimated-delay

Jonas Björkert 1 سال پیش
والد
کامیت
7b137c4b2b

+ 1 - 1
Config.xcconfig

@@ -6,4 +6,4 @@
 unique_id = ${DEVELOPMENT_TEAM}
 
 //Version (DEFAULT)
-LOOP_FOLLOW_MARKETING_VERSION = 2.2.9
+LOOP_FOLLOW_MARKETING_VERSION = 2.2.10

+ 12 - 0
LoopFollow.xcodeproj/project.pbxproj

@@ -89,6 +89,9 @@
 		DD9ED0CA2D355257000D2A63 /* LogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ED0C92D355256000D2A63 /* LogView.swift */; };
 		DD9ED0CC2D35526E000D2A63 /* SearchBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ED0CB2D35526E000D2A63 /* SearchBar.swift */; };
 		DD9ED0CE2D35587A000D2A63 /* LogEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ED0CD2D355879000D2A63 /* LogEntry.swift */; };
+		DDA9ACA82D6A66E200E6F1A9 /* ContactColorOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA9ACA72D6A66DD00E6F1A9 /* ContactColorOption.swift */; };
+		DDA9ACAA2D6A6B8300E6F1A9 /* ContactIncludeOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA9ACA92D6A6B8200E6F1A9 /* ContactIncludeOption.swift */; };
+		DDA9ACAC2D6B317100E6F1A9 /* ContactType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA9ACAB2D6B316F00E6F1A9 /* ContactType.swift */; };
 		DDAD162F2D2EF9830084BE10 /* RileyLinkHeartbeatBluetoothDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAD162E2D2EF97C0084BE10 /* RileyLinkHeartbeatBluetoothDevice.swift */; };
 		DDB0AF522BB1A8BE00AFA48B /* BuildDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDB0AF512BB1A8BE00AFA48B /* BuildDetails.swift */; };
 		DDB0AF552BB1B24A00AFA48B /* BuildDetails.plist in Resources */ = {isa = PBXBuildFile; fileRef = DDB0AF542BB1B24A00AFA48B /* BuildDetails.plist */; };
@@ -364,6 +367,9 @@
 		DD9ED0C92D355256000D2A63 /* LogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogView.swift; sourceTree = "<group>"; };
 		DD9ED0CB2D35526E000D2A63 /* SearchBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBar.swift; sourceTree = "<group>"; };
 		DD9ED0CD2D355879000D2A63 /* LogEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogEntry.swift; sourceTree = "<group>"; };
+		DDA9ACA72D6A66DD00E6F1A9 /* ContactColorOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactColorOption.swift; sourceTree = "<group>"; };
+		DDA9ACA92D6A6B8200E6F1A9 /* ContactIncludeOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactIncludeOption.swift; sourceTree = "<group>"; };
+		DDA9ACAB2D6B316F00E6F1A9 /* ContactType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactType.swift; sourceTree = "<group>"; };
 		DDAD162E2D2EF97C0084BE10 /* RileyLinkHeartbeatBluetoothDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RileyLinkHeartbeatBluetoothDevice.swift; sourceTree = "<group>"; };
 		DDB0AF502BB1A84500AFA48B /* capture-build-details.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "capture-build-details.sh"; sourceTree = "<group>"; };
 		DDB0AF512BB1A8BE00AFA48B /* BuildDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildDetails.swift; sourceTree = "<group>"; };
@@ -708,6 +714,9 @@
 		DD50C74D2D0828250057AE6F /* Contact */ = {
 			isa = PBXGroup;
 			children = (
+				DDA9ACAB2D6B316F00E6F1A9 /* ContactType.swift */,
+				DDA9ACA92D6A6B8200E6F1A9 /* ContactIncludeOption.swift */,
+				DDA9ACA72D6A66DD00E6F1A9 /* ContactColorOption.swift */,
 				DD50C7512D0828B40057AE6F /* Settings */,
 				DD50C7542D0862770057AE6F /* ContactImageUpdater.swift */,
 			);
@@ -1440,6 +1449,7 @@
 				FC16A97B249966A3003D6245 /* AlarmSound.swift in Sources */,
 				DDBE3ABD2CB5A961006B37DC /* OverrideView.swift in Sources */,
 				DDB0AF522BB1A8BE00AFA48B /* BuildDetails.swift in Sources */,
+				DDA9ACAA2D6A6B8300E6F1A9 /* ContactIncludeOption.swift in Sources */,
 				DD0C0C622C4175FD00DBADDF /* NSProfile.swift in Sources */,
 				DD58171E2D299FCA0041FB98 /* BluetoothDeviceDelegate.swift in Sources */,
 				DDE69ED22C7256260013EAEC /* RemoteType.swift in Sources */,
@@ -1460,6 +1470,7 @@
 				FCFEECA02488157B00402A7F /* Chart.swift in Sources */,
 				DDCF979424C0D380002C9752 /* UIViewExtension.swift in Sources */,
 				DD0C0C6D2C48606200DBADDF /* CarbMetric.swift in Sources */,
+				DDA9ACA82D6A66E200E6F1A9 /* ContactColorOption.swift in Sources */,
 				DD7E19882ACDA5DA00DBD158 /* Notes.swift in Sources */,
 				FCEF87AC24A141A700AE6FA0 /* Localizer.swift in Sources */,
 				FC1BDD3224A2585C001B652C /* DataStructs.swift in Sources */,
@@ -1522,6 +1533,7 @@
 				DD4878032C7B297E0048F05C /* StorageValue.swift in Sources */,
 				DD4878192C7C56D60048F05C /* TrioNightscoutRemoteController.swift in Sources */,
 				FC1BDD2B24A22650001B652C /* Stats.swift in Sources */,
+				DDA9ACAC2D6B317100E6F1A9 /* ContactType.swift in Sources */,
 				DDDF6F432D479A9900884336 /* LoopNightscoutRemoteView.swift in Sources */,
 				DDD10F052C529DA200D76A8E /* ObservableValue.swift in Sources */,
 				FC1BDD2D24A23204001B652C /* StatsView.swift in Sources */,

+ 27 - 0
LoopFollow/Contact/ContactColorOption.swift

@@ -0,0 +1,27 @@
+//
+//  ContactColorOption.swift
+//  LoopFollow
+//
+//  Created by Jonas Björkert on 2025-02-22.
+//  Copyright © 2025 Jon Fawcett. All rights reserved.
+//
+
+import UIKit
+
+enum ContactColorOption: String, CaseIterable {
+    case red, blue, cyan, green, yellow, orange, purple, white, black
+
+    var uiColor: UIColor {
+        switch self {
+        case .red:    return .red
+        case .blue:   return .blue
+        case .cyan:   return .cyan
+        case .green:  return .green
+        case .yellow: return .yellow
+        case .orange: return .orange
+        case .purple: return .purple
+        case .white:  return .white
+        case .black:  return .black
+        }
+    }
+}

+ 109 - 55
LoopFollow/Contact/ContactImageUpdater.swift

@@ -14,86 +14,140 @@ class ContactImageUpdater {
     private let contactStore = CNContactStore()
     private let queue = DispatchQueue(label: "ContactImageUpdaterQueue")
 
-    func updateContactImage(bgValue: String, extra: String, stale: Bool) {
+    private var savedBackgroundUIColor: UIColor {
+        let rawValue = Storage.shared.contactBackgroundColor.value
+        return ContactColorOption(rawValue: rawValue)?.uiColor ?? .black
+    }
+
+    private var savedTextUIColor: UIColor {
+        let rawValue = Storage.shared.contactTextColor.value
+        return ContactColorOption(rawValue: rawValue)?.uiColor ?? .white
+    }
+
+    func updateContactImage(bgValue: String, trend: String, delta: String, stale: Bool) {
         queue.async {
             guard CNContactStore.authorizationStatus(for: .contacts) == .authorized else {
                 LogManager.shared.log(category: .contact, message: "Access to contacts is not authorized.")
                 return
             }
 
-            guard let imageData = self.generateContactImage(bgValue: bgValue, extra: extra, stale: stale)?.pngData() else {
-                LogManager.shared.log(category: .contact, message: "Failed to generate contact image.")
-                return
-            }
-
             let bundleDisplayName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? "LoopFollow"
-            let contactName = "\(bundleDisplayName) - BG"
-            let predicate = CNContact.predicateForContacts(matchingName: contactName)
-            let keysToFetch = [CNContactGivenNameKey, CNContactFamilyNameKey, CNContactImageDataKey] as [CNKeyDescriptor]
-
-            do {
-                let contacts = try self.contactStore.unifiedContacts(matching: predicate, keysToFetch: keysToFetch)
-
-                if let contact = contacts.first, let mutableContact = contact.mutableCopy() as? CNMutableContact {
-                    mutableContact.imageData = imageData
-                    let saveRequest = CNSaveRequest()
-                    saveRequest.update(mutableContact)
-                    try self.contactStore.execute(saveRequest)
-                    LogManager.shared.log(category: .contact, message: "Contact image updated", isDebug: true)
-                } else {
-                    let newContact = CNMutableContact()
-                    newContact.givenName = contactName
-                    newContact.imageData = imageData
-                    let saveRequest = CNSaveRequest()
-                    saveRequest.add(newContact, toContainerWithIdentifier: nil)
-                    try self.contactStore.execute(saveRequest)
-                    LogManager.shared.log(category: .contact, message: "New contact created")
+
+            for contactType in ContactType.allCases {
+                if contactType == .Delta && Storage.shared.contactDelta.value != .separate {
+                    continue
+                }
+
+                if contactType == .Trend && Storage.shared.contactTrend.value != .separate {
+                    continue
+                }
+
+                let contactName = "\(bundleDisplayName) - \(contactType.rawValue)"
+
+                guard let imageData = self.generateContactImage(bgValue: bgValue, trend: trend, delta: delta, stale: stale, contactType: contactType)?.pngData() else {
+                    LogManager.shared.log(category: .contact, message: "Failed to generate contact image for \(contactName).")
+                    continue
+                }
+
+                let predicate = CNContact.predicateForContacts(matchingName: contactName)
+                let keysToFetch = [CNContactGivenNameKey, CNContactFamilyNameKey, CNContactImageDataKey] as [CNKeyDescriptor]
+
+                do {
+                    let contacts = try self.contactStore.unifiedContacts(matching: predicate, keysToFetch: keysToFetch)
+
+                    if let contact = contacts.first, let mutableContact = contact.mutableCopy() as? CNMutableContact {
+                        mutableContact.imageData = imageData
+                        let saveRequest = CNSaveRequest()
+                        saveRequest.update(mutableContact)
+                        try self.contactStore.execute(saveRequest)
+                        print("Contact image updated successfully for \(contactName).")
+                    } else {
+                        let newContact = CNMutableContact()
+                        newContact.givenName = contactName
+                        newContact.imageData = imageData
+                        let saveRequest = CNSaveRequest()
+                        saveRequest.add(newContact, toContainerWithIdentifier: nil)
+                        try self.contactStore.execute(saveRequest)
+                        print("New contact created with updated image for \(contactName).")
+                    }
+                } catch {
+                    LogManager.shared.log(category: .contact, message: "Failed to update or create contact for \(contactName): \(error)")
                 }
-            } catch {
-                LogManager.shared.log(category: .contact, message: "Failed to update or create contact: \(error)")
             }
         }
     }
 
-    private func generateContactImage(bgValue: String, extra: String, stale: Bool) -> UIImage? {
+    private func generateContactImage(bgValue: String, trend: String, delta: String, stale: Bool, contactType: ContactType) -> UIImage? {
         let size = CGSize(width: 300, height: 300)
         UIGraphicsBeginImageContextWithOptions(size, false, 0)
         guard let context = UIGraphicsGetCurrentContext() else { return nil }
 
-        UIColor.black.setFill()
+        savedBackgroundUIColor.setFill()
         context.fill(CGRect(origin: .zero, size: size))
 
         let paragraphStyle = NSMutableParagraphStyle()
         paragraphStyle.alignment = .center
 
-        let maxFontSize: CGFloat = extra.isEmpty ? 200 : 160
-        let fontSize = maxFontSize - CGFloat(bgValue.count * 15)
-
-        var bgAttributes: [NSAttributedString.Key: Any] = [
-            .font: UIFont.boldSystemFont(ofSize: fontSize),
-            .foregroundColor: stale ? UIColor.gray : UIColor.white,
-            .paragraphStyle: paragraphStyle
-        ]
-
-        if stale {
-            bgAttributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
-        }
+        // Format extraDelta based on the user's unit preference
+        let unitPreference = UserDefaultsRepository.units.value
+        let yOffset: CGFloat = 48
+        if contactType == .Trend && Storage.shared.contactTrend.value == .separate {
+            let trendRect = CGRect(x: 0, y: 46, width: size.width, height: size.height - 80)
+            let trendFontSize = max(40, 200 - CGFloat(trend.count * 15))
+
+            let trendAttributes: [NSAttributedString.Key: Any] = [
+                .font: UIFont.boldSystemFont(ofSize: trendFontSize),
+                .foregroundColor: stale ? UIColor.gray : savedTextUIColor,
+                .paragraphStyle: paragraphStyle
+            ]
+
+            trend.draw(in: trendRect, withAttributes: trendAttributes)
+        } else if contactType == .Delta && Storage.shared.contactDelta.value == .separate {
+            let deltaRect = CGRect(x: 0, y: yOffset, width: size.width, height: size.height - 80)
+            let deltaFontSize = max(40, 200 - CGFloat(delta.count * 15))
+
+            let deltaAttributes: [NSAttributedString.Key: Any] = [
+                .font: UIFont.boldSystemFont(ofSize: deltaFontSize),
+                .foregroundColor: stale ? UIColor.gray : savedTextUIColor,
+                .paragraphStyle: paragraphStyle
+            ]
+
+            delta.draw(in: deltaRect, withAttributes: deltaAttributes)
+        } else if contactType == .BG {
+            let includesExtra = Storage.shared.contactDelta.value == .include || Storage.shared.contactTrend.value == .include
+
+            let maxFontSize: CGFloat = includesExtra ? 160 : 200
+            let fontSize = maxFontSize - CGFloat(bgValue.count * 15)
+            var bgAttributes: [NSAttributedString.Key: Any] = [
+                .font: UIFont.boldSystemFont(ofSize: fontSize),
+                .foregroundColor: stale ? UIColor.gray : savedTextUIColor,
+                .paragraphStyle: paragraphStyle
+            ]
+
+            if stale {
+                // Force background color back to black if stale
+                UIColor.black.setFill()
+                context.fill(CGRect(origin: .zero, size: size))
+                bgAttributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
+            }
 
-        let extraAttributes: [NSAttributedString.Key: Any] = [
-            .font: UIFont.systemFont(ofSize: 90),
-            .foregroundColor: UIColor.white,
-            .paragraphStyle: paragraphStyle
-        ]
+            let bgRect: CGRect = includesExtra
+            ? CGRect(x: 0, y: yOffset - 20, width: size.width, height: size.height / 2)
+            : CGRect(x: 0, y: yOffset, width: size.width, height: size.height - 80)
 
-        let bgRect = extra.isEmpty
-        ? CGRect(x: 0, y: 46, width: size.width, height: size.height - 80)
-        : CGRect(x: 0, y: 26, width: size.width, height: size.height / 2)
+            bgValue.draw(in: bgRect, withAttributes: bgAttributes)
 
-        bgValue.draw(in: bgRect, withAttributes: bgAttributes)
+            if includesExtra {
+                let extraRect = CGRect(x: 0, y: size.height / 2 + 6, width: size.width, height: size.height / 2 - 20)
+                let extraAttributes: [NSAttributedString.Key: Any] = [
+                    .font: UIFont.systemFont(ofSize: 90),
+                    .foregroundColor: stale ? UIColor.gray : savedTextUIColor,
+                    .paragraphStyle: paragraphStyle
+                ]
 
-        if !extra.isEmpty {
-            let extraRect = CGRect(x: 0, y: size.height / 2 + 6, width: size.width, height: size.height / 2 - 20)
-            extra.draw(in: extraRect, withAttributes: extraAttributes)
+                let extra = Storage.shared.contactDelta.value == .include ? delta : trend
+                extra.draw(in: extraRect, withAttributes: extraAttributes)
+            }
         }
 
         let image = UIGraphicsGetImageFromCurrentImageContext()

+ 13 - 0
LoopFollow/Contact/ContactIncludeOption.swift

@@ -0,0 +1,13 @@
+//
+//  ContactIncludeOption.swift
+//  LoopFollow
+//
+//  Created by Jonas Björkert on 2025-02-22.
+//  Copyright © 2025 Jon Fawcett. All rights reserved.
+//
+
+enum ContactIncludeOption: String, Codable, Equatable, CaseIterable {
+    case off = "Off"
+    case include = "Include"
+    case separate = "Separate"
+}

+ 13 - 0
LoopFollow/Contact/ContactType.swift

@@ -0,0 +1,13 @@
+//
+//  ContactSuffix.swift
+//  LoopFollow
+//
+//  Created by Jonas Björkert on 2025-02-23.
+//  Copyright © 2025 Jon Fawcett. All rights reserved.
+//
+
+enum ContactType: String, CaseIterable {
+    case BG = "BG"
+    case Trend = "Trend"
+    case Delta = "Delta"
+}

+ 38 - 12
LoopFollow/Contact/Settings/ContactSettingsView.swift

@@ -36,22 +36,48 @@ struct ContactSettingsView: View {
                 }
 
                 if viewModel.contactEnabled {
+                    Section(header: Text("Color Options")) {
+                        Text("Select the colors for your BG values.  Note: not all watch faces allow control over colors. Recommend options like Activity or Modular Duo if you want to customize colors.")
+                            .font(.footnote)
+                            .foregroundColor(.secondary)
+                            .padding(.vertical, 4)
+
+                        Picker("Select Background Color", selection: $viewModel.contactBackgroundColor) {
+                            ForEach(ContactColorOption.allCases, id: \.rawValue) { option in
+                                Text(option.rawValue.capitalized).tag(option.rawValue)
+                            }
+                        }
+
+                        Picker("Select Text Color", selection: $viewModel.contactTextColor) {
+                            ForEach(ContactColorOption.allCases, id: \.rawValue) { option in
+                                Text(option.rawValue.capitalized).tag(option.rawValue)
+                            }
+                        }
+                    }
+
                     Section(header: Text("Additional Information")) {
-                        Toggle("Show Trend", isOn: $viewModel.contactTrend)
-                            .toggleStyle(SwitchToggleStyle())
-                            .onChange(of: viewModel.contactTrend) { isTrendEnabled in
-                                if isTrendEnabled {
-                                    viewModel.contactDelta = false
-                                }
+                        Text("To see your trend or delta, include one in the original '\(viewModel.contactName)' contact, or create separate contacts ending in '- Trend' and '- Delta' for up to three contacts on your watch face.")
+                            .font(.footnote)
+                            .foregroundColor(.secondary)
+                            .padding(.vertical, 4)
+
+                        Text("Show Trend")
+                            .font(.subheadline)
+                        Picker("Show Trend", selection: $viewModel.contactTrend) {
+                            ForEach(ContactIncludeOption.allCases, id: \.self) { option in
+                                Text(option.rawValue).tag(option)
                             }
+                        }
+                        .pickerStyle(SegmentedPickerStyle())
 
-                        Toggle("Show Delta", isOn: $viewModel.contactDelta)
-                            .toggleStyle(SwitchToggleStyle())
-                            .onChange(of: viewModel.contactDelta) { isDeltaEnabled in
-                                if isDeltaEnabled {
-                                    viewModel.contactTrend = false
-                                }
+                        Text("Show Delta")
+                            .font(.subheadline)
+                        Picker("Show Delta", selection: $viewModel.contactDelta) {
+                            ForEach(ContactIncludeOption.allCases, id: \.self) { option in
+                                Text(option.rawValue).tag(option)
                             }
+                        }
+                        .pickerStyle(SegmentedPickerStyle())
                     }
                 }
             }

+ 37 - 15
LoopFollow/Contact/Settings/ContactSettingsViewModel.swift

@@ -22,27 +22,41 @@ class ContactSettingsViewModel: ObservableObject {
 
     @Published var contactEnabled: Bool {
         didSet {
-            storage.contactEnabled.value = contactEnabled
+            Storage.shared.contactEnabled.value = contactEnabled
             triggerRefresh()
         }
     }
 
-    @Published var contactTrend: Bool {
+    @Published var contactTrend: ContactIncludeOption {
         didSet {
-            if contactTrend {
-                contactDelta = false
+            if contactTrend == .include && contactDelta == .include {
+                contactDelta = .off
             }
-            storage.contactTrend.value = contactTrend
+            Storage.shared.contactTrend.value = contactTrend
             triggerRefresh()
         }
     }
 
-    @Published var contactDelta: Bool {
+    @Published var contactDelta: ContactIncludeOption {
         didSet {
-            if contactDelta {
-                contactTrend = false
+            if contactDelta == .include && contactTrend == .include {
+                contactTrend = .off
             }
-            storage.contactDelta.value = contactDelta
+            Storage.shared.contactDelta.value = contactDelta
+            triggerRefresh()
+        }
+    }
+
+    @Published var contactBackgroundColor: String {
+        didSet {
+            Storage.shared.contactBackgroundColor.value = contactBackgroundColor
+            triggerRefresh()
+        }
+    }
+    
+    @Published var contactTextColor: String {
+        didSet {
+            Storage.shared.contactTextColor.value = contactTextColor
             triggerRefresh()
         }
     }
@@ -51,18 +65,26 @@ class ContactSettingsViewModel: ObservableObject {
     private var cancellables = Set<AnyCancellable>()
 
     init() {
-        self.contactEnabled = storage.contactEnabled.value
-        self.contactTrend = storage.contactTrend.value
-        self.contactDelta = storage.contactDelta.value
+        self.contactEnabled = Storage.shared.contactEnabled.value
+        self.contactTrend = Storage.shared.contactTrend.value
+        self.contactDelta = Storage.shared.contactDelta.value
+        self.contactBackgroundColor = Storage.shared.contactBackgroundColor.value
+        self.contactTextColor = Storage.shared.contactTextColor.value
 
-        storage.contactEnabled.$value
+        Storage.shared.contactEnabled.$value
             .assign(to: &$contactEnabled)
 
-        storage.contactTrend.$value
+        Storage.shared.contactTrend.$value
             .assign(to: &$contactTrend)
 
-        storage.contactDelta.$value
+        Storage.shared.contactDelta.$value
             .assign(to: &$contactDelta)
+
+        Storage.shared.contactBackgroundColor.$value
+            .assign(to: &$contactBackgroundColor)
+        
+        Storage.shared.contactTextColor.$value
+            .assign(to: &$contactTextColor)
     }
 
     private func triggerRefresh() {

+ 31 - 0
LoopFollow/Controllers/Alarms.swift

@@ -507,6 +507,30 @@ extension MainViewController {
             }
         }
 
+        // Check for rapid battery drop
+        if UserDefaultsRepository.alertBatteryDropActive.value && !UserDefaultsRepository.alertBatteryDropIsSnoozed.value {
+            let targetDate = Calendar.current.date(byAdding: .minute, value: Int(-UserDefaultsRepository.alertBatteryDropPeriod.value), to: Date())!
+            let currentBatteryLevel = UserDefaultsRepository.deviceBatteryLevel.value as Double
+            let dropPercentage = Double(UserDefaultsRepository.alertBatteryDropPercentage.value)
+
+            // find the closest matching entry to the user defined timeframe
+            // this allows flexibility for ingress data matching as it can come at different intervals
+            if let previousBatteryLevel = deviceBatteryData.min(by: {
+                abs($0.timestamp.timeIntervalSince(targetDate)) < abs($1.timestamp.timeIntervalSince(targetDate))
+            }) {
+                // ignore a drop with a previous level of 100 as it will trigger a false alarm
+                if (previousBatteryLevel.batteryLevel < 100) {
+                    if (previousBatteryLevel.batteryLevel - currentBatteryLevel) >= dropPercentage {
+                        AlarmSound.whichAlarm = "Battery Drop"
+
+                        if UserDefaultsRepository.alertBatteryDropRepeat.value { numLoops = -1 }
+                        triggerAlarm(sound: UserDefaultsRepository.alertBatteryDropSound.value, snooozedBGReadingTime: nil, overrideVolume: UserDefaultsRepository.overrideSystemOutputVolume.value, numLoops: numLoops, snoozeTime: UserDefaultsRepository.alertBatteryDropSnoozeHours.value, snoozeIncrement: 1, audio: true)
+                        return
+                    }
+                }
+            }
+        }
+
         if UserDefaultsRepository.alertRecBolusActive.value,
            !UserDefaultsRepository.alertRecBolusIsSnoozed.value
         {
@@ -855,6 +879,13 @@ extension MainViewController {
             alarms.reloadIsSnoozed(key: "alertBatteryIsSnoozed", value: false)
         }
 
+        if date > UserDefaultsRepository.alertBatteryDropSnoozedTime.value ?? date {
+            UserDefaultsRepository.alertBatteryDropSnoozedTime.setNil(key: "alertBatteryDropSnoozedTime")
+            UserDefaultsRepository.alertBatteryDropIsSnoozed.value = false
+            alarms.reloadSnoozeTime(key: "alertBatteryDropSnoozedTime", setNil: true)
+            alarms.reloadIsSnoozed(key: "alertBatteryDropIsSnoozed", value: false)
+        }
+
         if date > UserDefaultsRepository.alertRecBolusSnoozedTime.value ?? date {
             UserDefaultsRepository.alertRecBolusSnoozedTime.setNil(key: "alertRecBolusSnoozedTime")
             UserDefaultsRepository.alertRecBolusIsSnoozed.value = false

+ 2 - 10
LoopFollow/Controllers/Nightscout/BGData.swift

@@ -290,16 +290,8 @@ extension MainViewController {
             snoozer.DeltaLabel.text = snoozerDelta
 
             // Update contact
-            if ObservableUserDefaults.shared.contactEnabled.value {
-                var extra: String = ""
-
-                if ObservableUserDefaults.shared.contactTrend.value {
-                    extra = snoozerDirection
-                } else if ObservableUserDefaults.shared.contactDelta.value {
-                    extra = snoozerDelta
-                }
-
-                self.contactImageUpdater.updateContactImage(bgValue: bgTextStr, extra: extra, stale: deltaTime >= 12)
+            if Storage.shared.contactEnabled.value {
+                self.contactImageUpdater.updateContactImage(bgValue: bgTextStr, trend: snoozerDirection, delta: snoozerDelta, stale: deltaTime >= 12)
             }
         }
     }

+ 10 - 1
LoopFollow/Controllers/Nightscout/DeviceStatus.swift

@@ -112,10 +112,19 @@ extension MainViewController {
                    let upbat = uploader["battery"] as? Double {
                     infoManager.updateInfoData(type: .battery, value: String(format: "%.0f", upbat) + "%")
                     UserDefaultsRepository.deviceBatteryLevel.value = upbat
+                    let timestamp = uploader["timestamp"] as? Date ?? Date()
+
+                    let currentBattery = DataStructs.batteryStruct(batteryLevel: upbat, timestamp: timestamp)
+                    deviceBatteryData.append(currentBattery)
+
+                    // store only the last 30 battery readings
+                    if deviceBatteryData.count > 30 {
+                        deviceBatteryData.removeFirst()
+                    }
                 }
             }
         }
-        
+
         // Loop - handle new data
         if let lastLoopRecord = lastDeviceStatus?["loop"] as! [String : AnyObject]? {
             DeviceStatusLoop(formatter: formatter, lastLoopRecord: lastLoopRecord)

+ 13 - 1
LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift

@@ -159,10 +159,22 @@ extension MainViewController {
                 infoManager.updateInfoData(type: .tdd, value: tddMetric)
             }
 
+
+            let predBGsData: [String: AnyObject]? = {
+                if let enacted = lastLoopRecord["suggested"] as? [String: AnyObject],
+                   let predBGs = enacted["predBGs"] as? [String: AnyObject] {
+                    return predBGs
+                } else if let suggested = lastLoopRecord["enacted"] as? [String: AnyObject],
+                          let predBGs = suggested["predBGs"] as? [String: AnyObject] {
+                    return predBGs
+                }
+                return nil
+            }()
+
             let predictioncolor = UIColor.systemGray
             PredictionLabel.textColor = predictioncolor
             topPredictionBG = UserDefaultsRepository.minBGScale.value
-            if let predbgdata = enactedOrSuggested["predBGs"] as? [String: AnyObject] {
+            if let predbgdata = predBGsData {
                 let predictionTypes: [(type: String, colorName: String, dataIndex: Int)] = [
                     ("ZT", "ZT", 12),
                     ("IOB", "Insulin", 13),

+ 6 - 0
LoopFollow/Helpers/DataStructs.swift

@@ -36,6 +36,12 @@ class DataStructs {
         var note: String
     }
 
+    //NS Battery Data  Struct
+    struct batteryStruct: Codable {
+        var batteryLevel: Double
+        var timestamp: Date
+    }
+
     //NS Override Data  Struct
     struct overrideStruct: Codable {
         var insulNeedsScaleFactor: Double

+ 0 - 4
LoopFollow/Storage/ObservableUserDefaults.swift

@@ -17,9 +17,5 @@ class ObservableUserDefaults {
     var nsWriteAuth = ObservableUserDefaultsValue<Bool>(key: "nsWriteAuth", default: false)
     var nsAdminAuth = ObservableUserDefaultsValue<Bool>(key: "nsAdminAuth", default: false)
 
-    var contactEnabled = ObservableUserDefaultsValue<Bool>(key: "contactEnabled", default: false)
-    var contactTrend = ObservableUserDefaultsValue<Bool>(key: "contactTrend", default: false)
-    var contactDelta = ObservableUserDefaultsValue<Bool>(key: "contactDelta", default: false)
-
     private init() {}
 }

+ 6 - 0
LoopFollow/Storage/Storage.swift

@@ -37,6 +37,12 @@ class Storage {
 
     var debugLogLevel = StorageValue<Bool>(key: "debugLogLevel", defaultValue: false)
 
+    var contactTrend = StorageValue<ContactIncludeOption>(key: "contactTrend", defaultValue: .off)
+    var contactDelta = StorageValue<ContactIncludeOption>(key: "contactDelta", defaultValue: .off)
+    var contactEnabled = StorageValue<Bool>(key: "contactEnabled", defaultValue: false)
+    var contactBackgroundColor = StorageValue<String>(key: "contactBackgroundColor", defaultValue: ContactColorOption.black.rawValue)
+    var contactTextColor = StorageValue<String>(key: "contactTextColor", defaultValue: ContactColorOption.white.rawValue)
+    
     var sensorScheduleOffset = StorageValue<Double?>(key: "sensorScheduleOffset", defaultValue: nil)
 
     static let shared = Storage()

+ 9 - 0
LoopFollow/Storage/UserDefaults.swift

@@ -514,6 +514,15 @@ class UserDefaultsRepository {
     static let alertBatterySnoozeHours = UserDefaultsValue<Int>(key: "alertBatterySnoozeHours", default: 1)
     static var deviceBatteryLevel: UserDefaultsValue<Double> = UserDefaultsValue(key: "deviceBatteryLevel", default: 100.0)
 
+    static let alertBatteryDropActive = UserDefaultsValue<Bool>(key: "alertBatteryDropActive", default: false)
+    static let alertBatteryDropPercentage = UserDefaultsValue<Int>(key: "alertBatteryDropPercentage", default: 5)
+    static let alertBatteryDropPeriod = UserDefaultsValue<Int>(key: "alertBatteryDropPeriod", default: 15)
+    static let alertBatteryDropSound = UserDefaultsValue<String>(key: "alertBatteryDropSound", default: "Machine_Charge")
+    static let alertBatteryDropRepeat = UserDefaultsValue<Bool>(key: "alertBatteryDropRepeat", default: true)
+    static let alertBatteryDropIsSnoozed = UserDefaultsValue<Bool>(key: "alertBatteryDropIsSnoozed", default: false)
+    static let alertBatteryDropSnoozedTime = UserDefaultsValue<Date?>(key: "alertBatteryDropSnoozedTime", default: nil)
+    static let alertBatteryDropSnoozeHours = UserDefaultsValue<Int>(key: "alertBatteryDropSnoozeHours", default: 1)
+
     static let alertRecBolusActive = UserDefaultsValue<Bool>(key: "alertRecBolusActive", default: false)
     static let alertRecBolusLevel = UserDefaultsValue<Double>(key: "alertRecBolusLevel", default: 1)  //Unit[s]
     static let alertRecBolusSound = UserDefaultsValue<String>(key: "alertRecBolusSound", default: "Dhol_Shuffleloop")

+ 80 - 1
LoopFollow/ViewControllers/AlarmViewController.swift

@@ -351,7 +351,7 @@ class AlarmViewController: FormViewController {
 
         <<< SegmentedRow<String>("otherAlerts3"){ row in
             row.title = ""
-            row.options = ["IOB", "COB", "Battery"]
+            row.options = ["IOB", "COB", "Battery", "Battery Drop"]
             if !IsNightscoutEnabled() {
                 row.hidden = true
             }
@@ -428,6 +428,7 @@ class AlarmViewController: FormViewController {
         buildIOB()
         buildCOB()
         buildBatteryAlarm()
+        buildBatteryDropAlarm()
         buildRecBolus()
         buildTempTargetStart()
         buildTempTargetEnd()
@@ -3283,6 +3284,84 @@ class AlarmViewController: FormViewController {
         }
     }
 
+    func buildBatteryDropAlarm(){
+        form
+        +++ Section(header: "Battery Drop Alarm", footer: "Activates a notification alert whenever the battery level drops quickly based on a user-defined percentage and time interval, allowing for proactive device charging and power management.") { row in
+            row.hidden = "$otherAlerts3 != 'Battery Drop'"
+        }
+        <<< SwitchRow("alertBatteryDropActive"){ row in
+            row.title = "Active"
+            row.value = UserDefaultsRepository.alertBatteryDropActive.value
+        }.onChange { [weak self] row in
+            guard let value = row.value else { return }
+            UserDefaultsRepository.alertBatteryDropActive.value = value
+        }
+        <<< StepperRow("alertBatteryDropPercentage") { row in
+            row.title = "Battery Drop"
+            row.cell.stepper.stepValue = 5
+            row.cell.stepper.minimumValue = 5
+            row.cell.stepper.maximumValue = 100
+            row.value = Double(UserDefaultsRepository.alertBatteryDropPercentage.value)
+            row.displayValueFor = { value in
+                guard let value = value else { return nil }
+                return "\(Int(value))%"
+            }
+        }.onChange { [weak self] row in
+            guard let value = row.value else { return }
+            UserDefaultsRepository.alertBatteryDropPercentage.value = Int(value)
+        }
+        <<< StepperRow("alertBatteryDropPeriod") { row in
+            row.title = "Period (minutes)"
+            row.cell.stepper.stepValue = 5
+            row.cell.stepper.minimumValue = 5
+            row.cell.stepper.maximumValue = 30
+            row.value = Double(UserDefaultsRepository.alertBatteryDropPeriod.value)
+            row.displayValueFor = { value in
+                guard let value = value else { return nil }
+                return "\(Int(value))"
+            }
+        }.onChange { [weak self] row in
+            guard let value = row.value else { return }
+            UserDefaultsRepository.alertBatteryDropPeriod.value = Int(value)
+        }
+        <<< StepperRow("alertBatteryDropSnoozeHours") { row in
+            row.title = "Snooze Hours"
+            row.cell.stepper.stepValue = 1
+            row.cell.stepper.minimumValue = 1
+            row.cell.stepper.maximumValue = 24
+            row.value = Double(UserDefaultsRepository.alertBatterySnoozeHours.value)
+            row.displayValueFor = { value in
+                guard let value = value else { return nil }
+                return "\(Int(value))"
+            }
+        }.onChange { [weak self] row in
+            guard let value = row.value else { return }
+            UserDefaultsRepository.alertBatteryDropSnoozeHours.value = Int(value)
+        }
+        <<< PickerInputRow<String>("alertBatteryDropSound") { row in
+            row.title = "Sound"
+            row.options = soundFiles
+            row.value = UserDefaultsRepository.alertBatteryDropSound.value
+            row.displayValueFor = { value in
+                guard let value = value else { return nil }
+                return "\(String(value.replacingOccurrences(of: "_", with: " ")))"
+            }
+        }.onChange { [weak self] row in
+            guard let value = row.value else { return }
+            UserDefaultsRepository.alertBatteryDropSound.value = value
+            AlarmSound.setSoundFile(str: value)
+            AlarmSound.stop()
+            AlarmSound.playTest()
+        }
+        <<< SwitchRow("alertBatteryDropRepeat"){ row in
+            row.title = "Repeat Sound"
+            row.value = UserDefaultsRepository.alertBatteryDropRepeat.value
+        }.onChange { [weak self] row in
+            guard let value = row.value else { return }
+            UserDefaultsRepository.alertBatteryDropRepeat.value = value
+        }
+    }
+
     func buildRecBolus(){
         form
         +++ Section(header: "Rec. Bolus Alert", footer: "Activates a notification alert whenever recommended bolus is above a user-defined threshold, allowing for proactive manual bolusing.") { row in

+ 1 - 0
LoopFollow/ViewControllers/MainViewController.swift

@@ -102,6 +102,7 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
     var sensorStartGraphData: [DataStructs.timestampOnlyStruct] = []
     var noteGraphData: [DataStructs.noteStruct] = []
     var chartData = LineChartData()
+    var deviceBatteryData: [DataStructs.batteryStruct] = []
     var newBGPulled = false
     var lastCalDate: Double = 0
     var latestDirectionString = ""

+ 6 - 0
LoopFollow/ViewControllers/SnoozeViewController.swift

@@ -227,6 +227,12 @@ class SnoozeViewController: UIViewController, UNUserNotificationCenterDelegate {
             alarms.reloadIsSnoozed(key: "alertBatteryIsSnoozed", value: true)
             alarms.reloadSnoozeTime(key: "alertBatterySnoozedTime", setNil: false, value: currentDate.addingTimeInterval(longSnoozeDuration))
 
+        case "Battery Drop":
+            UserDefaultsRepository.alertBatteryDropIsSnoozed.value = true
+            UserDefaultsRepository.alertBatteryDropSnoozedTime.value = currentDate.addingTimeInterval(longSnoozeDuration)
+            alarms.reloadIsSnoozed(key: "alertBatteryDropIsSnoozed", value: true)
+            alarms.reloadSnoozeTime(key: "alertBatteryDropSnoozedTime", setNil: false, value: currentDate.addingTimeInterval(longSnoozeDuration))
+
         case "Rec. Bolus":
             UserDefaultsRepository.alertRecBolusIsSnoozed.value = true
             UserDefaultsRepository.alertRecBolusSnoozedTime.value = currentDate.addingTimeInterval(snoozeDuration)