Pārlūkot izejas kodu

Redesign live activity detail widget; new setting view

Co-Authored-By: polscm32 <polscm32@users.noreply.github.com>
Deniz Cengiz 1 gadu atpakaļ
vecāks
revīzija
192541e964

+ 4 - 0
FreeAPS.xcodeproj/project.pbxproj

@@ -297,6 +297,7 @@
 		BD2FF1A02AE29D43005D1C5D /* CheckboxToggleStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD2FF19F2AE29D43005D1C5D /* CheckboxToggleStyle.swift */; };
 		BD3CC0722B0B89D50013189E /* MainChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD3CC0712B0B89D50013189E /* MainChartView.swift */; };
 		BD4064D12C4ED26900582F43 /* CoreDataObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD4064D02C4ED26900582F43 /* CoreDataObserver.swift */; };
+		BD6EB2D62C7D049B0086BBB6 /* LiveActivityBottomRowConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD6EB2D52C7D049B0086BBB6 /* LiveActivityBottomRowConfiguration.swift */; };
 		BD7DA9A52AE06DFC00601B20 /* BolusCalculatorConfigDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD7DA9A42AE06DFC00601B20 /* BolusCalculatorConfigDataFlow.swift */; };
 		BD7DA9A72AE06E2B00601B20 /* BolusCalculatorConfigProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD7DA9A62AE06E2B00601B20 /* BolusCalculatorConfigProvider.swift */; };
 		BD7DA9A92AE06E9200601B20 /* BolusCalculatorStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD7DA9A82AE06E9200601B20 /* BolusCalculatorStateModel.swift */; };
@@ -936,6 +937,7 @@
 		BD2FF19F2AE29D43005D1C5D /* CheckboxToggleStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckboxToggleStyle.swift; sourceTree = "<group>"; };
 		BD3CC0712B0B89D50013189E /* MainChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainChartView.swift; sourceTree = "<group>"; };
 		BD4064D02C4ED26900582F43 /* CoreDataObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataObserver.swift; sourceTree = "<group>"; };
+		BD6EB2D52C7D049B0086BBB6 /* LiveActivityBottomRowConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityBottomRowConfiguration.swift; sourceTree = "<group>"; };
 		BD7DA9A42AE06DFC00601B20 /* BolusCalculatorConfigDataFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusCalculatorConfigDataFlow.swift; sourceTree = "<group>"; };
 		BD7DA9A62AE06E2B00601B20 /* BolusCalculatorConfigProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusCalculatorConfigProvider.swift; sourceTree = "<group>"; };
 		BD7DA9A82AE06E9200601B20 /* BolusCalculatorStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusCalculatorStateModel.swift; sourceTree = "<group>"; };
@@ -2682,6 +2684,7 @@
 			isa = PBXGroup;
 			children = (
 				DDF847E32C5C288F0049BB3B /* LiveActivitySettingsRootView.swift */,
+				BD6EB2D52C7D049B0086BBB6 /* LiveActivityBottomRowConfiguration.swift */,
 			);
 			path = View;
 			sourceTree = "<group>";
@@ -3416,6 +3419,7 @@
 				BD7DA9A72AE06E2B00601B20 /* BolusCalculatorConfigProvider.swift in Sources */,
 				38192E0D261BAF980094D973 /* ConvenienceExtensions.swift in Sources */,
 				88AB39B23C9552BD6E0C9461 /* ISFEditorRootView.swift in Sources */,
+				BD6EB2D62C7D049B0086BBB6 /* LiveActivityBottomRowConfiguration.swift in Sources */,
 				F816825E28DB441200054060 /* HeartBeatManager.swift in Sources */,
 				58F107742BD1A4D000B1A680 /* Determination+helper.swift in Sources */,
 				38FEF413273B317A00574A46 /* HKUnit.swift in Sources */,

+ 6 - 0
FreeAPS/Resources/json/defaults/freeaps/freeaps_settings.json

@@ -53,6 +53,12 @@
   "sweetMeals": false,
   "sweetMealFactor": 2,
   "lockScreenView": "simple",
+  "showChart": true,
+  "showCurrentGlucose": true,
+  "showChangeLabel": true,
+  "showIOB": true,
+  "showCOB": true,
+  "showUpdatedLabel": true,
   "useCalendar": false,
   "displayCalendarIOBandCOB": false,
   "displayCalendarEmojis": false

+ 30 - 0
FreeAPS/Sources/Models/FreeAPSSettings.swift

@@ -72,6 +72,12 @@ struct FreeAPSSettings: JSON, Equatable {
     var displayPresets: Bool = true
     var useLiveActivity: Bool = false
     var lockScreenView: LockScreenView = .simple
+    var showChart: Bool = true
+    var showCurrentGlucose: Bool = true
+    var showChangeLabel: Bool = true
+    var showIOB: Bool = true
+    var showCOB: Bool = true
+    var showUpdatedLabel: Bool = true
     var bolusShortcut: BolusShortcutLimit = .notAllowed
 }
 
@@ -310,6 +316,30 @@ extension FreeAPSSettings: Decodable {
             settings.lockScreenView = lockScreenView
         }
 
