expo-live-activity 0.4.3-alpha1 → 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.
@@ -10,6 +10,10 @@ public struct LiveActivityAttributes: ActivityAttributes {
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?
13
17
 
14
18
  public init(
15
19
  title: String,
@@ -17,7 +21,11 @@ public struct LiveActivityAttributes: ActivityAttributes {
17
21
  timerEndDateInMilliseconds: Double? = nil,
18
22
  progress: Double? = nil,
19
23
  imageName: String? = nil,
20
- dynamicIslandImageName: String? = nil
24
+ dynamicIslandImageName: String? = nil,
25
+ smallImageName: String? = nil,
26
+ elapsedTimerStartDateInMilliseconds: Double? = nil,
27
+ currentStep: Int? = nil,
28
+ totalSteps: Int? = nil
21
29
  ) {
22
30
  self.title = title
23
31
  self.subtitle = subtitle
@@ -25,6 +33,10 @@ public struct LiveActivityAttributes: ActivityAttributes {
25
33
  self.progress = progress
26
34
  self.imageName = imageName
27
35
  self.dynamicIslandImageName = dynamicIslandImageName
36
+ self.smallImageName = smallImageName
37
+ self.elapsedTimerStartDateInMilliseconds = elapsedTimerStartDateInMilliseconds
38
+ self.currentStep = currentStep
39
+ self.totalSteps = totalSteps
28
40
  }
29
41
  }
30
42
 
@@ -43,8 +55,14 @@ public struct LiveActivityAttributes: ActivityAttributes {
43
55
  var imageHeight: Int?
44
56
  var imageWidthPercent: Double?
45
57
  var imageHeightPercent: Double?
58
+ var smallImageWidth: Int?
59
+ var smallImageHeight: Int?
60
+ var smallImageWidthPercent: Double?
61
+ var smallImageHeightPercent: Double?
46
62
  var imageAlign: String?
47
63
  var contentFit: String?
64
+ var progressSegmentActiveColor: String?
65
+ var progressSegmentInactiveColor: String?
48
66
 
49
67
  public init(
50
68
  name: String,
@@ -62,8 +80,14 @@ public struct LiveActivityAttributes: ActivityAttributes {
62
80
  imageHeight: Int? = nil,
63
81
  imageWidthPercent: Double? = nil,
64
82
  imageHeightPercent: Double? = nil,
83
+ smallImageWidth: Int? = nil,
84
+ smallImageHeight: Int? = nil,
85
+ smallImageWidthPercent: Double? = nil,
86
+ smallImageHeightPercent: Double? = nil,
65
87
  imageAlign: String? = nil,
66
- contentFit: String? = nil
88
+ contentFit: String? = nil,
89
+ progressSegmentActiveColor: String? = nil,
90
+ progressSegmentInactiveColor: String? = nil
67
91
  ) {
68
92
  self.name = name
69
93
  self.backgroundColor = backgroundColor
@@ -80,8 +104,14 @@ public struct LiveActivityAttributes: ActivityAttributes {
80
104
  self.imageHeight = imageHeight
81
105
  self.imageWidthPercent = imageWidthPercent
82
106
  self.imageHeightPercent = imageHeightPercent
107
+ self.smallImageWidth = smallImageWidth
108
+ self.smallImageHeight = smallImageHeight
109
+ self.smallImageWidthPercent = smallImageWidthPercent
110
+ self.smallImageHeightPercent = smallImageHeightPercent
83
111
  self.imageAlign = imageAlign
84
112
  self.contentFit = contentFit
113
+ self.progressSegmentActiveColor = progressSegmentActiveColor
114
+ self.progressSegmentInactiveColor = progressSegmentInactiveColor
85
115
  }
86
116
 
87
117
  public enum DynamicIslandTimerType: String, Codable {
@@ -118,7 +148,7 @@ public struct LiveActivityAttributes: ActivityAttributes {
118
148
  @available(iOS 16.1, *)
119
149
  public struct LiveActivityWidget: Widget {
120
150
  public var body: some WidgetConfiguration {
121
- ActivityConfiguration(for: LiveActivityAttributes.self) { context in
151
+ let baseConfiguration = ActivityConfiguration(for: LiveActivityAttributes.self) { context in
122
152
  LiveActivityView(contentState: context.state, attributes: context.attributes)
123
153
  .activityBackgroundTint(
124
154
  context.attributes.backgroundColor.map { Color(hex: $0) }
@@ -141,7 +171,17 @@ public struct LiveActivityWidget: Widget {
141
171
  }
142
172
  }
143
173
  DynamicIslandExpandedRegion(.bottom) {
144
- 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 {
145
185
  dynamicIslandExpandedBottom(
146
186
  endDate: date, progressViewTint: context.attributes.progressViewTint
147
187
  )
@@ -162,7 +202,18 @@ public struct LiveActivityWidget: Widget {
162
202
  .applyWidgetURL(from: context.attributes.deepLinkUrl)
163
203
  }
164
204
  } compactTrailing: {
165
- 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 {
166
217
  compactTimer(
167
218
  endDate: date,
168
219
  timerType: context.attributes.timerType ?? .circular,
@@ -175,7 +226,15 @@ public struct LiveActivityWidget: Widget {
175
226
  ).applyWidgetURL(from: context.attributes.deepLinkUrl)
176
227
  }
177
228
  } minimal: {
178
- 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 {
179
238
  compactTimer(
180
239
  endDate: date,
181
240
  timerType: context.attributes.timerType ?? .circular,
@@ -189,6 +248,12 @@ public struct LiveActivityWidget: Widget {
189
248
  }
190
249
  }
191
250
  }
251
+
252
+ if #available(iOS 18.0, *) {
253
+ return baseConfiguration.supplementalActivityFamilies([.small])
254
+ } else {
255
+ return baseConfiguration
256
+ }
192
257
  }
193
258
 
194
259
  public init() {}
@@ -272,3 +337,27 @@ public struct LiveActivityWidget: Widget {
272
337
  .padding(.top, 5)
273
338
  }
274
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
+ }
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?) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-live-activity",
3
- "version": "0.4.3-alpha1",
3
+ "version": "0.5.0-alpha1",
4
4
  "description": "A module for adding Live Activity to a React Native app for iOS.",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
@@ -8,8 +8,7 @@
8
8
  "build": "expo-module build",
9
9
  "clean": "expo-module clean",
10
10
  "clean:plugin": "rm -rf plugin/build plugin/tsconfig.tsbuildinfo",
11
- "format:check": "prettier --check .",
12
- "format:fix": "prettier --write . && swiftformat --exclude \"**/node_modules/\" .",
11
+ "lint:fix": "expo-module eslint --fix && swiftformat --exclude \"**/node_modules/\" .",
13
12
  "lint": "expo-module eslint",
14
13
  "lint:libOnly": "expo-module eslint --ignore-pattern 'example/*'",
15
14
  "test": "expo-module test",
@@ -7,6 +7,7 @@ const config_plugins_1 = require("expo/config-plugins");
7
7
  const withConfig_1 = require("./withConfig");
8
8
  const withPlist_1 = __importDefault(require("./withPlist"));
9
9
  const withPushNotifications_1 = require("./withPushNotifications");
10
+ const withUnsupportedOS_1 = require("./withUnsupportedOS");
10
11
  const withWidgetExtensionEntitlements_1 = require("./withWidgetExtensionEntitlements");
11
12
  const withXcode_1 = require("./withXcode");
12
13
  const withWidgetsAndLiveActivities = (config, props) => {
@@ -37,6 +38,7 @@ const withWidgetsAndLiveActivities = (config, props) => {
37
38
  if (props?.enablePushNotifications) {
38
39
  config = (0, withPushNotifications_1.withPushNotifications)(config);
39
40
  }
41
+ config = (0, withUnsupportedOS_1.withUnsupportedOS)(config, { silentOnUnsupportedOS: props?.silentOnUnsupportedOS ?? false });
40
42
  return config;
41
43
  };
42
44
  exports.default = withWidgetsAndLiveActivities;
@@ -1,6 +1,7 @@
1
1
  import { ConfigPlugin } from '@expo/config-plugins';
2
2
  interface ConfigPluginProps {
3
3
  enablePushNotifications?: boolean;
4
+ silentOnUnsupportedOS?: boolean;
4
5
  }
5
6
  export type LiveActivityConfigPlugin = ConfigPlugin<ConfigPluginProps | undefined>;
6
7
  export {};
@@ -0,0 +1,4 @@
1
+ import { ConfigPlugin } from '@expo/config-plugins';
2
+ export declare const withUnsupportedOS: ConfigPlugin<{
3
+ silentOnUnsupportedOS: boolean;
4
+ }>;
@@ -0,0 +1,9 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.withUnsupportedOS = void 0;
4
+ const config_plugins_1 = require("@expo/config-plugins");
5
+ const withUnsupportedOS = (config, { silentOnUnsupportedOS }) => (0, config_plugins_1.withInfoPlist)(config, (mod) => {
6
+ mod.modResults['ExpoLiveActivity_SilentOnUnsupportedOS'] = silentOnUnsupportedOS;
7
+ return mod;
8
+ });
9
+ exports.withUnsupportedOS = withUnsupportedOS;
@@ -4,6 +4,7 @@ import type { LiveActivityConfigPlugin } from './types'
4
4
  import { withConfig } from './withConfig'
5
5
  import withPlist from './withPlist'
6
6
  import { withPushNotifications } from './withPushNotifications'
7
+ import { withUnsupportedOS } from './withUnsupportedOS'
7
8
  import { withWidgetExtensionEntitlements } from './withWidgetExtensionEntitlements'
8
9
  import { withXcode } from './withXcode'
9
10
 
@@ -39,6 +40,8 @@ const withWidgetsAndLiveActivities: LiveActivityConfigPlugin = (config, props) =
39
40
  config = withPushNotifications(config)
40
41
  }
41
42
 
43
+ config = withUnsupportedOS(config, { silentOnUnsupportedOS: props?.silentOnUnsupportedOS ?? false })
44
+
42
45
  return config
43
46
  }
44
47
 
@@ -2,6 +2,7 @@ import { ConfigPlugin } from '@expo/config-plugins'
2
2
 
3
3
  interface ConfigPluginProps {
4
4
  enablePushNotifications?: boolean
5
+ silentOnUnsupportedOS?: boolean
5
6
  }
6
7
 
7
8
  export type LiveActivityConfigPlugin = ConfigPlugin<ConfigPluginProps | undefined>
@@ -0,0 +1,10 @@
1
+ import { ConfigPlugin, withInfoPlist } from '@expo/config-plugins'
2
+
3
+ export const withUnsupportedOS: ConfigPlugin<{ silentOnUnsupportedOS: boolean }> = (
4
+ config,
5
+ { silentOnUnsupportedOS }
6
+ ) =>
7
+ withInfoPlist(config, (mod) => {
8
+ mod.modResults['ExpoLiveActivity_SilentOnUnsupportedOS'] = silentOnUnsupportedOS
9
+ return mod
10
+ })
@@ -1 +1 @@
1
- {"root":["./src/index.ts","./src/types.ts","./src/withConfig.ts","./src/withPlist.ts","./src/withPushNotifications.ts","./src/withWidgetExtensionEntitlements.ts","./src/withXcode.ts","./src/lib/getWidgetExtensionEntitlements.ts","./src/lib/getWidgetFiles.ts","./src/xcode/addBuildPhases.ts","./src/xcode/addPbxGroup.ts","./src/xcode/addProductFile.ts","./src/xcode/addTargetDependency.ts","./src/xcode/addToPbxNativeTargetSection.ts","./src/xcode/addToPbxProjectSection.ts","./src/xcode/addXCConfigurationList.ts"],"version":"5.8.3"}
1
+ {"root":["./src/index.ts","./src/types.ts","./src/withConfig.ts","./src/withPlist.ts","./src/withPushNotifications.ts","./src/withUnsupportedOS.ts","./src/withWidgetExtensionEntitlements.ts","./src/withXcode.ts","./src/lib/getWidgetExtensionEntitlements.ts","./src/lib/getWidgetFiles.ts","./src/xcode/addBuildPhases.ts","./src/xcode/addPbxGroup.ts","./src/xcode/addProductFile.ts","./src/xcode/addTargetDependency.ts","./src/xcode/addToPbxNativeTargetSection.ts","./src/xcode/addToPbxProjectSection.ts","./src/xcode/addXCConfigurationList.ts"],"version":"5.8.3"}
package/src/index.ts CHANGED
@@ -7,14 +7,38 @@ type Voidable<T> = T | void
7
7
 
8
8
  export type DynamicIslandTimerType = 'circular' | 'digital'
9
9
 
10
+ export type ElapsedTimer = {
11
+ startDate: number // milliseconds timestamp (past time when timer started)
12
+ }
13
+
10
14
  type ProgressBarType =
11
15
  | {
12
16
  date?: number
13
17
  progress?: undefined
18
+ elapsedTimer?: undefined
19
+ currentStep?: undefined
20
+ totalSteps?: undefined
14
21
  }
15
22
  | {
16
23
  date?: undefined
17
24
  progress?: number
25
+ elapsedTimer?: undefined
26
+ currentStep?: undefined
27
+ totalSteps?: undefined
28
+ }
29
+ | {
30
+ date?: undefined
31
+ progress?: undefined
32
+ elapsedTimer?: ElapsedTimer
33
+ currentStep?: undefined
34
+ totalSteps?: undefined
35
+ }
36
+ | {
37
+ date?: undefined
38
+ progress?: undefined
39
+ elapsedTimer?: undefined
40
+ currentStep?: number
41
+ totalSteps?: number
18
42
  }
19
43
 
20
44
  export type LiveActivityState = {
@@ -23,6 +47,7 @@ export type LiveActivityState = {
23
47
  progressBar?: ProgressBarType
24
48
  imageName?: string
25
49
  dynamicIslandImageName?: string
50
+ smallImageName?: string
26
51
  }
27
52
 
28
53
  export type NativeLiveActivityState = {
@@ -51,8 +76,8 @@ export type ImageAlign = 'top' | 'center' | 'bottom'
51
76
 
52
77
  export type ImageDimension = number | `${number}%`
53
78
  export type ImageSize = {
54
- width: ImageDimension
55
- height: ImageDimension
79
+ width?: ImageDimension
80
+ height?: ImageDimension
56
81
  }
57
82
 
58
83
  export type ImageContentFit = 'cover' | 'contain' | 'fill' | 'none' | 'scale-down'
@@ -69,7 +94,10 @@ export type LiveActivityConfig = {
69
94
  imagePosition?: ImagePosition
70
95
  imageAlign?: ImageAlign
71
96
  imageSize?: ImageSize
97
+ smallImageSize?: ImageSize
72
98
  contentFit?: ImageContentFit
99
+ progressSegmentActiveColor?: string
100
+ progressSegmentInactiveColor?: string
73
101
  }
74
102
 
75
103
  export type ActivityTokenReceivedEvent = {
@@ -107,15 +135,24 @@ function assertIOS(name: string) {
107
135
  function normalizeConfig(config?: LiveActivityConfig) {
108
136
  if (config === undefined) return config
109
137
 
110
- const { padding, imageSize, ...base } = config
138
+ const { padding, imageSize, smallImageSize, progressSegmentActiveColor, progressSegmentInactiveColor, ...base } =
139
+ config
111
140
  type NormalizedConfig = LiveActivityConfig & {
112
141
  paddingDetails?: Padding
113
142
  imageWidth?: number
114
143
  imageHeight?: number
115
144
  imageWidthPercent?: number
116
145
  imageHeightPercent?: number
146
+ smallImageWidth?: number
147
+ smallImageHeight?: number
148
+ smallImageWidthPercent?: number
149
+ smallImageHeightPercent?: number
150
+ }
151
+ const normalized: NormalizedConfig = {
152
+ ...base,
153
+ progressSegmentActiveColor,
154
+ progressSegmentInactiveColor,
117
155
  }
118
- const normalized: NormalizedConfig = { ...base }
119
156
 
120
157
  // Normalize padding: keep number in padding, object in paddingDetails
121
158
  if (typeof padding === 'number') {
@@ -124,33 +161,38 @@ function normalizeConfig(config?: LiveActivityConfig) {
124
161
  normalized.paddingDetails = padding
125
162
  }
126
163
 
127
- // Normalize imageSize: object with width/height each a number (points) or percent string like '50%'
128
- if (imageSize) {
164
+ // Helper to parse a dimension value (number or percent string like '50%')
165
+ const parseDimension = (
166
+ value: ImageDimension | undefined,
167
+ fieldName: string
168
+ ): { absolute?: number; percent?: number } => {
169
+ if (value === undefined) return {}
170
+
171
+ if (typeof value === 'number') return { absolute: value }
129
172
  const regExp = /^(100(?:\.0+)?|\d{1,2}(?:\.\d+)?)%$/ // Matches 0.0% to 100.0%
173
+ const match = value.trim().match(regExp)
174
+ if (match) return { percent: Number(match[1]) }
175
+ throw new Error(`${fieldName} percent string must be in format "0%" to "100%"`)
176
+ }
130
177
 
131
- const { width, height } = imageSize
132
-
133
- if (typeof width === 'number') {
134
- normalized.imageWidth = width
135
- } else if (typeof width === 'string') {
136
- const match = width.trim().match(regExp)
137
- if (match) {
138
- normalized.imageWidthPercent = Number(match[1])
139
- } else {
140
- throw new Error('imageSize.width percent string must be in format "0%" to "100%"')
141
- }
142
- }
178
+ // Normalize imageSize
179
+ if (imageSize) {
180
+ const w = parseDimension(imageSize.width, 'imageSize.width')
181
+ const h = parseDimension(imageSize.height, 'imageSize.height')
182
+ if (w.absolute !== undefined) normalized.imageWidth = w.absolute
183
+ if (w.percent !== undefined) normalized.imageWidthPercent = w.percent
184
+ if (h.absolute !== undefined) normalized.imageHeight = h.absolute
185
+ if (h.percent !== undefined) normalized.imageHeightPercent = h.percent
186
+ }
143
187
 
144
- if (typeof height === 'number') {
145
- normalized.imageHeight = height
146
- } else if (typeof height === 'string') {
147
- const match = height.trim().match(regExp)
148
- if (match) {
149
- normalized.imageHeightPercent = Number(match[1])
150
- } else {
151
- throw new Error('imageSize.height percent string must be in format "0%" to "100%"')
152
- }
153
- }
188
+ // Normalize smallImageSize
189
+ if (smallImageSize) {
190
+ const w = parseDimension(smallImageSize.width, 'smallImageSize.width')
191
+ const h = parseDimension(smallImageSize.height, 'smallImageSize.height')
192
+ if (w.absolute !== undefined) normalized.smallImageWidth = w.absolute
193
+ if (w.percent !== undefined) normalized.smallImageWidthPercent = w.percent
194
+ if (h.absolute !== undefined) normalized.smallImageHeight = h.absolute
195
+ if (h.percent !== undefined) normalized.smallImageHeightPercent = h.percent
154
196
  }
155
197
 
156
198
  return normalized
package/.prettierignore DELETED
@@ -1,5 +0,0 @@
1
- /build
2
- /example/.expo
3
- /example/ios
4
- /plugin/build
5
- /.eslintrc.js