@craft-native/ios 0.0.70
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +619 -0
- package/dist/cli.js +664 -0
- package/dist/index.js +584 -0
- package/package.json +33 -0
- package/templates/CraftActivityAttributes.swift +14 -0
- package/templates/CraftApp.swift +5169 -0
- package/templates/CraftAppExtensions.swift +361 -0
- package/templates/CraftLiveActivityWidget.swift.template +62 -0
- package/templates/CraftWatchApp.swift.template +155 -0
- package/templates/CraftWidget.swift +247 -0
- package/templates/Info.plist.template +66 -0
- package/templates/WatchApp.Info.plist.template +26 -0
- package/templates/WidgetExtension.Info.plist +31 -0
- package/templates/fastlane/Appfile +45 -0
- package/templates/fastlane/Fastfile +189 -0
- package/templates/fastlane/Gemfile +7 -0
- package/templates/fastlane/Matchfile +58 -0
- package/templates/github-workflow-ios.yml +192 -0
- package/templates/github-workflow-release.yml +175 -0
- package/templates/github-workflow-test.yml +272 -0
- package/templates/project.yml.template +36 -0
- package/templates/test-bridges.html +1463 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
import ActivityKit
|
|
3
|
+
import AppIntents
|
|
4
|
+
import TipKit
|
|
5
|
+
|
|
6
|
+
// MARK: - iOS 16+ Advanced Features
|
|
7
|
+
|
|
8
|
+
/// Live Activities (Dynamic Island) Support
|
|
9
|
+
@available(iOS 16.1, *)
|
|
10
|
+
struct CraftLiveActivity: ActivityAttributes {
|
|
11
|
+
public struct ContentState: Codable, Hashable {
|
|
12
|
+
var status: String
|
|
13
|
+
var progress: Double
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
var name: String
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
@available(iOS 16.1, *)
|
|
20
|
+
class LiveActivityManager {
|
|
21
|
+
static let shared = LiveActivityManager()
|
|
22
|
+
|
|
23
|
+
private var currentActivity: Activity<CraftLiveActivity>?
|
|
24
|
+
|
|
25
|
+
func startActivity(name: String, status: String, progress: Double) async throws {
|
|
26
|
+
let attributes = CraftLiveActivity(name: name)
|
|
27
|
+
let contentState = CraftLiveActivity.ContentState(status: status, progress: progress)
|
|
28
|
+
|
|
29
|
+
currentActivity = try Activity.request(
|
|
30
|
+
attributes: attributes,
|
|
31
|
+
content: .init(state: contentState, staleDate: nil)
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
func updateActivity(status: String, progress: Double) async {
|
|
36
|
+
guard let activity = currentActivity else { return }
|
|
37
|
+
|
|
38
|
+
let contentState = CraftLiveActivity.ContentState(status: status, progress: progress)
|
|
39
|
+
await activity.update(.init(state: contentState, staleDate: nil))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func endActivity() async {
|
|
43
|
+
guard let activity = currentActivity else { return }
|
|
44
|
+
await activity.end(nil, dismissalPolicy: .immediate)
|
|
45
|
+
currentActivity = nil
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// MARK: - App Intents (iOS 16+)
|
|
50
|
+
|
|
51
|
+
@available(iOS 16.0, *)
|
|
52
|
+
struct OpenCraftIntent: AppIntent {
|
|
53
|
+
static var title: LocalizedStringResource = "Open Craft App"
|
|
54
|
+
static var description = IntentDescription("Opens the Craft application")
|
|
55
|
+
|
|
56
|
+
func perform() async throws -> some IntentResult {
|
|
57
|
+
// Open app logic
|
|
58
|
+
return .result()
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
@available(iOS 16.0, *)
|
|
63
|
+
struct CraftShortcuts: AppShortcutsProvider {
|
|
64
|
+
static var appShortcuts: [AppShortcut] {
|
|
65
|
+
AppShortcut(
|
|
66
|
+
intent: OpenCraftIntent(),
|
|
67
|
+
phrases: [
|
|
68
|
+
"Open \(.applicationName)",
|
|
69
|
+
"Launch \(.applicationName)"
|
|
70
|
+
],
|
|
71
|
+
shortTitle: "Open App",
|
|
72
|
+
systemImageName: "app.fill"
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// MARK: - TipKit Integration (iOS 17+)
|
|
78
|
+
|
|
79
|
+
@available(iOS 17.0, *)
|
|
80
|
+
struct CraftWelcomeTip: Tip {
|
|
81
|
+
var title: Text {
|
|
82
|
+
Text("Welcome to Craft")
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
var message: Text? {
|
|
86
|
+
Text("Get started by exploring the features")
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
var image: Image? {
|
|
90
|
+
Image(systemName: "star.fill")
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
@available(iOS 17.0, *)
|
|
95
|
+
class TipKitManager {
|
|
96
|
+
static let shared = TipKitManager()
|
|
97
|
+
|
|
98
|
+
func configure() {
|
|
99
|
+
try? Tips.configure([
|
|
100
|
+
.displayFrequency(.immediate),
|
|
101
|
+
.datastoreLocation(.applicationDefault)
|
|
102
|
+
])
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// MARK: - SharePlay Integration
|
|
107
|
+
|
|
108
|
+
@available(iOS 15.0, *)
|
|
109
|
+
class SharePlayManager: ObservableObject {
|
|
110
|
+
static let shared = SharePlayManager()
|
|
111
|
+
|
|
112
|
+
@Published var isActive = false
|
|
113
|
+
|
|
114
|
+
func startSharePlay() async throws {
|
|
115
|
+
// SharePlay session logic
|
|
116
|
+
isActive = true
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
func endSharePlay() {
|
|
120
|
+
isActive = false
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// MARK: - App Clips Support
|
|
125
|
+
|
|
126
|
+
class AppClipManager {
|
|
127
|
+
static let shared = AppClipManager()
|
|
128
|
+
|
|
129
|
+
var isRunningInAppClip: Bool {
|
|
130
|
+
#if APPCLIP
|
|
131
|
+
return true
|
|
132
|
+
#else
|
|
133
|
+
return false
|
|
134
|
+
#endif
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
func configureAppClip() {
|
|
138
|
+
// App Clip specific configuration
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// MARK: - Focus Filters (iOS 16+)
|
|
143
|
+
|
|
144
|
+
@available(iOS 16.0, *)
|
|
145
|
+
class FocusFilterManager {
|
|
146
|
+
static let shared = FocusFilterManager()
|
|
147
|
+
|
|
148
|
+
func getCurrentFocus() -> String? {
|
|
149
|
+
// Return current Focus mode if available
|
|
150
|
+
return nil
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
func registerFocusFilter() {
|
|
154
|
+
// Register app-specific Focus filter
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// MARK: - StoreKit 2 Full Implementation
|
|
159
|
+
|
|
160
|
+
@available(iOS 15.0, *)
|
|
161
|
+
class StoreKitManager: ObservableObject {
|
|
162
|
+
static let shared = StoreKitManager()
|
|
163
|
+
|
|
164
|
+
@Published var products: [Product] = []
|
|
165
|
+
@Published var purchasedProductIDs: Set<String> = []
|
|
166
|
+
|
|
167
|
+
private var updateListenerTask: Task<Void, Error>?
|
|
168
|
+
|
|
169
|
+
func loadProducts(productIDs: [String]) async throws {
|
|
170
|
+
products = try await Product.products(for: productIDs)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
func purchase(_ product: Product) async throws -> Transaction? {
|
|
174
|
+
let result = try await product.purchase()
|
|
175
|
+
|
|
176
|
+
switch result {
|
|
177
|
+
case .success(let verification):
|
|
178
|
+
let transaction = try checkVerified(verification)
|
|
179
|
+
await transaction.finish()
|
|
180
|
+
await updatePurchasedProducts()
|
|
181
|
+
return transaction
|
|
182
|
+
|
|
183
|
+
case .userCancelled, .pending:
|
|
184
|
+
return nil
|
|
185
|
+
|
|
186
|
+
@unknown default:
|
|
187
|
+
return nil
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
func restorePurchases() async throws {
|
|
192
|
+
for await result in Transaction.currentEntitlements {
|
|
193
|
+
let transaction = try checkVerified(result)
|
|
194
|
+
await updatePurchasedProducts()
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
func startObservingTransactions() {
|
|
199
|
+
updateListenerTask = Task.detached {
|
|
200
|
+
for await result in Transaction.updates {
|
|
201
|
+
do {
|
|
202
|
+
let transaction = try self.checkVerified(result)
|
|
203
|
+
await transaction.finish()
|
|
204
|
+
await self.updatePurchasedProducts()
|
|
205
|
+
} catch {
|
|
206
|
+
print("Transaction verification failed: \(error)")
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
func stopObservingTransactions() {
|
|
213
|
+
updateListenerTask?.cancel()
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
|
|
217
|
+
switch result {
|
|
218
|
+
case .unverified:
|
|
219
|
+
throw StoreError.failedVerification
|
|
220
|
+
case .verified(let safe):
|
|
221
|
+
return safe
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
@MainActor
|
|
226
|
+
private func updatePurchasedProducts() async {
|
|
227
|
+
for await result in Transaction.currentEntitlements {
|
|
228
|
+
guard case .verified(let transaction) = result else {
|
|
229
|
+
continue
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if transaction.revocationDate == nil {
|
|
233
|
+
purchasedProductIDs.insert(transaction.productID)
|
|
234
|
+
} else {
|
|
235
|
+
purchasedProductIDs.remove(transaction.productID)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
enum StoreError: Error {
|
|
241
|
+
case failedVerification
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// MARK: - CarPlay Support
|
|
246
|
+
|
|
247
|
+
#if canImport(CarPlay)
|
|
248
|
+
import CarPlay
|
|
249
|
+
|
|
250
|
+
@available(iOS 12.0, *)
|
|
251
|
+
class CarPlayManager: NSObject, CPApplicationDelegate {
|
|
252
|
+
static let shared = CarPlayManager()
|
|
253
|
+
|
|
254
|
+
private var interfaceController: CPInterfaceController?
|
|
255
|
+
|
|
256
|
+
func application(_ application: UIApplication, didConnectCarInterfaceController interfaceController: CPInterfaceController, to window: CPWindow) {
|
|
257
|
+
self.interfaceController = interfaceController
|
|
258
|
+
|
|
259
|
+
let template = CPListTemplate(title: "Craft", sections: [
|
|
260
|
+
CPListSection(items: [
|
|
261
|
+
CPListItem(text: "Home", detailText: "Go to home screen")
|
|
262
|
+
])
|
|
263
|
+
])
|
|
264
|
+
|
|
265
|
+
interfaceController.setRootTemplate(template, animated: true)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
func application(_ application: UIApplication, didDisconnectCarInterfaceController interfaceController: CPInterfaceController, from window: CPWindow) {
|
|
269
|
+
self.interfaceController = nil
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
#endif
|
|
273
|
+
|
|
274
|
+
// MARK: - Bridge Integration Extensions
|
|
275
|
+
|
|
276
|
+
extension CraftWebView.Coordinator {
|
|
277
|
+
|
|
278
|
+
@available(iOS 16.1, *)
|
|
279
|
+
func startLiveActivity(name: String, status: String, progress: Double, callbackId: String?) {
|
|
280
|
+
Task {
|
|
281
|
+
do {
|
|
282
|
+
try await LiveActivityManager.shared.startActivity(name: name, status: status, progress: progress)
|
|
283
|
+
resolveCallback(callbackId, result: ["started": true])
|
|
284
|
+
} catch {
|
|
285
|
+
rejectCallback(callbackId, error: error.localizedDescription)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
@available(iOS 16.1, *)
|
|
291
|
+
func updateLiveActivity(status: String, progress: Double, callbackId: String?) {
|
|
292
|
+
Task {
|
|
293
|
+
await LiveActivityManager.shared.updateActivity(status: status, progress: progress)
|
|
294
|
+
resolveCallback(callbackId, result: ["updated": true])
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
@available(iOS 16.1, *)
|
|
299
|
+
func endLiveActivity(callbackId: String?) {
|
|
300
|
+
Task {
|
|
301
|
+
await LiveActivityManager.shared.endActivity()
|
|
302
|
+
resolveCallback(callbackId, result: ["ended": true])
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
@available(iOS 15.0, *)
|
|
307
|
+
func loadStoreProducts(productIDs: [String], callbackId: String?) {
|
|
308
|
+
Task {
|
|
309
|
+
do {
|
|
310
|
+
try await StoreKitManager.shared.loadProducts(productIDs: productIDs)
|
|
311
|
+
let products = StoreKitManager.shared.products.map { product in
|
|
312
|
+
[
|
|
313
|
+
"id": product.id,
|
|
314
|
+
"displayName": product.displayName,
|
|
315
|
+
"description": product.description,
|
|
316
|
+
"price": product.displayPrice
|
|
317
|
+
]
|
|
318
|
+
}
|
|
319
|
+
resolveCallback(callbackId, result: products)
|
|
320
|
+
} catch {
|
|
321
|
+
rejectCallback(callbackId, error: error.localizedDescription)
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
@available(iOS 15.0, *)
|
|
327
|
+
func purchaseProduct(productId: String, callbackId: String?) {
|
|
328
|
+
Task {
|
|
329
|
+
do {
|
|
330
|
+
guard let product = StoreKitManager.shared.products.first(where: { $0.id == productId }) else {
|
|
331
|
+
rejectCallback(callbackId, error: "Product not found")
|
|
332
|
+
return
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if let transaction = try await StoreKitManager.shared.purchase(product) {
|
|
336
|
+
resolveCallback(callbackId, result: [
|
|
337
|
+
"transactionId": transaction.id,
|
|
338
|
+
"productId": transaction.productID,
|
|
339
|
+
"purchased": true
|
|
340
|
+
])
|
|
341
|
+
} else {
|
|
342
|
+
resolveCallback(callbackId, result: ["purchased": false])
|
|
343
|
+
}
|
|
344
|
+
} catch {
|
|
345
|
+
rejectCallback(callbackId, error: error.localizedDescription)
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
@available(iOS 15.0, *)
|
|
351
|
+
func restorePurchases(callbackId: String?) {
|
|
352
|
+
Task {
|
|
353
|
+
do {
|
|
354
|
+
try await StoreKitManager.shared.restorePurchases()
|
|
355
|
+
resolveCallback(callbackId, result: ["restored": true])
|
|
356
|
+
} catch {
|
|
357
|
+
rejectCallback(callbackId, error: error.localizedDescription)
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import ActivityKit
|
|
2
|
+
import SwiftUI
|
|
3
|
+
import WidgetKit
|
|
4
|
+
|
|
5
|
+
@main
|
|
6
|
+
struct CraftLiveActivityBundle: WidgetBundle {
|
|
7
|
+
var body: some Widget {
|
|
8
|
+
CraftLiveActivityWidget()
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
struct CraftLiveActivityWidget: Widget {
|
|
13
|
+
var body: some WidgetConfiguration {
|
|
14
|
+
ActivityConfiguration(for: CraftActivityAttributes.self) { context in
|
|
15
|
+
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)
|
|
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())
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
.padding()
|
|
29
|
+
.activityBackgroundTint(Color(red: 0, green: 0.24, blue: 0.18))
|
|
30
|
+
.activitySystemActionForegroundColor(.white)
|
|
31
|
+
} dynamicIsland: { context in
|
|
32
|
+
DynamicIsland {
|
|
33
|
+
DynamicIslandExpandedRegion(.leading) {
|
|
34
|
+
Label(context.state.status, systemImage: "figure.run")
|
|
35
|
+
}
|
|
36
|
+
DynamicIslandExpandedRegion(.trailing) {
|
|
37
|
+
Text(distance(context.state.distanceMeters)).monospacedDigit()
|
|
38
|
+
}
|
|
39
|
+
DynamicIslandExpandedRegion(.bottom) {
|
|
40
|
+
ProgressView(value: context.state.progress)
|
|
41
|
+
.tint(.green)
|
|
42
|
+
}
|
|
43
|
+
} compactLeading: {
|
|
44
|
+
Image(systemName: "figure.run")
|
|
45
|
+
} compactTrailing: {
|
|
46
|
+
Text(distance(context.state.distanceMeters)).monospacedDigit()
|
|
47
|
+
} minimal: {
|
|
48
|
+
Image(systemName: "figure.run")
|
|
49
|
+
}
|
|
50
|
+
.keylineTint(.green)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private func distance(_ meters: Double) -> String {
|
|
55
|
+
String(format: "%.2f km", meters / 1000)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private func duration(_ seconds: Double) -> String {
|
|
59
|
+
let value = max(Int(seconds), 0)
|
|
60
|
+
return String(format: "%02d:%02d", value / 60, value % 60)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
import WatchConnectivity
|
|
3
|
+
|
|
4
|
+
@main
|
|
5
|
+
struct {{APP_NAME}}WatchApp: App {
|
|
6
|
+
@StateObject private var session = CraftWatchSession()
|
|
7
|
+
|
|
8
|
+
var body: some Scene {
|
|
9
|
+
WindowGroup {
|
|
10
|
+
CraftWatchRecordingView()
|
|
11
|
+
.environmentObject(session)
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
@MainActor
|
|
17
|
+
final class CraftWatchSession: NSObject, ObservableObject {
|
|
18
|
+
@Published private(set) var isPhoneReachable = false
|
|
19
|
+
@Published private(set) var isRecording = false
|
|
20
|
+
@Published private(set) var isPaused = false
|
|
21
|
+
@Published private(set) var distanceMeters = 0.0
|
|
22
|
+
@Published private(set) var durationSeconds = 0.0
|
|
23
|
+
@Published private(set) var status = "Ready"
|
|
24
|
+
|
|
25
|
+
private let session: WCSession?
|
|
26
|
+
|
|
27
|
+
override init() {
|
|
28
|
+
if WCSession.isSupported() {
|
|
29
|
+
session = .default
|
|
30
|
+
} else {
|
|
31
|
+
session = nil
|
|
32
|
+
}
|
|
33
|
+
super.init()
|
|
34
|
+
session?.delegate = self
|
|
35
|
+
session?.activate()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
func toggleRecording() {
|
|
39
|
+
let action: String
|
|
40
|
+
if !isRecording {
|
|
41
|
+
action = "start"
|
|
42
|
+
} else if isPaused {
|
|
43
|
+
action = "resume"
|
|
44
|
+
} else {
|
|
45
|
+
action = "pause"
|
|
46
|
+
}
|
|
47
|
+
send(action: action)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
func finishRecording() {
|
|
51
|
+
send(action: "finish")
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private func send(action: String) {
|
|
55
|
+
guard let session, session.activationState == .activated else {
|
|
56
|
+
status = "Open WildLoop on iPhone"
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
let message: [String: Any] = ["type": "recording-control", "action": action]
|
|
60
|
+
if session.isReachable {
|
|
61
|
+
session.sendMessage(message, replyHandler: { [weak self] reply in
|
|
62
|
+
Task { @MainActor in self?.apply(reply) }
|
|
63
|
+
}, errorHandler: { [weak self] _ in
|
|
64
|
+
Task { @MainActor in self?.status = "Could not reach iPhone" }
|
|
65
|
+
})
|
|
66
|
+
} else {
|
|
67
|
+
session.transferUserInfo(message)
|
|
68
|
+
status = "Command queued"
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private func apply(_ payload: [String: Any]) {
|
|
73
|
+
if let recording = payload["recording"] as? Bool { isRecording = recording }
|
|
74
|
+
if let paused = payload["paused"] as? Bool { isPaused = paused }
|
|
75
|
+
if let distance = payload["distanceMeters"] as? Double { distanceMeters = max(distance, 0) }
|
|
76
|
+
if let duration = payload["durationSeconds"] as? Double { durationSeconds = max(duration, 0) }
|
|
77
|
+
if let value = payload["status"] as? String { status = value }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
extension CraftWatchSession: WCSessionDelegate {
|
|
82
|
+
nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
|
|
83
|
+
Task { @MainActor in
|
|
84
|
+
isPhoneReachable = session.isReachable
|
|
85
|
+
status = error == nil ? "Ready" : "Connection unavailable"
|
|
86
|
+
apply(session.receivedApplicationContext)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
nonisolated func sessionReachabilityDidChange(_ session: WCSession) {
|
|
91
|
+
Task { @MainActor in isPhoneReachable = session.isReachable }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
nonisolated func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
|
|
95
|
+
Task { @MainActor in apply(applicationContext) }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
|
|
99
|
+
Task { @MainActor in apply(message) }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
|
|
103
|
+
Task { @MainActor in apply(userInfo) }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
struct CraftWatchRecordingView: View {
|
|
108
|
+
@EnvironmentObject private var session: CraftWatchSession
|
|
109
|
+
|
|
110
|
+
var body: some View {
|
|
111
|
+
ScrollView {
|
|
112
|
+
VStack(spacing: 12) {
|
|
113
|
+
Image(systemName: "figure.run.circle.fill")
|
|
114
|
+
.font(.system(size: 34))
|
|
115
|
+
.foregroundStyle(.green)
|
|
116
|
+
.accessibilityHidden(true)
|
|
117
|
+
Text(session.status)
|
|
118
|
+
.font(.headline)
|
|
119
|
+
.multilineTextAlignment(.center)
|
|
120
|
+
HStack {
|
|
121
|
+
metric(distance, label: "Distance")
|
|
122
|
+
metric(duration, label: "Time")
|
|
123
|
+
}
|
|
124
|
+
Button(session.isRecording && !session.isPaused ? "Pause" : session.isPaused ? "Resume" : "Start") {
|
|
125
|
+
session.toggleRecording()
|
|
126
|
+
}
|
|
127
|
+
.buttonStyle(.borderedProminent)
|
|
128
|
+
.tint(.green)
|
|
129
|
+
if session.isRecording {
|
|
130
|
+
Button("Finish", role: .destructive) { session.finishRecording() }
|
|
131
|
+
}
|
|
132
|
+
Text(session.isPhoneReachable ? "iPhone connected" : "Commands sync when iPhone reconnects")
|
|
133
|
+
.font(.caption2)
|
|
134
|
+
.foregroundStyle(.secondary)
|
|
135
|
+
.multilineTextAlignment(.center)
|
|
136
|
+
}
|
|
137
|
+
.padding(.horizontal, 8)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private var distance: String { String(format: "%.2f km", session.distanceMeters / 1000) }
|
|
142
|
+
private var duration: String {
|
|
143
|
+
let seconds = max(Int(session.durationSeconds), 0)
|
|
144
|
+
return String(format: "%02d:%02d", seconds / 60, seconds % 60)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private func metric(_ value: String, label: String) -> some View {
|
|
148
|
+
VStack(spacing: 2) {
|
|
149
|
+
Text(value).font(.headline.monospacedDigit())
|
|
150
|
+
Text(label).font(.caption2).foregroundStyle(.secondary)
|
|
151
|
+
}
|
|
152
|
+
.frame(maxWidth: .infinity)
|
|
153
|
+
.accessibilityElement(children: .combine)
|
|
154
|
+
}
|
|
155
|
+
}
|