+        if let showChart = try? container.decode(Bool.self, forKey: .showChart) {
+            settings.showChart = showChart
+        }
+
+        if let showCurrentGlucose = try? container.decode(Bool.self, forKey: .showCurrentGlucose) {
+            settings.showCurrentGlucose = showCurrentGlucose
+        }
+
+        if let showChangeLabel = try? container.decode(Bool.self, forKey: .showChangeLabel) {
+            settings.showChangeLabel = showChangeLabel
+        }
+
+        if let showIOB = try? container.decode(Bool.self, forKey: .showIOB) {
+            settings.showIOB = showIOB
+        }
+
+        if let showCOB = try? container.decode(Bool.self, forKey: .showCOB) {
+            settings.showCOB = showCOB
+        }
+
+        if let showUpdatedLabel = try? container.decode(Bool.self, forKey: .showUpdatedLabel) {
+            settings.showUpdatedLabel = showUpdatedLabel
+        }
+
         if let bolusShortcut = try? container.decode(BolusShortcutLimit.self, forKey: .bolusShortcut) {
             settings.bolusShortcut = bolusShortcut
         }

+ 1 - 1
FreeAPS/Sources/Modules/Home/View/Chart/MainChartView.swift

@@ -254,7 +254,7 @@ extension MainChartView {
             }
         }
         .id("DummyMainChart")
-        .frame(minHeight: geo.size.height * 0.28)
+        .frame(minHeight: geo.size.height * 0.28, maxHeight: geo.size.height * 0.45)
         .frame(width: screenSize.width - 10)
         .chartXAxis { mainChartXAxis }
         .chartXScale(domain: startMarker ... endMarker)

+ 19 - 0
FreeAPS/Sources/Modules/LiveActivitySettings/LiveActivitySettingsStateModel.swift

@@ -9,12 +9,25 @@ extension LiveActivitySettings {
         @Published var units: GlucoseUnits = .mgdL
         @Published var useLiveActivity = false
         @Published var lockScreenView: LockScreenView = .simple
+        @Published var showChart: Bool = true
+        @Published var showCurrentGlucose: Bool = true
+        @Published var showChangeLabel: Bool = true
+        @Published var showIOB: Bool = true
+        @Published var showCOB: Bool = true
+        @Published var showUpdatedLabel: Bool = true
 
         override func subscribe() {
             units = settingsManager.settings.units
 
             subscribeSetting(\.useLiveActivity, on: $useLiveActivity) { useLiveActivity = $0 }
             subscribeSetting(\.lockScreenView, on: $lockScreenView) { lockScreenView = $0 }
+
+            subscribeSetting(\.showChart, on: $showChart) { showChart = $0 }
+            subscribeSetting(\.showCurrentGlucose, on: $showCurrentGlucose) { showCurrentGlucose = $0 }
+            subscribeSetting(\.showChangeLabel, on: $showChangeLabel) { showChangeLabel = $0 }
+            subscribeSetting(\.showIOB, on: $showIOB) { showIOB = $0 }
+            subscribeSetting(\.showCOB, on: $showCOB) { showCOB = $0 }
+            subscribeSetting(\.showUpdatedLabel, on: $showUpdatedLabel) { showUpdatedLabel = $0 }
         }
     }
 }
