expo-live-activity 0.4.2 → 0.5.0-alpha1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,6 +18,8 @@ import WidgetKit
18
18
  struct DebugLog: View {
19
19
  #if DEBUG
20
20
  private let message: String
21
+
22
+ @discardableResult
21
23
  init(_ message: String) {
22
24
  self.message = message
23
25
  print(message)
@@ -29,11 +31,31 @@ import WidgetKit
29
31
  .foregroundStyle(.red)
30
32
  }
31
33
  #else
34
+ @discardableResult
32
35
  init(_: String) {}
33
36
  var body: some View { EmptyView() }
34
37
  #endif
35
38
  }
36
39
 
40
+ struct SegmentedProgressView: View {
41
+ let currentStep: Int
42
+ let totalSteps: Int
43
+ let activeColor: Color
44
+ let inactiveColor: Color
45
+ var height: CGFloat = 4
46
+
47
+ var body: some View {
48
+ let clampedCurrentStep = min(max(currentStep, 0), totalSteps)
49
+ HStack(spacing: 4) {
50
+ ForEach(0 ..< totalSteps, id: \.self) { index in
51
+ RoundedRectangle(cornerRadius: 2)
52
+ .fill(index < clampedCurrentStep ? activeColor : inactiveColor)
53
+ .frame(height: height)
54
+ }
55
+ }
56
+ }
57
+ }
58
+
37
59
  struct LiveActivityView: View {
38
60
  let contentState: LiveActivityAttributes.ContentState
39
61
  let attributes: LiveActivityAttributes
@@ -43,7 +65,7 @@ import WidgetKit
43
65
  attributes.progressViewTint.map { Color(hex: $0) }
44
66
  }
45
67
 
