polscm32 1 год назад
Родитель
Сommit
5b43f76ab8

+ 8 - 1
FreeAPS/Sources/Services/LiveActivity/LiveActitiyAttributes.swift

@@ -7,6 +7,14 @@ struct LiveActivityAttributes: ActivityAttributes {
         let direction: String?
         let change: String
         let date: Date
+
+        let detailedViewState: ContentAdditionalState?
+
+        /// true for the first state that is set on the activity
+        let isInitialState: Bool
+    }
+
+    public struct ContentAdditionalState: Codable, Hashable {
         let chart: [Double]
         let chartDate: [Date?]
         let rotationDegrees: Double
@@ -14,7 +22,6 @@ struct LiveActivityAttributes: ActivityAttributes {
         let lowGlucose: Double
         let cob: Decimal
         let iob: Decimal
-        let lockScreenView: String
         let unit: String
         let isOverrideActive: Bool
     }

+ 37 - 22
FreeAPS/Sources/Services/LiveActivity/LiveActivityAttributes+Helper.swift

@@ -74,35 +74,50 @@ extension LiveActivityAttributes.ContentState {
 
         let trendString = bg.direction?.symbol as? String
         let change = Self.calculateChange(chart: chart, units: units)
-        let chartBG = chart.map(\.glucose)
-        let conversionFactor: Double = settings.units == .mmolL ? 18.0 : 1.0
-        let convertedChartBG = chartBG.map { Double($0) / conversionFactor }
-        let chartDate = chart.map(\.date)
 
-        /// glucose limits from UI settings, not from notifications settings
-        let highGlucose = settings.high / Decimal(conversionFactor)
-        let lowGlucose = settings.low / Decimal(conversionFactor)
-        let cob = determination?.cob ?? 0
-        let iob = determination?.iob ?? 0
-        let lockScreenView = settings.lockScreenView.displayName
-        let unit = settings.units == .mmolL ? " mmol/L" : " mg/dL"
-        let isOverrideActive = override?.isActive ?? false
+        let detailedState: LiveActivityAttributes.ContentAdditionalState?
+
+        switch settings.lockScreenView {
+        case .detailed:
+            let chartBG = chart.map(\.glucose)
+
+            let conversionFactor: Double = settings.units == .mmolL ? 18.0 : 1.0
+            let convertedChartBG = chartBG.map { Double($0) / conversionFactor }
+
+            let chartDate = chart.map(\.date)
+
+            /// glucose limits from UI settings, not from notifications settings
+            let highGlucose = settings.high / Decimal(conversionFactor)
+            let lowGlucose = settings.low / Decimal(conversionFactor)
+
+            let cob = determination?.cob ?? 0
+            let iob = determination?.iob ?? 0
+            let unit = settings.units == .mmolL ? " mmol/L" : " mg/dL"
+            let isOverrideActive = override?.isActive ?? false
+
+            detailedState = LiveActivityAttributes.ContentAdditionalState(
+                chart: convertedChartBG,
+                chartDate: chartDate,
+                rotationDegrees: rotationDegrees,
+                highGlucose: Double(highGlucose),
+                lowGlucose: Double(lowGlucose),
+                cob: Decimal(cob),
+                iob: iob as Decimal,
+                unit: unit,
+                isOverrideActive: isOverrideActive
+            )
+
+        case .simple:
+            detailedState = nil
+        }
 
         self.init(
             bg: formattedBG,
             direction: trendString,
             change: change,
             date: bg.date,
-            chart: convertedChartBG,
-            chartDate: chartDate,
-            rotationDegrees: rotationDegrees,
-            highGlucose: Double(highGlucose),
-            lowGlucose: Double(lowGlucose),
-            cob: Decimal(cob),
-            iob: iob as Decimal,
-            lockScreenView: lockScreenView,
-            unit: unit,
-            isOverrideActive: isOverrideActive
+            detailedViewState: detailedState,
+            isInitialState: false
         )
     }
 }

+ 36 - 15
FreeAPS/Sources/Services/LiveActivity/LiveActivityBridge.swift

@@ -70,6 +70,15 @@ import UIKit
         setupGlucoseArray()
     }
 