@@ -22,5 +35,11 @@ extension LiveActivitySettings {
 extension LiveActivitySettings.StateModel: SettingsObserver {
     func settingsDidChange(_: FreeAPSSettings) {
         units = settingsManager.settings.units
+//        showChart = settingsManager.settings.showChart
+//        showCurrentGlucose = settingsManager.settings.showCurrentGlucose
+//        showChangeLabel = settingsManager.settings.showChangeLabel
+//        showIOB = settingsManager.settings.showIOB
+//        showCOB = settingsManager.settings.showCOB
+//        showUpdatedLabel = settingsManager.settings.showUpdatedLabel
     }
 }

+ 477 - 0
FreeAPS/Sources/Modules/LiveActivitySettings/View/LiveActivityBottomRowConfiguration.swift

@@ -0,0 +1,477 @@
+import Charts
+import Foundation
+import SwiftUI
+import Swinject
+import UniformTypeIdentifiers
+
+struct LiveActivityBottomRowConfiguration: BaseView {
+    let resolver: Resolver
+
+    @ObservedObject var state: LiveActivitySettings.StateModel
+
+    @State private var selectedItems: [LiveActivityItem] = []
+    @State private var showAddItemDialog: Bool = false
+    @State private var isEditMode: Bool = false
+    @State private var draggingItem: LiveActivityItem?
+
+    @Environment(\.colorScheme) var colorScheme
+
+    private var color: LinearGradient {
+        colorScheme == .dark ? LinearGradient(
+            gradient: Gradient(colors: [
+                Color.bgDarkBlue,
+                Color.bgDarkerDarkBlue
+            ]),
+            startPoint: .top,
+            endPoint: .bottom
+        )
+            :
+            LinearGradient(
+                gradient: Gradient(colors: [Color.gray.opacity(0.1)]),
+                startPoint: .top,
+                endPoint: .bottom
+            )
+    }
+
+    // Dummy data for glucose levels
+    private var glucoseData: [DummyChart] {
+        var data = [DummyChart]()
+        let totalMinutes = 6 * 60 // 6 hours in minutes
+        let interval = 5 // 5 minutes interval for each data point
+
+        for minute in stride(from: 0, to: totalMinutes, by: interval) {
+            let time = Double(minute) / 60.0 // Convert minutes to hours
+            let glucoseLevel = 100 + 20 * sin(time) // Oscillating sine wave pattern
+            data.append(DummyChart(time: Double(minute), glucoseLevel: glucoseLevel))
+        }
+        return data
+    }
+
+    var body: some View {
+        VStack {
+            Group {
+                VStack(alignment: .leading, spacing: 0) {
+                    Text("Live Activity Personalization".uppercased())
+                        .frame(maxWidth: .infinity, alignment: .leading)
+                        .foregroundColor(.secondary)
+                        .font(.footnote)
+                        .padding(.leading)
+                }
+                VStack {
+                    Text(
+                        "Trio offers you to customize your Live Activity lock screen widget. The default configuration will display current glucose, IOB, COB and the time of last algorithm run."
+                    )
+                    .padding()
+                    .font(.footnote)
+                    .foregroundColor(.secondary)
+                }
+                .background(
+                    RoundedRectangle(cornerRadius: 10, style: .continuous)
+                        .fill(Color.chart)
+                )
+            }
+
+            GroupBox {
+                VStack {
+                    dummyChart
+
+                    HStack {
+                        if selectedItems.isEmpty {
+                            Spacer()
+                            Button(action: {
+                                showAddItemDialog.toggle()
+                            }) {
+                                VStack {
+                                    Image(systemName: "plus")
+                                        .font(.title2)
+                                        .foregroundColor(.gray)
+                                }
+                                .frame(width: 60, height: 40)
+                                .overlay(
+                                    RoundedRectangle(cornerRadius: 12)
+                                        .stroke(style: StrokeStyle(lineWidth: 2, dash: [5]))
+                                        .foregroundColor(.gray)
+                                )
+                            }
+                            Spacer()
+                        } else {
+                            ForEach(Array(selectedItems.enumerated()), id: \.element) { index, item in
+                                if index > 0 {
+                                    Divider()
+                                        .frame(height: 50)
+                                }
+
+                                ZStack(alignment: .topTrailing) {
+                                    getItemPreview(for: item)
+                                        .frame(width: 50, height: 50)
+                                        .padding(5)
+                                        .background(
+                                            draggingItem == item ? Color.blue.opacity(0.2) : Color.clear
+                                        )
+                                        .cornerRadius(12)
+                                        .overlay(
+                                            RoundedRectangle(cornerRadius: 12)
+                                                .stroke(
+                                                    draggingItem == item ? Color.blue : Color.primary,
+                                                    lineWidth: draggingItem == item ? 2 : 1
+                                                )
+                                        )
+                                        .opacity(draggingItem == item ? 0.85 : 1.0)
+                                        .onDrag {
+                                            self.draggingItem = item
+                                            return NSItemProvider(object: item.rawValue as NSString)
+                                        }
+                                        .onDrop(
+                                            of: [UTType.text],
+                                            delegate: DropViewDelegate(
+                                                item: item,
+                                                items: $selectedItems,
+                                                draggingItem: $draggingItem
+                                            )
+                                        )
+                                        .disabled(!isEditMode)
+                                    // TODO: fix the jiggle modifier to make use of animation
+//                                        .jiggle(amount: 2, isEnabled: showItemDeleteButtons)
+                                    if isEditMode {
+                                        Button(action: {
+                                            removeItem(item)
+                                        }) {
+                                            Image(systemName: "minus.circle.fill")
+                                                .foregroundColor(Color(UIColor.systemGray2)) // Opaque foreground color
+                                                .background(Color.white) // Adding a background for contrast
+                                                .clipShape(Circle()) // Make sure the background stays circular
+                                                .font(.system(size: 20))
+                                        }
+                                        .offset(x: -45, y: -10)
+                                    }
+                                }
+                                .animation(.easeInOut, value: draggingItem)
+                                .frame(maxWidth: .infinity)
+                            }
+                        }
+                    }
+                    .padding()
+                    .overlay(
+                        RoundedRectangle(cornerRadius: 12)
+                            .stroke(style: StrokeStyle(lineWidth: 2, dash: [5]))
+                            .foregroundColor(.gray)
+                    )
+                    .cornerRadius(12)
+                }
+            }.padding(.vertical).groupBoxStyle(.dummyChart)
+
+            if isEditMode {
+                HStack {
+                    Image(systemName: "hand.draw.fill")
+                    Text("Tap 'Add +' to add a widget. Press, drag and drop a widget to re-order a widget.")
+                }.frame(maxWidth: .infinity, alignment: .leading)
+                    .foregroundColor(.secondary)
+                    .font(.footnote)
+                    .padding(.horizontal)
+            }
+
+            Spacer()
+        }
+        .padding()
+        .scrollContentBackground(.hidden).background(color)
+        .navigationTitle("Widget Configuration")
+        .navigationBarTitleDisplayMode(.automatic)
+        .toolbar {
+            ToolbarItem(placement: .topBarTrailing) {
+                Button {
+                    isEditMode.toggle()
+                } label: {
+                    HStack {
+                        Text("Edit")
+                    }
+                }
+            }
+            ToolbarItem(placement: .topBarTrailing) {
+                Button {
+                    showAddItemDialog.toggle()
+                } label: {
+                    HStack {
+                        Text("Add")
+                        Image(systemName: "plus")
+                    }
+                }
+            }
+        }
+        .confirmationDialog("Add Widget", isPresented: $showAddItemDialog, titleVisibility: .visible) {
+            ForEach(LiveActivityItem.allCases, id: \.self) { item in
+                Button(item.displayName) {
+                    addItem(item)
+                }.disabled(selectedItems.contains(item))
+            }
+        }
+        .onAppear {
+            configureView()
+            loadOrder()
+        }
+    }
+
+    private func getItemPreview(for item: LiveActivityItem) -> some View {
+        switch item {
+        case .currentGlucose:
+            return AnyView(currentGlucosePreview)
+        case .cob:
+            return AnyView(cobPreview)
+        case .iob:
+            return AnyView(iobPreview)
+        case .updatedLabel:
+            return AnyView(updatedLabelPreview)
+        }
+    }
+
+    private var dummyChart: some View {
+        Chart {
+            ForEach(glucoseData) { data in
+                PointMark(
+                    x: .value("Time", data.time),
+                    y: .value("Glucose Level", data.glucoseLevel)
+                ).foregroundStyle(.green.gradient).symbolSize(15)
+            }
+        }
+        .chartPlotStyle { plotContent in
+            plotContent
+                .background(
+                    RoundedRectangle(cornerRadius: 12)
+                        .fill(Color.cyan.opacity(0.15))
+                )
+                .clipShape(RoundedRectangle(cornerRadius: 12))
+        }
+        .chartYAxis {
+            AxisMarks(position: .trailing) { _ in
+                AxisGridLine(stroke: .init(lineWidth: 0.2, dash: [2, 3])).foregroundStyle(Color.primary)
+            }
+        }
+        .chartYAxis(.hidden)
+        .chartYScale(domain: 40 ... 200)
+        .chartXAxis {
+            AxisMarks(position: .automatic) { _ in
+                AxisGridLine(stroke: .init(lineWidth: 0.2, dash: [2, 3])).foregroundStyle(Color.primary)
+            }
+        }
+        .chartXAxis(.hidden)
+        .frame(height: 100)
+    }
+
+    private var currentGlucosePreview: some View {
+        VStack {
+            HStack(alignment: .center) {
+                Text("123")
+                    .fontWeight(.bold)
+                    .font(.caption)
+            }
+            HStack(spacing: -5) {
+                HStack {
+                    Text("\u{2192}")
+                    Text("+6")
+                }.foregroundStyle(.primary).font(.caption2)
+            }
+        }
+    }
+
+    private var cobPreview: some View {
+        VStack(spacing: 2) {
+            Text("25 g").fontWeight(.bold).font(.caption)
+            Text("COB").font(.caption2).foregroundStyle(.primary)
+        }
+    }
+
+    private var iobPreview: some View {
+        VStack(spacing: 2) {
+            Text("2 U").fontWeight(.bold).font(.caption)
+            Text("IOB").font(.caption2).foregroundStyle(.primary)
+        }
+    }
+
+    private var updatedLabelPreview: some View {
+        VStack {
+            Text("19:05")
+                .fontWeight(.bold)
+                .font(.caption)
+                .foregroundStyle(.primary)
+
+            Text("Updated").font(.caption2).foregroundStyle(.primary)
+        }
+    }
+
+    private func loadOrder() {
+        if let savedItems = UserDefaults.standard.loadLiveActivityOrder() {
+            selectedItems = savedItems
+        } else {
+            selectedItems = LiveActivityItem.defaultItems
+            saveOrder()
+        }
+        updateVisibilityForSelectedItems()
+    }
+
+    private func saveOrder() {
+        UserDefaults.standard.saveLiveActivityOrder(selectedItems)
+    }
+
+    private func addItem(_ item: LiveActivityItem) {
+        setItemVisibility(item: item, isVisible: true)
+        selectedItems.append(item)
+        saveOrder()
+    }
+
+    private func removeItem(_ item: LiveActivityItem) {
+        setItemVisibility(item: item, isVisible: false)
+        selectedItems.removeAll { $0 == item }
+        saveOrder()
+    }
+
+    private func setItemVisibility(item: LiveActivityItem, isVisible: Bool) {
+        switch item {
+        case .currentGlucose:
+            state.showCurrentGlucose = isVisible
+        case .iob:
+            state.showIOB = isVisible
+        case .cob:
+            state.showCOB = isVisible
+        case .updatedLabel:
+            state.showUpdatedLabel = isVisible
+        }
+    }
+
+    private func updateVisibilityForSelectedItems() {
+        for item in selectedItems {
+            setItemVisibility(item: item, isVisible: true)
+        }
+        let allItems = LiveActivityItem.allCases
+        let hiddenItems = allItems.filter { !selectedItems.contains($0) }
+        for item in hiddenItems {
+            setItemVisibility(item: item, isVisible: false)
+        }
+    }
+
+    @ViewBuilder func jiggle(amount: Double = 2, isEnabled: Bool = true) -> some View {
+        if isEnabled {
+            modifier(JiggleViewModifier(amount: amount))
+        } else {
+            self
+        }
+    }
+}
+
+struct DropViewDelegate: DropDelegate {
+    let item: LiveActivityItem
+    @Binding var items: [LiveActivityItem]
+    @Binding var draggingItem: LiveActivityItem?
+
+    func dropEntered(info _: DropInfo) {
+        guard let draggingItem = draggingItem else { return }
+
+        if draggingItem != item {
+            let fromIndex = items.firstIndex(of: draggingItem)!
+            let toIndex = items.firstIndex(of: item)!
+
+            withAnimation {
+                items.move(fromOffsets: IndexSet(integer: fromIndex), toOffset: toIndex > fromIndex ? toIndex + 1 : toIndex)
+            }
+        }
+    }
+
+    func performDrop(info _: DropInfo) -> Bool {
+        draggingItem = nil
+        return true
+    }
+}
+
+// Extension for UserDefaults to save and load the order
+extension UserDefaults {
+    private enum Keys {
+        static let liveActivityOrder = "liveActivityOrder"
+    }
+
+    func saveLiveActivityOrder(_ items: [LiveActivityItem]) {
+        let itemStrings = items.map(\.rawValue)
+        set(itemStrings, forKey: Keys.liveActivityOrder)
+    }
+
+    func loadLiveActivityOrder() -> [LiveActivityItem]? {
+        if let itemStrings = array(forKey: Keys.liveActivityOrder) as? [String] {
+            return itemStrings.compactMap { LiveActivityItem(rawValue: $0) }
+        }
+        return nil
+    }
+}
+
+// Enum to represent each live activity item
+enum LiveActivityItem: String, CaseIterable, Identifiable {
+    case currentGlucose
+    case iob
+    case cob
+    case updatedLabel
+
+    var id: String { rawValue }
+
+    static var defaultItems: [LiveActivityItem] {
+        [.currentGlucose, .iob, .cob, .updatedLabel]
+    }
+
+    var displayName: String {
+        switch self {
+        case .currentGlucose:
+            return "Current Glucose"
+        case .iob:
+            return "IOB"
+        case .cob:
+            return "COB"
+        case .updatedLabel:
+            return "Updated Label"
+        }
+    }
+}
+
+struct DummyChart: Identifiable {
+    let id = UUID()
+    let time: Double // Time in hours
+    let glucoseLevel: Double // Glucose level in mg/dL
+}
+
+struct DummyChartGroupBoxStyle: GroupBoxStyle {
+    func makeBody(configuration: Configuration) -> some View {
+        VStack {
+            configuration.content
+        }
+        .padding()
+        .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
+        .background(Color.chart, in: RoundedRectangle(cornerRadius: 12))
+        .frame(width: UIScreen.main.bounds.width * 0.9)
+    }
+}
+
+extension GroupBoxStyle where Self == DummyChartGroupBoxStyle {
+    static var dummyChart: DummyChartGroupBoxStyle { .init() }
+}
+
+struct JiggleViewModifier: ViewModifier {
+    let amount: Double
+
+    @State private var isJiggling = false
+
+    func body(content: Content) -> some View {
+        content
+            .rotationEffect(.degrees(isJiggling ? amount : 0))
+            .animation(
+                .easeInOut(duration: randomize(interval: 0.14, withVariance: 0.025))
+                    .repeatForever(autoreverses: true),
+                value: isJiggling
+            )
+            .animation(
+                .easeInOut(duration: randomize(interval: 0.18, withVariance: 0.025))
+                    .repeatForever(autoreverses: true),
+                value: isJiggling
+            )
+            .onAppear {
+                isJiggling.toggle()
+            }
+    }
+
+    private func randomize(interval: TimeInterval, withVariance variance: Double) -> TimeInterval {
+        interval + variance * (Double.random(in: 500 ... 1000) / 500)
+    }
+}

+ 11 - 0
FreeAPS/Sources/Modules/LiveActivitySettings/View/LiveActivitySettingsRootView.swift

@@ -116,6 +116,17 @@ extension LiveActivitySettings {
                                 }.padding(.top)
                             }.padding(.bottom)
                         }.listRowBackground(Color.chart)
