├── bun.lockb
├── modules
└── live-activities
│ ├── app.plugin.js
│ ├── expo-module.config.json
│ ├── plugin
│ └── withLiveActivities.js
│ ├── ios
│ ├── Attributes.swift
│ ├── ExpoLiveActivities.podspec
│ ├── ExpoLiveActivitiesAppDelegate.swift
│ └── ExpoLiveActivities.swift
│ ├── src
│ ├── LiveActivities.types.ts
│ ├── LiveActivitiesModule.ios.ts
│ └── LiveActivitiesModule.ts
│ └── index.ts
├── assets
├── icon.png
├── favicon.png
├── splash-icon.png
└── adaptive-icon.png
├── targets
└── widgets
│ ├── Assets.xcassets
│ ├── Contents.json
│ ├── Airple.imageset
│ │ ├── Airple.png
│ │ └── Contents.json
│ ├── Car_side.imageset
│ │ ├── Car.png
│ │ └── Contents.json
│ ├── Airple_light.imageset
│ │ ├── Airple_light.png
│ │ └── Contents.json
│ ├── Car.imageset
│ │ ├── Airple Customer Mobile Top.png
│ │ └── Contents.json
│ ├── Location.imageset
│ │ ├── Airple Customer App Location.png
│ │ └── Contents.json
│ ├── AppIcon.appiconset
│ │ └── Contents.json
│ └── Color.colorset
│ │ └── Contents.json
│ ├── expo-target.config.js
│ ├── Info.plist
│ ├── Attributes.swift
│ ├── index.swift
│ ├── PrivacyInfo.xcprivacy
│ └── AirpleWidget.swift
├── tsconfig.json
├── index.ts
├── .gitignore
├── package.json
├── app.json
├── App.tsx
└── README.md
/bun.lockb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mrevanzak/expo-live-activities-demo/HEAD/bun.lockb
--------------------------------------------------------------------------------
/modules/live-activities/app.plugin.js:
--------------------------------------------------------------------------------
1 | module.exports = require("./plugin/withLiveActivities");
2 |
--------------------------------------------------------------------------------
/assets/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mrevanzak/expo-live-activities-demo/HEAD/assets/icon.png
--------------------------------------------------------------------------------
/assets/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mrevanzak/expo-live-activities-demo/HEAD/assets/favicon.png
--------------------------------------------------------------------------------
/assets/splash-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mrevanzak/expo-live-activities-demo/HEAD/assets/splash-icon.png
--------------------------------------------------------------------------------
/assets/adaptive-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mrevanzak/expo-live-activities-demo/HEAD/assets/adaptive-icon.png
--------------------------------------------------------------------------------
/targets/widgets/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/targets/widgets/Assets.xcassets/Airple.imageset/Airple.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mrevanzak/expo-live-activities-demo/HEAD/targets/widgets/Assets.xcassets/Airple.imageset/Airple.png
--------------------------------------------------------------------------------
/targets/widgets/Assets.xcassets/Car_side.imageset/Car.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mrevanzak/expo-live-activities-demo/HEAD/targets/widgets/Assets.xcassets/Car_side.imageset/Car.png
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "expo/tsconfig.base",
3 | "compilerOptions": {
4 | "strict": true,
5 | "paths": {
6 | "@local:*": ["./modules/*"]
7 | }
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/targets/widgets/Assets.xcassets/Airple_light.imageset/Airple_light.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mrevanzak/expo-live-activities-demo/HEAD/targets/widgets/Assets.xcassets/Airple_light.imageset/Airple_light.png
--------------------------------------------------------------------------------
/targets/widgets/Assets.xcassets/Car.imageset/Airple Customer Mobile Top.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mrevanzak/expo-live-activities-demo/HEAD/targets/widgets/Assets.xcassets/Car.imageset/Airple Customer Mobile Top.png
--------------------------------------------------------------------------------
/modules/live-activities/expo-module.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "platforms": ["apple"],
3 | "apple": {
4 | "modules": ["ExpoLiveActivities"],
5 | "appDelegateSubscribers": ["ExpoLiveActivitiesAppDelegate"]
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/targets/widgets/Assets.xcassets/Location.imageset/Airple Customer App Location.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mrevanzak/expo-live-activities-demo/HEAD/targets/widgets/Assets.xcassets/Location.imageset/Airple Customer App Location.png
--------------------------------------------------------------------------------
/targets/widgets/expo-target.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('@bacons/apple-targets/app.plugin').ConfigFunction} */
2 | module.exports = {
3 | type: "widget",
4 | name: "Live Activity",
5 | frameworks: ["SwiftUI", "ActivityKit"],
6 | deploymentTarget: "16.2",
7 | };
8 |
--------------------------------------------------------------------------------
/targets/widgets/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "platform" : "ios",
6 | "size" : "1024x1024"
7 | }
8 | ],
9 | "info" : {
10 | "author" : "xcode",
11 | "version" : 1
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/index.ts:
--------------------------------------------------------------------------------
1 | import { registerRootComponent } from 'expo';
2 |
3 | import App from './App';
4 |
5 | // registerRootComponent calls AppRegistry.registerComponent('main', () => App);
6 | // It also ensures that whether you load the app in Expo Go or in a native build,
7 | // the environment is set up appropriately
8 | registerRootComponent(App);
9 |
--------------------------------------------------------------------------------
/targets/widgets/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | NSExtension
6 |
7 | NSExtensionPointIdentifier
8 | com.apple.widgetkit-extension
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/targets/widgets/Assets.xcassets/Airple.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "Airple.png",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/targets/widgets/Assets.xcassets/Car_side.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "Car.png",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/targets/widgets/Assets.xcassets/Airple_light.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "Airple_light.png",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/targets/widgets/Assets.xcassets/Car.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "Airple Customer Mobile Top.png",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/targets/widgets/Assets.xcassets/Location.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "Airple Customer App Location.png",
5 | "idiom" : "universal",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "universal",
10 | "scale" : "2x"
11 | },
12 | {
13 | "idiom" : "universal",
14 | "scale" : "3x"
15 | }
16 | ],
17 | "info" : {
18 | "author" : "xcode",
19 | "version" : 1
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/modules/live-activities/plugin/withLiveActivities.js:
--------------------------------------------------------------------------------
1 | const { withInfoPlist } = require("@expo/config-plugins");
2 |
3 | /**
4 | * @type {import('@expo/config-plugins').ConfigPlugin<{ frequentUpdates?: boolean }>}
5 | */
6 | const withLiveActivities = (config, { frequentUpdates = false }) =>
7 | withInfoPlist(config, (config) => {
8 | config.modResults.NSSupportsLiveActivities = true;
9 | config.modResults.NSSupportsLiveActivitiesFrequentUpdates = frequentUpdates;
10 | return config;
11 | });
12 |
13 | module.exports = withLiveActivities;
14 |
--------------------------------------------------------------------------------
/targets/widgets/Attributes.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AirpleAttributes.swift
3 | // Airple
4 | //
5 | // Created by Revanza on 2024-08-09.
6 | //
7 |
8 | import ActivityKit
9 | import SwiftUI
10 |
11 | struct AirpleAttributes: ActivityAttributes {
12 | public typealias AirpleStatus = ContentState
13 |
14 | public struct ContentState: Codable, Hashable {
15 | var progress: Double
16 | var title: String
17 | var status: String
18 | var estimated: String
19 | var widgetUrl: String?
20 | }
21 |
22 | var key: String
23 | }
--------------------------------------------------------------------------------
/modules/live-activities/ios/Attributes.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AirpleAttributes.swift
3 | // Airple
4 | //
5 | // Created by Revanza on 2024-08-09.
6 | //
7 |
8 | import ActivityKit
9 | import SwiftUI
10 |
11 | struct AirpleAttributes: ActivityAttributes {
12 | public typealias AirpleStatus = ContentState
13 |
14 | public struct ContentState: Codable, Hashable {
15 | var progress: Double
16 | var title: String
17 | var status: String
18 | var estimated: String
19 | var widgetUrl: String?
20 | }
21 |
22 | var key: String
23 | }
24 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
2 |
3 | # dependencies
4 | node_modules/
5 |
6 | # Expo
7 | .expo/
8 | dist/
9 | web-build/
10 | expo-env.d.ts
11 |
12 | # Native
13 | *.orig.*
14 | *.jks
15 | *.p8
16 | *.p12
17 | *.key
18 | *.mobileprovision
19 |
20 | # Metro
21 | .metro-health-check*
22 |
23 | # debug
24 | npm-debug.*
25 | yarn-debug.*
26 | yarn-error.*
27 |
28 | # macOS
29 | .DS_Store
30 | *.pem
31 |
32 | # local env files
33 | .env*.local
34 |
35 | # typescript
36 | *.tsbuildinfo
37 |
38 | # native build
39 | android
40 | ios
41 | !/modules/**/ios
42 |
--------------------------------------------------------------------------------
/targets/widgets/index.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import WidgetKit
3 |
4 | @main
5 | struct exportWidgets: WidgetBundle {
6 | var body: some Widget {
7 | if #available(iOSApplicationExtension 18.0, *) {
8 | return iOS18Widgets
9 | } else {
10 | return widgets
11 | }
12 | }
13 |
14 | @available(iOSApplicationExtension 18.0, *)
15 | @WidgetBundleBuilder
16 | private var iOS18Widgets: some Widget {
17 | AirpleWidgetIOS18()
18 | }
19 |
20 | @WidgetBundleBuilder
21 | private var widgets: some Widget {
22 | AirpleWidget()
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/modules/live-activities/ios/ExpoLiveActivities.podspec:
--------------------------------------------------------------------------------
1 | Pod::Spec.new do |s|
2 | s.name = 'ExpoLiveActivities'
3 | s.version = '1.0.0'
4 | s.summary = 'Tracking Live Activity Module for Airple'
5 | s.description = 'Tracking Live Activity Module for Airple'
6 | s.author = 'mrevanzak'
7 | s.homepage = 'https://docs.expo.dev/modules/'
8 | s.platforms = { :ios => '13.4' }
9 | s.source = { git: '' }
10 | s.static_framework = true
11 |
12 | s.dependency 'ExpoModulesCore'
13 |
14 | # Swift/Objective-C compatibility
15 | s.pod_target_xcconfig = {
16 | 'DEFINES_MODULE' => 'YES',
17 | 'SWIFT_COMPILATION_MODE' => 'wholemodule',
18 | }
19 |
20 | s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
21 | end
22 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "expo-live-activities-demo",
3 | "version": "1.0.0",
4 | "main": "index.ts",
5 | "scripts": {
6 | "start": "expo start",
7 | "android": "expo run:android",
8 | "ios": "expo run:ios",
9 | "web": "expo start --web"
10 | },
11 | "dependencies": {
12 | "@bacons/apple-targets": "^0.1.15",
13 | "expo": "~52.0.23",
14 | "expo-notifications": "^0.29.11",
15 | "expo-status-bar": "~2.0.0",
16 | "react": "18.3.1",
17 | "react-native": "0.76.5"
18 | },
19 | "devDependencies": {
20 | "@babel/core": "^7.25.2",
21 | "@types/react": "~18.3.12",
22 | "typescript": "^5.3.3"
23 | },
24 | "private": true,
25 | "expo": {
26 | "autolinking": {
27 | "nativeModulesDir": "./modules"
28 | }
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/modules/live-activities/src/LiveActivities.types.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * state of the live activity
3 | * should be match with property on Attributes.swift
4 | * @see ios/Attributes.swift
5 | */
6 | export interface LiveActivityState {
7 | progress: number;
8 | title: string;
9 | status: string;
10 | estimated: string;
11 | widgetUrl?: string;
12 | }
13 |
14 | export type LiveActivityFn = (key: string, state: LiveActivityState) => void;
15 |
16 | export interface onPushTokenChangePayload {
17 | token: string;
18 | }
19 |
20 | export type LiveActivitiesModuleEvent = {
21 | "LiveActivities.pushTokenDidChange": (
22 | params: onPushTokenChangePayload & { key: string },
23 | ) => void;
24 | "LiveActivities.startTokenDidChange": (
25 | params: onPushTokenChangePayload,
26 | ) => void;
27 | };
28 |
--------------------------------------------------------------------------------
/targets/widgets/Assets.xcassets/Color.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "color-space" : "display-p3",
6 | "components" : {
7 | "alpha" : "1.000",
8 | "blue" : "0xA1",
9 | "green" : "0x54",
10 | "red" : "0x19"
11 | }
12 | },
13 | "idiom" : "universal"
14 | },
15 | {
16 | "appearances" : [
17 | {
18 | "appearance" : "luminosity",
19 | "value" : "dark"
20 | }
21 | ],
22 | "color" : {
23 | "color-space" : "srgb",
24 | "components" : {
25 | "alpha" : "1.000",
26 | "blue" : "0xFF",
27 | "green" : "0xC2",
28 | "red" : "0x62"
29 | }
30 | },
31 | "idiom" : "universal"
32 | }
33 | ],
34 | "info" : {
35 | "author" : "xcode",
36 | "version" : 1
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/modules/live-activities/src/LiveActivitiesModule.ios.ts:
--------------------------------------------------------------------------------
1 | import { NativeModule, requireNativeModule } from "expo-modules-core";
2 | import type { LiveActivitiesModuleEvent } from "./LiveActivities.types";
3 |
4 | declare class ExpoLiveActivities extends NativeModule {
5 | areActivitiesEnabled(): boolean;
6 | startActivity(
7 | key: string,
8 | progress: number,
9 | title: string,
10 | status: string,
11 | estimated: string,
12 | widgetUrl?: string
13 | ): void;
14 | updateActivity(
15 | key: string,
16 | progress: number,
17 | title: string,
18 | status: string,
19 | estimated: string,
20 | widgetUrl?: string
21 | ): void;
22 | endActivity(
23 | key: string,
24 | progress: number,
25 | title: string,
26 | status: string,
27 | estimated: string,
28 | widgetUrl?: string
29 | ): void;
30 | }
31 |
32 | export default requireNativeModule("ExpoLiveActivities");
33 |
--------------------------------------------------------------------------------
/modules/live-activities/src/LiveActivitiesModule.ts:
--------------------------------------------------------------------------------
1 | import { NativeModule, requireNativeModule } from "expo-modules-core";
2 | import type { LiveActivitiesModuleEvent } from "./LiveActivities.types";
3 |
4 | class ExpoLiveActivities extends NativeModule {
5 | areActivitiesEnabled() {
6 | return false;
7 | }
8 | startActivity(
9 | _key: string,
10 | _progress: number,
11 | _title: string,
12 | _status: string,
13 | _estimated: string,
14 | _widgetUrl?: string
15 | ) {
16 | return;
17 | }
18 | updateActivity(
19 | _key: string,
20 | _progress: number,
21 | _title: string,
22 | _status: string,
23 | _estimated: string,
24 | _widgetUrl?: string
25 | ) {
26 | return;
27 | }
28 | endActivity(
29 | _key: string,
30 | _progress: number,
31 | _title: string,
32 | _status: string,
33 | _estimated: string,
34 | _widgetUrl?: string
35 | ) {
36 | return;
37 | }
38 | }
39 |
40 | export default requireNativeModule("LiveActivities");
41 |
--------------------------------------------------------------------------------
/app.json:
--------------------------------------------------------------------------------
1 | {
2 | "expo": {
3 | "name": "expo-live-activities-demo",
4 | "slug": "expo-live-activities-demo",
5 | "version": "1.0.0",
6 | "orientation": "portrait",
7 | "icon": "./assets/icon.png",
8 | "userInterfaceStyle": "light",
9 | "newArchEnabled": true,
10 | "splash": {
11 | "image": "./assets/splash-icon.png",
12 | "resizeMode": "contain",
13 | "backgroundColor": "#ffffff"
14 | },
15 | "ios": {
16 | "supportsTablet": true,
17 | "bundleIdentifier": "com.mrevanzak.expo-live-activities-demo"
18 | },
19 | "android": {
20 | "adaptiveIcon": {
21 | "foregroundImage": "./assets/adaptive-icon.png",
22 | "backgroundColor": "#ffffff"
23 | },
24 | "package": "com.mrevanzak.expoliveactivitiesdemo"
25 | },
26 | "web": {
27 | "favicon": "./assets/favicon.png"
28 | },
29 | "plugins": [
30 | "@bacons/apple-targets",
31 | [
32 | "./modules/live-activities/app.plugin.js",
33 | {
34 | "frequentUpdates": true
35 | }
36 | ],
37 | [
38 | "expo-notifications",
39 | {
40 | "enableBackgroundRemoteNotifications": true
41 | }
42 | ]
43 | ]
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/targets/widgets/PrivacyInfo.xcprivacy:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | NSPrivacyAccessedAPITypes
6 |
7 |
8 | NSPrivacyAccessedAPIType
9 | NSPrivacyAccessedAPICategoryFileTimestamp
10 | NSPrivacyAccessedAPITypeReasons
11 |
12 | C617.1
13 | 0A2A.1
14 | 3B52.1
15 |
16 |
17 |
18 | NSPrivacyAccessedAPIType
19 | NSPrivacyAccessedAPICategoryUserDefaults
20 | NSPrivacyAccessedAPITypeReasons
21 |
22 | CA92.1
23 | 1C8F.1
24 | C56D.1
25 |
26 |
27 |
28 | NSPrivacyAccessedAPIType
29 | NSPrivacyAccessedAPICategorySystemBootTime
30 | NSPrivacyAccessedAPITypeReasons
31 |
32 | 35F9.1
33 |
34 |
35 |
36 | NSPrivacyAccessedAPIType
37 | NSPrivacyAccessedAPICategoryDiskSpace
38 | NSPrivacyAccessedAPITypeReasons
39 |
40 | E174.1
41 | 85F4.1
42 |
43 |
44 |
45 | NSPrivacyCollectedDataTypes
46 |
47 | NSPrivacyTracking
48 |
49 |
50 |
51 |
--------------------------------------------------------------------------------
/modules/live-activities/ios/ExpoLiveActivitiesAppDelegate.swift:
--------------------------------------------------------------------------------
1 | import ActivityKit
2 | import ExpoModulesCore
3 |
4 | public class ExpoLiveActivitiesAppDelegate: ExpoAppDelegateSubscriber {
5 | public func application(
6 | _ application: UIApplication,
7 | didFinishLaunchingWithOptions launchOptions: [UIApplication
8 | .LaunchOptionsKey: Any]? = nil
9 | ) -> Bool {
10 | if #available(iOS 17.2, *) {
11 | Task {
12 | for await pushToken in Activity
13 | .pushToStartTokenUpdates
14 | {
15 | let pushTokenString = pushToken.reduce("") {
16 | $0 + String(format: "%02x", $1)
17 | }
18 | NotificationCenter.default.post(
19 | name: .onStartPushTokenChange, object: pushTokenString)
20 | }
21 | }
22 | }
23 |
24 | if #available(iOS 17.2, *) {
25 | Task {
26 | for await activity in Activity.activityUpdates
27 | {
28 | Task {
29 | for await pushToken in activity.pushTokenUpdates {
30 | let pushTokenString = pushToken.reduce("") {
31 | $0 + String(format: "%02x", $1)
32 | }
33 | NotificationCenter.default.post(
34 | name: .onPushTokenChange,
35 | object: pushTokenString,
36 | userInfo: ["key": activity.attributes.key]
37 | )
38 | }
39 | }
40 | }
41 | }
42 | }
43 | return true
44 | }
45 | }
46 |
47 | extension Notification.Name {
48 | static var onStartPushTokenChange: Notification.Name {
49 | return .init("LiveActivities.onStartPushTokenChange")
50 | }
51 | static var onPushTokenChange: Notification.Name {
52 | return .init("LiveActivities.onPushTokenChange")
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/App.tsx:
--------------------------------------------------------------------------------
1 | import { StatusBar } from "expo-status-bar";
2 | import { Button, StyleSheet, Text, View } from "react-native";
3 |
4 | import * as LiveActivities from "@local:live-activities";
5 | import { useState } from "react";
6 |
7 | export default function App() {
8 | const [token, setToken] = useState();
9 | const [startToken, setStartToken] = useState();
10 |
11 | LiveActivities.useLiveActivitiesSetup(({ token }) => {
12 | setStartToken(token);
13 | });
14 |
15 | LiveActivities.useGetPushToken(({ token }) => {
16 | setToken(token);
17 | });
18 |
19 | return (
20 |
21 |
22 |
23 | Token: {token}
24 | Start Token: {startToken}
25 |
52 | );
53 | }
54 |
55 | const styles = StyleSheet.create({
56 | container: {
57 | flex: 1,
58 | backgroundColor: "#fff",
59 | alignItems: "center",
60 | justifyContent: "center",
61 | },
62 | });
63 |
--------------------------------------------------------------------------------
/modules/live-activities/index.ts:
--------------------------------------------------------------------------------
1 | import { CodedError, EventSubscription } from "expo-modules-core";
2 |
3 | import type {
4 | LiveActivityFn,
5 | LiveActivityState,
6 | onPushTokenChangePayload,
7 | } from "./src/LiveActivities.types";
8 | import LiveActivitiesModule from "./src/LiveActivitiesModule";
9 | import { useEffect } from "react";
10 |
11 | /**
12 | * Live Activities module.
13 | * @author github.com/mrevanzak
14 | */
15 |
16 | export function addPushTokenListener(
17 | listener: (event: onPushTokenChangePayload) => void
18 | ): EventSubscription {
19 | return LiveActivitiesModule.addListener(
20 | "LiveActivities.pushTokenDidChange",
21 | listener
22 | );
23 | }
24 |
25 | export function addStartToPushTokenListener(
26 | listener: (event: onPushTokenChangePayload) => void
27 | ): EventSubscription {
28 | return LiveActivitiesModule.addListener(
29 | "LiveActivities.startTokenDidChange",
30 | listener
31 | );
32 | }
33 |
34 | /**
35 | * Subscribes to the push token changes.
36 | */
37 | export function useGetPushToken(fn: (opt: onPushTokenChangePayload) => void) {
38 | useEffect(() => {
39 | const subscription = addPushTokenListener(fn);
40 | return () => {
41 | subscription.remove();
42 | };
43 | }, []);
44 | }
45 |
46 | /**
47 | * setup the live activities
48 | * subscribe to the push token changes
49 | */
50 | export function useLiveActivitiesSetup(
51 | fn: (opt: onPushTokenChangePayload) => void
52 | ) {
53 | useEffect(() => {
54 | const subscription = addStartToPushTokenListener(fn);
55 | return () => {
56 | subscription.remove();
57 | };
58 | }, []);
59 | }
60 |
61 | /**
62 | * Checks if the Live Activity feature is enabled on the current device.
63 | * iOS 16.2+
64 | * @platform ios
65 | */
66 | export function areActivitiesEnabled(): boolean {
67 | return LiveActivitiesModule.areActivitiesEnabled();
68 | }
69 |
70 | function validateActivityOptions({
71 | progress,
72 | }: Pick): void {
73 | if (typeof progress !== "number" || progress < 0 || progress > 1) {
74 | throw new CodedError(
75 | "ERR_ACTIVITY_PROGRESS",
76 | "Progress should be a number between 0 and 1"
77 | );
78 | }
79 | }
80 |
81 | /**
82 | * Starts an iOS Live Activity.
83 | */
84 | export const startActivity: LiveActivityFn = (
85 | key,
86 | { progress, title, status, estimated, widgetUrl }
87 | ) => {
88 | validateActivityOptions({ progress });
89 | try {
90 | LiveActivitiesModule.startActivity(
91 | key,
92 | Number(progress.toFixed(2)),
93 | title,
94 | status,
95 | estimated,
96 | widgetUrl
97 | );
98 | } catch (error) {
99 | console.error(error);
100 | throw new CodedError("ERR_ACTIVITY_START", "Could not start activity");
101 | }
102 | };
103 |
104 | /**
105 | * Updates an iOS Live Activity.
106 | */
107 | export const updateActivity: LiveActivityFn = (
108 | key,
109 | { progress, title, status, estimated, widgetUrl }
110 | ) => {
111 | validateActivityOptions({ progress });
112 | LiveActivitiesModule.updateActivity(
113 | key,
114 | Number(progress.toFixed(2)),
115 | title,
116 | status,
117 | estimated,
118 | widgetUrl
119 | );
120 | };
121 |
122 | /**
123 | * Ends an iOS Live Activity.
124 | */
125 | export const endActivity: LiveActivityFn = (
126 | key,
127 | { progress, title, status, estimated, widgetUrl }
128 | ) => {
129 | validateActivityOptions({ progress });
130 | LiveActivitiesModule.endActivity(
131 | key,
132 | Number(progress.toFixed(2)),
133 | title,
134 | status,
135 | estimated,
136 | widgetUrl
137 | );
138 | };
139 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Live Activities on Expo
2 | > Live activities can ran smoothly on Expo with help of expo modules. If you have apple watch, it will also shows up on the smart stack
3 |
4 | ## How to Start/Update with APNs
5 | ```ts
6 | import * as apn from "@parse/node-apn";
7 |
8 | type ContentState = {
9 | progress: number;
10 | title: string;
11 | status: string;
12 | estimated: string;
13 | widgetUrl: string;
14 | };
15 |
16 | async function startUserLiveActivity(
17 | key: number,
18 | token: string,
19 | contentState: ContentState,
20 | ) {
21 | try {
22 | const notification = new apn.Notification();
23 | // TODO: change with your app bundle
24 | notification.topic = "${bundleId}.push-type.liveactivity";
25 | notification.pushType = "liveactivity";
26 | //@ts-expect-error - type outdated
27 | notification.event = "start";
28 | //@ts-expect-error - type outdated
29 | notification.timestamp = Math.floor(Date.now() / 1000) + 10;
30 | //@ts-expect-error - type outdated
31 | notification.staleDate = Math.floor(Date.now() / 1000) + 3600;
32 | //@ts-expect-error - type outdated
33 | notification.contentState = contentState;
34 |
35 | // TODO: change to your attribute name on `Attributes.swift`
36 | notification.aps["attributes-type"] = "AirpleAttributes";
37 | //@ts-expect-error - type outdated
38 | notification.aps.attributes = {
39 | key,
40 | };
41 | // can change it to other, its the same as notification title and body
42 | notification.alert = {
43 | title: contentState.title,
44 | body: contentState.status,
45 | //@ts-expect-error - type outdated
46 | sound: "a.wav",
47 | };
48 |
49 | this.apns.send(notification, token).then((response) => {
50 | console.log(response);
51 | });
52 | } catch (error) {
53 | console.log(error);
54 | }
55 | }
56 |
57 | async function updateUserLiveActivity(
58 | token: string,
59 | contentState: ContentState,
60 | event: "update" | "end",
61 | ) {
62 | try {
63 | const notification = new apn.Notification();
64 | // TODO: change with your app bundle
65 | notification.topic = "${bundleId}.push-type.liveactivity";
66 | notification.pushType = "liveactivity";
67 | //@ts-expect-error - type outdated
68 | notification.event = event;
69 | //@ts-expect-error - type outdated
70 | notification.timestamp = Math.floor(Date.now() / 1000) + 10;
71 | //@ts-expect-error - type outdated
72 | notification.staleDate = Math.floor(Date.now() / 1000) + 3600;
73 | //@ts-expect-error - type outdated
74 | notification.contentState = contentState;
75 |
76 | notification.alert = {
77 | title: contentState.title,
78 | body: contentState.status,
79 | //@ts-expect-error - type outdated
80 | sound: "a.wav",
81 | };
82 |
83 | this.apns.send(notification, token).then(async (response) => {
84 | console.log("Update live activity response", response);
85 | });
86 | } catch (error) {
87 | console.log(error);
88 | }
89 | }
90 |
91 | ```
92 |
93 |
94 | | DEMO Video | Smart Stack WatchOS |
95 | | ---------------------------------------------------------------------------------------------------- | -------------------------- |
96 | | |  |
97 |
98 | > Reference
99 | - https://fizl.io/blog/posts/live-activities
100 | - https://github.com/EvanBacon/expo-apple-targets
101 | - https://evanbacon.dev/blog/apple-home-screen-widgets
102 |
103 |
--------------------------------------------------------------------------------
/modules/live-activities/ios/ExpoLiveActivities.swift:
--------------------------------------------------------------------------------
1 | import ActivityKit
2 | import ExpoModulesCore
3 |
4 | let pushTokenDidChange: String =
5 | "LiveActivities.pushTokenDidChange"
6 | let startTokenDidChange: String =
7 | "LiveActivities.startTokenDidChange"
8 |
9 | public class ExpoLiveActivities: Module {
10 | let logger = Logger(logHandlers: [
11 | createOSLogHandler(category: Logger.EXPO_LOG_CATEGORY)
12 | ])
13 |
14 | // Each module class must implement the definition function. The definition consists of components
15 | // that describes the module's functionality and behavior.
16 | // See https://docs.expo.dev/modules/module-api for more details about available components.
17 | public func definition() -> ModuleDefinition {
18 | // Sets the name of the module that JavaScript code will use to refer to the module. Takes a string as an argument.
19 | // Can be inferred from module's class name, but it's recommended to set it explicitly for clarity.
20 | // The module will be accessible from `requireNativeModule('ExpoLiveActivities')` in JavaScript.
21 | Name("ExpoLiveActivities")
22 |
23 | Events(pushTokenDidChange, startTokenDidChange)
24 |
25 | Function("areActivitiesEnabled") { () -> Bool in
26 | logger.info("areActivitiesEnabled()")
27 |
28 | if #available(iOS 16.2, *) {
29 | return ActivityAuthorizationInfo().areActivitiesEnabled
30 | } else {
31 | return false
32 | }
33 | }
34 |
35 | Function("startActivity") {
36 | (
37 | key: String, progress: Double, title: String, status: String,
38 | estimated: String, widgetUrl: String?
39 | ) throws -> Void in
40 | try handleActivity(
41 | action: .start,
42 | key: key,
43 | progress: progress,
44 | title: title,
45 | status: status,
46 | estimated: estimated,
47 | widgetUrl: widgetUrl
48 | )
49 | }
50 |
51 | Function("updateActivity") {
52 | (
53 | key: String, progress: Double, title: String, status: String,
54 | estimated: String, widgetUrl: String?
55 | ) throws -> Void in
56 | try handleActivity(
57 | action: .update,
58 | key: key,
59 | progress: progress,
60 | title: title,
61 | status: status,
62 | estimated: estimated,
63 | widgetUrl: widgetUrl
64 | )
65 | }
66 |
67 | Function("endActivity") {
68 | (
69 | key: String, progress: Double, title: String, status: String,
70 | estimated: String, widgetUrl: String?
71 | ) throws -> Void in
72 | try handleActivity(
73 | action: .end,
74 | key: key,
75 | progress: progress,
76 | title: title,
77 | status: status,
78 | estimated: estimated,
79 | widgetUrl: widgetUrl
80 | )
81 | }
82 |
83 | OnStartObserving {
84 | NotificationCenter.default
85 | .addObserver(
86 | self,
87 | selector: #selector(startTokenListener(notification:)),
88 | name: .onStartPushTokenChange,
89 | object: nil)
90 | NotificationCenter.default
91 | .addObserver(
92 | self,
93 | selector: #selector(pushTokenListener(notification:)),
94 | name: .onPushTokenChange,
95 | object: nil)
96 | }
97 |
98 | OnStopObserving {
99 | NotificationCenter.default.removeObserver(
100 | self, name: .onStartPushTokenChange, object: nil)
101 | NotificationCenter.default.removeObserver(
102 | self, name: .onPushTokenChange, object: nil)
103 | }
104 | }
105 |
106 | private enum ActivityAction {
107 | case start, update, end
108 | }
109 |
110 | private func handleActivity(
111 | action: ActivityAction, key: String, progress: Double, title: String,
112 | status: String, estimated: String, widgetUrl: String?
113 | ) throws {
114 | logger.info("\(action)Activity()")
115 |
116 | guard #available(iOS 16.2, *) else {
117 | logger.info(
118 | "iOS version is lower than 16.2. Live Activity is not available."
119 | )
120 | throw NSError(
121 | domain: "LiveActivities", code: 1,
122 | userInfo: [
123 | NSLocalizedDescriptionKey:
124 | "iOS version is lower than 16.2. Live Activity is not available."
125 | ])
126 | }
127 |
128 | let attributes = AirpleAttributes(key: key)
129 | let contentState = AirpleAttributes.ContentState(
130 | progress: progress, title: title, status: status,
131 | estimated: estimated, widgetUrl: widgetUrl)
132 | let activityContent = ActivityContent(
133 | state: contentState, staleDate: nil)
134 |
135 | switch action {
136 | case .start:
137 | do {
138 | for activity in Activity.activities {
139 | if activity.attributes.key == key {
140 | logger.info(
141 | "The Live Activity \(activity.id) is already started."
142 | )
143 | return
144 | }
145 | }
146 |
147 | let activity = try Activity.request(
148 | attributes: attributes, content: activityContent,
149 | pushType: .token)
150 | logger.info(
151 | "Requested a Live Activity \(String(describing: activity.id))."
152 | )
153 |
154 | } catch {
155 | logger.error(
156 | "Error requesting Live Activity: \(error.localizedDescription)"
157 | )
158 | throw error
159 | }
160 | case .update:
161 | Task {
162 | var activityFound = false
163 | for activity in Activity.activities {
164 | if activity.attributes.key == key {
165 | await activity.update(activityContent)
166 | logger.info(
167 | "Updating the Live Activity: \(activity.id)")
168 | activityFound = true
169 | }
170 | }
171 |
172 | if !activityFound {
173 | logger.info(
174 | "No existing activity found with key \(key), starting a new one"
175 | )
176 | try handleActivity(
177 | action: .start,
178 | key: key,
179 | progress: progress,
180 | title: title,
181 | status: status,
182 | estimated: estimated,
183 | widgetUrl: widgetUrl
184 | )
185 | }
186 | }
187 | case .end:
188 | Task {
189 | for activity in Activity.activities {
190 | if activity.attributes.key == key {
191 | await activity.end(
192 | activityContent)
193 | logger.info("Ending the Live Activity: \(activity.id)")
194 | }
195 | }
196 | }
197 | }
198 |
199 | }
200 |
201 | @objc private func startTokenListener(notification: Notification) {
202 | logger.info(
203 | "start to push token: \(String(describing: notification.object))")
204 | sendEvent(
205 | startTokenDidChange,
206 | [
207 | "token": notification.object
208 | ])
209 |
210 | }
211 |
212 | @objc private func pushTokenListener(notification: Notification) {
213 | logger.info("push token: \(String(describing: notification.object))")
214 | sendEvent(
215 | pushTokenDidChange,
216 | [
217 | "token": notification.object,
218 | "key": notification.userInfo?["key"] as? String ?? "",
219 | ])
220 | }
221 |
222 | }
223 |
--------------------------------------------------------------------------------
/targets/widgets/AirpleWidget.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AirpleWidget.swift
3 | // Airple Activity
4 | //
5 | // Created by Revanza on 2024-08-09.
6 | //
7 |
8 | import ActivityKit
9 | import SwiftUI
10 | import WidgetKit
11 |
12 | extension Comparable {
13 | func clamped(to limits: ClosedRange) -> Self {
14 | return min(max(self, limits.lowerBound), limits.upperBound)
15 | }
16 | }
17 |
18 | struct TrackingProgressViewStyle: ProgressViewStyle {
19 | var height: Double = 4
20 | var limit: ClosedRange = -150...150
21 |
22 | func makeBody(configuration: Configuration) -> some View {
23 | let progress = configuration.fractionCompleted ?? 0.0
24 |
25 | GeometryReader { geometry in
26 | ZStack {
27 | Spacer()
28 | RoundedRectangle(cornerRadius: 10.0)
29 | .frame(height: height)
30 | .frame(width: geometry.size.width)
31 | .overlay(alignment: .leading) {
32 | RoundedRectangle(cornerRadius: 10.0)
33 | .fill(Color("Color"))
34 | .frame(width: geometry.size.width * progress)
35 |
36 | }
37 |
38 | let dynamicLimit = (-geometry.size.width / 2 + 28)...(geometry.size.width / 2 - 24)
39 | Image("Car").offset(
40 | x: ((-geometry.size.width / 2 + geometry.size.width
41 | * progress) - 24).clamped(to: dynamicLimit)
42 | )
43 | }
44 | }.frame(height: height + 20)
45 | }
46 | }
47 |
48 | @available(iOSApplicationExtension 18.0, *)
49 | struct AirpleActivityViewWithFamily: View {
50 | let context: ActivityViewContext
51 | @Environment(\.colorScheme) var colorScheme
52 | @Environment(\.activityFamily) var activityFamily
53 |
54 | var body: some View {
55 | switch activityFamily {
56 | case .small:
57 | AirpleActivityViewSmall(context: context)
58 | case .medium:
59 | AirpleActivityView(context: context)
60 | @unknown default:
61 | AirpleActivityView(context: context)
62 | }
63 | }
64 | }
65 |
66 | struct AirpleActivityView: View {
67 | let context: ActivityViewContext
68 | @Environment(\.colorScheme) var colorScheme
69 |
70 | var body: some View {
71 | VStack(alignment: .leading, spacing: 10) {
72 | Image(colorScheme == .dark ? "Airple_light" : "Airple")
73 | .resizable()
74 | .aspectRatio(contentMode: .fit)
75 | .frame(height: 14)
76 |
77 | VStack(alignment: .leading) {
78 | HStack {
79 | Text(context.state.status)
80 | .font(.title2)
81 | .fontWeight(.semibold)
82 |
83 | Text(context.state.estimated)
84 | .font(.title2)
85 | .fontWeight(.semibold)
86 | .foregroundColor(Color("Color"))
87 |
88 | }.padding(.top, 5)
89 | Text(context.state.title)
90 | .font(.subheadline)
91 | }
92 |
93 | GeometryReader { geometry in
94 | HStack {
95 | ProgressView(value: context.state.progress)
96 | .progressViewStyle(
97 | TrackingProgressViewStyle())
98 | Image("Location")
99 | .offset(y: -2)
100 | }
101 | }
102 | Spacer()
103 |
104 | }
105 | .padding(.all)
106 | }
107 | }
108 |
109 | struct AirpleActivityViewSmall: View {
110 | let context: ActivityViewContext
111 | @Environment(\.colorScheme) var colorScheme
112 |
113 | var body: some View {
114 | HStack {
115 | VStack {
116 | HStack(alignment: .bottom) {
117 | VStack(alignment: .leading) {
118 | Text(context.state.status)
119 | .font(.headline)
120 | .fontWeight(.semibold)
121 |
122 | Text(context.state.estimated)
123 | .font(.body)
124 | .fontWeight(.semibold)
125 | .foregroundColor(Color("Color"))
126 | }
127 |
128 | Spacer()
129 |
130 | Image("Car_side")
131 | .resizable()
132 | .aspectRatio(contentMode: .fit)
133 | .padding(.bottom, 2.0)
134 | .frame(height: 12)
135 | }
136 |
137 | ProgressView(
138 | value: context.state.progress
139 | ).tint(
140 | Color("Color"))
141 | }
142 | }
143 | .padding(.all)
144 | }
145 | }
146 |
147 | struct AirpleIslandBottom: View {
148 | let context: ActivityViewContext
149 |
150 | var body: some View {
151 | VStack(alignment: .leading, spacing: 10) {
152 | VStack(alignment: .leading) {
153 | HStack {
154 | Text(context.state.status)
155 | .font(.title2)
156 | .fontWeight(.semibold)
157 |
158 | Text(context.state.estimated)
159 | .font(.title2)
160 | .fontWeight(.semibold)
161 | .foregroundColor(Color("Color"))
162 |
163 | }.padding(.top, 5)
164 | Text(context.state.title)
165 | .font(.subheadline)
166 | }
167 | Spacer()
168 |
169 | GeometryReader { geometry in
170 | HStack {
171 | ProgressView(
172 | value: context.state.progress
173 | )
174 | .progressViewStyle(
175 | TrackingProgressViewStyle(limit: -135...150))
176 | Image("Location")
177 | .offset(y: -2)
178 | }
179 | }
180 | Spacer()
181 | }
182 | .padding(.horizontal)
183 | }
184 | }
185 |
186 | @available(iOSApplicationExtension 18.0, *)
187 | struct AirpleWidgetIOS18: Widget {
188 | let kind: String = "Airple_Widget"
189 | @Environment(\.colorScheme) var colorScheme
190 |
191 | var body: some WidgetConfiguration {
192 | ActivityConfiguration(for: AirpleAttributes.self) { context in
193 | AirpleActivityViewWithFamily(context: context)
194 | .widgetURL(URL(string: context.state.widgetUrl ?? ""))
195 | } dynamicIsland: { context in
196 | DynamicIsland {
197 | DynamicIslandExpandedRegion(.leading) {
198 | Image("Airple_light")
199 | .resizable()
200 | .aspectRatio(contentMode: .fit)
201 | .frame(height: 14)
202 | .padding(.leading)
203 |
204 | }
205 | DynamicIslandExpandedRegion(.trailing) {
206 | }
207 | DynamicIslandExpandedRegion(.bottom) {
208 | AirpleIslandBottom(context: context)
209 | }
210 | } compactLeading: {
211 | Image("Airple_light")
212 | .resizable()
213 | .aspectRatio(contentMode: .fit)
214 | .frame(height: 10)
215 | } compactTrailing: {
216 | Image("Car")
217 | .resizable()
218 | .aspectRatio(contentMode: .fit)
219 | .frame(height: 20)
220 | } minimal: {
221 | Image("Car")
222 | .resizable()
223 | .aspectRatio(contentMode: .fit)
224 | .frame(height: 20)
225 | }
226 | .widgetURL(URL(string: context.state.widgetUrl ?? ""))
227 | }
228 | .supplementalActivityFamilies([.small, .medium])
229 | }
230 | }
231 |
232 | struct AirpleWidget: Widget {
233 | let kind: String = "Airple_Widget"
234 | @Environment(\.colorScheme) var colorScheme
235 |
236 | var body: some WidgetConfiguration {
237 | ActivityConfiguration(for: AirpleAttributes.self) { context in
238 | AirpleActivityView(context: context)
239 | .widgetURL(URL(string: context.state.widgetUrl ?? ""))
240 | } dynamicIsland: { context in
241 | DynamicIsland {
242 | DynamicIslandExpandedRegion(.leading) {
243 | Image("Airple_light")
244 | .resizable()
245 | .aspectRatio(contentMode: .fit)
246 | .frame(height: 14)
247 | .padding(.leading)
248 |
249 | }
250 | DynamicIslandExpandedRegion(.trailing) {
251 | }
252 | DynamicIslandExpandedRegion(.bottom) {
253 | AirpleIslandBottom(context: context)
254 | }
255 | } compactLeading: {
256 | Image("Airple_light")
257 | .resizable()
258 | .aspectRatio(contentMode: .fit)
259 | .frame(height: 10)
260 | } compactTrailing: {
261 | Image("Car")
262 | .resizable()
263 | .aspectRatio(contentMode: .fit)
264 | .frame(height: 20)
265 | } minimal: {
266 | Image("Car")
267 | .resizable()
268 | .aspectRatio(contentMode: .fit)
269 | .frame(height: 20)
270 | }
271 | .widgetURL(URL(string: context.state.widgetUrl ?? ""))
272 | }
273 | }
274 | }
275 |
276 | extension AirpleAttributes {
277 | fileprivate static var preview: AirpleAttributes {
278 | AirpleAttributes(key: "test")
279 | }
280 | }
281 |
282 | extension AirpleAttributes.ContentState {
283 | fileprivate static var state: AirpleAttributes.ContentState {
284 | AirpleAttributes.ContentState(
285 | progress: 0.5, title: "Technician is on the way!",
286 | status: "Arriving", estimated: "13.30 - 13.40")
287 | }
288 | }
289 |
290 | struct AirpleActivityView_Previews: PreviewProvider {
291 | static var previews: some View {
292 | Group {
293 | AirpleAttributes.preview
294 | .previewContext(
295 | AirpleAttributes.ContentState.state, viewKind: .content
296 | )
297 | .previewDisplayName("Content View")
298 |
299 | AirpleAttributes.preview
300 | .previewContext(
301 | AirpleAttributes.ContentState.state,
302 | viewKind: .dynamicIsland(.compact)
303 | )
304 | .previewDisplayName("Dynamic Island Compact")
305 |
306 | AirpleAttributes.preview
307 | .previewContext(
308 | AirpleAttributes.ContentState.state,
309 | viewKind: .dynamicIsland(.expanded)
310 | )
311 | .previewDisplayName("Dynamic Island Expanded")
312 |
313 | AirpleAttributes.preview
314 | .previewContext(
315 | AirpleAttributes.ContentState.state,
316 | viewKind: .dynamicIsland(.minimal)
317 | )
318 | .previewDisplayName("Dynamic Island Minimal")
319 |
320 | AirpleAttributes.preview
321 | .previewContext(
322 | AirpleAttributes.ContentState.state,
323 | viewKind: .dynamicIsland(.minimal)
324 | )
325 | .previewDisplayName("Dynamic Island Minimal")
326 | }
327 | }
328 | }
--------------------------------------------------------------------------------