@craft-native/ios 0.0.193 → 0.0.195
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/dist/cli.js +1 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
- package/templates/CraftActivityAttributes.swift +12 -0
- package/templates/CraftApp.swift +19 -111
- package/templates/CraftLiveActivities.swift +98 -0
- package/templates/CraftLiveActivityWidget.swift.template +77 -24
- package/templates/CraftNativeActions.swift +6 -0
- package/templates/CraftNativeScreen.swift +6 -0
package/dist/cli.js
CHANGED
|
@@ -881,6 +881,7 @@ function writeShell(output, config) {
|
|
|
881
881
|
cpSync(join3(TEMPLATES_DIR, "CraftWebAudio.swift"), join3(output, "Sources", "CraftWebAudio.swift"));
|
|
882
882
|
cpSync(join3(TEMPLATES_DIR, "CraftHybrid.swift"), join3(output, "Sources", "CraftHybrid.swift"));
|
|
883
883
|
writeFileSync(join3(output, "Sources", "CraftCrashReporter.swift"), readFileSync(join3(TEMPLATES_DIR, "CraftCrashReporter.swift"), "utf-8").replace(/\{\{CRAFT_VERSION\}\}/g, CRAFT_VERSION));
|
|
884
|
+
cpSync(join3(TEMPLATES_DIR, "CraftLiveActivities.swift"), join3(output, "Sources", "CraftLiveActivities.swift"));
|
|
884
885
|
cpSync(join3(TEMPLATES_DIR, "CraftActivityAttributes.swift"), join3(output, "Shared", "CraftActivityAttributes.swift"));
|
|
885
886
|
for (const [enabled, folder, template, filename] of [
|
|
886
887
|
[config.enableLiveActivities, "WidgetExtension", "CraftLiveActivityWidget.swift.template", `${name}LiveActivity.swift`],
|
package/dist/index.js
CHANGED
|
@@ -1018,6 +1018,7 @@ function writeShell(output, config) {
|
|
|
1018
1018
|
cpSync(join3(TEMPLATES_DIR, "CraftWebAudio.swift"), join3(output, "Sources", "CraftWebAudio.swift"));
|
|
1019
1019
|
cpSync(join3(TEMPLATES_DIR, "CraftHybrid.swift"), join3(output, "Sources", "CraftHybrid.swift"));
|
|
1020
1020
|
writeFileSync2(join3(output, "Sources", "CraftCrashReporter.swift"), readFileSync2(join3(TEMPLATES_DIR, "CraftCrashReporter.swift"), "utf-8").replace(/\{\{CRAFT_VERSION\}\}/g, CRAFT_VERSION));
|
|
1021
|
+
cpSync(join3(TEMPLATES_DIR, "CraftLiveActivities.swift"), join3(output, "Sources", "CraftLiveActivities.swift"));
|
|
1021
1022
|
cpSync(join3(TEMPLATES_DIR, "CraftActivityAttributes.swift"), join3(output, "Shared", "CraftActivityAttributes.swift"));
|
|
1022
1023
|
for (const [enabled, folder, template, filename] of [
|
|
1023
1024
|
[config.enableLiveActivities, "WidgetExtension", "CraftLiveActivityWidget.swift.template", `${name}LiveActivity.swift`],
|
package/package.json
CHANGED
|
@@ -7,8 +7,20 @@ struct CraftActivityAttributes: ActivityAttributes {
|
|
|
7
7
|
let distanceMeters: Double
|
|
8
8
|
let durationSeconds: Double
|
|
9
9
|
let progress: Double
|
|
10
|
+
var staleStatus: String?
|
|
11
|
+
var detail: String?
|
|
12
|
+
/// Epoch milliseconds. SwiftUI advances these timers without app updates.
|
|
13
|
+
var timerStartedAt: Double?
|
|
14
|
+
var timerEndsAt: Double?
|
|
15
|
+
var paused: Bool?
|
|
16
|
+
var remainingSeconds: Double?
|
|
17
|
+
var heartRate: Double?
|
|
18
|
+
var showsDistance: Bool?
|
|
10
19
|
}
|
|
11
20
|
|
|
12
21
|
let activityId: String
|
|
13
22
|
let title: String
|
|
23
|
+
var symbol: String?
|
|
24
|
+
var deepLink: String?
|
|
25
|
+
var tint: String?
|
|
14
26
|
}
|
package/templates/CraftApp.swift
CHANGED
|
@@ -2752,6 +2752,10 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
2752
2752
|
}
|
|
2753
2753
|
|
|
2754
2754
|
// MARK: - Live Activities
|
|
2755
|
+
case "isLiveActivityAvailable":
|
|
2756
|
+
liveActivity(method: "isAvailable", args: [], callbackId: callbackId)
|
|
2757
|
+
case "listLiveActivities":
|
|
2758
|
+
liveActivity(method: "list", args: [], callbackId: callbackId)
|
|
2755
2759
|
case "startLiveActivity":
|
|
2756
2760
|
if config.enableLiveActivities {
|
|
2757
2761
|
startLiveActivity(body: body, callbackId: callbackId)
|
|
@@ -5380,6 +5384,8 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
5380
5384
|
};
|
|
5381
5385
|
})();
|
|
5382
5386
|
craft.liveActivity = {
|
|
5387
|
+
isAvailable: function() { return craft._invoke('isLiveActivityAvailable'); },
|
|
5388
|
+
list: function() { return craft._invoke('listLiveActivities'); },
|
|
5383
5389
|
start: function(options) { return craft._invoke('startLiveActivity', options || {}); },
|
|
5384
5390
|
update: function(idOrState, state) {
|
|
5385
5391
|
if (typeof idOrState !== 'string') return craft._invoke('updateLiveActivity', idOrState || {});
|
|
@@ -7463,123 +7469,25 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
7463
7469
|
}
|
|
7464
7470
|
|
|
7465
7471
|
// MARK: - Live Activities
|
|
7466
|
-
private func
|
|
7467
|
-
|
|
7468
|
-
|
|
7469
|
-
return
|
|
7470
|
-
|
|
7471
|
-
|
|
7472
|
-
|
|
7473
|
-
|
|
7474
|
-
)
|
|
7475
|
-
let state = CraftActivityAttributes.ContentState(
|
|
7476
|
-
status: body["status"] as? String ?? "Recording",
|
|
7477
|
-
distanceMeters: body["distanceMeters"] as? Double ?? 0,
|
|
7478
|
-
durationSeconds: body["durationSeconds"] as? Double ?? 0,
|
|
7479
|
-
progress: min(max(body["progress"] as? Double ?? 0, 0), 1)
|
|
7480
|
-
)
|
|
7481
|
-
do {
|
|
7482
|
-
let activity = try Activity.request(
|
|
7483
|
-
attributes: attributes,
|
|
7484
|
-
content: ActivityContent(state: state, staleDate: nil),
|
|
7485
|
-
pushType: nil
|
|
7486
|
-
)
|
|
7487
|
-
resolveCallback(callbackId, result: ["id": activity.id])
|
|
7488
|
-
} catch {
|
|
7489
|
-
rejectCallback(callbackId, error: error.localizedDescription)
|
|
7472
|
+
private func liveActivity(method: String, args: [Any], callbackId: String?) {
|
|
7473
|
+
let token = armDeadline(Coordinator.liveActivityDeadline, callbackId: callbackId, error: "Live Activity request timed out")
|
|
7474
|
+
CraftLiveActivities.perform(method: method, args: args, enabled: config.enableLiveActivities, appName: config.appName) { [weak self] result in
|
|
7475
|
+
guard let self, self.claimDeadline(token) else { return }
|
|
7476
|
+
switch result {
|
|
7477
|
+
case .success(let value): self.resolveCallback(callbackId, result: value)
|
|
7478
|
+
case .failure(let error): self.rejectCallback(callbackId, error: error.message)
|
|
7479
|
+
}
|
|
7490
7480
|
}
|
|
7491
7481
|
}
|
|
7492
7482
|
|
|
7483
|
+
private func startLiveActivity(body: [String: Any], callbackId: String?) {
|
|
7484
|
+
liveActivity(method: "start", args: [body], callbackId: callbackId)
|
|
7485
|
+
}
|
|
7493
7486
|
private func updateLiveActivity(body: [String: Any], callbackId: String?) {
|
|
7494
|
-
|
|
7495
|
-
rejectCallback(callbackId, error: "No Live Activity is running")
|
|
7496
|
-
return
|
|
7497
|
-
}
|
|
7498
|
-
let activities = Activity<CraftActivityAttributes>.activities
|
|
7499
|
-
let activity: Activity<CraftActivityAttributes>
|
|
7500
|
-
if let activityId = body["id"] as? String {
|
|
7501
|
-
guard let matchingActivity = activities.first(where: { $0.id == activityId }) else {
|
|
7502
|
-
rejectCallback(callbackId, error: "No Live Activity with id \(activityId) is running")
|
|
7503
|
-
return
|
|
7504
|
-
}
|
|
7505
|
-
activity = matchingActivity
|
|
7506
|
-
} else {
|
|
7507
|
-
guard let currentActivity = activities.first else {
|
|
7508
|
-
rejectCallback(callbackId, error: "No Live Activity is running")
|
|
7509
|
-
return
|
|
7510
|
-
}
|
|
7511
|
-
activity = currentActivity
|
|
7512
|
-
}
|
|
7513
|
-
let current = activity.content.state
|
|
7514
|
-
let state = CraftActivityAttributes.ContentState(
|
|
7515
|
-
status: body["status"] as? String ?? current.status,
|
|
7516
|
-
distanceMeters: body["distanceMeters"] as? Double ?? current.distanceMeters,
|
|
7517
|
-
durationSeconds: body["durationSeconds"] as? Double ?? current.durationSeconds,
|
|
7518
|
-
progress: min(max(body["progress"] as? Double ?? current.progress, 0), 1)
|
|
7519
|
-
)
|
|
7520
|
-
let token = armDeadline(
|
|
7521
|
-
Coordinator.liveActivityDeadline,
|
|
7522
|
-
callbackId: callbackId,
|
|
7523
|
-
error: "Activity.update did not return within \(Int(Coordinator.liveActivityDeadline))s"
|
|
7524
|
-
)
|
|
7525
|
-
Task {
|
|
7526
|
-
await activity.update(ActivityContent(state: state, staleDate: nil))
|
|
7527
|
-
await MainActor.run { [weak self] in
|
|
7528
|
-
guard let self, self.claimDeadline(token) else { return }
|
|
7529
|
-
self.resolveCallback(callbackId, result: ["updated": true])
|
|
7530
|
-
}
|
|
7531
|
-
}
|
|
7487
|
+
liveActivity(method: "update", args: [body["id"] ?? NSNull(), body], callbackId: callbackId)
|
|
7532
7488
|
}
|
|
7533
|
-
|
|
7534
7489
|
private func endLiveActivity(body: [String: Any], callbackId: String?) {
|
|
7535
|
-
|
|
7536
|
-
resolveCallback(callbackId, result: ["ended": false])
|
|
7537
|
-
return
|
|
7538
|
-
}
|
|
7539
|
-
let activities = Activity<CraftActivityAttributes>.activities
|
|
7540
|
-
let activity: Activity<CraftActivityAttributes>
|
|
7541
|
-
if let activityId = body["id"] as? String {
|
|
7542
|
-
guard let matchingActivity = activities.first(where: { $0.id == activityId }) else {
|
|
7543
|
-
rejectCallback(callbackId, error: "No Live Activity with id \(activityId) is running")
|
|
7544
|
-
return
|
|
7545
|
-
}
|
|
7546
|
-
activity = matchingActivity
|
|
7547
|
-
} else {
|
|
7548
|
-
guard let currentActivity = activities.first else {
|
|
7549
|
-
resolveCallback(callbackId, result: ["ended": false])
|
|
7550
|
-
return
|
|
7551
|
-
}
|
|
7552
|
-
activity = currentActivity
|
|
7553
|
-
}
|
|
7554
|
-
let hasFinalState = body["status"] != nil
|
|
7555
|
-
|| body["distanceMeters"] != nil
|
|
7556
|
-
|| body["durationSeconds"] != nil
|
|
7557
|
-
|| body["progress"] != nil
|
|
7558
|
-
let finalContent: ActivityContent<CraftActivityAttributes.ContentState>?
|
|
7559
|
-
if hasFinalState {
|
|
7560
|
-
let current = activity.content.state
|
|
7561
|
-
let state = CraftActivityAttributes.ContentState(
|
|
7562
|
-
status: body["status"] as? String ?? current.status,
|
|
7563
|
-
distanceMeters: body["distanceMeters"] as? Double ?? current.distanceMeters,
|
|
7564
|
-
durationSeconds: body["durationSeconds"] as? Double ?? current.durationSeconds,
|
|
7565
|
-
progress: min(max(body["progress"] as? Double ?? current.progress, 0), 1)
|
|
7566
|
-
)
|
|
7567
|
-
finalContent = ActivityContent(state: state, staleDate: nil)
|
|
7568
|
-
} else {
|
|
7569
|
-
finalContent = nil
|
|
7570
|
-
}
|
|
7571
|
-
let token = armDeadline(
|
|
7572
|
-
Coordinator.liveActivityDeadline,
|
|
7573
|
-
callbackId: callbackId,
|
|
7574
|
-
error: "Activity.end did not return within \(Int(Coordinator.liveActivityDeadline))s"
|
|
7575
|
-
)
|
|
7576
|
-
Task {
|
|
7577
|
-
await activity.end(finalContent, dismissalPolicy: .default)
|
|
7578
|
-
await MainActor.run { [weak self] in
|
|
7579
|
-
guard let self, self.claimDeadline(token) else { return }
|
|
7580
|
-
self.resolveCallback(callbackId, result: ["ended": true])
|
|
7581
|
-
}
|
|
7582
|
-
}
|
|
7490
|
+
liveActivity(method: "end", args: [body["id"] ?? NSNull(), body], callbackId: callbackId)
|
|
7583
7491
|
}
|
|
7584
7492
|
|
|
7585
7493
|
// MARK: - Screen Capture
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import ActivityKit
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
/// ActivityKit belongs to the process, not a web view or a retained STX screen.
|
|
5
|
+
/// A stable application activityId reattaches after a screen or app restart.
|
|
6
|
+
enum CraftLiveActivities {
|
|
7
|
+
static func perform(method: String, args: [Any], enabled: Bool, appName: String, completion: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
|
|
8
|
+
guard enabled else {
|
|
9
|
+
if method == "isAvailable" { completion(.success(false)); return }
|
|
10
|
+
completion(.failure(CraftNativeActionError(code: "CAPABILITY_DISABLED", message: "Live Activities are disabled")))
|
|
11
|
+
return
|
|
12
|
+
}
|
|
13
|
+
guard #available(iOS 16.2, *) else {
|
|
14
|
+
if method == "isAvailable" { completion(.success(false)) }
|
|
15
|
+
else { completion(.failure(CraftNativeActionError(code: "UNSUPPORTED", message: "Live Activities require iOS 16.2"))) }
|
|
16
|
+
return
|
|
17
|
+
}
|
|
18
|
+
Task { @MainActor in
|
|
19
|
+
do {
|
|
20
|
+
let activities = Activity<CraftActivityAttributes>.activities.filter { $0.activityState == .active || $0.activityState == .stale }
|
|
21
|
+
switch method {
|
|
22
|
+
case "isAvailable": completion(.success(ActivityAuthorizationInfo().areActivitiesEnabled))
|
|
23
|
+
case "list": completion(.success(activities.map { ["id": $0.id, "activityId": $0.attributes.activityId, "title": $0.attributes.title] }))
|
|
24
|
+
case "start":
|
|
25
|
+
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
|
|
26
|
+
throw CraftNativeActionError(code: "NOT_ALLOWED", message: "Live Activities are turned off in Settings")
|
|
27
|
+
}
|
|
28
|
+
let body = args.first as? [String: Any] ?? [:]
|
|
29
|
+
guard let key = body["activityId"] as? String, !key.isEmpty,
|
|
30
|
+
let title = body["title"] as? String, !title.isEmpty else {
|
|
31
|
+
throw CraftNativeActionError(code: "INVALID_ARGUMENT", message: "A Live Activity needs activityId and title")
|
|
32
|
+
}
|
|
33
|
+
let state = content(body)
|
|
34
|
+
if let existing = activities.first(where: { $0.attributes.activityId == key }) {
|
|
35
|
+
await existing.update(ActivityContent(state: state, staleDate: staleDate(body)))
|
|
36
|
+
completion(.success(["id": existing.id]))
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
let attributes = CraftActivityAttributes(activityId: key, title: String(title.prefix(160)), symbol: body["symbol"] as? String, deepLink: safeURL(body["deepLink"] as? String), tint: body["tint"] as? String)
|
|
40
|
+
let activity = try Activity.request(attributes: attributes, content: ActivityContent(state: state, staleDate: staleDate(body)), pushType: nil)
|
|
41
|
+
completion(.success(["id": activity.id]))
|
|
42
|
+
case "update", "end":
|
|
43
|
+
let id = args.first as? String
|
|
44
|
+
let activity = id == nil ? activities.first : activities.first(where: { $0.id == id })
|
|
45
|
+
guard let activity else {
|
|
46
|
+
if method == "end" { completion(.success(["ended": false])); return }
|
|
47
|
+
throw CraftNativeActionError(code: "NOT_FOUND", message: "No matching Live Activity is running")
|
|
48
|
+
}
|
|
49
|
+
let body = args.count > 1 ? args[1] as? [String: Any] ?? [:] : [:]
|
|
50
|
+
let state = content(body, previous: activity.content.state)
|
|
51
|
+
if method == "end" {
|
|
52
|
+
await activity.end(ActivityContent(state: state, staleDate: nil), dismissalPolicy: .immediate)
|
|
53
|
+
completion(.success(["ended": true]))
|
|
54
|
+
} else {
|
|
55
|
+
await activity.update(ActivityContent(state: state, staleDate: staleDate(body)))
|
|
56
|
+
completion(.success(["updated": true]))
|
|
57
|
+
}
|
|
58
|
+
default: throw CraftNativeActionError(code: "UNKNOWN_ACTION", message: "Unsupported Live Activities method")
|
|
59
|
+
}
|
|
60
|
+
} catch let error as CraftNativeActionError { completion(.failure(error)) }
|
|
61
|
+
catch { completion(.failure(CraftNativeActionError(code: "ACTIVITY_ERROR", message: error.localizedDescription))) }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private static func safeURL(_ value: String?) -> String? {
|
|
66
|
+
guard let value, let url = URL(string: value), let scheme = url.scheme?.lowercased(),
|
|
67
|
+
!["javascript", "data", "file"].contains(scheme) else { return nil }
|
|
68
|
+
return value
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private static func finite(_ body: [String: Any], _ key: String, _ fallback: Double? = nil) -> Double? {
|
|
72
|
+
guard body.keys.contains(key) else { return fallback }
|
|
73
|
+
guard let number = body[key] as? NSNumber, number.doubleValue.isFinite else { return nil }
|
|
74
|
+
return max(0, number.doubleValue)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private static func staleDate(_ body: [String: Any]) -> Date? {
|
|
78
|
+
finite(body, "staleAt").map { Date(timeIntervalSince1970: $0 / 1000) }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
@available(iOS 16.2, *)
|
|
82
|
+
private static func content(_ body: [String: Any], previous: CraftActivityAttributes.ContentState? = nil) -> CraftActivityAttributes.ContentState {
|
|
83
|
+
CraftActivityAttributes.ContentState(
|
|
84
|
+
status: String((body["status"] as? String ?? previous?.status ?? "").prefix(120)),
|
|
85
|
+
distanceMeters: finite(body, "distanceMeters", previous?.distanceMeters) ?? 0,
|
|
86
|
+
durationSeconds: finite(body, "durationSeconds", previous?.durationSeconds) ?? 0,
|
|
87
|
+
progress: min(finite(body, "progress", previous?.progress) ?? 0, 1),
|
|
88
|
+
staleStatus: body["staleStatus"] as? String ?? previous?.staleStatus,
|
|
89
|
+
detail: body["detail"] as? String ?? previous?.detail,
|
|
90
|
+
timerStartedAt: finite(body, "timerStartedAt", previous?.timerStartedAt),
|
|
91
|
+
timerEndsAt: finite(body, "timerEndsAt", previous?.timerEndsAt),
|
|
92
|
+
paused: body["paused"] as? Bool ?? previous?.paused,
|
|
93
|
+
remainingSeconds: finite(body, "remainingSeconds", previous?.remainingSeconds),
|
|
94
|
+
heartRate: finite(body, "heartRate", previous?.heartRate),
|
|
95
|
+
showsDistance: body["showsDistance"] as? Bool ?? previous?.showsDistance
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -4,59 +4,112 @@ import WidgetKit
|
|
|
4
4
|
|
|
5
5
|
@main
|
|
6
6
|
struct CraftLiveActivityBundle: WidgetBundle {
|
|
7
|
-
var body: some Widget {
|
|
8
|
-
CraftLiveActivityWidget()
|
|
9
|
-
}
|
|
7
|
+
var body: some Widget { CraftLiveActivityWidget() }
|
|
10
8
|
}
|
|
11
9
|
|
|
12
10
|
struct CraftLiveActivityWidget: Widget {
|
|
13
11
|
var body: some WidgetConfiguration {
|
|
14
12
|
ActivityConfiguration(for: CraftActivityAttributes.self) { context in
|
|
15
13
|
HStack(spacing: 12) {
|
|
16
|
-
Image(systemName:
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
14
|
+
Image(systemName: symbol(context)).font(.title2).foregroundStyle(tint(context))
|
|
15
|
+
VStack(alignment: .leading, spacing: 4) {
|
|
16
|
+
Text(context.attributes.title).font(.headline).lineLimit(1)
|
|
17
|
+
if let detail = context.state.detail, !detail.isEmpty {
|
|
18
|
+
Text(detail).font(.subheadline).lineLimit(1)
|
|
19
|
+
}
|
|
20
|
+
Text(context.isStale ? context.state.staleStatus ?? context.state.status : context.state.status).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
|
21
21
|
}
|
|
22
|
-
Spacer()
|
|
23
|
-
VStack(alignment: .trailing, spacing:
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
Spacer(minLength: 8)
|
|
23
|
+
VStack(alignment: .trailing, spacing: 4) {
|
|
24
|
+
timer(context).font(.title2.monospacedDigit().weight(.semibold))
|
|
25
|
+
metrics(context).font(.caption.monospacedDigit())
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
.padding()
|
|
29
|
-
.activityBackgroundTint(Color(red: 0, green: 0.
|
|
29
|
+
.activityBackgroundTint(Color(red: 0.07, green: 0.10, blue: 0.16))
|
|
30
30
|
.activitySystemActionForegroundColor(.white)
|
|
31
|
+
.widgetURL(context.attributes.deepLink.flatMap(URL.init(string:)))
|
|
31
32
|
} dynamicIsland: { context in
|
|
32
33
|
DynamicIsland {
|
|
33
34
|
DynamicIslandExpandedRegion(.leading) {
|
|
34
|
-
|
|
35
|
+
Image(systemName: symbol(context)).foregroundStyle(tint(context)).font(.title2)
|
|
35
36
|
}
|
|
36
37
|
DynamicIslandExpandedRegion(.trailing) {
|
|
37
|
-
|
|
38
|
+
timer(context).font(.title2.monospacedDigit().weight(.semibold))
|
|
39
|
+
}
|
|
40
|
+
DynamicIslandExpandedRegion(.center) {
|
|
41
|
+
VStack(spacing: 3) {
|
|
42
|
+
Text(context.attributes.title).font(.headline).lineLimit(1)
|
|
43
|
+
Text(context.isStale ? context.state.staleStatus ?? context.state.status : context.state.status).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
|
44
|
+
}
|
|
38
45
|
}
|
|
39
46
|
DynamicIslandExpandedRegion(.bottom) {
|
|
40
|
-
|
|
41
|
-
|
|
47
|
+
VStack(spacing: 8) {
|
|
48
|
+
HStack {
|
|
49
|
+
Text(context.state.detail ?? "").font(.subheadline).lineLimit(1)
|
|
50
|
+
Spacer()
|
|
51
|
+
metrics(context).font(.caption.monospacedDigit())
|
|
52
|
+
}
|
|
53
|
+
if let end = context.state.timerEndsAt, context.state.paused != true, let start = context.state.timerStartedAt, end > start {
|
|
54
|
+
ProgressView(timerInterval: date(start)...date(end), countsDown: false).labelsHidden().tint(tint(context))
|
|
55
|
+
} else {
|
|
56
|
+
ProgressView(value: context.state.progress).tint(tint(context))
|
|
57
|
+
}
|
|
58
|
+
}
|
|
42
59
|
}
|
|
43
60
|
} compactLeading: {
|
|
44
|
-
Image(systemName:
|
|
61
|
+
Image(systemName: symbol(context)).foregroundStyle(tint(context))
|
|
45
62
|
} compactTrailing: {
|
|
46
|
-
|
|
63
|
+
timer(context).monospacedDigit().frame(maxWidth: 64)
|
|
47
64
|
} minimal: {
|
|
48
|
-
Image(systemName:
|
|
65
|
+
Image(systemName: symbol(context)).foregroundStyle(tint(context))
|
|
49
66
|
}
|
|
50
|
-
.
|
|
67
|
+
.widgetURL(context.attributes.deepLink.flatMap(URL.init(string:)))
|
|
68
|
+
.keylineTint(tint(context))
|
|
51
69
|
}
|
|
52
70
|
}
|
|
53
71
|
|
|
54
|
-
private func
|
|
55
|
-
|
|
72
|
+
private func symbol(_ context: ActivityViewContext<CraftActivityAttributes>) -> String {
|
|
73
|
+
context.state.paused == true ? "pause.fill" : context.attributes.symbol ?? "timer"
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private func tint(_ context: ActivityViewContext<CraftActivityAttributes>) -> Color {
|
|
77
|
+
let raw = context.attributes.tint?.trimmingCharacters(in: CharacterSet(charactersIn: "#")) ?? "007aff"
|
|
78
|
+
guard raw.count == 6, let value = UInt32(raw, radix: 16) else { return .blue }
|
|
79
|
+
return Color(red: Double((value >> 16) & 255) / 255, green: Double((value >> 8) & 255) / 255, blue: Double(value & 255) / 255)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private func date(_ milliseconds: Double) -> Date { Date(timeIntervalSince1970: milliseconds / 1000) }
|
|
83
|
+
|
|
84
|
+
@ViewBuilder
|
|
85
|
+
private func timer(_ context: ActivityViewContext<CraftActivityAttributes>) -> some View {
|
|
86
|
+
if context.state.paused == true {
|
|
87
|
+
Text(duration(context.state.remainingSeconds ?? context.state.durationSeconds))
|
|
88
|
+
} else if let end = context.state.timerEndsAt {
|
|
89
|
+
// Clamped at zero, with no JavaScript timer or per-second Activity.update.
|
|
90
|
+
let start = min(date(context.state.timerStartedAt ?? end - 1), date(end))
|
|
91
|
+
Text(timerInterval: start...date(end), countsDown: true)
|
|
92
|
+
} else if let start = context.state.timerStartedAt {
|
|
93
|
+
Text(date(start), style: .timer)
|
|
94
|
+
} else {
|
|
95
|
+
Text(duration(context.state.durationSeconds))
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
@ViewBuilder
|
|
100
|
+
private func metrics(_ context: ActivityViewContext<CraftActivityAttributes>) -> some View {
|
|
101
|
+
HStack(spacing: 8) {
|
|
102
|
+
if let bpm = context.state.heartRate, bpm > 0 {
|
|
103
|
+
Label("\(Int(bpm))", systemImage: "heart.fill").foregroundStyle(.pink).privacySensitive()
|
|
104
|
+
}
|
|
105
|
+
if context.state.showsDistance != false {
|
|
106
|
+
Text(Measurement(value: context.state.distanceMeters, unit: UnitLength.meters), format: .measurement(width: .abbreviated, usage: .road)).privacySensitive()
|
|
107
|
+
}
|
|
108
|
+
}
|
|
56
109
|
}
|
|
57
110
|
|
|
58
111
|
private func duration(_ seconds: Double) -> String {
|
|
59
112
|
let value = max(Int(seconds), 0)
|
|
60
|
-
return String(format: "%02d:%02d", value / 60, value % 60)
|
|
113
|
+
return value >= 3600 ? String(format: "%d:%02d:%02d", value / 3600, value / 60 % 60, value % 60) : String(format: "%d:%02d", value / 60, value % 60)
|
|
61
114
|
}
|
|
62
115
|
}
|
|
@@ -48,6 +48,7 @@ enum CraftNativeActions {
|
|
|
48
48
|
if config.enableDeepLinks { values.append("deepLinks") }
|
|
49
49
|
if config.enableLocalNotifications { values.append("notifications") }
|
|
50
50
|
if config.enableHealthKit { values.append("health") }
|
|
51
|
+
if config.enableLiveActivities { values.append("liveActivities") }
|
|
51
52
|
// The in-app browser and the system's web sign-in sheet (CraftBrowser).
|
|
52
53
|
values.append("browser")
|
|
53
54
|
if config.enableSocialAuth { values.append("appleSignIn") }
|
|
@@ -328,6 +329,11 @@ enum CraftNativeActions {
|
|
|
328
329
|
}
|
|
329
330
|
}
|
|
330
331
|
return
|
|
332
|
+
case ("LiveActivities", _):
|
|
333
|
+
CraftLiveActivities.perform(method: method, args: args, enabled: config.enableLiveActivities, appName: config.appName) { result in
|
|
334
|
+
complete(requestToken, result, completion)
|
|
335
|
+
}
|
|
336
|
+
return
|
|
331
337
|
case ("Health", _):
|
|
332
338
|
// Apple Health, the same as a page's `craft.health` (CraftHealth.swift).
|
|
333
339
|
guard config.enableHealthKit else {
|
|
@@ -4406,6 +4406,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4406
4406
|
navigationController?.setNavigationBarHidden(hidden, animated: view.window != nil)
|
|
4407
4407
|
}
|
|
4408
4408
|
}
|
|
4409
|
+
if options.keys.contains("leftButtons") {
|
|
4410
|
+
let buttons = (options["leftButtons"] as? [[String: Any]]) ?? []
|
|
4411
|
+
navigationItem.leftItemsSupplementBackButton = true
|
|
4412
|
+
navigationItem.leftBarButtonItems = buttons.compactMap(navigationButton)
|
|
4413
|
+
}
|
|
4409
4414
|
if options.keys.contains("rightButtons") {
|
|
4410
4415
|
let buttons = (options["rightButtons"] as? [[String: Any]]) ?? []
|
|
4411
4416
|
navigationItem.rightBarButtonItems = buttons.compactMap(navigationButton).reversed()
|
|
@@ -4480,6 +4485,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4480
4485
|
} else {
|
|
4481
4486
|
item.accessibilityLabel = id
|
|
4482
4487
|
}
|
|
4488
|
+
if spec["variant"] as? String == "neutral" { item.tintColor = .label }
|
|
4483
4489
|
if let tint = (spec["color"] as? String).flatMap({ UIColor(hex: $0) }) { item.tintColor = tint }
|
|
4484
4490
|
// `image`: a picture (an https URL, the account's own photo), drawn
|
|
4485
4491
|
// round as iOS draws an account button. The symbol or title shows
|