Просмотр исходного кода

pull in live activity fixes from @nas10 and solve merge conflicts

polscm32 2 лет назад
Родитель
Сommit
e9ab2f3c48

+ 2 - 2
FreeAPS.xcodeproj/project.pbxproj

@@ -3577,7 +3577,7 @@
 				INFOPLIST_FILE = LiveActivity/Info.plist;
 				INFOPLIST_KEY_CFBundleDisplayName = LiveActivity;
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
-				IPHONEOS_DEPLOYMENT_TARGET = 17.0;
+				IPHONEOS_DEPLOYMENT_TARGET = 16.2;
 				LD_RUNPATH_SEARCH_PATHS = (
 					"$(inherited)",
 					"@executable_path/Frameworks",
@@ -3611,7 +3611,7 @@
 				INFOPLIST_FILE = LiveActivity/Info.plist;
 				INFOPLIST_KEY_CFBundleDisplayName = LiveActivity;
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
-				IPHONEOS_DEPLOYMENT_TARGET = 17.0;
+				IPHONEOS_DEPLOYMENT_TARGET = 16.2;
 				LD_RUNPATH_SEARCH_PATHS = (
 					"$(inherited)",
 					"@executable_path/Frameworks",

+ 47 - 11
FreeAPS/Sources/Modules/NotificationsConfig/View/NotificationsConfigRootView.swift

@@ -1,10 +1,20 @@
 import SwiftUI
 import Swinject
+import ActivityKit
+import Combine
 
 extension NotificationsConfig {
     struct RootView: BaseView {
         let resolver: Resolver
         @StateObject var state = StateModel()
+        
+        @State private var systemLiveActivitySetting: Bool = {
+               if #available(iOS 16.1, *) {
+                   ActivityAuthorizationInfo().areActivitiesEnabled
+               } else {
+                   false
+               }
+           }()
 
         private var glucoseFormatter: NumberFormatter {
             let formatter = NumberFormatter()
@@ -44,6 +54,41 @@ extension NotificationsConfig {
                     endPoint: .bottom
                 )
         }
+        
+        @ViewBuilder private func liveActivitySection() -> some View {
+            if #available(iOS 16.2, *) {
+                              Section(
+                                  header: Text("Live Activity"),
+                                  footer: Text(
+                                      liveActivityFooterText()
+                                  ),
+                                  content: {
+                                      if !systemLiveActivitySetting {
+                                          Button("Open Settings App") {
+                                              UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!)
+                                          }
+                                      } else {
+                                          Toggle("Show Live Activity", isOn: $state.useLiveActivity) }
+                                  }
+                              )
+                              .onReceive(resolver.resolve(LiveActivityBridge.self)!.$systemEnabled, perform: {
+                                  self.systemLiveActivitySetting = $0
+                              })
+                          }
+        }
+        
+        private func liveActivityFooterText() -> String {
+                 var footer =
+                     "Live activity displays blood glucose live on the lock screen and on the dynamic island (if available)"
+
+                 if !systemLiveActivitySetting {
+                     footer =
+                         "Live activities are turned OFF in system settings. To enable live activities, go to Settings app -> iAPS -> Turn live Activities ON.\n\n" +
+                         footer
+                 }
+
+                 return footer
+             }
 
         var body: some View {
             Form {
@@ -76,17 +121,8 @@ extension NotificationsConfig {
                         Text("g").foregroundColor(.secondary)
                     }
                 }
-
-                if #available(iOS 16.2, *) {
-                    Section(
-                        header: Text("Live Activity"),
-                        footer: Text(
-                            "Live activity displays blood glucose live on the lock screen and on the dynamic island (if available)"
-                        )
-                    ) {
-                        Toggle("Show live activity", isOn: $state.useLiveActivity)
-                    }
-                }
+                
+                liveActivitySection()
             }.scrollContentBackground(.hidden).background(color)
                 .onAppear(perform: configureView)
                 .navigationBarTitle("Notifications")

+ 1 - 1
FreeAPS/Sources/Services/LiveActivity/LiveActitiyShared.swift

