craft-native 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.
@@ -571,6 +571,7 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
571
571
  val content = FrameLayout(context)
572
572
  private val bar = LinearLayout(context)
573
573
  private val backButton = ImageView(context)
574
+ private val leadingButtons = LinearLayout(context)
574
575
  private val titleView = TextView(context)
575
576
  private val largeTitleView = TextView(context)
576
577
  private val buttons = LinearLayout(context)
@@ -609,6 +610,9 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
609
610
  backButton.background = rippleBackground()
610
611
  backButton.setOnClickListener { onBack?.invoke() }
611
612
  bar.addView(backButton, LayoutParams(dp(48), dp(48)))
613
+ leadingButtons.orientation = HORIZONTAL
614
+ leadingButtons.gravity = Gravity.CENTER_VERTICAL
615
+ bar.addView(leadingButtons, LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, dp(56)))
612
616
  titleView.textSize = 20f
613
617
  titleView.typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL)
614
618
  titleView.isSingleLine = true
@@ -717,6 +721,12 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
717
721
  largeTitleView.text = title
718
722
  largeTitleView.visibility = if (!hidden && large && title.isNotBlank()) View.VISIBLE else View.GONE
719
723
  buttons.removeAllViews()
724
+ leadingButtons.removeAllViews()
725
+ val leading = options.optJSONArray("leftButtons") ?: JSONArray()
726
+ for (index in 0 until leading.length()) {
727
+ val button = leading.optJSONObject(index) ?: continue
728
+ leadingButtons.addView(navButton(button))
729
+ }
720
730
  val list = options.optJSONArray("rightButtons") ?: JSONArray()
721
731
  for (index in 0 until list.length()) {
722
732
  val button = list.optJSONObject(index) ?: continue
@@ -796,7 +806,6 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
796
806
  icon.foreground = vector
797
807
  icon.text = if (vector != null) "" else number ?: CraftNativeSymbols.glyph(tabs[index].symbol)
798
808
  }
799
- for (index in 0 until buttons.childCount) (buttons.getChildAt(index) as? TextView)?.setTextColor(accent)
800
809
  }
801
810
 
802
811
  private fun navButton(button: JSONObject): View {
@@ -805,7 +814,8 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
805
814
  val symbol = button.optString("symbol")
806
815
  val view = TextView(context)
807
816
  val dark = Color.luminance(ContextCompat.getColor(context, R.color.background)) < 0.5
808
- val tint = button.optString("color").takeIf { it.isNotBlank() }?.let { runCatching { craftNativeColor(it) }.getOrNull() } ?: accentColor(dark)
817
+ val defaultTint = if (button.optString("variant") == "neutral") { if (dark) Color.WHITE else Color.parseColor("#1C1C1E") } else accentColor(dark)
818
+ val tint = button.optString("color").takeIf { it.isNotBlank() }?.let { runCatching { craftNativeColor(it) }.getOrNull() } ?: defaultTint
809
819
  val vector = if (symbol.isNotBlank()) CraftNativeIcons.drawable(context, null, symbol, tint) else null
810
820
  view.text = if (vector != null) "" else if (symbol.isNotBlank()) CraftNativeSymbols.glyph(symbol) else title
811
821
  view.textSize = if (symbol.isNotBlank()) 22f else 16f
@@ -1632,12 +1632,27 @@ export declare const health: {
1632
1632
  onHeartRate(callback: (samples: HealthHeartRateSample[]) => void): () => void;
1633
1633
  };
1634
1634
  export interface LiveActivityState {
1635
+ /** What to show once staleAt passes without fresh app state. */
1636
+ staleStatus?: string;
1637
+ /** Current step, interval or session detail. */
1638
+ detail?: string;
1639
+ /** Epoch milliseconds; system-rendered timers keep advancing while the app sleeps. */
1640
+ timerStartedAt?: number | null;
1641
+ timerEndsAt?: number | null;
1642
+ paused?: boolean;
1643
+ remainingSeconds?: number;
1644
+ heartRate?: number | null;
1645
+ showsDistance?: boolean;
1646
+ staleAt?: number | null;
1635
1647
  status?: string;
1636
1648
  distanceMeters?: number;
1637
1649
  durationSeconds?: number;
1638
1650
  progress?: number;
1639
1651
  }
1640
1652
  export interface LiveActivityOptions extends LiveActivityState {
1653
+ symbol?: string;
1654
+ deepLink?: string;
1655
+ tint?: string;
1641
1656
  activityId: string;
1642
1657
  title: string;
1643
1658
  }
@@ -1647,6 +1662,12 @@ export interface LiveActivityHandle {
1647
1662
  end(finalState?: LiveActivityState): Promise<void>;
1648
1663
  }
1649
1664
  export interface LiveActivitiesApi {
1665
+ isAvailable(): Promise<boolean>;
1666
+ list(): Promise<Array<{
1667
+ id: string;
1668
+ activityId: string;
1669
+ title: string;
1670
+ }>>;
1650
1671
  start(options: LiveActivityOptions): Promise<LiveActivityHandle>;
1651
1672
  update(id: string, state: LiveActivityState): Promise<void>;
1652
1673
  /** @deprecated Pass the activity id returned by start, or use handle.update(state). */
package/dist/cli.js CHANGED
@@ -3424,7 +3424,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3424
3424
  `);
3425
3425
  }
3426
3426
  // package.json
3427
- var version = "0.0.193";
3427
+ var version = "0.0.195";
3428
3428
 
3429
3429
  // bin/cli.ts
3430
3430
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -309,7 +309,7 @@ function craftBinaryNotFoundMessage(triedPath) {
309
309
  `);
310
310
  }