46
- private var imageAlignment: Alignment {
68
+ private var imageVerticalAlignment: VerticalAlignment {
47
69
  switch attributes.imageAlign {
48
70
  case "center":
49
71
  return .center
@@ -54,69 +76,142 @@ import WidgetKit
54
76
  }
55
77
  }
56
78
 
57
- private func alignedImage(imageName: String) -> some View {
58
- let defaultHeight: CGFloat = 64
59
- let defaultWidth: CGFloat = 64
79
+ private var hasImage: Bool {
80
+ contentState.imageName != nil
81
+ }
82
+
83
+ private var isLeftImage: Bool {
84
+ (attributes.imagePosition ?? "right").hasPrefix("left")
85
+ }
86
+
87
+ private var isStretch: Bool {
88
+ (attributes.imagePosition ?? "right").contains("Stretch")
89
+ }
90
+
91
+ var body: some View {
92
+ if #available(iOS 18.0, *) {
93
+ LiveActivityView_iOS18(
94
+ contentState: contentState,
95
+ attributes: attributes,
96
+ imageContainerSize: $imageContainerSize,
97
+ smallView: {
98
+ LiveActivitySmallView(
99
+ contentState: contentState,
100
+ attributes: attributes,
101
+ imageContainerSize: $imageContainerSize,
102
+ alignedImage: { imageName, horizontalAlignment, isSmallView in
103
+ AnyView(alignedImage(imageName: imageName, horizontalAlignment: horizontalAlignment, isSmallView: isSmallView))
104
+ }
105
+ )
106
+ },
107
+ mediumView: {
108
+ LiveActivityMediumView(
109
+ contentState: contentState,
110
+ attributes: attributes,
111
+ imageContainerSize: $imageContainerSize,
112
+ alignedImage: { imageName, horizontalAlignment, isSmallView in
113
+ AnyView(alignedImage(imageName: imageName, horizontalAlignment: horizontalAlignment, isSmallView: isSmallView))
114
+ }
115
+ )
116
+ }
117
+ )
118
+ } else {
119
+ // iOS 17: missing activityFamily in environment -> default to medium
120
+ LiveActivityMediumView(
121
+ contentState: contentState,
122
+ attributes: attributes,
123
+ imageContainerSize: $imageContainerSize,
124
+ alignedImage: { imageName, horizontalAlignment, isSmallView in
125
+ AnyView(alignedImage(imageName: imageName, horizontalAlignment: horizontalAlignment, isSmallView: isSmallView))
126
+ }
127
+ )
128
+ }
129
+ }
130
+
131
+ private func alignedImage(imageName: String, horizontalAlignment: HorizontalAlignment, isSmallView: Bool = false) -> some View {
132
+ let defaultHeight: CGFloat = isSmallView ? 28 : 64
133
+ let defaultWidth: CGFloat = isSmallView ? 28 : 64
60
134
  let containerHeight = imageContainerSize?.height
61
135
  let containerWidth = imageContainerSize?.width
62
- let hasWidthConstraint = (attributes.imageWidthPercent != nil) || (attributes.imageWidth != nil)
136
+
137
+ // For small view, check small-specific dimensions first, then fall back to global
138
+ let imageWidth = isSmallView ? (attributes.smallImageWidth ?? attributes.imageWidth) : attributes.imageWidth
139
+ let imageHeight = isSmallView ? (attributes.smallImageHeight ?? attributes.imageHeight) : attributes.imageHeight
140
+ let imageWidthPercent = isSmallView ? (attributes.smallImageWidthPercent ?? attributes.imageWidthPercent) : attributes.imageWidthPercent
141
+ let imageHeightPercent = isSmallView ? (attributes.smallImageHeightPercent ?? attributes.imageHeightPercent) : attributes.imageHeightPercent
142
+
143
+ let hasWidthConstraint = (imageWidthPercent != nil) || (imageWidth != nil)
63
144
 
64
145
  let computedHeight: CGFloat? = {
65
- if let percent = attributes.imageHeightPercent {
146
+ if let percent = imageHeightPercent {
66
147
  let clamped = min(max(percent, 0), 100) / 100.0
67
148
  // Use the row height as a base. Fallback to default when row height is not measured yet.
68
149
  let base = (containerHeight ?? defaultHeight)
69
150
  return base * clamped
70
- } else if let size = attributes.imageHeight {
151
+ } else if let size = imageHeight {
71
152
  return CGFloat(size)
72
153
  } else if hasWidthConstraint {
73
154
  // Mimic CSS: when only width is set, keep height automatic to preserve aspect ratio
74
155
  return nil
75
156
  } else {
76
157
  // Mimic CSS: this works against CSS but provides a better default behavior.
77
- // When no width/height is set, use a default size (64pt)
158
+ // When no width/height is set, use a default size (64 / 28pt)
78
159
  // Width will adjust automatically base on aspect ratio
79
160
  return defaultHeight
80
161
  }
81
162
  }()
82
163
 
83
164
  let computedWidth: CGFloat? = {
84
- if let percent = attributes.imageWidthPercent {
165
+ if let percent = imageWidthPercent {
85
166
  let clamped = min(max(percent, 0), 100) / 100.0
86
167
  let base = (containerWidth ?? defaultWidth)
87
168
  return base * clamped
88
- } else if let size = attributes.imageWidth {
169
+ } else if let size = imageWidth {
89
170
  return CGFloat(size)
90
171
  } else {
91
172
  return nil // Keep aspect fit based on height
92
173
  }
93
174
  }()
94
175
 
176
+ let resolvedHeight = computedHeight ?? defaultHeight
177
+
178
+ let resolvedWidth: CGFloat? = {
179
+ if let w = computedWidth { return w }
180
+
181
+ if let uiImage = UIImage.dynamic(assetNameOrPath: imageName) {
182
+ let h = max(uiImage.size.height, 1)
183
+ let ratio = uiImage.size.width / h
184
+ return resolvedHeight * ratio
185
+ }
186
+
187
+ return nil
188
+ }()
189
+
95
190
  return ZStack(alignment: .center) {
96
191
  Group {
97
- let fit = attributes.contentFit ?? "cover"
192
+ let fit = attributes.contentFit ?? "contain"
98
193
  switch fit {
99
194
  case "contain":
100
- Image.dynamic(assetNameOrPath: imageName).resizable().scaledToFit().frame(width: computedWidth, height: computedHeight)
195
+ Image.dynamic(assetNameOrPath: imageName).resizable().scaledToFit().frame(width: resolvedWidth, height: resolvedHeight)
101
196
  case "fill":
102
197
  Image.dynamic(assetNameOrPath: imageName).resizable().frame(
103
- width: computedWidth,
104
- height: computedHeight
198
+ width: resolvedWidth,
199
+ height: resolvedHeight
105
200
  )
106
201
  case "none":
107
- Image.dynamic(assetNameOrPath: imageName).renderingMode(.original).frame(width: computedWidth, height: computedHeight)
202
+ Image.dynamic(assetNameOrPath: imageName).renderingMode(.original).frame(width: resolvedWidth, height: resolvedHeight)
108
203
  case "scale-down":
109
204
  if let uiImage = UIImage.dynamic(assetNameOrPath: imageName) {
110
205
  // Determine the target box. When width/height are nil, we use image's intrinsic dimension for comparison.
111
- let targetHeight = computedHeight ?? uiImage.size.height
112
- let targetWidth = computedWidth ?? uiImage.size.width
206
+ let targetHeight = resolvedHeight
207
+ let targetWidth = resolvedWidth ?? uiImage.size.width
113
208
  let shouldScaleDown = uiImage.size.height > targetHeight || uiImage.size.width > targetWidth
114
209
 
115
210
  if shouldScaleDown {
116
211
  Image(uiImage: uiImage)
117
212
  .resizable()
118
213
  .scaledToFit()
119
- .frame(width: computedWidth, height: computedHeight)
214
+ .frame(width: resolvedWidth, height: resolvedHeight)
120
215
  } else {
121
216
  Image(uiImage: uiImage)
122
217
  .renderingMode(.original)
@@ -127,15 +222,19 @@ import WidgetKit
127
222
  }
128
223
  case "cover":
129
224
  Image.dynamic(assetNameOrPath: imageName).resizable().scaledToFill().frame(
130
- width: computedWidth,
131
- height: computedHeight
225
+ width: resolvedWidth,
226
+ height: resolvedHeight
132
227
  ).clipped()
133
228
  default:
134
229
  DebugLog("⚠️[ExpoLiveActivity] Unknown contentFit '\(fit)'")
135
230
  }
136
231
  }
137
232
  }
138
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: imageAlignment)
233
+ .frame(
234
+ maxWidth: .infinity,
235
+ maxHeight: .infinity,
236
+ alignment: Alignment(horizontal: horizontalAlignment, vertical: imageVerticalAlignment)
237
+ )
139
238
  .background(
140
239
  GeometryReader { proxy in
141
240
  Color.clear
@@ -149,98 +248,28 @@ import WidgetKit
149
248
  }
150
249
  )
151
250
  }
251
+ }
152
252
 
153
- var body: some View {
154
- let defaultPadding = 24
155
-
156
- let top = CGFloat(
157
- attributes.paddingDetails?.top
158
- ?? attributes.paddingDetails?.vertical
159
- ?? attributes.padding
160
- ?? defaultPadding
161
- )
162
-
163
- let bottom = CGFloat(
164
- attributes.paddingDetails?.bottom
165
- ?? attributes.paddingDetails?.vertical
166
- ?? attributes.padding
167
- ?? defaultPadding
168
- )
169
-
170
- let leading = CGFloat(
171
- attributes.paddingDetails?.left
172
- ?? attributes.paddingDetails?.horizontal
173
- ?? attributes.padding
174
- ?? defaultPadding
175
- )
176
-
177
- let trailing = CGFloat(
178
- attributes.paddingDetails?.right
179
- ?? attributes.paddingDetails?.horizontal
180
- ?? attributes.padding
181
- ?? defaultPadding
182
- )
183
-
184
- VStack(alignment: .leading) {
185
- let position = attributes.imagePosition ?? "right"
186
- let isStretch = position.contains("Stretch")
187
- let isLeftImage = position.hasPrefix("left")
188
- let hasImage = contentState.imageName != nil
189
- let effectiveStretch = isStretch && hasImage
190
-
191
- HStack(alignment: .center) {
192
- if hasImage, isLeftImage {
193
- if let imageName = contentState.imageName {
194
- alignedImage(imageName: imageName)
195
- }
196
- }
197
-
198
- VStack(alignment: .leading, spacing: 2) {
199
- Text(contentState.title)
200
- .font(.title2)
201
- .fontWeight(.semibold)
202
- .modifier(ConditionalForegroundViewModifier(color: attributes.titleColor))
253
+ @available(iOS 18.0, *)
254
+ private struct LiveActivityView_iOS18<Small: View, Medium: View>: View {
255
+ let contentState: LiveActivityAttributes.ContentState
256
+ let attributes: LiveActivityAttributes
257
+ @Binding var imageContainerSize: CGSize?
203
258
 
204
- if let subtitle = contentState.subtitle {
205
- Text(subtitle)
206
- .font(.title3)
207
- .modifier(ConditionalForegroundViewModifier(color: attributes.subtitleColor))
208
- }
259
+ let smallView: () -> Small
260
+ let mediumView: () -> Medium
209
261
 
210
- if effectiveStretch {
211
- if let date = contentState.timerEndDateInMilliseconds {
212
- ProgressView(timerInterval: Date.toTimerInterval(miliseconds: date))
213
- .tint(progressViewTint)
214
- .modifier(ConditionalForegroundViewModifier(color: attributes.progressViewLabelColor))
215
- } else if let progress = contentState.progress {
216
- ProgressView(value: progress)
217
- .tint(progressViewTint)
218
- .modifier(ConditionalForegroundViewModifier(color: attributes.progressViewLabelColor))
219
- }
220
- }
221
- }.layoutPriority(1)
262
+ @Environment(\.activityFamily) private var activityFamily
222
263
 
223
- if hasImage, !isLeftImage { // right side (default)
224
- Spacer()
225
- if let imageName = contentState.imageName {
226
- alignedImage(imageName: imageName)
227
- }
228
- }
229
- }
230
-
231
- if !effectiveStretch {
232
- if let date = contentState.timerEndDateInMilliseconds {
233
- ProgressView(timerInterval: Date.toTimerInterval(miliseconds: date))
234
- .tint(progressViewTint)
235
- .modifier(ConditionalForegroundViewModifier(color: attributes.progressViewLabelColor))
236
- } else if let progress = contentState.progress {
237
- ProgressView(value: progress)
238
- .tint(progressViewTint)
239
- .modifier(ConditionalForegroundViewModifier(color: attributes.progressViewLabelColor))
240
- }
241
- }
264
+ var body: some View {
265
+ switch activityFamily {
266
+ case .small:
267
+ smallView()
268
+ case .medium:
269
+ mediumView()
270
+ @unknown default:
271
+ mediumView()
242
272
  }
243
- .padding(EdgeInsets(top: top, leading: leading, bottom: bottom, trailing: trailing))
244
273
  }
245
274
  }
246
275
 
@@ -2,14 +2,42 @@ import ActivityKit
2
2
  import SwiftUI
3
3
  import WidgetKit
4
4
 
5
- struct LiveActivityAttributes: ActivityAttributes {
6
- struct ContentState: Codable, Hashable {
5
+ public struct LiveActivityAttributes: ActivityAttributes {
6
+ public struct ContentState: Codable, Hashable {
7
7
  var title: String
8
8
  var subtitle: String?
9
9
  var timerEndDateInMilliseconds: Double?
10
10
  var progress: Double?
11
11
  var imageName: String?
12
12
  var dynamicIslandImageName: String?
13
+ var smallImageName: String?
14
+ var elapsedTimerStartDateInMilliseconds: Double?
15
+ var currentStep: Int?
16
+ var totalSteps: Int?
17
+
18
+ public init(
19
+ title: String,
20
+ subtitle: String? = nil,
21
+ timerEndDateInMilliseconds: Double? = nil,
22
+ progress: Double? = nil,
23
+ imageName: String? = nil,
24
+ dynamicIslandImageName: String? = nil,
25
+ smallImageName: String? = nil,
26
+ elapsedTimerStartDateInMilliseconds: Double? = nil,
27
+ currentStep: Int? = nil,
28
+ totalSteps: Int? = nil
29
+ ) {
30
+ self.title = title
31
+ self.subtitle = subtitle
32
+ self.timerEndDateInMilliseconds = timerEndDateInMilliseconds
33
+ self.progress = progress
34
+ self.imageName = imageName
35
+ self.dynamicIslandImageName = dynamicIslandImageName
36
+ self.smallImageName = smallImageName
37
+ self.elapsedTimerStartDateInMilliseconds = elapsedTimerStartDateInMilliseconds
38
+ self.currentStep = currentStep
39
+ self.totalSteps = totalSteps
40
+ }
13
41
  }
14
42
 
15
43
  var name: String
@@ -27,27 +55,100 @@ struct LiveActivityAttributes: ActivityAttributes {
27
55
  var imageHeight: Int?
28
56
  var imageWidthPercent: Double?
29
57
  var imageHeightPercent: Double?
58
+ var smallImageWidth: Int?
59
+ var smallImageHeight: Int?
60
+ var smallImageWidthPercent: Double?
61
+ var smallImageHeightPercent: Double?
30
62
  var imageAlign: String?
31
63
  var contentFit: String?
64
+ var progressSegmentActiveColor: String?
65
+ var progressSegmentInactiveColor: String?
32
66
 
33
- enum DynamicIslandTimerType: String, Codable {
67
+ public init(
68
+ name: String,
69
+ backgroundColor: String? = nil,
70
+ titleColor: String? = nil,
71
+ subtitleColor: String? = nil,
72
+ progressViewTint: String? = nil,
73
+ progressViewLabelColor: String? = nil,
74
+ deepLinkUrl: String? = nil,
75
+ timerType: DynamicIslandTimerType? = nil,
76
+ padding: Int? = nil,
77
+ paddingDetails: PaddingDetails? = nil,
78
+ imagePosition: String? = nil,
79
+ imageWidth: Int? = nil,
80
+ imageHeight: Int? = nil,
81
+ imageWidthPercent: Double? = nil,
82
+ imageHeightPercent: Double? = nil,
83
+ smallImageWidth: Int? = nil,
84
+ smallImageHeight: Int? = nil,
85
+ smallImageWidthPercent: Double? = nil,
86
+ smallImageHeightPercent: Double? = nil,
87
+ imageAlign: String? = nil,
88
+ contentFit: String? = nil,
89
+ progressSegmentActiveColor: String? = nil,
90
+ progressSegmentInactiveColor: String? = nil
91
+ ) {
92
+ self.name = name
93
+ self.backgroundColor = backgroundColor
94
+ self.titleColor = titleColor
95
+ self.subtitleColor = subtitleColor
96
+ self.progressViewTint = progressViewTint
97
+ self.progressViewLabelColor = progressViewLabelColor
98
+ self.deepLinkUrl = deepLinkUrl
99
+ self.timerType = timerType
100
+ self.padding = padding
101
+ self.paddingDetails = paddingDetails
102
+ self.imagePosition = imagePosition
103
+ self.imageWidth = imageWidth
104
+ self.imageHeight = imageHeight
105
+ self.imageWidthPercent = imageWidthPercent
106
+ self.imageHeightPercent = imageHeightPercent
107
+ self.smallImageWidth = smallImageWidth
108
+ self.smallImageHeight = smallImageHeight
109
+ self.smallImageWidthPercent = smallImageWidthPercent
110
+ self.smallImageHeightPercent = smallImageHeightPercent
111
+ self.imageAlign = imageAlign
112
+ self.contentFit = contentFit
113
+ self.progressSegmentActiveColor = progressSegmentActiveColor
114
+ self.progressSegmentInactiveColor = progressSegmentInactiveColor
115
+ }
116
+
117
+ public enum DynamicIslandTimerType: String, Codable {
34
118
  case circular
35
119
  case digital
36
120
  }
37
121
 
38
- struct PaddingDetails: Codable, Hashable {
122
+ public struct PaddingDetails: Codable, Hashable {
39
123
  var top: Int?
40
124
  var bottom: Int?
41
125
  var left: Int?
42
126
  var right: Int?
43
127
  var vertical: Int?
44
128
  var horizontal: Int?
129
+
130
+ public init(
131
+ top: Int? = nil,
132
+ bottom: Int? = nil,
133
+ left: Int? = nil,
134
+ right: Int? = nil,
135
+ vertical: Int? = nil,
136
+ horizontal: Int? = nil
137
+ ) {
138
+ self.top = top
139
+ self.bottom = bottom
140
+ self.left = left
141
+ self.right = right
142
+ self.vertical = vertical
143
+ self.horizontal = horizontal
144
+ }
45
145
  }
46
146
  }
47
147
 
48
- struct LiveActivityWidget: Widget {
49
- var body: some WidgetConfiguration {
50
- ActivityConfiguration(for: LiveActivityAttributes.self) { context in
148
+ @available(iOS 16.1, *)
149
+ public struct LiveActivityWidget: Widget {
150
+ public var body: some WidgetConfiguration {
151
+ let baseConfiguration = ActivityConfiguration(for: LiveActivityAttributes.self) { context in
51
152
  LiveActivityView(contentState: context.state, attributes: context.attributes)
52
153
  .activityBackgroundTint(
53
154
  context.attributes.backgroundColor.map { Color(hex: $0) }
@@ -70,12 +171,28 @@ struct LiveActivityWidget: Widget {
70
171
  }
71
172
  }
72
173
  DynamicIslandExpandedRegion(.bottom) {
73
- if let date = context.state.timerEndDateInMilliseconds {
174
+ if let startDate = context.state.elapsedTimerStartDateInMilliseconds {
175
+ ElapsedTimerText(
176
+ startTimeMilliseconds: startDate,
177
+ color: context.attributes.progressViewTint.map { Color(hex: $0) } ?? .white
178
+ )
179
+ .font(.title2)
180
+ .fontWeight(.semibold)
181
+ .padding(.top, 5)
182
+ .padding(.horizontal, 5)
183
+ .applyWidgetURL(from: context.attributes.deepLinkUrl)
184
+ } else if let date = context.state.timerEndDateInMilliseconds {
74
185
  dynamicIslandExpandedBottom(
75
186
  endDate: date, progressViewTint: context.attributes.progressViewTint
76
187
  )
77
188
  .padding(.horizontal, 5)
78
189
  .applyWidgetURL(from: context.attributes.deepLinkUrl)
190
+ } else if let progress = context.state.progress {
191
+ dynamicIslandExpandedBottomProgress(
192
+ progress: progress, progressViewTint: context.attributes.progressViewTint
193
+ )
194
+ .padding(.horizontal, 5)
195
+ .applyWidgetURL(from: context.attributes.deepLinkUrl)
79
196
  }
80
197
  }
81
198
  } compactLeading: {
@@ -85,25 +202,62 @@ struct LiveActivityWidget: Widget {
85
202
  .applyWidgetURL(from: context.attributes.deepLinkUrl)
86
203
  }
87
204
  } compactTrailing: {
88
- if let date = context.state.timerEndDateInMilliseconds {
205
+ if let startDate = context.state.elapsedTimerStartDateInMilliseconds {
206
+ ElapsedTimerText(
207
+ startTimeMilliseconds: startDate,
208
+ color: nil
209
+ )
210
+ .font(.system(size: 15))
211
+ .minimumScaleFactor(0.8)
212
+ .fontWeight(.semibold)
213
+ .frame(maxWidth: 60)
214
+ .multilineTextAlignment(.trailing)
215
+ .applyWidgetURL(from: context.attributes.deepLinkUrl)
216
+ } else if let date = context.state.timerEndDateInMilliseconds {
89
217
  compactTimer(
90
218
  endDate: date,
91
219
  timerType: context.attributes.timerType ?? .circular,
92
220
  progressViewTint: context.attributes.progressViewTint
93
221
  ).applyWidgetURL(from: context.attributes.deepLinkUrl)
222
+ } else if let progress = context.state.progress {
223
+ compactProgress(
224
+ progress: progress,
225
+ progressViewTint: context.attributes.progressViewTint
226
+ ).applyWidgetURL(from: context.attributes.deepLinkUrl)
94
227
  }
95
228
  } minimal: {
96
- if let date = context.state.timerEndDateInMilliseconds {
229
+ if let startDate = context.state.elapsedTimerStartDateInMilliseconds {
230
+ ElapsedTimerText(
231
+ startTimeMilliseconds: startDate,
232
+ color: context.attributes.progressViewTint.map { Color(hex: $0) }
233
+ )
234
+ .font(.system(size: 11))
235
+ .minimumScaleFactor(0.6)
236
+ .applyWidgetURL(from: context.attributes.deepLinkUrl)
237
+ } else if let date = context.state.timerEndDateInMilliseconds {
97
238
  compactTimer(
98
239
  endDate: date,
99
240
  timerType: context.attributes.timerType ?? .circular,
100
241
  progressViewTint: context.attributes.progressViewTint
101
242
  ).applyWidgetURL(from: context.attributes.deepLinkUrl)
243
+ } else if let progress = context.state.progress {
244
+ compactProgress(
245
+ progress: progress,
246
+ progressViewTint: context.attributes.progressViewTint
247
+ ).applyWidgetURL(from: context.attributes.deepLinkUrl)
102
248
  }
103
249
  }
104
250
  }
251
+
252
+ if #available(iOS 18.0, *) {
253
+ return baseConfiguration.supplementalActivityFamilies([.small])
254
+ } else {
255
+ return baseConfiguration
256
+ }
105
257
  }
106
258
 
259
+ public init() {}
260
+
107
261
  @ViewBuilder
108
262
  private func compactTimer(
109
263
  endDate: Double,
@@ -166,4 +320,44 @@ struct LiveActivityWidget: Widget {
166
320
  )
167
321
  .progressViewStyle(.circular)
168
322
  }
323
+
324
+ private func compactProgress(
325
+ progress: Double,
326
+ progressViewTint: String?
327
+ ) -> some View {
328
+ ProgressView(value: progress)
329
+ .progressViewStyle(.circular)
330
+ .tint(progressViewTint.map { Color(hex: $0) })
331
+ }
332
+
333
+ private func dynamicIslandExpandedBottomProgress(progress: Double, progressViewTint: String?) -> some View {
334
+ ProgressView(value: progress)
335
+ .foregroundStyle(.white)
336
+ .tint(progressViewTint.map { Color(hex: $0) })
337
+ .padding(.top, 5)
338
+ }
339
+ }
340
+
341
+ // MARK: - Elapsed Timer View
342
+
343
+ struct ElapsedTimerText: View {
344
+ let startTimeMilliseconds: Double
345
+ let color: Color?
346
+
347
+ private var startTime: Date {
348
+ Date(timeIntervalSince1970: startTimeMilliseconds / 1000)
349
+ }
350
+
351
+ var body: some View {
352
+ // Use Text with timerInterval for Live Activities - iOS handles the updates automatically
353
+ // The range goes from startTime to a far future date, with countsDown: false to count UP
354
+ Text(
355
+ timerInterval: startTime ... Date.distantFuture,
356
+ pauseTime: nil,
357
+ countsDown: false,
358
+ showsHours: true
359
+ )
360
+ .monospacedDigit()
361
+ .foregroundStyle(color ?? .primary)
362
+ }
169
363
  }
@@ -11,6 +11,47 @@ func resizableImage(imageName: String, height: CGFloat?, width: CGFloat?) -> som
11
11
  .frame(width: width, height: height)
12
12
  }
13
13
 
14
+ @ViewBuilder
15
+ func fixedSizeImage(name: String, size: CGFloat) -> some View {
16
+ Image.dynamic(assetNameOrPath: name)
17
+ .resizable()
18
+ .scaledToFit()
19
+ .frame(width: size, height: size)
20
+ .layoutPriority(0)
21
+ }
22
+
23
+ @ViewBuilder
24
+ func smallTimerText(
25
+ endDate: Double,
26
+ isSubtitleDisplayed: Bool,
27
+ carPlayView: Bool = false,
28
+ labelColor: String?
29
+ ) -> some View {
30
+ Text(timerInterval: Date.toTimerInterval(miliseconds: endDate))
31
+ .font(carPlayView
32
+ ? (isSubtitleDisplayed ? .footnote : .title2)
33
+ : (isSubtitleDisplayed ? .footnote : .callout))
34
+ .fontWeight(carPlayView && !isSubtitleDisplayed ? .semibold : .medium)
35
+ .modifier(ConditionalForegroundViewModifier(color: labelColor))
36
+ .padding(.top, isSubtitleDisplayed ? 3 : 0)
37
+ }
38
+
39
+ @ViewBuilder
40
+ func styledLinearProgressView<Content: View>(
41
+ tint: Color?,
42
+ labelColor: String?,
43
+ @ViewBuilder content: () -> Content
44
+ ) -> some View {
45
+ content()
46
+ .progressViewStyle(.linear)
47
+ .tint(tint)
48
+ .frame(height: 8.0)
49
+ .scaleEffect(x: 1, y: 2, anchor: .center)
50
+ .clipShape(RoundedRectangle(cornerRadius: 10))
51
+ .modifier(ConditionalForegroundViewModifier(color: labelColor))
52
+ .padding(.bottom, 6)
53
+ }
54
+
14
55
  private struct ContainerSizeKey: PreferenceKey {
15
56
  static var defaultValue: CGSize?
16
57
  static func reduce(value: inout CGSize?, nextValue: () -> CGSize?) {