+
+                        if state.lockScreenView == .detailed {
+                            Section(
+                                content: {
+                                    NavigationLink(
+                                        "Widget Configuration",
+                                        destination: LiveActivityBottomRowConfiguration(resolver: resolver, state: state)
+                                    )
+                                }
+                            ).listRowBackground(Color.chart)
+                        }
                     }
                 }
             }

+ 3 - 0
FreeAPS/Sources/Router/Screen.swift

@@ -39,6 +39,7 @@ enum Screen: Identifiable, Hashable {
     case featureSettings
     case notificationSettings
     case liveActivitySettings
+    case liveActivityBottomRowSettings
     case calendarEventSettings
     case serviceSettings
     case autosensSettings
@@ -127,6 +128,8 @@ extension Screen {
             NotificationsView(resolver: resolver, state: Settings.StateModel())
         case .liveActivitySettings:
             LiveActivitySettings.RootView(resolver: resolver)
+        case .liveActivityBottomRowSettings:
+            LiveActivityBottomRowConfiguration(resolver: resolver, state: LiveActivitySettings.StateModel())
         case .calendarEventSettings:
             CalendarEventSettings.RootView(resolver: resolver)
         case .serviceSettings:

+ 3 - 2
FreeAPS/Sources/Services/LiveActivity/Data/DataManager.swift

@@ -33,7 +33,7 @@ extension LiveActivityBridge {
             key: "deliverAt",
             ascending: false,
             fetchLimit: 1,
-            propertiesToFetch: ["iob", "cob"]
+            propertiesToFetch: ["iob", "cob", "currentTarget"]
         )
 
         guard let determinationResults = results as? [[String: Any]] else {
@@ -44,7 +44,8 @@ extension LiveActivityBridge {
             self.determination = determinationResults.first.map {
                 DeterminationData(
                     cob: ($0["cob"] as? Int) ?? 0,
-                    iob: ($0["iob"] as? NSDecimalNumber)?.decimalValue ?? 0
+                    iob: ($0["iob"] as? NSDecimalNumber)?.decimalValue ?? 0,
+                    target: ($0["currentTarget"] as? NSDecimalNumber)?.decimalValue ?? 0
                 )
             }
         }

+ 1 - 0
FreeAPS/Sources/Services/LiveActivity/Data/DeterminationData.swift

@@ -3,4 +3,5 @@ import Foundation
 struct DeterminationData {
     let cob: Int
     let iob: Decimal
+    let target: Decimal
 }

+ 6 - 0
FreeAPS/Sources/Services/LiveActivity/LiveActitiyAttributes.swift

@@ -10,6 +10,11 @@ struct LiveActivityAttributes: ActivityAttributes {
 
         let detailedViewState: ContentAdditionalState?
 
+        let showCOB: Bool
+        let showIOB: Bool
+        let showCurrentGlucose: Bool
+        let showUpdatedLabel: Bool
+
         /// true for the first state that is set on the activity
         let isInitialState: Bool
     }
@@ -20,6 +25,7 @@ struct LiveActivityAttributes: ActivityAttributes {
         let rotationDegrees: Double
         let highGlucose: Decimal
         let lowGlucose: Decimal
+        let target: Decimal
         let cob: Decimal
         let iob: Decimal
         let unit: String

+ 5 - 0
FreeAPS/Sources/Services/LiveActivity/LiveActivityAttributes+Helper.swift

@@ -89,6 +89,7 @@ extension LiveActivityAttributes.ContentState {
                 rotationDegrees: rotationDegrees,
                 highGlucose: settings.high,
                 lowGlucose: settings.low,
+                target: determination?.target ?? 0 as Decimal,
                 cob: Decimal(determination?.cob ?? 0),
                 iob: determination?.iob ?? 0 as Decimal,
                 unit: settings.units.rawValue,
@@ -105,6 +106,10 @@ extension LiveActivityAttributes.ContentState {
             change: change,
             date: bg.date,
             detailedViewState: detailedState,
+            showCOB: settings.showCOB,
+            showIOB: settings.showIOB,
+            showCurrentGlucose: settings.showCurrentGlucose,
+            showUpdatedLabel: settings.showUpdatedLabel,
             isInitialState: false
         )
     }

+ 28 - 1
FreeAPS/Sources/Services/LiveActivity/LiveActivityBridge.swift

@@ -24,7 +24,7 @@ import UIKit
     }
 }
 
-@available(iOS 16.2, *) final class LiveActivityBridge: Injectable, ObservableObject
+@available(iOS 16.2, *) final class LiveActivityBridge: Injectable, ObservableObject, SettingsObserver
 {
     @Injected() private var settingsManager: SettingsManager!
     @Injected() private var broadcaster: Broadcaster!
@@ -55,6 +55,7 @@ import UIKit
         registerHandler()
         monitorForLiveActivityAuthorizationChanges()
         setupGlucoseArray()
+        broadcaster.register(SettingsObserver.self, observer: self)
     }
 
     private func setupNotifications() {
@@ -71,6 +72,28 @@ import UIKit
             }
     }
 
+    // TODO: - use a delegate or a custom notification here instead
+
+    func settingsDidChange(_: FreeAPSSettings) {
+        guard let latestGlucose = latestGlucose else { return }
+
+        let content = LiveActivityAttributes.ContentState(
+            new: latestGlucose,
+            prev: latestGlucose,
+            units: settings.units,
+            chart: glucoseFromPersistence ?? [],
+            settings: settings,
+            determination: determination,
+            override: isOverridesActive
+        )
+
+        if let content = content {
+            Task {
+                await pushUpdate(content)
+            }
+        }
+    }
+
     private func registerHandler() {
         // Since we are only using this info to show if an Override is active or not in the Live Activity it is enough to observe only the 'OverrideStored' Entity
         coreDataObserver?.registerHandler(for: "OverrideStored") { [weak self] in
@@ -179,6 +202,10 @@ import UIKit
                         change: "--",
                         date: Date.now,
                         detailedViewState: nil,
+                        showCOB: true,
+                        showIOB: true,
+                        showCurrentGlucose: true,
+                        showUpdatedLabel: true,
                         isInitialState: true
                     ),
                     staleDate: Date.now.addingTimeInterval(60)

+ 150 - 50
LiveActivity/LiveActivity.swift

@@ -70,7 +70,7 @@ struct LiveActivity: Widget {
     private var bolusFormatter: NumberFormatter {
         let formatter = NumberFormatter()
         formatter.numberStyle = .decimal
-        formatter.maximumFractionDigits = 2
+        formatter.maximumFractionDigits = 1
         formatter.decimalSeparator = "."
         return formatter
     }
@@ -83,11 +83,55 @@ struct LiveActivity: Widget {
     }
 
     @ViewBuilder private func changeLabel(context: ActivityViewContext<LiveActivityAttributes>) -> some View {
-        if !context.state.change.isEmpty {
-            Text(context.state.change).foregroundStyle(.primary.opacity(0.5)).font(.headline)
-                .strikethrough(context.isStale, pattern: .solid, color: .red.opacity(0.6))
-        } else {
-            Text("--")
+        HStack(spacing: -5) {
+            if !context.state.change.isEmpty {
+                Text(context.state.change).foregroundStyle(.primary).font(.subheadline)
+                    .strikethrough(context.isStale, pattern: .solid, color: .red.opacity(0.6))
+            } else {
+                Text("--")
+            }
+        }
+    }
+
+    @ViewBuilder func cobLabel(
+        context: ActivityViewContext<LiveActivityAttributes>,
+        additionalState: LiveActivityAttributes.ContentAdditionalState
+    ) -> some View {
+        VStack(spacing: 2) {
+//            Image(systemName: "fork.knife")
+//                .font(.title3)
+//                .foregroundColor(.yellow)
+
+            HStack {
+                Text(
+                    carbsFormatter.string(from: additionalState.cob as NSNumber) ?? "--"
+                ).fontWeight(.bold).font(.title3).strikethrough(context.isStale, pattern: .solid, color: .red.opacity(0.6))
+                Text(NSLocalizedString("g", comment: "grams of carbs")).foregroundStyle(.primary).font(.headline)
+                    .fontWeight(.bold)
+            }
+
+            Text("COB").font(.subheadline).foregroundStyle(.primary)
+        }
+    }
+
+    @ViewBuilder func iobLabel(
+        context: ActivityViewContext<LiveActivityAttributes>,
+        additionalState: LiveActivityAttributes.ContentAdditionalState
+    ) -> some View {
+        VStack(spacing: 2) {
+//            Image(systemName: "syringe.fill")
+//                .font(.title3)
+//                .foregroundColor(.blue)
+
+            HStack {
+                Text(
+                    bolusFormatter.string(from: additionalState.iob as NSNumber) ?? "--"
+                ).font(.title3).fontWeight(.bold).strikethrough(context.isStale, pattern: .solid, color: .red.opacity(0.6))
+                Text(NSLocalizedString("U", comment: "Unit in number of units delivered (keep the space character!)"))
+                    .foregroundStyle(.primary).font(.headline).fontWeight(.bold)
+            }
+
+            Text("IOB").font(.subheadline).foregroundStyle(.primary)
         }
     }
 
@@ -96,7 +140,7 @@ struct LiveActivity: Widget {
         additionalState: LiveActivityAttributes.ContentAdditionalState
     ) -> some View {
         HStack {
-            VStack(alignment: .leading, spacing: 1, content: {
+            VStack(alignment: .leading, spacing: 0, content: {
                 HStack {
                     Image(systemName: "fork.knife")
                         .font(.title3)
@@ -108,19 +152,19 @@ struct LiveActivity: Widget {
                         .foregroundColor(.blue)
                 }
             })
-            VStack(alignment: .trailing, spacing: 1, content: {
+            VStack(alignment: .trailing, spacing: 0, content: {
                 HStack {
                     Text(
                         carbsFormatter.string(from: additionalState.cob as NSNumber) ?? "--"
-                    ).fontWeight(.bold).font(.headline).strikethrough(context.isStale, pattern: .solid, color: .red.opacity(0.6))
-                    Text(NSLocalizedString(" g", comment: "grams of carbs")).foregroundStyle(.secondary).font(.footnote)
+                    ).fontWeight(.bold).font(.title3).strikethrough(context.isStale, pattern: .solid, color: .red.opacity(0.6))
+                    Text(NSLocalizedString(" g", comment: "grams of carbs")).foregroundStyle(.primary).font(.headline)
                 }
                 HStack {
                     Text(
                         bolusFormatter.string(from: additionalState.iob as NSNumber) ?? "--"
-                    ).font(.headline).fontWeight(.bold).strikethrough(context.isStale, pattern: .solid, color: .red.opacity(0.6))
+                    ).font(.title3).fontWeight(.bold).strikethrough(context.isStale, pattern: .solid, color: .red.opacity(0.6))
                     Text(NSLocalizedString(" U", comment: "Unit in number of units delivered (keep the space character!)"))
-                        .foregroundStyle(.secondary).font(.footnote)
+                        .foregroundStyle(.primary).font(.headline)
                 }
             })
             VStack(alignment: .trailing, spacing: 1, content: {
@@ -147,35 +191,38 @@ struct LiveActivity: Widget {
             .minimumScaleFactor(0.01)
     }
 
-    private func updatedLabel(context: ActivityViewContext<LiveActivityAttributes>) -> Text {
-        let text = Text("Updated: \(dateFormatter.string(from: context.state.date))")
-            .font(.caption2)
-        if context.isStale {
-            // foregroundStyle is not available in <iOS 17 hence the check here
-            if #available(iOSApplicationExtension 17.0, *) {
-                return text.bold().foregroundStyle(.red)
-            } else {
-                return text.bold().foregroundColor(.red)
-            }
-        } else {
-            if #available(iOSApplicationExtension 17.0, *) {
-                return text.bold().foregroundStyle(.secondary)
+    @ViewBuilder private func updatedLabel(context: ActivityViewContext<LiveActivityAttributes>) -> some View {
+        VStack {
+            let dateText = Text("\(dateFormatter.string(from: context.state.date))").font(.title3).foregroundStyle(.primary)
+
+            if context.isStale {
+                if #available(iOSApplicationExtension 17.0, *) {
+                    dateText.bold().foregroundStyle(.red)
+                } else {
+                    dateText.bold().foregroundColor(.red)
+                }
             } else {
-                return text.bold().foregroundColor(.red)
+                if #available(iOSApplicationExtension 17.0, *) {
+                    dateText.bold().foregroundStyle(.primary)
+                } else {
+                    dateText.bold().foregroundColor(.primary)
+                }
             }
+
+            Text("Updated").font(.subheadline).foregroundStyle(.primary)
         }
     }
 
     @ViewBuilder private func bgLabel(
         context: ActivityViewContext<LiveActivityAttributes>,
-        additionalState: LiveActivityAttributes.ContentAdditionalState
+        additionalState _: LiveActivityAttributes.ContentAdditionalState
     ) -> some View {
         HStack(alignment: .center) {
             Text(context.state.bg)
                 .fontWeight(.bold)
-                .font(.largeTitle)
+                .font(.title3)
                 .strikethrough(context.isStale, pattern: .solid, color: .red.opacity(0.6))
-            Text(additionalState.unit).foregroundStyle(.secondary).font(.subheadline).offset(x: -5, y: 5)
+//            Text(additionalState.unit).foregroundStyle(.primary).font(.footnote)
         }
     }
 
@@ -250,10 +297,10 @@ struct LiveActivity: Widget {
         ], startPoint: .leading, endPoint: .trailing)
 
         if !context.isStale {
-            Image(systemName: "arrow.right")
-                .font(.title)
+            Image(systemName: "arrowshape.right.circle")
+                .font(.headline)
                 .rotationEffect(.degrees(additionalState.rotationDegrees))
-                .foregroundStyle(gradient)
+//                .foregroundStyle(gradient)
         }
     }
 
@@ -272,12 +319,14 @@ struct LiveActivity: Widget {
                 .asMmolL
             let yAxisRuleMarkMax = additionalState.unit == "mg/dL" ? additionalState.highGlucose : additionalState.highGlucose
                 .asMmolL
+            let target = additionalState.unit == "mg/dL" ? additionalState.target : additionalState.target.asMmolL
 
             Chart {
                 RuleMark(y: .value("Low", yAxisRuleMarkMin))
                     .lineStyle(.init(lineWidth: 0.5, dash: [5]))
                 RuleMark(y: .value("High", yAxisRuleMarkMax))
                     .lineStyle(.init(lineWidth: 0.5, dash: [5]))
+                RuleMark(y: .value("Target", target)).foregroundStyle(.green.gradient).lineStyle(.init(lineWidth: 1))
 
                 ForEach(additionalState.chart.indices, id: \.self) { index in
                     let currentValue = additionalState.chart[index]
@@ -300,10 +349,19 @@ struct LiveActivity: Widget {
             .chartYAxis {
                 AxisMarks(position: .trailing) { _ in
                     AxisGridLine(stroke: .init(lineWidth: 0.2, dash: [2, 3])).foregroundStyle(Color.white)
-                    AxisValueLabel().foregroundStyle(.secondary).font(.footnote)
+                    AxisValueLabel().foregroundStyle(.primary).font(.footnote)
                 }
             }
-            .chartYScale(domain: additionalState.unit == "mg/dL" ? min ... max : min.asMmolL ... max.asMmolL)
+//            .chartYScale(domain: additionalState.unit == "mg/dL" ? min ... max : min.asMmolL ... max.asMmolL)
+            .chartYAxis(.hidden)
+            .chartPlotStyle { plotContent in
+                plotContent
+                    .background(
+                        RoundedRectangle(cornerRadius: 12)
+                            .fill(Color.cyan.opacity(0.15))
+                    )
+                    .clipShape(RoundedRectangle(cornerRadius: 12))
+            }
             .chartXAxis {
                 AxisMarks(position: .automatic) { _ in
                     AxisGridLine(stroke: .init(lineWidth: 0.2, dash: [2, 3])).foregroundStyle(Color.white)
@@ -314,24 +372,42 @@ struct LiveActivity: Widget {
 
     @ViewBuilder func content(context: ActivityViewContext<LiveActivityAttributes>) -> some View {
         if let detailedViewState = context.state.detailedViewState {
-            HStack(spacing: 12) {
-                chart(context: context, additionalState: detailedViewState).frame(maxWidth: UIScreen.main.bounds.width / 1.8)
-                VStack(alignment: .leading) {
-                    Spacer()
-                    bgLabel(context: context, additionalState: detailedViewState)
-                    HStack {
-                        changeLabel(context: context)
-                        trendArrow(context: context, additionalState: detailedViewState)
+            VStack(content: {
+                chart(context: context, additionalState: detailedViewState)
+                    .frame(maxWidth: UIScreen.main.bounds.width * 0.9)
+                    .frame(height: 80)
+
+                HStack {
+                    if context.state.showCurrentGlucose {
+                        VStack {
+                            bgLabel(context: context, additionalState: detailedViewState)
+                            HStack {
+                                changeLabel(context: context)
+                            }
+                        }
+                        Divider().foregroundStyle(.primary).fontWeight(.bold).frame(width: 10)
+                    }
+
+                    if context.state.showIOB {
+                        iobLabel(context: context, additionalState: detailedViewState)
+                        Divider().foregroundStyle(.primary).fontWeight(.bold).frame(width: 10)
+                    }
+
+                    if context.state.showCOB {
+                        cobLabel(context: context, additionalState: detailedViewState)
+                        Divider().foregroundStyle(.primary).fontWeight(.bold).frame(width: 10)
+                    }
+
+                    if context.state.showUpdatedLabel {
+                        updatedLabel(context: context)
                     }
-                    mealLabel(context: context, additionalState: detailedViewState).padding(.bottom, 8)
-                    updatedLabel(context: context).padding(.bottom, 10)
                 }
-            }
-            .privacySensitive()
-            .padding(.all, 14)
-            .imageScale(.small)
-            .foregroundColor(Color.white)
-            .activityBackgroundTint(Color.black.opacity(0.8))
+            })
+                .privacySensitive()
+                .padding(.all, 14)
+                .imageScale(.small)
+                .foregroundStyle(Color.primary)
+                .activityBackgroundTint(Color.clear)
         } else {
             Group {
                 if context.state.isInitialState {
@@ -440,6 +516,10 @@ private extension LiveActivityAttributes.ContentState {
             change: "+0.0",
             date: Date(),
             detailedViewState: nil,
+            showCOB: true,
+            showIOB: true,
+            showCurrentGlucose: true,
+            showUpdatedLabel: true,
             isInitialState: false
         )
     }
@@ -451,6 +531,10 @@ private extension LiveActivityAttributes.ContentState {
             change: "+0.0",
             date: Date(),
             detailedViewState: nil,
+            showCOB: true,
+            showIOB: true,
+            showCurrentGlucose: true,
+            showUpdatedLabel: true,
             isInitialState: false
         )
     }
@@ -462,6 +546,10 @@ private extension LiveActivityAttributes.ContentState {
             change: "+0.0",
             date: Date(),
             detailedViewState: nil,
+            showCOB: true,
+            showIOB: true,
+            showCurrentGlucose: true,
+            showUpdatedLabel: true,
             isInitialState: false
         )
     }
@@ -474,6 +562,10 @@ private extension LiveActivityAttributes.ContentState {
             change: "+0",
             date: Date(),
             detailedViewState: nil,
+            showCOB: true,
+            showIOB: true,
+            showCurrentGlucose: true,
+            showUpdatedLabel: true,
             isInitialState: false
         )
     }
@@ -485,6 +577,10 @@ private extension LiveActivityAttributes.ContentState {
             change: "+00",
             date: Date(),
             detailedViewState: nil,
+            showCOB: true,
+            showIOB: true,
+            showCurrentGlucose: true,
+            showUpdatedLabel: true,
             isInitialState: false
         )
     }
@@ -496,6 +592,10 @@ private extension LiveActivityAttributes.ContentState {
             change: "--",
             date: Date().addingTimeInterval(-60 * 60),
             detailedViewState: nil,
+            showCOB: true,
+            showIOB: true,
+            showCurrentGlucose: true,
+            showUpdatedLabel: true,
             isInitialState: true
         )
     }