311
311
  // package.json
312
- var version = "0.0.193";
312
+ var version = "0.0.195";
313
313
 
314
314
  // src/sdk-version.ts
315
315
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -57,7 +57,7 @@ function craftBinaryNotFoundMessage(triedPath) {
57
57
  `);
58
58
  }
59
59
  // package.json
60
- var version = "0.0.193";
60
+ var version = "0.0.195";
61
61
 
62
62
  // src/sdk-version.ts
63
63
  var SDK_VERSION = version;
@@ -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`],
@@ -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
  }
@@ -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 startLiveActivity(body: [String: Any], callbackId: String?) {
7467
- guard #available(iOS 16.2, *), ActivityAuthorizationInfo().areActivitiesEnabled else {
7468
- rejectCallback(callbackId, error: "Live Activities are unavailable")
7469
- return
7470
- }
7471
- let attributes = CraftActivityAttributes(
7472
- activityId: body["activityId"] as? String ?? UUID().uuidString,
7473
- title: body["title"] as? String ?? config.appName
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
- guard #available(iOS 16.2, *) else {
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
- guard #available(iOS 16.2, *) else {
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: "figure.run")
17
- .foregroundStyle(.green)
18
- VStack(alignment: .leading, spacing: 3) {
19
- Text(context.attributes.title).font(.headline)
20
- Text(context.state.status).font(.caption).foregroundStyle(.secondary)
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: 3) {
24
- Text(distance(context.state.distanceMeters)).font(.headline.monospacedDigit())
25
- Text(duration(context.state.durationSeconds)).font(.caption.monospacedDigit())
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.24, blue: 0.18))
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
- Label(context.state.status, systemImage: "figure.run")
35
+ Image(systemName: symbol(context)).foregroundStyle(tint(context)).font(.title2)
35
36
  }
36
37
  DynamicIslandExpandedRegion(.trailing) {
37
- Text(distance(context.state.distanceMeters)).monospacedDigit()
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
- ProgressView(value: context.state.progress)
41
- .tint(.green)
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: "figure.run")
61
+ Image(systemName: symbol(context)).foregroundStyle(tint(context))
45
62
  } compactTrailing: {
46
- Text(distance(context.state.distanceMeters)).monospacedDigit()
63
+ timer(context).monospacedDigit().frame(maxWidth: 64)
47
64
  } minimal: {
48
- Image(systemName: "figure.run")
65
+ Image(systemName: symbol(context)).foregroundStyle(tint(context))
49
66
  }
50
- .keylineTint(.green)
67
+ .widgetURL(context.attributes.deepLink.flatMap(URL.init(string:)))
68
+ .keylineTint(tint(context))
51
69
  }
52
70
  }
53
71
 
54
- private func distance(_ meters: Double) -> String {
55
- String(format: "%.2f km", meters / 1000)
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
package/dist/mobile.js CHANGED
@@ -1550,6 +1550,12 @@ async function endLiveActivity(id, finalState) {
1550
1550
  await craft.liveActivity.end();
1551
1551
  }
1552
1552
  var liveActivities = {
1553
+ async isAvailable() {
1554
+ return Boolean(await getCraftRoot()?.liveActivity?.isAvailable?.());
1555
+ },
1556
+ async list() {
1557
+ return await getCraftRoot()?.liveActivity?.list?.() ?? [];
1558
+ },
1553
1559
  async start(options) {
1554
1560
  const craft = getCraftRoot();
1555
1561
  if (!craft?.liveActivity?.start)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.193",
3
+ "version": "0.0.195",
4
4
  "type": "module",
5
5
  "description": "Build desktop apps with web languages - TypeScript SDK for Craft",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",