@@ -4,7 +4,7 @@ import Foundation
 struct LiveActivityAttributes: ActivityAttributes {
     public struct ContentState: Codable, Hashable {
         let bg: String
-        let trendSystemImage: String?
+        let direction: String?
         let change: String
         let date: Date
         let chart: [Double]

+ 67 - 25
FreeAPS/Sources/Services/LiveActivity/LiveActivityBridge.swift

@@ -29,48 +29,37 @@ extension LiveActivityAttributes.ContentState {
         settings: FreeAPSSettings,
         suggestion: Suggestion
     ) {
-        guard let glucose = bg.glucose,
-              bg.dateString.timeIntervalSinceNow > -TimeInterval(minutes: 6)
-        else {
+        guard let glucose = bg.glucose else {
             return nil
         }
 
         let formattedBG = Self.formatGlucose(glucose, mmol: mmol, forceSign: false)
-
-        let trendString: String?
+        
         var rotationDegrees: Double = 0.0
+        
         switch bg.direction {
         case .doubleUp,
              .singleUp,
              .tripleUp:
-            trendString = "arrow.up"
             rotationDegrees = -90
-
         case .fortyFiveUp:
-            trendString = "arrow.up.right"
             rotationDegrees = -45
-
         case .flat:
-            trendString = "arrow.right"
             rotationDegrees = 0
-
         case .fortyFiveDown:
-            trendString = "arrow.down.right"
             rotationDegrees = 45
-
         case .doubleDown,
              .singleDown,
              .tripleDown:
-            trendString = "arrow.down"
             rotationDegrees = 90
-
         case .notComputable,
              Optional.none,
              .rateOutOfRange,
              .some(.none):
-            trendString = nil
             rotationDegrees = 0
         }
+        
+        let trendString = bg.direction?.symbol
 
         let change = prev?.glucose.map({
             Self.formatGlucose(glucose - $0, mmol: mmol, forceSign: true)
@@ -94,7 +83,7 @@ extension LiveActivityAttributes.ContentState {
 
         self.init(
             bg: formattedBG,
-            trendSystemImage: trendString,
+            direction: trendString,
             change: change,
             date: bg.dateString,
             chart: convertedChartBG,
@@ -116,10 +105,10 @@ extension LiveActivityAttributes.ContentState {
     func needsRecreation() -> Bool {
         switch activity.activityState {
         case .dismissed,
-             .ended:
+             .ended,
+             .stale:
             return true
-        case .active,
-             .stale: break
+        case .active: break
         @unknown default:
             return true
         }
@@ -129,11 +118,14 @@ extension LiveActivityAttributes.ContentState {
     }
 }
 
-@available(iOS 16.2, *) final class LiveActivityBridge: Injectable {
+@available(iOS 16.2, *) final class LiveActivityBridge: Injectable, ObservableObject {
     @Injected() private var settingsManager: SettingsManager!
     @Injected() private var glucoseStorage: GlucoseStorage!
     @Injected() private var broadcaster: Broadcaster!
     @Injected() private var storage: FileStorage!
+    
+    private let activityAuthorizationInfo = ActivityAuthorizationInfo()
+       @Published private(set) var systemEnabled: Bool
 
     private var settings: FreeAPSSettings {
         settingsManager.settings
@@ -147,6 +139,7 @@ extension LiveActivityAttributes.ContentState {
     private var latestGlucose: BloodGlucose?
 
     init(resolver: Resolver) {
+        systemEnabled = activityAuthorizationInfo.areActivitiesEnabled
         injectServices(resolver)
         broadcaster.register(GlucoseObserver.self, observer: self)
 
@@ -165,6 +158,20 @@ extension LiveActivityAttributes.ContentState {
         ) { _ in
             self.forceActivityUpdate()
         }
+       
+        monitorForLiveActivityAuthorizationChanges()
+    }
+
+    private func monitorForLiveActivityAuthorizationChanges() {
+        Task {
+            for await activityState in activityAuthorizationInfo.activityEnablementUpdates {
+                if activityState != systemEnabled {
+                    await MainActor.run {
+                        systemEnabled = activityState
+                    }
+                }
+            }
+        }
     }
 
     /// creates and tries to present a new activity update from the current GlucoseStorage values if live activities are enabled in settings
@@ -193,24 +200,50 @@ extension LiveActivityAttributes.ContentState {
             await unknownActivity.end(nil, dismissalPolicy: .immediate)
         }
 
-        let content = ActivityContent(state: state, staleDate: state.date.addingTimeInterval(TimeInterval(6 * 60)))
-
         if let currentActivity {
             if currentActivity.needsRecreation(), UIApplication.shared.applicationState == .active {
                 // activity is no longer visible or old. End it and try to push the update again
                 await endActivity()
                 await pushUpdate(state)
             } else {
+                let content = ActivityContent(
+                                   state: state,
+                                   staleDate: min(state.date, Date.now).addingTimeInterval(TimeInterval(6 * 60))
+                               )
                 await currentActivity.activity.update(content)
             }
         } else {
             do {
+                // always push a non-stale content as the first update
+                // pushing a stale content as the frst content results in the activity not being shown at all
+                // we want it shown though even if it is iniially stale, as we expect new BG readings to become available soon, which should then be displayed
+                let nonStale = ActivityContent(
+                    state: LiveActivityAttributes.ContentState(
+                        bg: "--",
+                        direction: nil,
+                        change: "--",
+                        date: Date.now,
+                        chart: [],
+                        chartDate: [],
+                        rotationDegrees: 0,
+                        highGlucose: Double(180),
+                        lowGlucose: Double(70),
+                        cob: 0,
+                        iob: 0,
+                        lockScreenView: "Simple"
+                    ),
+                    staleDate: Date.now.addingTimeInterval(60)
+                )
+
                 let activity = try Activity.request(
                     attributes: LiveActivityAttributes(startDate: Date.now),
-                    content: content,
+                    content: nonStale,
                     pushType: nil
                 )
                 currentActivity = ActiveActivity(activity: activity, startDate: Date.now)
+                
+                // then show the actual content
+               await pushUpdate(state)
             } catch {
                 print("activity creation error: \(error)")
             }
@@ -220,7 +253,7 @@ extension LiveActivityAttributes.ContentState {
     /// ends all live activities immediateny
     private func endActivity() async {
         if let currentActivity {
-            await currentActivity.activity.end(nil, dismissalPolicy: ActivityUIDismissalPolicy.immediate)
+            await currentActivity.activity.end(nil, dismissalPolicy: .immediate)
             self.currentActivity = nil
         }
 
@@ -234,6 +267,15 @@ extension LiveActivityAttributes.ContentState {
 @available(iOS 16.2, *)
 extension LiveActivityBridge: GlucoseObserver {
     func glucoseDidUpdate(_ glucose: [BloodGlucose]) {
+        guard settings.useLiveActivity else {
+                   if currentActivity != nil {
+                       Task {
+                           await self.endActivity()
+                       }
+                   }
+                   return
+               }
+        
         // backfill latest glucose if contained in this update
         if glucose.count > 1 {
             latestGlucose = glucose[glucose.count - 2]

+ 133 - 65
LiveActivity/LiveActivity.swift

@@ -3,8 +3,14 @@ import Charts
 import SwiftUI
 import WidgetKit
 
+private enum Size {
+    case minimal
+    case compact
+    case expanded
+}
+
 struct LiveActivity: Widget {
-    let dateFormatter: DateFormatter = {
+    private let dateFormatter: DateFormatter = {
         var f = DateFormatter()
         f.dateStyle = .none
         f.timeStyle = .short
@@ -26,26 +32,19 @@ struct LiveActivity: Widget {
         return formatter
     }
 
-    func changeLabel(context: ActivityViewContext<LiveActivityAttributes>) -> Text {
-        if !context.isStale && !context.state.change.isEmpty {
-            Text(context.state.change)
+    @ViewBuilder private func changeLabel(context: ActivityViewContext<LiveActivityAttributes>) -> some View {
+        if !context.state.change.isEmpty {
+            if context.isStale {
+                Text(context.state.change).foregroundStyle(.primary.opacity(0.5))
+                    .strikethrough(pattern: .solid, color: .red.opacity(0.6))
+            } else {
+                Text(context.state.change)
+            }
         } else {
             Text("--")
         }
     }
 
-    func updatedLabel(context: ActivityViewContext<LiveActivityAttributes>) -> Text {
-        Text("Updated: \(dateFormatter.string(from: context.state.date))").italic()
-    }
-
-    func bgLabel(context: ActivityViewContext<LiveActivityAttributes>) -> Text {
-        if context.isStale {
-            Text("--")
-        } else {
-            Text(context.state.bg).fontWeight(.bold)
-        }
-    }
-
     @ViewBuilder func mealLabel(context: ActivityViewContext<LiveActivityAttributes>) -> some View {
         VStack(alignment: .leading, spacing: 1, content: {
             HStack {
@@ -69,25 +68,90 @@ struct LiveActivity: Widget {
         if context.isStale {
             Text("--")
         } else {
-            if let trendSystemImage = context.state.trendSystemImage {
+            if let trendSystemImage = context.state.direction {
                 Image(systemName: trendSystemImage)
             }
         }
     }
-
-    @ViewBuilder func bgAndTrend(context: ActivityViewContext<LiveActivityAttributes>) -> some View {
-        if context.isStale {
-            Text("--")
-        } else {
-            HStack {
-                Text(context.state.bg).fontWeight(.bold)
-                if let trendSystemImage = context.state.trendSystemImage {
-                    Image(systemName: trendSystemImage)
+    
+    private func updatedLabel(context: ActivityViewContext<LiveActivityAttributes>) -> Text {
+            let text = Text("Updated: \(dateFormatter.string(from: context.state.date))")
+            if context.isStale {
+                if #available(iOSApplicationExtension 17.0, *) {
+                    return text.bold().foregroundStyle(.red)
+                } else {
+                    return text.bold().foregroundColor(.red)
                 }
+            } else {
+                return text
             }
         }
+    
+    private func bgLabel(context: ActivityViewContext<LiveActivityAttributes>) -> Text {
+        Text(context.state.bg)
+            .fontWeight(.bold)
+            .strikethrough(context.isStale, pattern: .solid, color: .red.opacity(0.6))
     }
 
+    private func bgAndTrend(context: ActivityViewContext<LiveActivityAttributes>, size: Size) -> (some View, Int) {
+          var characters = 0
+
+          let bgText = context.state.bg
+          characters += bgText.count
+
+          // narrow mode is for the minimal dynamic island view
+          // there is not enough space to show all three arrow there
+          // and everything has to be squeezed together to some degree
+          // only display the first arrow character and make it red in case there were more characters
+          var directionText: String?
+          var warnColor: Color?
+          if let direction = context.state.direction {
+              if size == .compact {
+                  directionText = String(direction[direction.startIndex ... direction.startIndex])
+
+                  if direction.count > 1 {
+                      warnColor = Color.red
+                  }
+              } else {
+                  directionText = direction
+              }
+
+              characters += directionText!.count
+          }
+
+          let spacing: CGFloat
+          switch size {
+          case .minimal: spacing = -1
+          case .compact: spacing = 0
+          case .expanded: spacing = 3
+          }
+
+          let stack = HStack(spacing: spacing) {
+              Text(bgText)
+                  .strikethrough(context.isStale, pattern: .solid, color: .red.opacity(0.6))
+              if let direction = directionText {
+                  let text = Text(direction)
+                  switch size {
+                  case .minimal:
+                      let scaledText = text.scaleEffect(x: 0.7, y: 0.7, anchor: .leading)
+                      if let warnColor {
+                          scaledText.foregroundStyle(warnColor)
+                      } else {
+                          scaledText
+                      }
+                  case .compact:
+                      text.scaleEffect(x: 0.8, y: 0.8, anchor: .leading).padding(.trailing, -3)
+
+                  case .expanded:
+                      text.scaleEffect(x: 0.7, y: 0.7, anchor: .leading).padding(.trailing, -5)
+                  }
+              }
+          }
+          .foregroundStyle(context.isStale ? Color.primary.opacity(0.5) : Color.primary)
+
+          return (stack, characters)
+      }
+
     @ViewBuilder func bobble(context: ActivityViewContext<LiveActivityAttributes>) -> some View {
         @State var angularGradient = AngularGradient(colors: [
             Color(red: 0.7215686275, green: 0.3411764706, blue: 1),
@@ -151,20 +215,21 @@ struct LiveActivity: Widget {
             // Lock screen/banner UI goes here
             if context.state.lockScreenView == "Simple" {
                 HStack(spacing: 3) {
-                    bgAndTrend(context: context).font(.title)
+                    bgAndTrend(context: context, size: .expanded).0.font(.title)
                     Spacer()
                     VStack(alignment: .trailing, spacing: 5) {
                         changeLabel(context: context).font(.title3)
-                        updatedLabel(context: context).font(.caption).foregroundStyle(.black.opacity(0.7))
+                        updatedLabel(context: context).font(.caption).foregroundStyle(.primary.opacity(0.7))
                     }
                 }
                 .privacySensitive()
-                .imageScale(.small)
-                .padding(.all, 15)
-                .background(Color.white.opacity(0.2))
-                .foregroundColor(Color.black)
-                .activityBackgroundTint(Color.cyan.opacity(0.2))
-                .activitySystemActionForegroundColor(Color.black)
+               .padding(.all, 15)
+               // Semantic BackgroundStyle and Color values work here. They adapt to the given interface style (light mode, dark mode)
+               // Semantic UIColors do NOT (as of iOS 17.1.1). Like UIColor.systemBackgroundColor (it does not adapt to changes of the interface style)
+               // The colorScheme environment varaible that is usually used to detect dark mode does NOT work here (it reports false values)
+               .foregroundStyle(Color.primary)
+               .background(BackgroundStyle.background.opacity(0.4))
+               .activityBackgroundTint(Color.clear)
             } else {
                 HStack(spacing: 2) {
                     VStack {
@@ -181,7 +246,7 @@ struct LiveActivity: Widget {
                                 bgLabel(context: context).font(.title2).imageScale(.small)
                                 changeLabel(context: context).font(.callout)
                             }
-                        }.scaleEffect(0.85).offset(y: 15)
+                        }.scaleEffect(0.85).offset(y: 18)
                         mealLabel(context: context).padding(.bottom, 8)
                         updatedLabel(context: context).font(.caption).padding(.bottom, 50)
                     }
@@ -198,48 +263,51 @@ struct LiveActivity: Widget {
                 // Expanded UI goes here.  Compose the expanded UI through
                 // various regions, like leading/trailing/center/bottom
                 DynamicIslandExpandedRegion(.leading) {
-                    HStack(spacing: 3) {
-                        bgAndTrend(context: context)
-                    }.imageScale(.small).font(.title).padding(.leading, 5)
+                    bgAndTrend(context: context, size: .expanded).0.font(.title2).padding(.leading, 5)
                 }
                 DynamicIslandExpandedRegion(.trailing) {
-                    changeLabel(context: context).font(.title).padding(.trailing, 5)
+                    changeLabel(context: context).font(.title2).padding(.trailing, 5)
                 }
                 DynamicIslandExpandedRegion(.bottom) {
-                    chart(context: context)
+                    if context.state.lockScreenView == "Simple" {
+                        Group {
+                             updatedLabel(context: context).font(.caption).foregroundStyle(Color.secondary)
+                         }
+                         .frame(
+                             maxHeight: .infinity,
+                             alignment: .bottom
+                         )
+                    } else {
+                        chart(context: context)
+                    }
                 }
                 DynamicIslandExpandedRegion(.center) {
-                    updatedLabel(context: context).font(.caption).foregroundStyle(Color.secondary)
+                    if context.state.lockScreenView == "Detailed" {
+                        updatedLabel(context: context).font(.caption).foregroundStyle(Color.secondary)
+                    }
                 }
             } compactLeading: {
-                HStack(spacing: 1) {
-                    bgAndTrend(context: context)
-                }.bold().imageScale(.small).padding(.leading, 5)
+                bgAndTrend(context: context, size: .compact).0.padding(.leading, 4)
             } compactTrailing: {
-                changeLabel(context: context).padding(.trailing, 5)
+                changeLabel(context: context).padding(.trailing, 4)
             } minimal: {
-                bgLabel(context: context).bold()
+                let (_label, characterCount) = bgAndTrend(context: context, size: .minimal)
+
+              let label = _label.padding(.leading, 7).padding(.trailing, 3)
+
+              if characterCount < 4 {
+                  label
+              } else if characterCount < 5 {
+                  label.fontWidth(.condensed)
+              } else {
+                  label.fontWidth(.compressed)
+              }
             }
             .widgetURL(URL(string: "freeaps-x://"))
-            .keylineTint(Color.cyan.opacity(0.5))
+            .keylineTint(Color.purple)
+           .contentMargins(.horizontal, 0, for: .minimal)
+           .contentMargins(.trailing, 0, for: .compactLeading)
+           .contentMargins(.leading, 0, for: .compactTrailing)
         }
     }
 }
-
-// private extension LiveActivityAttributes {
-//    static var preview: LiveActivityAttributes {
-//        LiveActivityAttributes(startDate: Date())
-//    }
-// }
-//
-// private extension LiveActivityAttributes.ContentState {
-//    static var test: LiveActivityAttributes.ContentState {
-//        LiveActivityAttributes.ContentState(bg: "100", trendSystemImage: "arrow.right", change: "+2", date: Date())
-//    }
-// }
-//
-// #Preview("Notification", as: .content, using: LiveActivityAttributes.preview) {
-//    LiveActivity()
-// } contentStates: {
-//    LiveActivityAttributes.ContentState.test
-// }