expo-live-activity 0.2.0-alpha1 → 0.2.0-alpha3
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.
- package/README.md +56 -4
- package/build/index.d.ts +16 -8
- package/build/index.d.ts.map +1 -1
- package/build/index.js +10 -5
- package/build/index.js.map +1 -1
- package/ios/Data+download.swift +13 -0
- package/ios/ExpoLiveActivityModule.swift +164 -118
- package/ios/Helpers.swift +22 -0
- package/ios/LiveActivityAttributes.swift +4 -4
- package/ios-files/Color+hex.swift +29 -29
- package/ios-files/Date+toTimerInterval.swift +13 -0
- package/ios-files/Image+dynamic.swift +23 -0
- package/ios-files/LiveActivityView.swift +39 -45
- package/ios-files/{LiveActivityLiveActivity.swift → LiveActivityWidget.swift} +42 -33
- package/ios-files/LiveActivityWidgetBundle.swift +16 -0
- package/ios-files/ViewHelpers.swift +13 -0
- package/package.json +6 -2
- package/plugin/build/index.d.ts +2 -2
- package/plugin/build/index.js +5 -1
- package/plugin/build/lib/getWidgetFiles.js +1 -1
- package/plugin/build/types.d.ts +6 -0
- package/plugin/build/types.js +2 -0
- package/plugin/build/withPushNotifications.d.ts +2 -0
- package/plugin/build/withPushNotifications.js +12 -0
- package/plugin/src/index.ts +9 -6
- package/plugin/src/lib/getWidgetFiles.ts +1 -1
- package/plugin/src/types.ts +9 -0
- package/plugin/src/withPushNotifications.ts +17 -0
- package/plugin/tsconfig.tsbuildinfo +1 -1
- package/src/index.ts +24 -8
- package/ios-files/LiveActivityBundle.swift +0 -16
package/README.md
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
> [!WARNING]
|
|
2
|
+
> This library is in early development stage, breaking changes can be introduced in minor version upgrades.
|
|
3
|
+
|
|
1
4
|
# expo-live-activity
|
|
2
5
|
|
|
3
6
|
`expo-live-activity` is a React Native module designed for use with Expo to manage and display live activities on iOS devices exclusively. This module leverages the Live Activities feature introduced in iOS 16, allowing developers to deliver timely updates right on the lock screen.
|
|
@@ -6,6 +9,7 @@
|
|
|
6
9
|
- Start, update, and stop live activities directly from your React Native application.
|
|
7
10
|
- Easy integration with a comprehensive API.
|
|
8
11
|
- Custom image support within live activities with a pre-configured path.
|
|
12
|
+
- Listen and handle changes in push notification tokens associated with a live activity.
|
|
9
13
|
|
|
10
14
|
## Platform compatibility
|
|
11
15
|
**Note:** This module is intended for use on **iOS devices only**. When methods are invoked on platforms other than iOS, they will throw an error, ensuring that they are used in the correct context.
|
|
@@ -30,7 +34,7 @@ The module comes with a built-in config plugin that creates a target in iOS with
|
|
|
30
34
|
}
|
|
31
35
|
```
|
|
32
36
|
2. **Assets configuration:**
|
|
33
|
-
Place images intended for live activities in the `assets/
|
|
37
|
+
Place images intended for live activities in the `assets/liveActivity` folder. The plugin manages these assets automatically.
|
|
34
38
|
|
|
35
39
|
### Step 3: Usage in Your React Native App
|
|
36
40
|
Import the functionalities provided by the `expo-live-activity` module in your JavaScript or TypeScript files:
|
|
@@ -38,18 +42,51 @@ Import the functionalities provided by the `expo-live-activity` module in your J
|
|
|
38
42
|
import * as LiveActivity from "expo-live-activity";
|
|
39
43
|
```
|
|
40
44
|
|
|
45
|
+
## Push notifications
|
|
46
|
+
By default, updating live activity is possible only via API. There is also a way to update live activity using push notifications. To enable that feature, add `"enablePushNotifications": true`. Then, the notification payload should be looking like this:
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{
|
|
50
|
+
"aps":{
|
|
51
|
+
"event":"update",
|
|
52
|
+
"content-state":{
|
|
53
|
+
"title":"Hello",
|
|
54
|
+
"subtitle":"World",
|
|
55
|
+
"date":1754064245000
|
|
56
|
+
},
|
|
57
|
+
"timestamp":1754063621319
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Where `date` value is a timestamp in milliseconds corresponding to the target point of the counter displayed in live activity view.
|
|
63
|
+
|
|
64
|
+
## Image support
|
|
65
|
+
Live activity view also supports image display. There are two dedicated fields for that:
|
|
66
|
+
- `imageName`
|
|
67
|
+
- `dynamicIslandImageName`
|
|
68
|
+
Currently, it's possible to set them only via API, but we plan on to add that feature to push notifications as well. The value of each field can be:
|
|
69
|
+
- a string which maps to an asset name
|
|
70
|
+
- a URL to remote image
|
|
71
|
+
The latter requires adding "App Groups" capability to both "main app" and "live activity" targets.
|
|
72
|
+
|
|
41
73
|
## API
|
|
42
74
|
`expo-live-activity` module exports three primary functions to manage live activities:
|
|
43
75
|
|
|
44
|
-
|
|
76
|
+
### Managing Live Activities
|
|
77
|
+
- **`startActivity(state: LiveActivityState, styles?: LiveActivityStyles)`**:
|
|
45
78
|
Start a new live activity. Takes a `state` configuration object for initial activity state and an optional `styles` object to customize appearance. It returns the `ID` of the created live activity, which should be stored for future reference.
|
|
46
79
|
|
|
47
|
-
- **`updateActivity(
|
|
80
|
+
- **`updateActivity(id: string, state: LiveActivityState)`**:
|
|
48
81
|
Update an existing live activity. The `state` object should contain updated information. The `activityId` indicates which activity should be updated.
|
|
49
82
|
|
|
50
|
-
- **`stopActivity(
|
|
83
|
+
- **`stopActivity(id: string, state: LiveActivityState)`**:
|
|
51
84
|
Terminate an ongoing live activity. The `state` object should contain the final state of the activity. The `activityId` indicates which activity should be stopped.
|
|
52
85
|
|
|
86
|
+
### Handling Push Notification Tokens
|
|
87
|
+
- **`addActivityTokenListener(listener: (event: ActivityTokenReceivedEvent) => void): EventSubscription)`**:
|
|
88
|
+
Subscribe to changes in the push notification token associated with live activities.
|
|
89
|
+
|
|
53
90
|
### State Object Structure
|
|
54
91
|
The `state` object should include:
|
|
55
92
|
```javascript
|
|
@@ -76,6 +113,7 @@ The `styles` object should include:
|
|
|
76
113
|
```
|
|
77
114
|
|
|
78
115
|
## Example Usage
|
|
116
|
+
Managing a live activity:
|
|
79
117
|
```javascript
|
|
80
118
|
const state = {
|
|
81
119
|
title: "Title",
|
|
@@ -98,3 +136,17 @@ const activityId = LiveActivity.startActivity(state, styles);
|
|
|
98
136
|
// Store activityId for future reference
|
|
99
137
|
```
|
|
100
138
|
This will initiate a live activity with the specified title, subtitle, image from your configured assets folder and a time to which there will be a countdown in a progress view.
|
|
139
|
+
|
|
140
|
+
Subscribing to push token changes:
|
|
141
|
+
```javascript
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
const subscription = LiveActivity.addActivityTokenListener(({
|
|
144
|
+
activityID: newActivityID,
|
|
145
|
+
activityPushToken: newToken
|
|
146
|
+
}) => {
|
|
147
|
+
// Send token to a remote server to update live activity with push notifications
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
return () => subscription.remove();
|
|
151
|
+
}, []);
|
|
152
|
+
```
|
package/build/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { EventSubscription } from 'expo-modules-core';
|
|
1
2
|
export type DynamicIslandTimerType = 'circular' | 'digital';
|
|
2
3
|
export type LiveActivityState = {
|
|
3
4
|
title: string;
|
|
@@ -12,26 +13,33 @@ export type LiveActivityStyles = {
|
|
|
12
13
|
subtitleColor?: string;
|
|
13
14
|
progressViewTint?: string;
|
|
14
15
|
progressViewLabelColor?: string;
|
|
15
|
-
timerType
|
|
16
|
+
timerType?: DynamicIslandTimerType;
|
|
17
|
+
};
|
|
18
|
+
export type ActivityTokenReceivedEvent = {
|
|
19
|
+
activityID: string;
|
|
20
|
+
activityPushToken: string;
|
|
21
|
+
};
|
|
22
|
+
export type LiveActivityModuleEvents = {
|
|
23
|
+
onTokenReceived: (params: ActivityTokenReceivedEvent) => void;
|
|
16
24
|
};
|
|
17
25
|
/**
|
|
18
26
|
* @param {LiveActivityState} state The state for the live activity.
|
|
27
|
+
* @param {LiveActivityStyles} styles Live activity styling object.
|
|
19
28
|
* @returns {string} The identifier of the started activity.
|
|
20
|
-
* @throws {Error} When function is called on platform different
|
|
29
|
+
* @throws {Error} When function is called on a platform different from iOS.
|
|
21
30
|
*/
|
|
22
31
|
export declare function startActivity(state: LiveActivityState, styles?: LiveActivityStyles): string;
|
|
23
32
|
/**
|
|
24
33
|
* @param {string} id The identifier of the activity to stop.
|
|
25
34
|
* @param {LiveActivityState} state The updated state for the live activity.
|
|
26
|
-
* @
|
|
27
|
-
* @throws {Error} When function is called on platform different than iOS.
|
|
35
|
+
* @throws {Error} When function is called on a platform different from iOS.
|
|
28
36
|
*/
|
|
29
|
-
export declare function stopActivity(id: string, state: LiveActivityState):
|
|
37
|
+
export declare function stopActivity(id: string, state: LiveActivityState): any;
|
|
30
38
|
/**
|
|
31
39
|
* @param {string} id The identifier of the activity to update.
|
|
32
40
|
* @param {LiveActivityState} state The updated state for the live activity.
|
|
33
|
-
* @
|
|
34
|
-
* @throws {Error} When function is called on platform different than iOS.
|
|
41
|
+
* @throws {Error} When function is called on a platform different from iOS.
|
|
35
42
|
*/
|
|
36
|
-
export declare function updateActivity(id: string, state: LiveActivityState):
|
|
43
|
+
export declare function updateActivity(id: string, state: LiveActivityState): any;
|
|
44
|
+
export declare function addActivityTokenListener(listener: (event: ActivityTokenReceivedEvent) => void): EventSubscription;
|
|
37
45
|
//# sourceMappingURL=index.d.ts.map
|
package/build/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAEtD,MAAM,MAAM,sBAAsB,GAAG,UAAU,GAAG,SAAS,CAAA;AAE3D,MAAM,MAAM,iBAAiB,GAAG;IAC9B,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,sBAAsB,CAAC,EAAE,MAAM,CAAC;CACjC,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG;IAC/B,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,SAAS,CAAC,EAAE,sBAAsB,CAAC;CACpC,CAAC;AAEF,MAAM,MAAM,0BAA0B,GAAG;IACvC,UAAU,EAAE,MAAM,CAAC;IACnB,iBAAiB,EAAE,MAAM,CAAC;CAC3B,CAAC;AAEF,MAAM,MAAM,wBAAwB,GAAG;IACrC,eAAe,EAAE,CAAC,MAAM,EAAE,0BAA0B,KAAK,IAAI,CAAC;CAC/D,CAAC;AAEF;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,iBAAiB,EAAE,MAAM,CAAC,EAAE,kBAAkB,GAAG,MAAM,CAK3F;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,iBAAiB,OAKhE;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,iBAAiB,OAKlE;AAED,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,0BAA0B,KAAK,IAAI,GAAG,iBAAiB,CAKjH"}
|
package/build/index.js
CHANGED
|
@@ -2,8 +2,9 @@ import ExpoLiveActivityModule from "./ExpoLiveActivityModule";
|
|
|
2
2
|
import { Platform } from "react-native";
|
|
3
3
|
/**
|
|
4
4
|
* @param {LiveActivityState} state The state for the live activity.
|
|
5
|
+
* @param {LiveActivityStyles} styles Live activity styling object.
|
|
5
6
|
* @returns {string} The identifier of the started activity.
|
|
6
|
-
* @throws {Error} When function is called on platform different
|
|
7
|
+
* @throws {Error} When function is called on a platform different from iOS.
|
|
7
8
|
*/
|
|
8
9
|
export function startActivity(state, styles) {
|
|
9
10
|
if (Platform.OS !== "ios") {
|
|
@@ -14,8 +15,7 @@ export function startActivity(state, styles) {
|
|
|
14
15
|
/**
|
|
15
16
|
* @param {string} id The identifier of the activity to stop.
|
|
16
17
|
* @param {LiveActivityState} state The updated state for the live activity.
|
|
17
|
-
* @
|
|
18
|
-
* @throws {Error} When function is called on platform different than iOS.
|
|
18
|
+
* @throws {Error} When function is called on a platform different from iOS.
|
|
19
19
|
*/
|
|
20
20
|
export function stopActivity(id, state) {
|
|
21
21
|
if (Platform.OS !== "ios") {
|
|
@@ -26,8 +26,7 @@ export function stopActivity(id, state) {
|
|
|
26
26
|
/**
|
|
27
27
|
* @param {string} id The identifier of the activity to update.
|
|
28
28
|
* @param {LiveActivityState} state The updated state for the live activity.
|
|
29
|
-
* @
|
|
30
|
-
* @throws {Error} When function is called on platform different than iOS.
|
|
29
|
+
* @throws {Error} When function is called on a platform different from iOS.
|
|
31
30
|
*/
|
|
32
31
|
export function updateActivity(id, state) {
|
|
33
32
|
if (Platform.OS !== "ios") {
|
|
@@ -35,4 +34,10 @@ export function updateActivity(id, state) {
|
|
|
35
34
|
}
|
|
36
35
|
return ExpoLiveActivityModule.updateActivity(id, state);
|
|
37
36
|
}
|
|
37
|
+
export function addActivityTokenListener(listener) {
|
|
38
|
+
if (Platform.OS !== "ios") {
|
|
39
|
+
throw new Error("updateActivity is only available on iOS");
|
|
40
|
+
}
|
|
41
|
+
return ExpoLiveActivityModule.addListener('onTokenReceived', listener);
|
|
42
|
+
}
|
|
38
43
|
//# sourceMappingURL=index.js.map
|
package/build/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,MAAM,0BAA0B,CAAC;AAC9D,OAAO,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,MAAM,0BAA0B,CAAC;AAC9D,OAAO,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AA+BxC;;;;;GAKG;AACH,MAAM,UAAU,aAAa,CAAC,KAAwB,EAAE,MAA2B;IACjF,IAAI,QAAQ,CAAC,EAAE,KAAK,KAAK,EAAE,CAAC;QAC1B,MAAM,IAAI,KAAK,CAAC,wCAAwC,CAAC,CAAC;IAC5D,CAAC;IACD,OAAO,sBAAsB,CAAC,aAAa,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;AAC7D,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,YAAY,CAAC,EAAU,EAAE,KAAwB;IAC/D,IAAI,QAAQ,CAAC,EAAE,KAAK,KAAK,EAAE,CAAC;QAC1B,MAAM,IAAI,KAAK,CAAC,uCAAuC,CAAC,CAAC;IAC3D,CAAC;IACD,OAAO,sBAAsB,CAAC,YAAY,CAAC,EAAE,EAAE,KAAK,CAAC,CAAC;AACxD,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,cAAc,CAAC,EAAU,EAAE,KAAwB;IACjE,IAAI,QAAQ,CAAC,EAAE,KAAK,KAAK,EAAE,CAAC;QAC1B,MAAM,IAAI,KAAK,CAAC,yCAAyC,CAAC,CAAC;IAC7D,CAAC;IACD,OAAO,sBAAsB,CAAC,cAAc,CAAC,EAAE,EAAE,KAAK,CAAC,CAAC;AAC1D,CAAC;AAED,MAAM,UAAU,wBAAwB,CAAC,QAAqD;IAC5F,IAAI,QAAQ,CAAC,EAAE,KAAK,KAAK,EAAE,CAAC;QAC1B,MAAM,IAAI,KAAK,CAAC,yCAAyC,CAAC,CAAC;IAC7D,CAAC;IACD,OAAO,sBAAsB,CAAC,WAAW,CAAC,iBAAiB,EAAE,QAAQ,CAAC,CAAC;AACzE,CAAC","sourcesContent":["import ExpoLiveActivityModule from \"./ExpoLiveActivityModule\";\nimport { Platform } from \"react-native\";\nimport { EventSubscription } from 'expo-modules-core';\n\nexport type DynamicIslandTimerType = 'circular' | 'digital'\n\nexport type LiveActivityState = {\n title: string;\n subtitle?: string;\n date?: number;\n imageName?: string;\n dynamicIslandImageName?: string;\n};\n\nexport type LiveActivityStyles = {\n backgroundColor?: string;\n titleColor?: string;\n subtitleColor?: string;\n progressViewTint?: string;\n progressViewLabelColor?: string;\n timerType?: DynamicIslandTimerType;\n};\n\nexport type ActivityTokenReceivedEvent = {\n activityID: string;\n activityPushToken: string;\n};\n\nexport type LiveActivityModuleEvents = {\n onTokenReceived: (params: ActivityTokenReceivedEvent) => void;\n};\n\n/**\n * @param {LiveActivityState} state The state for the live activity.\n * @param {LiveActivityStyles} styles Live activity styling object.\n * @returns {string} The identifier of the started activity.\n * @throws {Error} When function is called on a platform different from iOS.\n */\nexport function startActivity(state: LiveActivityState, styles?: LiveActivityStyles): string {\n if (Platform.OS !== \"ios\") {\n throw new Error(\"startActivity is only available on iOS\");\n }\n return ExpoLiveActivityModule.startActivity(state, styles);\n}\n\n/**\n * @param {string} id The identifier of the activity to stop.\n * @param {LiveActivityState} state The updated state for the live activity.\n * @throws {Error} When function is called on a platform different from iOS.\n */\nexport function stopActivity(id: string, state: LiveActivityState) {\n if (Platform.OS !== \"ios\") {\n throw new Error(\"stopActivity is only available on iOS\");\n }\n return ExpoLiveActivityModule.stopActivity(id, state);\n}\n\n/**\n * @param {string} id The identifier of the activity to update.\n * @param {LiveActivityState} state The updated state for the live activity.\n * @throws {Error} When function is called on a platform different from iOS.\n */\nexport function updateActivity(id: string, state: LiveActivityState) {\n if (Platform.OS !== \"ios\") {\n throw new Error(\"updateActivity is only available on iOS\");\n }\n return ExpoLiveActivityModule.updateActivity(id, state);\n}\n\nexport function addActivityTokenListener(listener: (event: ActivityTokenReceivedEvent) => void): EventSubscription {\n if (Platform.OS !== \"ios\") {\n throw new Error(\"updateActivity is only available on iOS\");\n }\n return ExpoLiveActivityModule.addListener('onTokenReceived', listener);\n}\n"]}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Data+download.swift
|
|
3
|
+
//
|
|
4
|
+
//
|
|
5
|
+
// Created by Artur Bilski on 04/08/2025.
|
|
6
|
+
//
|
|
7
|
+
|
|
8
|
+
extension Data {
|
|
9
|
+
static func download(from url: URL) async throws -> Self {
|
|
10
|
+
let (data, _) = try await URLSession.shared.data(from: url)
|
|
11
|
+
return data
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -2,139 +2,185 @@ import ActivityKit
|
|
|
2
2
|
import ExpoModulesCore
|
|
3
3
|
|
|
4
4
|
enum ModuleErrors: Error {
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
case unsupported
|
|
6
|
+
case liveActivitiesNotEnabled
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
public class ExpoLiveActivityModule: Module {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
struct LiveActivityState: Record {
|
|
11
|
+
@Field
|
|
12
|
+
var title: String
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
@Field
|
|
15
|
+
var subtitle: String?
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
@Field
|
|
18
|
+
var date: Double?
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
@Field
|
|
21
|
+
var imageName: String?
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
23
|
+
@Field
|
|
24
|
+
var dynamicIslandImageName: String?
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
struct LiveActivityStyles: Record {
|
|
28
|
+
@Field
|
|
29
|
+
var backgroundColor: String?
|
|
30
|
+
|
|
31
|
+
@Field
|
|
32
|
+
var titleColor: String?
|
|
33
|
+
|
|
34
|
+
@Field
|
|
35
|
+
var subtitleColor: String?
|
|
36
|
+
|
|
37
|
+
@Field
|
|
38
|
+
var progressViewTint: String?
|
|
39
|
+
|
|
40
|
+
@Field
|
|
41
|
+
var progressViewLabelColor: String?
|
|
42
|
+
|
|
43
|
+
@Field
|
|
44
|
+
var timerType: DynamicIslandTimerType?
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
enum DynamicIslandTimerType: String, Enumerable {
|
|
48
|
+
case circular
|
|
49
|
+
case digital
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
func sendPushToken(activityID: String, activityPushToken: String) {
|
|
53
|
+
sendEvent(
|
|
54
|
+
"onTokenReceived",
|
|
55
|
+
[
|
|
56
|
+
"activityID": activityID,
|
|
57
|
+
"activityPushToken": activityPushToken,
|
|
58
|
+
]
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
func toContentStateDate(date: Double?) -> Date? {
|
|
63
|
+
return date.map { Date(timeIntervalSince1970: $0 / 1000) }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
func updateImages(state: LiveActivityState, newState: inout LiveActivityAttributes.ContentState) async throws {
|
|
67
|
+
if let name = state.imageName {
|
|
68
|
+
print("imageName: \(name)")
|
|
69
|
+
newState.imageName = try await resolveImage(from: name)
|
|
45
70
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
71
|
+
|
|
72
|
+
if let name = state.dynamicIslandImageName {
|
|
73
|
+
print("dynamicIslandImageName: \(name)")
|
|
74
|
+
newState.dynamicIslandImageName = try await resolveImage(from: name)
|
|
50
75
|
}
|
|
76
|
+
}
|
|
51
77
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
78
|
+
public func definition() -> ModuleDefinition {
|
|
79
|
+
Name("ExpoLiveActivity")
|
|
80
|
+
|
|
81
|
+
Events("onTokenReceived")
|
|
82
|
+
|
|
83
|
+
Function("startActivity") { (state: LiveActivityState, styles: LiveActivityStyles?) -> String in
|
|
84
|
+
print("Starting activity")
|
|
85
|
+
if #available(iOS 16.2, *) {
|
|
86
|
+
if ActivityAuthorizationInfo().areActivitiesEnabled {
|
|
87
|
+
do {
|
|
88
|
+
let attributes = LiveActivityAttributes(
|
|
89
|
+
name: "ExpoLiveActivity",
|
|
90
|
+
backgroundColor: styles?.backgroundColor,
|
|
91
|
+
titleColor: styles?.titleColor,
|
|
92
|
+
subtitleColor: styles?.subtitleColor,
|
|
93
|
+
progressViewTint: styles?.progressViewTint,
|
|
94
|
+
progressViewLabelColor: styles?.progressViewLabelColor,
|
|
95
|
+
timerType: styles?.timerType == .digital ? .digital : .circular
|
|
96
|
+
)
|
|
97
|
+
let initialState = LiveActivityAttributes.ContentState(
|
|
98
|
+
title: state.title,
|
|
99
|
+
subtitle: state.subtitle,
|
|
100
|
+
date: toContentStateDate(date: state.date),
|
|
101
|
+
)
|
|
102
|
+
let pushNotificationsEnabled =
|
|
103
|
+
Bundle.main.object(forInfoDictionaryKey: "ExpoLiveActivity_EnablePushNotifications")
|
|
104
|
+
let activity = try Activity.request(
|
|
105
|
+
attributes: attributes,
|
|
106
|
+
content: .init(state: initialState, staleDate: nil),
|
|
107
|
+
pushType: pushNotificationsEnabled == nil ? nil : .token
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
Task {
|
|
111
|
+
for await pushToken in activity.pushTokenUpdates {
|
|
112
|
+
let pushTokenString = pushToken.reduce("") { $0 + String(format: "%02x", $1) }
|
|
113
|
+
|
|
114
|
+
sendPushToken(activityID: activity.id, activityPushToken: pushTokenString)
|
|
115
|
+
}
|
|
87
116
|
}
|
|
88
|
-
}
|
|
89
117
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
title: state.title,
|
|
95
|
-
subtitle: state.subtitle,
|
|
96
|
-
date: state.date != nil ? Date(timeIntervalSince1970: state.date! / 1000) : nil,
|
|
97
|
-
imageName: state.imageName,
|
|
98
|
-
dynamicIslandImageName: state.dynamicIslandImageName)
|
|
99
|
-
if let activity = Activity<LiveActivityAttributes>.activities.first(where: {
|
|
100
|
-
$0.id == activityId
|
|
101
|
-
}) {
|
|
102
|
-
Task {
|
|
103
|
-
print("Stopping activity with id: \(activityId)")
|
|
104
|
-
await activity.end(
|
|
105
|
-
ActivityContent(state: endState, staleDate: nil),
|
|
106
|
-
dismissalPolicy: .immediate)
|
|
107
|
-
}
|
|
108
|
-
} else {
|
|
109
|
-
print("Didn't find activity with ID \(activityId)")
|
|
110
|
-
}
|
|
111
|
-
} else {
|
|
112
|
-
throw ModuleErrors.unsupported
|
|
118
|
+
Task {
|
|
119
|
+
var newState = activity.content.state
|
|
120
|
+
try await updateImages(state: state, newState: &newState)
|
|
121
|
+
await activity.update(ActivityContent(state: newState, staleDate: nil))
|
|
113
122
|
}
|
|
123
|
+
|
|
124
|
+
return activity.id
|
|
125
|
+
} catch (let error) {
|
|
126
|
+
print("Error with live activity: \(error)")
|
|
127
|
+
}
|
|
114
128
|
}
|
|
129
|
+
throw ModuleErrors.liveActivitiesNotEnabled
|
|
130
|
+
} else {
|
|
131
|
+
throw ModuleErrors.unsupported
|
|
132
|
+
}
|
|
133
|
+
}
|
|
115
134
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
135
|
+
Function("stopActivity") { (activityId: String, state: LiveActivityState) -> Void in
|
|
136
|
+
if #available(iOS 16.2, *) {
|
|
137
|
+
print("Attempting to stop")
|
|
138
|
+
var newState = LiveActivityAttributes.ContentState(
|
|
139
|
+
title: state.title,
|
|
140
|
+
subtitle: state.subtitle,
|
|
141
|
+
date: toContentStateDate(date: state.date),
|
|
142
|
+
)
|
|
143
|
+
if let activity = Activity<LiveActivityAttributes>.activities.first(where: {
|
|
144
|
+
$0.id == activityId
|
|
145
|
+
}) {
|
|
146
|
+
Task {
|
|
147
|
+
print("Stopping activity with id: \(activityId)")
|
|
148
|
+
try await updateImages(state: state, newState: &newState)
|
|
149
|
+
await activity.end(
|
|
150
|
+
ActivityContent(state: newState, staleDate: nil),
|
|
151
|
+
dismissalPolicy: .immediate
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
} else {
|
|
155
|
+
print("Didn't find activity with ID \(activityId)")
|
|
156
|
+
}
|
|
157
|
+
} else {
|
|
158
|
+
throw ModuleErrors.unsupported
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
Function("updateActivity") { (activityId: String, state: LiveActivityState) -> Void in
|
|
163
|
+
if #available(iOS 16.2, *) {
|
|
164
|
+
print("Attempting to update")
|
|
165
|
+
var newState = LiveActivityAttributes.ContentState(
|
|
166
|
+
title: state.title,
|
|
167
|
+
subtitle: state.subtitle,
|
|
168
|
+
date: toContentStateDate(date: state.date),
|
|
169
|
+
)
|
|
170
|
+
if let activity = Activity<LiveActivityAttributes>.activities.first(where: {
|
|
171
|
+
$0.id == activityId
|
|
172
|
+
}) {
|
|
173
|
+
Task {
|
|
174
|
+
print("Updating activity with id: \(activityId)")
|
|
175
|
+
try await updateImages(state: state, newState: &newState)
|
|
176
|
+
await activity.update(ActivityContent(state: newState, staleDate: nil))
|
|
177
|
+
}
|
|
178
|
+
} else {
|
|
179
|
+
print("Didn't find activity with ID \(activityId)")
|
|
138
180
|
}
|
|
181
|
+
} else {
|
|
182
|
+
throw ModuleErrors.unsupported
|
|
183
|
+
}
|
|
139
184
|
}
|
|
185
|
+
}
|
|
140
186
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Helpers.swift
|
|
3
|
+
//
|
|
4
|
+
//
|
|
5
|
+
// Created by Artur Bilski on 04/08/2025.
|
|
6
|
+
//
|
|
7
|
+
|
|
8
|
+
func resolveImage(from string: String) async throws -> String {
|
|
9
|
+
if let url = URL(string: string), url.scheme?.hasPrefix("http") == true,
|
|
10
|
+
let container = FileManager.default.containerURL(
|
|
11
|
+
forSecurityApplicationGroupIdentifier: "group.expoLiveActivity.sharedData"
|
|
12
|
+
)
|
|
13
|
+
{
|
|
14
|
+
let data = try await Data.download(from: url)
|
|
15
|
+
let filename = UUID().uuidString + ".png"
|
|
16
|
+
let fileURL = container.appendingPathComponent(filename)
|
|
17
|
+
try data.write(to: fileURL)
|
|
18
|
+
return fileURL.lastPathComponent
|
|
19
|
+
} else {
|
|
20
|
+
return string
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
// Created by Anna Olak on 03/06/2025.
|
|
6
6
|
//
|
|
7
7
|
|
|
8
|
-
import Foundation
|
|
9
8
|
import ActivityKit
|
|
9
|
+
import Foundation
|
|
10
10
|
|
|
11
11
|
struct LiveActivityAttributes: ActivityAttributes {
|
|
12
12
|
public struct ContentState: Codable, Hashable {
|
|
@@ -24,9 +24,9 @@ struct LiveActivityAttributes: ActivityAttributes {
|
|
|
24
24
|
var progressViewTint: String?
|
|
25
25
|
var progressViewLabelColor: String?
|
|
26
26
|
var timerType: DynamicIslandTimerType
|
|
27
|
-
|
|
27
|
+
|
|
28
28
|
enum DynamicIslandTimerType: String, Codable {
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
case circular
|
|
30
|
+
case digital
|
|
31
31
|
}
|
|
32
32
|
}
|
|
@@ -1,37 +1,37 @@
|
|
|
1
1
|
import SwiftUI
|
|
2
2
|
|
|
3
3
|
extension Color {
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
init(hex: String) {
|
|
5
|
+
var cString: String = hex.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
if cString.hasPrefix("#") {
|
|
8
|
+
cString.remove(at: cString.startIndex)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
if (cString.count) != 6 && (cString.count) != 8 {
|
|
12
|
+
self.init(.white)
|
|
13
|
+
return
|
|
14
|
+
}
|
|
10
15
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
return
|
|
14
|
-
}
|
|
16
|
+
var rgbValue: UInt64 = 0
|
|
17
|
+
Scanner(string: cString).scanHexInt64(&rgbValue)
|
|
15
18
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
blue: Double((rgbValue >> 00) & 0xff) / 255,
|
|
33
|
-
opacity: 1
|
|
34
|
-
)
|
|
35
|
-
}
|
|
19
|
+
if (cString.count) == 8 {
|
|
20
|
+
self.init(
|
|
21
|
+
.sRGB,
|
|
22
|
+
red: Double((rgbValue >> 24) & 0xff) / 255,
|
|
23
|
+
green: Double((rgbValue >> 16) & 0xff) / 255,
|
|
24
|
+
blue: Double((rgbValue >> 08) & 0xff) / 255,
|
|
25
|
+
opacity: Double((rgbValue >> 00) & 0xff) / 255,
|
|
26
|
+
)
|
|
27
|
+
} else {
|
|
28
|
+
self.init(
|
|
29
|
+
.sRGB,
|
|
30
|
+
red: Double((rgbValue >> 16) & 0xff) / 255,
|
|
31
|
+
green: Double((rgbValue >> 08) & 0xff) / 255,
|
|
32
|
+
blue: Double((rgbValue >> 00) & 0xff) / 255,
|
|
33
|
+
opacity: 1
|
|
34
|
+
)
|
|
36
35
|
}
|
|
36
|
+
}
|
|
37
37
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Date+toTimerInterval.swift
|
|
3
|
+
//
|
|
4
|
+
//
|
|
5
|
+
// Created by Artur Bilski on 04/08/2025.
|
|
6
|
+
//
|
|
7
|
+
import SwiftUI
|
|
8
|
+
|
|
9
|
+
extension Date {
|
|
10
|
+
static func toTimerInterval(miliseconds: Double) -> ClosedRange<Self> {
|
|
11
|
+
Self.now...max(Self.now, Date(timeIntervalSince1970: miliseconds / 1000))
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Image+dynamic.swift
|
|
3
|
+
//
|
|
4
|
+
//
|
|
5
|
+
// Created by Artur Bilski on 04/08/2025.
|
|
6
|
+
//
|
|
7
|
+
import SwiftUI
|
|
8
|
+
|
|
9
|
+
extension Image {
|
|
10
|
+
static func dynamic(assetNameOrPath: String) -> Self {
|
|
11
|
+
if let container = FileManager.default.containerURL(
|
|
12
|
+
forSecurityApplicationGroupIdentifier: "group.expoLiveActivity.sharedData"
|
|
13
|
+
) {
|
|
14
|
+
let contentsOfFile = container.appendingPathComponent(assetNameOrPath).path
|
|
15
|
+
|
|
16
|
+
if let uiImage = UIImage(contentsOfFile: contentsOfFile) {
|
|
17
|
+
return Image(uiImage: uiImage)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return Image(assetNameOrPath)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -10,64 +10,58 @@ import WidgetKit
|
|
|
10
10
|
|
|
11
11
|
#if canImport(ActivityKit)
|
|
12
12
|
|
|
13
|
-
struct
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
13
|
+
struct ConditionalForegroundViewModifier: ViewModifier {
|
|
14
|
+
let color: String?
|
|
15
|
+
|
|
16
|
+
func body(content: Content) -> some View {
|
|
17
|
+
if let color = color {
|
|
18
|
+
content.foregroundStyle(Color(hex: color))
|
|
19
|
+
} else {
|
|
20
|
+
content
|
|
21
|
+
}
|
|
22
|
+
}
|
|
19
23
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
24
|
+
|
|
25
|
+
struct LiveActivityView: View {
|
|
26
|
+
let contentState: LiveActivityAttributes.ContentState
|
|
27
|
+
let attributes: LiveActivityAttributes
|
|
28
|
+
|
|
29
|
+
var progressViewTint: Color? {
|
|
30
|
+
attributes.progressViewTint.map { Color(hex: $0) }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
var body: some View {
|
|
34
|
+
VStack(alignment: .leading) {
|
|
35
|
+
HStack(alignment: .center) {
|
|
36
|
+
VStack(alignment: .leading, spacing: 2) {
|
|
31
37
|
Text(contentState.title)
|
|
32
38
|
.font(.title2)
|
|
33
39
|
.fontWeight(.semibold)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if let subtitleColor = attributes.subtitleColor {
|
|
38
|
-
Text(subtitle)
|
|
39
|
-
.font(.title3)
|
|
40
|
-
.foregroundStyle(Color(hex: subtitleColor))
|
|
41
|
-
} else {
|
|
40
|
+
.modifier(ConditionalForegroundViewModifier(color: attributes.titleColor))
|
|
41
|
+
|
|
42
|
+
if let subtitle = contentState.subtitle {
|
|
42
43
|
Text(subtitle)
|
|
43
44
|
.font(.title3)
|
|
45
|
+
.modifier(ConditionalForegroundViewModifier(color: attributes.subtitleColor))
|
|
44
46
|
}
|
|
45
47
|
}
|
|
48
|
+
|
|
49
|
+
Spacer()
|
|
50
|
+
|
|
51
|
+
if let imageName = contentState.imageName {
|
|
52
|
+
resizableImage(imageName: imageName)
|
|
53
|
+
.frame(maxHeight: 64)
|
|
54
|
+
}
|
|
46
55
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if let imageName = contentState.imageName {
|
|
51
|
-
Image(imageName)
|
|
52
|
-
.resizable()
|
|
53
|
-
.scaledToFit()
|
|
54
|
-
.frame(maxHeight: 64)
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
if let date = contentState.date {
|
|
59
|
-
if let progressViewLabelColor = attributes.progressViewLabelColor {
|
|
60
|
-
ProgressView(timerInterval: Date.now...max(Date.now, date))
|
|
61
|
-
.tint(progressViewTint)
|
|
62
|
-
.foregroundStyle(Color(hex: progressViewLabelColor))
|
|
63
|
-
} else {
|
|
64
|
-
ProgressView(timerInterval: Date.now...max(Date.now, date))
|
|
56
|
+
|
|
57
|
+
if let date = contentState.date {
|
|
58
|
+
ProgressView(timerInterval: Date.toTimerInterval(miliseconds: date))
|
|
65
59
|
.tint(progressViewTint)
|
|
60
|
+
.modifier(ConditionalForegroundViewModifier(color: attributes.progressViewLabelColor))
|
|
66
61
|
}
|
|
67
62
|
}
|
|
63
|
+
.padding(24)
|
|
68
64
|
}
|
|
69
|
-
.padding(24)
|
|
70
65
|
}
|
|
71
|
-
}
|
|
72
66
|
|
|
73
67
|
#endif
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
//
|
|
2
|
-
//
|
|
2
|
+
// LiveActivityWidget.swift
|
|
3
3
|
// LiveActivity
|
|
4
4
|
//
|
|
5
5
|
// Created by Anna Olak on 02/06/2025.
|
|
@@ -13,7 +13,7 @@ struct LiveActivityAttributes: ActivityAttributes {
|
|
|
13
13
|
public struct ContentState: Codable, Hashable {
|
|
14
14
|
var title: String
|
|
15
15
|
var subtitle: String?
|
|
16
|
-
var date:
|
|
16
|
+
var date: Double?
|
|
17
17
|
var imageName: String?
|
|
18
18
|
var dynamicIslandImageName: String?
|
|
19
19
|
}
|
|
@@ -25,18 +25,20 @@ struct LiveActivityAttributes: ActivityAttributes {
|
|
|
25
25
|
var progressViewTint: String?
|
|
26
26
|
var progressViewLabelColor: String?
|
|
27
27
|
var timerType: DynamicIslandTimerType
|
|
28
|
-
|
|
28
|
+
|
|
29
29
|
enum DynamicIslandTimerType: String, Codable {
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
case circular
|
|
31
|
+
case digital
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
struct
|
|
35
|
+
struct LiveActivityWidget: Widget {
|
|
36
36
|
var body: some WidgetConfiguration {
|
|
37
37
|
ActivityConfiguration(for: LiveActivityAttributes.self) { context in
|
|
38
38
|
LiveActivityView(contentState: context.state, attributes: context.attributes)
|
|
39
|
-
.activityBackgroundTint(
|
|
39
|
+
.activityBackgroundTint(
|
|
40
|
+
context.attributes.backgroundColor != nil ? Color(hex: context.attributes.backgroundColor!) : nil
|
|
41
|
+
)
|
|
40
42
|
.activitySystemActionForegroundColor(Color.black)
|
|
41
43
|
|
|
42
44
|
} dynamicIsland: { context in
|
|
@@ -65,29 +67,41 @@ struct LiveActivityLiveActivity: Widget {
|
|
|
65
67
|
}
|
|
66
68
|
} compactTrailing: {
|
|
67
69
|
if let date = context.state.date {
|
|
68
|
-
compactTimer(
|
|
70
|
+
compactTimer(
|
|
71
|
+
endDate: date,
|
|
72
|
+
timerType: context.attributes.timerType,
|
|
73
|
+
progressViewTint: context.attributes.progressViewTint
|
|
74
|
+
)
|
|
69
75
|
}
|
|
70
76
|
} minimal: {
|
|
71
77
|
if let date = context.state.date {
|
|
72
|
-
compactTimer(
|
|
78
|
+
compactTimer(
|
|
79
|
+
endDate: date,
|
|
80
|
+
timerType: context.attributes.timerType,
|
|
81
|
+
progressViewTint: context.attributes.progressViewTint
|
|
82
|
+
)
|
|
73
83
|
}
|
|
74
84
|
}
|
|
75
85
|
}
|
|
76
86
|
}
|
|
77
|
-
|
|
87
|
+
|
|
78
88
|
@ViewBuilder
|
|
79
|
-
private func compactTimer(
|
|
89
|
+
private func compactTimer(
|
|
90
|
+
endDate: Double,
|
|
91
|
+
timerType: LiveActivityAttributes.DynamicIslandTimerType,
|
|
92
|
+
progressViewTint: String?
|
|
93
|
+
) -> some View {
|
|
80
94
|
if timerType == .digital {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
95
|
+
Text(timerInterval: Date.toTimerInterval(miliseconds: endDate))
|
|
96
|
+
.font(.system(size: 15))
|
|
97
|
+
.minimumScaleFactor(0.8)
|
|
98
|
+
.fontWeight(.semibold)
|
|
99
|
+
.frame(maxWidth: 60)
|
|
100
|
+
.multilineTextAlignment(.trailing)
|
|
101
|
+
} else {
|
|
102
|
+
circularTimer(endDate: endDate)
|
|
103
|
+
.tint(progressViewTint != nil ? Color(hex: progressViewTint!) : nil)
|
|
104
|
+
}
|
|
91
105
|
}
|
|
92
106
|
|
|
93
107
|
private func dynamicIslandExpandedLeading(title: String, subtitle: String?) -> some View {
|
|
@@ -106,7 +120,7 @@ struct LiveActivityLiveActivity: Widget {
|
|
|
106
120
|
Spacer()
|
|
107
121
|
}
|
|
108
122
|
}
|
|
109
|
-
|
|
123
|
+
|
|
110
124
|
private func dynamicIslandExpandedTrailing(imageName: String) -> some View {
|
|
111
125
|
VStack {
|
|
112
126
|
Spacer()
|
|
@@ -115,17 +129,17 @@ struct LiveActivityLiveActivity: Widget {
|
|
|
115
129
|
Spacer()
|
|
116
130
|
}
|
|
117
131
|
}
|
|
118
|
-
|
|
119
|
-
private func dynamicIslandExpandedBottom(endDate:
|
|
120
|
-
ProgressView(timerInterval: Date.
|
|
132
|
+
|
|
133
|
+
private func dynamicIslandExpandedBottom(endDate: Double, progressViewTint: String?) -> some View {
|
|
134
|
+
ProgressView(timerInterval: Date.toTimerInterval(miliseconds: endDate))
|
|
121
135
|
.foregroundStyle(.white)
|
|
122
136
|
.tint(progressViewTint != nil ? Color(hex: progressViewTint!) : nil)
|
|
123
137
|
.padding(.top, 5)
|
|
124
138
|
}
|
|
125
|
-
|
|
126
|
-
private func circularTimer(endDate:
|
|
139
|
+
|
|
140
|
+
private func circularTimer(endDate: Double) -> some View {
|
|
127
141
|
ProgressView(
|
|
128
|
-
timerInterval: Date.
|
|
142
|
+
timerInterval: Date.toTimerInterval(miliseconds: endDate),
|
|
129
143
|
countsDown: false,
|
|
130
144
|
label: { EmptyView() },
|
|
131
145
|
currentValueLabel: {
|
|
@@ -134,9 +148,4 @@ struct LiveActivityLiveActivity: Widget {
|
|
|
134
148
|
)
|
|
135
149
|
.progressViewStyle(.circular)
|
|
136
150
|
}
|
|
137
|
-
private func resizableImage(imageName: String) -> some View {
|
|
138
|
-
Image(imageName)
|
|
139
|
-
.resizable()
|
|
140
|
-
.scaledToFit()
|
|
141
|
-
}
|
|
142
151
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
//
|
|
2
|
+
// LiveActivityWidgetBundle.swift
|
|
3
|
+
// LiveActivity
|
|
4
|
+
//
|
|
5
|
+
// Created by Anna Olak on 02/06/2025.
|
|
6
|
+
//
|
|
7
|
+
|
|
8
|
+
import SwiftUI
|
|
9
|
+
import WidgetKit
|
|
10
|
+
|
|
11
|
+
@main
|
|
12
|
+
struct LiveActivityWidgetBundle: WidgetBundle {
|
|
13
|
+
var body: some Widget {
|
|
14
|
+
LiveActivityWidget()
|
|
15
|
+
}
|
|
16
|
+
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "expo-live-activity",
|
|
3
|
-
"version": "0.2.0-
|
|
3
|
+
"version": "0.2.0-alpha3",
|
|
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",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"build": "expo-module build",
|
|
9
9
|
"clean": "expo-module clean",
|
|
10
|
+
"clean:plugin": "rm -rf plugin/build plugin/tsconfig.tsbuildinfo",
|
|
10
11
|
"lint": "expo-module lint",
|
|
11
12
|
"test": "expo-module test",
|
|
12
13
|
"prepare": "expo-module prepare",
|
|
@@ -22,7 +23,10 @@
|
|
|
22
23
|
"apple"
|
|
23
24
|
],
|
|
24
25
|
"author": "Kamil Owczarz <kamil.owczarz@swmansion.com> (https://github.com/kowczarz)",
|
|
25
|
-
"contributors": [
|
|
26
|
+
"contributors": [
|
|
27
|
+
"Anna Olak <anna.olak@swmansion.com> (https://github.com/anna1901)",
|
|
28
|
+
"Artur Bilski <artur.bilski@swmansion.com> (https://github.com/arthwood)"
|
|
29
|
+
],
|
|
26
30
|
"repository": "https://github.com/software-mansion-labs/expo-live-activity.git",
|
|
27
31
|
"bugs": {
|
|
28
32
|
"url": "https://github.com/software-mansion-labs/expo-live-activity/issues"
|
package/plugin/build/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import {
|
|
2
|
-
declare const withWidgetsAndLiveActivities:
|
|
1
|
+
import type { LiveActivityConfigPlugin } from "./types";
|
|
2
|
+
declare const withWidgetsAndLiveActivities: LiveActivityConfigPlugin;
|
|
3
3
|
export default withWidgetsAndLiveActivities;
|
package/plugin/build/index.js
CHANGED
|
@@ -5,7 +5,8 @@ const withConfig_1 = require("./withConfig");
|
|
|
5
5
|
const withPodfile_1 = require("./withPodfile");
|
|
6
6
|
const withXcode_1 = require("./withXcode");
|
|
7
7
|
const withWidgetExtensionEntitlements_1 = require("./withWidgetExtensionEntitlements");
|
|
8
|
-
const
|
|
8
|
+
const withPushNotifications_1 = require("./withPushNotifications");
|
|
9
|
+
const withWidgetsAndLiveActivities = (config, props) => {
|
|
9
10
|
const deploymentTarget = "16.2";
|
|
10
11
|
const targetName = `${config_plugins_1.IOSConfig.XcodeUtils.sanitizedName(config.name)}LiveActivity`;
|
|
11
12
|
const bundleIdentifier = `${config.ios?.bundleIdentifier}.${targetName}`;
|
|
@@ -30,6 +31,9 @@ const withWidgetsAndLiveActivities = (config) => {
|
|
|
30
31
|
[withPodfile_1.withPodfile, { targetName }],
|
|
31
32
|
[withConfig_1.withConfig, { targetName, bundleIdentifier }],
|
|
32
33
|
]);
|
|
34
|
+
if (props?.enablePushNotifications) {
|
|
35
|
+
config = (0, withPushNotifications_1.withPushNotifications)(config);
|
|
36
|
+
}
|
|
33
37
|
return config;
|
|
34
38
|
};
|
|
35
39
|
exports.default = withWidgetsAndLiveActivities;
|
|
@@ -46,7 +46,7 @@ function getWidgetFiles(targetPath) {
|
|
|
46
46
|
console.log("Building for example app");
|
|
47
47
|
}
|
|
48
48
|
const liveActivityFilesPath = path.join(packagePath ? packagePath : "..", "/ios-files");
|
|
49
|
-
const imageAssetsPath = "./assets/
|
|
49
|
+
const imageAssetsPath = "./assets/liveActivity";
|
|
50
50
|
const widgetFiles = {
|
|
51
51
|
swiftFiles: [],
|
|
52
52
|
entitlementFiles: [],
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.withPushNotifications = void 0;
|
|
4
|
+
const config_plugins_1 = require("@expo/config-plugins");
|
|
5
|
+
const withPushNotifications = (config) => (0, config_plugins_1.withInfoPlist)((0, config_plugins_1.withEntitlementsPlist)(config, (mod) => {
|
|
6
|
+
mod.modResults["aps-environment"] = "development";
|
|
7
|
+
return mod;
|
|
8
|
+
}), (mod) => {
|
|
9
|
+
mod.modResults["ExpoLiveActivity_EnablePushNotifications"] = true;
|
|
10
|
+
return mod;
|
|
11
|
+
});
|
|
12
|
+
exports.withPushNotifications = withPushNotifications;
|
package/plugin/src/index.ts
CHANGED
|
@@ -1,16 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { IOSConfig, withPlugins } from "expo/config-plugins";
|
|
2
|
+
import type { LiveActivityConfigPlugin } from "./types";
|
|
2
3
|
import { withConfig } from "./withConfig";
|
|
3
4
|
import { withPodfile } from "./withPodfile";
|
|
4
|
-
|
|
5
5
|
import { withXcode } from "./withXcode";
|
|
6
6
|
import { withWidgetExtensionEntitlements } from "./withWidgetExtensionEntitlements";
|
|
7
|
+
import { withPushNotifications } from "./withPushNotifications";
|
|
7
8
|
|
|
8
|
-
const withWidgetsAndLiveActivities:
|
|
9
|
-
config
|
|
10
|
-
) => {
|
|
9
|
+
const withWidgetsAndLiveActivities: LiveActivityConfigPlugin = (config, props) => {
|
|
11
10
|
const deploymentTarget = "16.2";
|
|
12
11
|
const targetName = `${IOSConfig.XcodeUtils.sanitizedName(
|
|
13
|
-
config.name
|
|
12
|
+
config.name,
|
|
14
13
|
)}LiveActivity`;
|
|
15
14
|
const bundleIdentifier = `${config.ios?.bundleIdentifier}.${targetName}`;
|
|
16
15
|
|
|
@@ -37,6 +36,10 @@ const withWidgetsAndLiveActivities: ConfigPlugin = (
|
|
|
37
36
|
[withConfig, { targetName, bundleIdentifier }],
|
|
38
37
|
]);
|
|
39
38
|
|
|
39
|
+
if (props?.enablePushNotifications) {
|
|
40
|
+
config = withPushNotifications(config);
|
|
41
|
+
}
|
|
42
|
+
|
|
40
43
|
return config;
|
|
41
44
|
};
|
|
42
45
|
|
|
@@ -20,7 +20,7 @@ export function getWidgetFiles(
|
|
|
20
20
|
console.log("Building for example app")
|
|
21
21
|
}
|
|
22
22
|
const liveActivityFilesPath = path.join(packagePath ? packagePath : "..", "/ios-files");
|
|
23
|
-
const imageAssetsPath = "./assets/
|
|
23
|
+
const imageAssetsPath = "./assets/liveActivity";
|
|
24
24
|
|
|
25
25
|
const widgetFiles: WidgetFiles = {
|
|
26
26
|
swiftFiles: [],
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ConfigPlugin,
|
|
3
|
+
withEntitlementsPlist,
|
|
4
|
+
withInfoPlist,
|
|
5
|
+
} from "@expo/config-plugins";
|
|
6
|
+
|
|
7
|
+
export const withPushNotifications: ConfigPlugin = (config) =>
|
|
8
|
+
withInfoPlist(
|
|
9
|
+
withEntitlementsPlist(config, (mod) => {
|
|
10
|
+
mod.modResults["aps-environment"] = "development";
|
|
11
|
+
return mod;
|
|
12
|
+
}),
|
|
13
|
+
(mod) => {
|
|
14
|
+
mod.modResults["ExpoLiveActivity_EnablePushNotifications"] = true;
|
|
15
|
+
return mod;
|
|
16
|
+
},
|
|
17
|
+
);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"root":["./src/index.ts","./src/withConfig.ts","./src/withPodfile.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/withPodfile.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"}
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import ExpoLiveActivityModule from "./ExpoLiveActivityModule";
|
|
2
2
|
import { Platform } from "react-native";
|
|
3
|
+
import { EventSubscription } from 'expo-modules-core';
|
|
3
4
|
|
|
4
5
|
export type DynamicIslandTimerType = 'circular' | 'digital'
|
|
5
6
|
|
|
@@ -17,13 +18,23 @@ export type LiveActivityStyles = {
|
|
|
17
18
|
subtitleColor?: string;
|
|
18
19
|
progressViewTint?: string;
|
|
19
20
|
progressViewLabelColor?: string;
|
|
20
|
-
timerType
|
|
21
|
+
timerType?: DynamicIslandTimerType;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type ActivityTokenReceivedEvent = {
|
|
25
|
+
activityID: string;
|
|
26
|
+
activityPushToken: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type LiveActivityModuleEvents = {
|
|
30
|
+
onTokenReceived: (params: ActivityTokenReceivedEvent) => void;
|
|
21
31
|
};
|
|
22
32
|
|
|
23
33
|
/**
|
|
24
34
|
* @param {LiveActivityState} state The state for the live activity.
|
|
35
|
+
* @param {LiveActivityStyles} styles Live activity styling object.
|
|
25
36
|
* @returns {string} The identifier of the started activity.
|
|
26
|
-
* @throws {Error} When function is called on platform different
|
|
37
|
+
* @throws {Error} When function is called on a platform different from iOS.
|
|
27
38
|
*/
|
|
28
39
|
export function startActivity(state: LiveActivityState, styles?: LiveActivityStyles): string {
|
|
29
40
|
if (Platform.OS !== "ios") {
|
|
@@ -35,10 +46,9 @@ export function startActivity(state: LiveActivityState, styles?: LiveActivitySty
|
|
|
35
46
|
/**
|
|
36
47
|
* @param {string} id The identifier of the activity to stop.
|
|
37
48
|
* @param {LiveActivityState} state The updated state for the live activity.
|
|
38
|
-
* @
|
|
39
|
-
* @throws {Error} When function is called on platform different than iOS.
|
|
49
|
+
* @throws {Error} When function is called on a platform different from iOS.
|
|
40
50
|
*/
|
|
41
|
-
export function stopActivity(id: string, state: LiveActivityState)
|
|
51
|
+
export function stopActivity(id: string, state: LiveActivityState) {
|
|
42
52
|
if (Platform.OS !== "ios") {
|
|
43
53
|
throw new Error("stopActivity is only available on iOS");
|
|
44
54
|
}
|
|
@@ -48,12 +58,18 @@ export function stopActivity(id: string, state: LiveActivityState): string {
|
|
|
48
58
|
/**
|
|
49
59
|
* @param {string} id The identifier of the activity to update.
|
|
50
60
|
* @param {LiveActivityState} state The updated state for the live activity.
|
|
51
|
-
* @
|
|
52
|
-
* @throws {Error} When function is called on platform different than iOS.
|
|
61
|
+
* @throws {Error} When function is called on a platform different from iOS.
|
|
53
62
|
*/
|
|
54
|
-
export function updateActivity(id: string, state: LiveActivityState)
|
|
63
|
+
export function updateActivity(id: string, state: LiveActivityState) {
|
|
55
64
|
if (Platform.OS !== "ios") {
|
|
56
65
|
throw new Error("updateActivity is only available on iOS");
|
|
57
66
|
}
|
|
58
67
|
return ExpoLiveActivityModule.updateActivity(id, state);
|
|
59
68
|
}
|
|
69
|
+
|
|
70
|
+
export function addActivityTokenListener(listener: (event: ActivityTokenReceivedEvent) => void): EventSubscription {
|
|
71
|
+
if (Platform.OS !== "ios") {
|
|
72
|
+
throw new Error("updateActivity is only available on iOS");
|
|
73
|
+
}
|
|
74
|
+
return ExpoLiveActivityModule.addListener('onTokenReceived', listener);
|
|
75
|
+
}
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
//
|
|
2
|
-
// LiveActivityBundle.swift
|
|
3
|
-
// LiveActivity
|
|
4
|
-
//
|
|
5
|
-
// Created by Anna Olak on 02/06/2025.
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
import WidgetKit
|
|
9
|
-
import SwiftUI
|
|
10
|
-
|
|
11
|
-
@main
|
|
12
|
-
struct LiveActivityBundle: WidgetBundle {
|
|
13
|
-
var body: some Widget {
|
|
14
|
-
LiveActivityLiveActivity()
|
|
15
|
-
}
|
|
16
|
-
}
|