+    @objc private func determinationDidUpdate() {
+        Task {
+            await fetchAndMapDetermination()
+            if let determination = determination {
+                await self.pushDeterminationUpdate(determination)
+            }
+        }
+    }
+
     private func setupGlucoseArray() {
         Task {
             // Fetch and map glucose to GlucoseData struct
@@ -79,7 +88,7 @@ import UIKit
             await fetchAndMapDetermination()
 
             // Fetch and map Override to OverrideData struct
-            /// to show if there is an active Override
+            /// shows if there is an active Override
             await fetchAndMapOverride()
 
             // Push the update to the Live Activity
@@ -138,23 +147,17 @@ import UIKit
             }
         } else {
             do {
-                // Create initial non-stale content
-                let nonStaleContent = ActivityContent(
+                // 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
+                // apparently this initial state is also what is shown after the live activity expires (after 8h)
+                let expired = ActivityContent(
                     state: LiveActivityAttributes.ContentState(
                         bg: "--",
                         direction: nil,
                         change: "--",
                         date: Date.now,
-                        chart: [],
-                        chartDate: [],
-                        rotationDegrees: 0,
-                        highGlucose: 180,
-                        lowGlucose: 70,
-                        cob: 0,
-                        iob: 0,
-                        lockScreenView: "Simple",
-                        unit: "--",
-                        isOverrideActive: false
+                        detailedViewState: nil,
+                        isInitialState: true
                     ),
                     staleDate: Date.now.addingTimeInterval(60)
                 )
@@ -162,12 +165,12 @@ import UIKit
                 // Request a new activity
                 let activity = try Activity.request(
                     attributes: LiveActivityAttributes(startDate: Date.now),
-                    content: nonStaleContent,
+                    content: expired,
                     pushType: nil
                 )
                 currentActivity = ActiveActivity(activity: activity, startDate: Date.now)
 
-                // Push the actual content
+                // then show the actual content
                 await pushUpdate(state)
             } catch {
                 print("Activity creation error: \(error)")
@@ -175,6 +178,24 @@ import UIKit
         }
     }
 
+    @MainActor private func pushDeterminationUpdate(_ determination: DeterminationData) async {
+        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 {
+            await pushUpdate(content)
+        }
+    }
+
     /// ends all live activities immediateny
     private func endActivity() async {
         if let currentActivity {

+ 238 - 140
LiveActivity/LiveActivity.swift

@@ -47,7 +47,10 @@ struct LiveActivity: Widget {
         }
     }
 
-    @ViewBuilder func mealLabel(context: ActivityViewContext<LiveActivityAttributes>) -> some View {
+    @ViewBuilder func mealLabel(
+        context _: ActivityViewContext<LiveActivityAttributes>,
+        additionalState: LiveActivityAttributes.ContentAdditionalState
+    ) -> some View {
         HStack {
             VStack(alignment: .leading, spacing: 1, content: {
                 HStack {
@@ -63,46 +66,23 @@ struct LiveActivity: Widget {
             })
             VStack(alignment: .trailing, spacing: 1, content: {
                 HStack {
-                    if context.isStale {
-                        Text(
-                            carbsFormatter.string(from: context.state.cob as NSNumber) ?? "--"
-                        ).fontWeight(.bold).font(.headline).strikethrough(pattern: .solid, color: .red.opacity(0.6))
-                            .font(.callout)
-                        Text(NSLocalizedString(" g", comment: "grams of carbs")).foregroundStyle(.secondary).font(.footnote)
-                    } else {
-                        Text(
-                            carbsFormatter.string(from: context.state.cob as NSNumber) ?? "--"
-                        ).fontWeight(.bold).font(.headline)
-                        Text(NSLocalizedString(" g", comment: "grams of carbs")).foregroundStyle(.secondary).font(.footnote)
-                    }
+                    Text(
+                        carbsFormatter.string(from: additionalState.cob as NSNumber) ?? "--"
+                    ).fontWeight(.bold).font(.headline)
+                    Text(NSLocalizedString(" g", comment: "grams of carbs")).foregroundStyle(.secondary).font(.footnote)
                 }
                 HStack {
-                    if context.isStale {
-                        Text(
-                            bolusFormatter.string(from: context.state.iob as NSNumber) ?? "--"
-                        ).font(.headline).fontWeight(.bold).strikethrough(pattern: .solid, color: .red.opacity(0.6))
-                            .font(.callout)
-                        Text(NSLocalizedString(" U", comment: "Unit in number of units delivered (keep the space character!)"))
-                            .foregroundStyle(.secondary).font(.footnote)
-                    } else {
-                        Text(
-                            bolusFormatter.string(from: context.state.iob as NSNumber) ?? "--"
-                        ).font(.headline).fontWeight(.bold)
-                        Text(NSLocalizedString(" U", comment: "Unit in number of units delivered (keep the space character!)"))
-                            .foregroundStyle(.secondary).font(.footnote)
-                    }
+                    Text(
+                        bolusFormatter.string(from: additionalState.iob as NSNumber) ?? "--"
+                    ).font(.headline).fontWeight(.bold)
+                    Text(NSLocalizedString(" U", comment: "Unit in number of units delivered (keep the space character!)"))
+                        .foregroundStyle(.secondary).font(.footnote)
                 }
             })
             VStack(alignment: .trailing, spacing: 1, content: {
-                if context.state.isOverrideActive {
-                    if !context.isStale {
-                        Image(systemName: "person.crop.circle.fill.badge.checkmark")
-                            .font(.title3)
-                    } else {
-                        Image(systemName: "person.crop.circle.fill.badge.checkmark")
-                            .font(.title3)
-                            .strikethrough(pattern: .solid, color: .red.opacity(0.6))
-                    }
+                if additionalState.isOverrideActive {
+                    Image(systemName: "person.crop.circle.fill.badge.checkmark")
+                        .font(.title3)
                 }
             })
         }
@@ -118,6 +98,11 @@ struct LiveActivity: Widget {
         }
     }
 
+    private func expiredLabel() -> some View {
+        Text("Live Activity Expired. Open Trio to Refresh")
+            .minimumScaleFactor(0.01)
+    }
+
     private func updatedLabel(context: ActivityViewContext<LiveActivityAttributes>) -> Text {
         let text = Text("Updated: \(dateFormatter.string(from: context.state.date))")
             .font(.caption2)
@@ -137,13 +122,16 @@ struct LiveActivity: Widget {
         }
     }
 
-    @ViewBuilder private func bgLabel(context: ActivityViewContext<LiveActivityAttributes>) -> some View {
+    @ViewBuilder private func bgLabel(
+        context: ActivityViewContext<LiveActivityAttributes>,
+        additionalState: LiveActivityAttributes.ContentAdditionalState
+    ) -> some View {
         HStack(alignment: .center) {
             Text(context.state.bg)
                 .fontWeight(.bold)
                 .font(.largeTitle)
                 .strikethrough(context.isStale, pattern: .solid, color: .red.opacity(0.6))
-            Text(context.state.unit).foregroundStyle(.secondary).font(.subheadline).offset(x: -5, y: 5)
+            Text(additionalState.unit).foregroundStyle(.secondary).font(.subheadline).offset(x: -5, y: 5)
         }
     }
 
@@ -206,7 +194,10 @@ struct LiveActivity: Widget {
         return (stack, characters)
     }
 
-    @ViewBuilder func bobble(context: ActivityViewContext<LiveActivityAttributes>) -> some View {
+    @ViewBuilder func trendArrow(
+        context: ActivityViewContext<LiveActivityAttributes>,
+        additionalState: LiveActivityAttributes.ContentAdditionalState
+    ) -> some View {
         let gradient = LinearGradient(colors: [
             Color(red: 0.6235294118, green: 0.4235294118, blue: 0.9803921569),
             Color(red: 0.4862745098, green: 0.5450980392, blue: 0.9529411765),
@@ -217,43 +208,43 @@ struct LiveActivity: Widget {
         if !context.isStale {
             Image(systemName: "arrow.right")
                 .font(.title)
-                .rotationEffect(.degrees(context.state.rotationDegrees))
+                .rotationEffect(.degrees(additionalState.rotationDegrees))
                 .foregroundStyle(gradient)
         }
     }
 
-    @ViewBuilder func chart(context: ActivityViewContext<LiveActivityAttributes>) -> some View {
+    @ViewBuilder func chart(
+        context: ActivityViewContext<LiveActivityAttributes>,
+        additionalState: LiveActivityAttributes.ContentAdditionalState
+    ) -> some View {
         if context.isStale {
             Text("No data available")
         } else {
-            // determine scale
-            let min = (context.state.chart.min() ?? 40 * (context.state.unit == " mmol/L" ? 0.0555 : 1)) - 20 *
-                (context.state.unit == " mmol/L" ? 0.0555 : 1)
-            let max = (context.state.chart.max() ?? 270 * (context.state.unit == " mmol/L" ? 0.0555 : 1)) + 50 *
-                (context.state.unit == " mmol/L" ? 0.0555 : 1)
+            // Determine scale
+            let conversionFactor = additionalState.unit == "mmol/L" ? 0.0555 : 1
+            let min = (additionalState.chart.min() ?? 40 * conversionFactor) - 20 * conversionFactor
+            let max = (additionalState.chart.max() ?? 270 * conversionFactor) + 50 * conversionFactor
 
             Chart {
-                RuleMark(y: .value("high", context.state.highGlucose))
+                RuleMark(y: .value("High", additionalState.highGlucose))
                     .lineStyle(.init(lineWidth: 0.5, dash: [5]))
-                RuleMark(y: .value("low", context.state.lowGlucose))
+                RuleMark(y: .value("Low", additionalState.lowGlucose))
                     .lineStyle(.init(lineWidth: 0.5, dash: [5]))
-                ForEach(context.state.chart.indices, id: \.self) { index in
-                    let currentValue = context.state.chart[index]
-                    if currentValue > context.state.highGlucose {
-                        PointMark(
-                            x: .value("Time", context.state.chartDate[index] ?? Date()),
-                            y: .value("Value", currentValue)
-                        ).foregroundStyle(Color.orange.gradient).symbolSize(15)
-                    } else if currentValue < context.state.lowGlucose {
-                        PointMark(
-                            x: .value("Time", context.state.chartDate[index] ?? Date()),
-                            y: .value("Value", currentValue)
-                        ).foregroundStyle(Color.red.gradient).symbolSize(15)
+
+                ForEach(additionalState.chart.indices, id: \.self) { index in
+                    let currentValue = additionalState.chart[index]
+                    let chartDate = additionalState.chartDate[index] ?? Date()
+                    let pointMark = PointMark(
+                        x: .value("Time", chartDate),
+                        y: .value("Value", currentValue)
+                    ).symbolSize(15)
+
+                    if currentValue > additionalState.highGlucose {
+                        pointMark.foregroundStyle(Color.orange.gradient)
+                    } else if currentValue < additionalState.lowGlucose {
+                        pointMark.foregroundStyle(Color.red.gradient)
                     } else {
-                        PointMark(
-                            x: .value("Time", context.state.chartDate[index] ?? Date()),
-                            y: .value("Value", currentValue)
-                        ).foregroundStyle(Color.green.gradient).symbolSize(15)
+                        pointMark.foregroundStyle(Color.green.gradient)
                     }
                 }
             }
@@ -272,96 +263,203 @@ struct LiveActivity: Widget {
         }
     }
 
-    var body: some WidgetConfiguration {
-        ActivityConfiguration(for: LiveActivityAttributes.self) { context in
-            // Lock screen/banner UI goes here
-            if context.state.lockScreenView == "Simple" {
-                HStack(spacing: 3) {
-                    bgAndTrend(context: context, size: .expanded).0.font(.title)
+    @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()
-                    VStack(alignment: .trailing, spacing: 5) {
-                        changeLabel(context: context).font(.title3)
-                        updatedLabel(context: context).font(.caption).foregroundStyle(.primary.opacity(0.7))
+                    bgLabel(context: context, additionalState: detailedViewState)
+                    HStack {
+                        changeLabel(context: context)
+                        trendArrow(context: context, additionalState: detailedViewState)
                     }
+                    mealLabel(context: context, additionalState: detailedViewState).padding(.bottom, 8)
+                    updatedLabel(context: context).padding(.bottom, 10)
                 }
-                .privacySensitive()
-                .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: 12) {
-                    chart(context: context).frame(maxWidth: UIScreen.main.bounds.width / 1.8)
-                    VStack(alignment: .leading) {
+            }
+            .privacySensitive()
+            .padding(.all, 14)
+            .imageScale(.small)
+            .foregroundColor(Color.white)
+            .activityBackgroundTint(Color.black.opacity(0.8))
+        } else {
+            Group {
+                if context.state.isInitialState {
+                    // add vertical and horizontal spacers around the label to ensure that the live activity view gets filled completely
+                    HStack {
                         Spacer()
-                        bgLabel(context: context)
-                        HStack {
-                            changeLabel(context: context)
-                            bobble(context: context)
+                        VStack {
+                            Spacer()
+                            expiredLabel()
+                            Spacer()
                         }
-                        mealLabel(context: context).padding(.bottom, 8)
-                        updatedLabel(context: context).padding(.bottom, 10)
+                        Spacer()
                     }
-                }
-                .privacySensitive()
-                .padding(.all, 14)
-                .imageScale(.small)
-                .foregroundColor(Color.white)
-                .activityBackgroundTint(Color.black.opacity(0.8))
-            }
-        } dynamicIsland: { context in
-            DynamicIsland {
-                // Expanded UI goes here.  Compose the expanded UI through
-                // various regions, like leading/trailing/center/bottom
-                DynamicIslandExpandedRegion(.leading) {
-                    bgAndTrend(context: context, size: .expanded).0.font(.title2).padding(.leading, 5)
-                }
-                DynamicIslandExpandedRegion(.trailing) {
-                    changeLabel(context: context).font(.title2).padding(.trailing, 5)
-                }
-                DynamicIslandExpandedRegion(.bottom) {
-                    if context.state.lockScreenView == "Simple" {
-                        Group {
-                            updatedLabel(context: context).font(.caption).foregroundStyle(Color.secondary)
+                } else {
+                    HStack(spacing: 3) {
+                        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(.primary.opacity(0.7))
                         }
-                        .frame(
-                            maxHeight: .infinity,
-                            alignment: .bottom
-                        )
-                    } else {
-                        chart(context: context)
                     }
                 }
-                DynamicIslandExpandedRegion(.center) {
-                    if context.state.lockScreenView == "Detailed" {
+            }
+            .privacySensitive()
+            .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)
+        }
+    }
+
+    func dynamicIsland(context: ActivityViewContext<LiveActivityAttributes>) -> DynamicIsland {
+        DynamicIsland {
+            DynamicIslandExpandedRegion(.leading) {
+                bgAndTrend(context: context, size: .expanded).0.font(.title2).padding(.leading, 5)
+            }
+            DynamicIslandExpandedRegion(.trailing) {
+                changeLabel(context: context).font(.title2).padding(.trailing, 5)
+            }
+            DynamicIslandExpandedRegion(.bottom) {
+                if context.state.isInitialState {
+                    expiredLabel()
+                } else if let detailedViewState = context.state.detailedViewState {
+                    chart(context: context, additionalState: detailedViewState)
+                } else {
+                    Group {
                         updatedLabel(context: context).font(.caption).foregroundStyle(Color.secondary)
                     }
+                    .frame(
+                        maxHeight: .infinity,
+                        alignment: .bottom
+                    )
                 }
-            } compactLeading: {
-                bgAndTrend(context: context, size: .compact).0.padding(.leading, 4)
-            } compactTrailing: {
-                changeLabel(context: context).padding(.trailing, 4)
-            } minimal: {
-                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)
+            }
+            DynamicIslandExpandedRegion(.center) {
+                if context.state.detailedViewState != nil {
+                    updatedLabel(context: context).font(.caption).foregroundStyle(Color.secondary)
                 }
             }
-            .widgetURL(URL(string: "freeaps-x://"))
-            .keylineTint(Color.purple)
-            .contentMargins(.horizontal, 0, for: .minimal)
-            .contentMargins(.trailing, 0, for: .compactLeading)
-            .contentMargins(.leading, 0, for: .compactTrailing)
+        } compactLeading: {
+            bgAndTrend(context: context, size: .compact).0.padding(.leading, 4)
+        } compactTrailing: {
+            changeLabel(context: context).padding(.trailing, 4)
+        } minimal: {
+            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: "Trio://"))
+        .keylineTint(Color.purple)
+        .contentMargins(.horizontal, 0, for: .minimal)
+        .contentMargins(.trailing, 0, for: .compactLeading)
+        .contentMargins(.leading, 0, for: .compactTrailing)
+    }
+
+    var body: some WidgetConfiguration {
+        ActivityConfiguration(for: LiveActivityAttributes.self, content: self.content, dynamicIsland: self.dynamicIsland)
+    }
+}
+
+private extension LiveActivityAttributes {
+    static var preview: LiveActivityAttributes {
+        LiveActivityAttributes(startDate: Date())
+    }
+}
+
+private extension LiveActivityAttributes.ContentState {
+    // 0 is the widest digit. Use this to get an upper bound on text width.
+
+    // Use mmol/l notation with decimal point as well for the same reason, it uses up to 4 characters, while mg/dl uses up to 3
+    static var testWide: LiveActivityAttributes.ContentState {
+        LiveActivityAttributes.ContentState(
+            bg: "00.0",
+            direction: "→",
+            change: "+0.0",
+            date: Date(),
+            detailedViewState: nil,
+            isInitialState: false
+        )
+    }
+
+    static var testVeryWide: LiveActivityAttributes.ContentState {
+        LiveActivityAttributes.ContentState(
+            bg: "00.0",
+            direction: "↑↑",
+            change: "+0.0",
+            date: Date(),
+            detailedViewState: nil,
+            isInitialState: false
+        )
+    }
+
+    static var testSuperWide: LiveActivityAttributes.ContentState {
+        LiveActivityAttributes.ContentState(
+            bg: "00.0",
+            direction: "↑↑↑",
+            change: "+0.0",
+            date: Date(),
+            detailedViewState: nil,
+            isInitialState: false
+        )
+    }
+
+    // 2 characters for BG, 1 character for change is the minimum that will be shown
+    static var testNarrow: LiveActivityAttributes.ContentState {
+        LiveActivityAttributes.ContentState(
+            bg: "00",
+            direction: "↑",
+            change: "+0",
+            date: Date(),
+            detailedViewState: nil,
+            isInitialState: false
+        )
+    }
+
+    static var testMedium: LiveActivityAttributes.ContentState {
+        LiveActivityAttributes.ContentState(
+            bg: "000",
+            direction: "↗︎",
+            change: "+00",
+            date: Date(),
+            detailedViewState: nil,
+            isInitialState: false
+        )
     }
+
+    static var testExpired: LiveActivityAttributes.ContentState {
+        LiveActivityAttributes.ContentState(
+            bg: "--",
+            direction: nil,
+            change: "--",
+            date: Date().addingTimeInterval(-60 * 60),
+            detailedViewState: nil,
+            isInitialState: true
+        )
+    }
+}
+
+@available(iOS 17.0, iOSApplicationExtension 17.0, *)
+#Preview("Notification", as: .content, using: LiveActivityAttributes.preview) {
+    LiveActivity()
+} contentStates: {
+    LiveActivityAttributes.ContentState.testSuperWide
+    LiveActivityAttributes.ContentState.testVeryWide
+    LiveActivityAttributes.ContentState.testWide
+    LiveActivityAttributes.ContentState.testMedium
+    LiveActivityAttributes.ContentState.testNarrow
+    LiveActivityAttributes.ContentState.testExpired
 }

+ 2 - 2
Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved

@@ -1,5 +1,5 @@
 {
-  "originHash" : "f5c836c216c4ca7d356e3777e58d6d4f9502b03f3974891349eb775f4c4cf750",
+  "originHash" : "59ac7eba66375d6eb406e758cb0b9964f4b3b0ae45c5665596f00384c32262b9",
   "pins" : [
     {
       "identity" : "cryptoswift",
@@ -49,7 +49,7 @@
     {
       "identity" : "swiftcharts",
       "kind" : "remoteSourceControl",
-      "location" : "https://github.com/ivanschuetz/SwiftCharts",
+      "location" : "https://github.com/ivanschuetz/SwiftCharts.git",
       "state" : {
         "branch" : "master",
         "revision" : "c354c1945bb35a1f01b665b22474f6db28cba4a2"