craft-native 0.0.162 → 0.0.164
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/android/templates/MainActivityNative.kt.template +133 -114
- package/dist/api/mobile.d.ts +147 -2
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.d.ts +18 -0
- package/dist/ios/src/index.js +12 -1
- package/dist/ios/templates/CraftApp.swift +68 -0
- package/dist/ios/templates/CraftHealth.swift +48 -2
- package/dist/ios/templates/CraftHybrid.swift +49 -0
- package/dist/ios/templates/CraftNativeActions.swift +125 -1
- package/dist/ios/templates/CraftNativeFiles.swift +107 -0
- package/dist/ios/templates/CraftNativeScreen.swift +38 -3
- package/dist/ios/templates/CraftScheduledWorkouts.swift +524 -0
- package/dist/ios/templates/project.yml.template +1 -0
- package/dist/mobile.js +75 -0
- package/package.json +1 -1
package/dist/api/mobile.d.ts
CHANGED
|
@@ -937,6 +937,142 @@ export declare const heartRate: {
|
|
|
937
937
|
/** Bluetooth, scanning or the connection changed. */
|
|
938
938
|
onState: (callback: (state: HeartRateState) => void) => (() => void);
|
|
939
939
|
};
|
|
940
|
+
/**
|
|
941
|
+
* Structured workouts scheduled onto the paired Apple Watch (iOS 17+, an app
|
|
942
|
+
* built with `enableScheduledWorkouts`): each appears in the watch's Workout
|
|
943
|
+
* app on its day, with its steps, goals and alerts. Plain JSON in SI units.
|
|
944
|
+
* In a browser, on Android and in a build without the capability
|
|
945
|
+
* `isSupported()` answers false and every other call rejects with
|
|
946
|
+
* `unsupported` (code `UNSUPPORTED`).
|
|
947
|
+
*/
|
|
948
|
+
export type ScheduledWorkoutsAuthorization = 'authorized' | 'denied' | 'notDetermined' | 'restricted' | 'unsupported';
|
|
949
|
+
export type AppleWorkoutGoal = {
|
|
950
|
+
type: 'open';
|
|
951
|
+
} | {
|
|
952
|
+
type: 'time';
|
|
953
|
+
seconds: number;
|
|
954
|
+
} | {
|
|
955
|
+
type: 'distance';
|
|
956
|
+
meters: number;
|
|
957
|
+
} | {
|
|
958
|
+
type: 'energy';
|
|
959
|
+
kilocalories: number;
|
|
960
|
+
};
|
|
961
|
+
export type AppleWorkoutAlert =
|
|
962
|
+
/** Beats per minute. */
|
|
963
|
+
{
|
|
964
|
+
type: 'heartRate';
|
|
965
|
+
min: number;
|
|
966
|
+
max: number;
|
|
967
|
+
} | {
|
|
968
|
+
type: 'heartRateZone';
|
|
969
|
+
zone: number;
|
|
970
|
+
}
|
|
971
|
+
/** Watts. */
|
|
972
|
+
| {
|
|
973
|
+
type: 'power';
|
|
974
|
+
min: number;
|
|
975
|
+
max: number;
|
|
976
|
+
} | {
|
|
977
|
+
type: 'powerZone';
|
|
978
|
+
zone: number;
|
|
979
|
+
}
|
|
980
|
+
/** Metres per second (the current speed); a pace range is sent as speed. */
|
|
981
|
+
| {
|
|
982
|
+
type: 'speed';
|
|
983
|
+
min: number;
|
|
984
|
+
max: number;
|
|
985
|
+
}
|
|
986
|
+
/** Steps or revolutions per minute. */
|
|
987
|
+
| {
|
|
988
|
+
type: 'cadence';
|
|
989
|
+
min: number;
|
|
990
|
+
max: number;
|
|
991
|
+
};
|
|
992
|
+
export interface AppleWorkoutStep {
|
|
993
|
+
goal: AppleWorkoutGoal;
|
|
994
|
+
/** Dropped (the step kept) when WorkoutKit refuses it for the activity. */
|
|
995
|
+
alert?: AppleWorkoutAlert;
|
|
996
|
+
/** Shown on the watch from iOS 18; ignored on 17. At most 64 characters. */
|
|
997
|
+
displayName?: string;
|
|
998
|
+
}
|
|
999
|
+
export interface AppleIntervalBlock {
|
|
1000
|
+
/** 1 or more. */
|
|
1001
|
+
iterations: number;
|
|
1002
|
+
steps: Array<{
|
|
1003
|
+
purpose: 'work' | 'recovery';
|
|
1004
|
+
step: AppleWorkoutStep;
|
|
1005
|
+
}>;
|
|
1006
|
+
}
|
|
1007
|
+
export interface AppleCustomWorkout {
|
|
1008
|
+
/** An HKWorkoutActivityType case name: 'running', 'cycling', 'swimming', 'traditionalStrengthTraining'... */
|
|
1009
|
+
activity: string;
|
|
1010
|
+
location: 'indoor' | 'outdoor' | 'unknown';
|
|
1011
|
+
/** Swimming only: a pool swim is scheduled indoor, open water outdoor, when `location` is 'unknown'. */
|
|
1012
|
+
swimmingLocation?: 'pool' | 'openWater';
|
|
1013
|
+
/** Accepted for the record; WorkoutKit takes no pool length (the watch asks when the swim starts). */
|
|
1014
|
+
poolLengthMeters?: number;
|
|
1015
|
+
displayName: string;
|
|
1016
|
+
warmup?: AppleWorkoutStep;
|
|
1017
|
+
cooldown?: AppleWorkoutStep;
|
|
1018
|
+
blocks: AppleIntervalBlock[];
|
|
1019
|
+
}
|
|
1020
|
+
export interface AppleWorkoutPlan {
|
|
1021
|
+
/** A UUID; the plan's identity on the watch. */
|
|
1022
|
+
id: string;
|
|
1023
|
+
/** The local calendar day, 'YYYY-MM-DD'. */
|
|
1024
|
+
date: string;
|
|
1025
|
+
/** Local time of day, 07:00 when not given. */
|
|
1026
|
+
hour?: number;
|
|
1027
|
+
minute?: number;
|
|
1028
|
+
workout: AppleCustomWorkout;
|
|
1029
|
+
}
|
|
1030
|
+
/** A plan on the watch's schedule. */
|
|
1031
|
+
export interface ScheduledWorkout {
|
|
1032
|
+
/** Lowercase UUID. */
|
|
1033
|
+
id: string;
|
|
1034
|
+
date: string;
|
|
1035
|
+
hour?: number;
|
|
1036
|
+
minute?: number;
|
|
1037
|
+
/** The person has done it. */
|
|
1038
|
+
complete: boolean;
|
|
1039
|
+
}
|
|
1040
|
+
export interface ScheduleWorkoutResult {
|
|
1041
|
+
id: string;
|
|
1042
|
+
/** Alerts WorkoutKit refused and left out, each by where it was ("block 1 step 2: power alert..."). */
|
|
1043
|
+
droppedAlerts?: string[];
|
|
1044
|
+
}
|
|
1045
|
+
export type ScheduledWorkoutsMethod = 'isSupported' | 'authorization' | 'requestAuthorization' | 'list' | 'schedule' | 'remove' | 'removeAll';
|
|
1046
|
+
/** The rejection every call but isSupported() gets where nothing can schedule. */
|
|
1047
|
+
export declare function scheduledWorkoutsUnsupported(): Error;
|
|
1048
|
+
/** A row of `list()`, or null for one that is not a scheduled plan. */
|
|
1049
|
+
export declare function normalizeScheduledWorkout(value: unknown): ScheduledWorkout | null;
|
|
1050
|
+
export declare const scheduledWorkouts: {
|
|
1051
|
+
/** Whether this app build has the bridge at all (not whether this phone can schedule: `isSupported()`). */
|
|
1052
|
+
isAvailable(): boolean;
|
|
1053
|
+
/** Whether this app build answers `method`; an older build may lack one. */
|
|
1054
|
+
supports(method: ScheduledWorkoutsMethod): boolean;
|
|
1055
|
+
/** iOS 17 or later and WorkoutKit can schedule here; false everywhere else, never rejects. */
|
|
1056
|
+
isSupported(): Promise<boolean>;
|
|
1057
|
+
/** What the person allowed, without asking. */
|
|
1058
|
+
authorization: () => Promise<ScheduledWorkoutsAuthorization>;
|
|
1059
|
+
/** iOS's prompt the first time; its remembered answer after that. Call it from the person's tap. */
|
|
1060
|
+
requestAuthorization: () => Promise<ScheduledWorkoutsAuthorization>;
|
|
1061
|
+
/** Every plan on the watch's schedule. */
|
|
1062
|
+
list: () => Promise<ScheduledWorkout[]>;
|
|
1063
|
+
/**
|
|
1064
|
+
* Put a plan on the watch's schedule (again on another day moves it).
|
|
1065
|
+
* Rejects with a message naming what WorkoutKit refused: an activity or a
|
|
1066
|
+
* goal it cannot schedule (an alert it refuses is dropped instead, listed in
|
|
1067
|
+
* `droppedAlerts`), more plans than the watch holds (LIMIT_REACHED), or a
|
|
1068
|
+
* schedule nothing kept, such as no paired watch (NOT_SCHEDULED).
|
|
1069
|
+
*/
|
|
1070
|
+
schedule: (plan: AppleWorkoutPlan) => Promise<ScheduleWorkoutResult>;
|
|
1071
|
+
/** Take a plan off every day it is scheduled; true when there was one. */
|
|
1072
|
+
remove: (id: string) => Promise<boolean>;
|
|
1073
|
+
/** Clear the schedule; how many plans it held. */
|
|
1074
|
+
removeAll: () => Promise<number>;
|
|
1075
|
+
};
|
|
940
1076
|
export declare const deepLinks: {
|
|
941
1077
|
getInitialURL(): Promise<string | null>;
|
|
942
1078
|
/**
|
|
@@ -1239,12 +1375,14 @@ export declare const pushNotifications: {
|
|
|
1239
1375
|
onReceive(callback: (data: Record<string, unknown>) => void): () => void;
|
|
1240
1376
|
};
|
|
1241
1377
|
export type HealthDataType = 'steps' | 'heartRate' | 'activeEnergy' | 'distance' | 'workouts' | 'restingHeartRate' | 'heartRateVariability' | 'bodyMass' | 'sleep'
|
|
1242
|
-
/** Food logged by any app, per day: kcal eaten, then grams of protein, carbohydrate and fat.
|
|
1378
|
+
/** Food logged by any app, per day: kcal eaten, then grams of protein, carbohydrate and fat. */
|
|
1243
1379
|
| 'dietaryEnergy' | 'dietaryProtein' | 'dietaryCarbohydrates' | 'dietaryFat'
|
|
1244
1380
|
/** Mindful minutes (meditation, breathing): read with getMindfulSessions, written with saveMindfulSession. */
|
|
1245
1381
|
| 'mindful';
|
|
1246
1382
|
/** The types with one value per day. `sleep` is hours asleep, counted on the day you woke. */
|
|
1247
1383
|
export type HealthDailyType = Exclude<HealthDataType, 'workouts' | 'mindful'>;
|
|
1384
|
+
/** Quantity types that can be written with `health.saveData()`. */
|
|
1385
|
+
export type HealthQuantityType = Exclude<HealthDataType, 'workouts' | 'sleep' | 'mindful'>;
|
|
1248
1386
|
export interface HealthAuthorizationOptions {
|
|
1249
1387
|
/** `false` asks to read only. Default: also allowed to save workouts. */
|
|
1250
1388
|
write?: boolean;
|
|
@@ -1257,6 +1395,10 @@ export interface HealthDataResult {
|
|
|
1257
1395
|
value: number;
|
|
1258
1396
|
unit: string;
|
|
1259
1397
|
}
|
|
1398
|
+
export interface HealthDataSaveResult {
|
|
1399
|
+
/** HealthKit's UUID for the saved quantity sample. */
|
|
1400
|
+
id: string;
|
|
1401
|
+
}
|
|
1260
1402
|
export type HealthWorkoutType = 'running' | 'walking' | 'hiking' | 'cycling';
|
|
1261
1403
|
export interface HealthWorkoutLocation {
|
|
1262
1404
|
latitude: number;
|
|
@@ -1394,7 +1536,7 @@ export interface HealthMindfulSample {
|
|
|
1394
1536
|
deviceName?: string;
|
|
1395
1537
|
}
|
|
1396
1538
|
/** The health reads and writes a host may or may not have, depending on the app build it runs in. */
|
|
1397
|
-
export type HealthReadMethod = 'getWorkouts' | 'getDailyStatistics' | 'getHeartRateSamples' | 'getSleepIntervals' | 'saveMindfulSession' | 'getMindfulSessions';
|
|
1539
|
+
export type HealthReadMethod = 'getWorkouts' | 'getDailyStatistics' | 'getHeartRateSamples' | 'getSleepIntervals' | 'saveData' | 'saveMindfulSession' | 'getMindfulSessions';
|
|
1398
1540
|
export declare const health: {
|
|
1399
1541
|
/**
|
|
1400
1542
|
* Whether this app build answers `method`. An app installed before a read
|
|
@@ -1408,6 +1550,8 @@ export declare const health: {
|
|
|
1408
1550
|
*/
|
|
1409
1551
|
requestAuthorization(types: HealthDataType[], options?: HealthAuthorizationOptions): Promise<boolean>;
|
|
1410
1552
|
getData(type: HealthDataType, options?: HealthDataOptions): Promise<HealthDataResult>;
|
|
1553
|
+
/** Save one quantity sample, such as a meal's dietary energy or macros. iOS only for now. */
|
|
1554
|
+
saveData(type: HealthQuantityType, value: number, options?: HealthDataOptions): Promise<HealthDataSaveResult>;
|
|
1411
1555
|
saveWorkout(workout: HealthWorkout): Promise<HealthWorkoutResult>;
|
|
1412
1556
|
/** Workouts in Apple Health, newest first. iOS only for now. */
|
|
1413
1557
|
getWorkouts(options?: HealthWorkoutQuery): Promise<HealthWorkoutSample[]>;
|
|
@@ -1474,6 +1618,7 @@ declare const mobile: {
|
|
|
1474
1618
|
speech: typeof speech;
|
|
1475
1619
|
music: typeof music;
|
|
1476
1620
|
heartRate: typeof heartRate;
|
|
1621
|
+
scheduledWorkouts: typeof scheduledWorkouts;
|
|
1477
1622
|
deepLinks: typeof deepLinks;
|
|
1478
1623
|
network: typeof network;
|
|
1479
1624
|
appReview: typeof appReview;
|
package/dist/cli.js
CHANGED
|
@@ -3303,7 +3303,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
|
|
|
3303
3303
|
`);
|
|
3304
3304
|
}
|
|
3305
3305
|
// package.json
|
|
3306
|
-
var version = "0.0.
|
|
3306
|
+
var version = "0.0.164";
|
|
3307
3307
|
|
|
3308
3308
|
// bin/cli.ts
|
|
3309
3309
|
var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
|
package/dist/index.cjs
CHANGED
package/dist/index.js
CHANGED
package/dist/ios/src/index.d.ts
CHANGED
|
@@ -88,6 +88,17 @@ export interface CraftConfig {
|
|
|
88
88
|
* playback session).
|
|
89
89
|
*/
|
|
90
90
|
enableBackgroundAudio?: boolean;
|
|
91
|
+
/**
|
|
92
|
+
* Schedule structured workouts onto the paired Apple Watch through
|
|
93
|
+
* `craft.scheduledWorkouts` (WorkoutKit's WorkoutScheduler, iOS 17+): each
|
|
94
|
+
* appears in the watch's Workout app on its day, with its steps, goals and
|
|
95
|
+
* alerts. Compiles CraftScheduledWorkouts.swift with the
|
|
96
|
+
* `CRAFT_SCHEDULED_WORKOUTS` condition, so an app without it never links
|
|
97
|
+
* WorkoutKit; below iOS 17 it is weak-linked and answers unsupported. iOS
|
|
98
|
+
* asks the person the first time the page calls
|
|
99
|
+
* `craft.scheduledWorkouts.requestAuthorization()`, never at launch.
|
|
100
|
+
*/
|
|
101
|
+
enableScheduledWorkouts?: boolean;
|
|
91
102
|
/**
|
|
92
103
|
* Purpose strings in the app's own words, by Info.plist key
|
|
93
104
|
* (`{ NSAppleMusicUsageDescription: 'Shows the song playing during a workout.' }`).
|
|
@@ -381,6 +392,13 @@ export declare function renderWatchEntitlements(config: CraftConfig): string;
|
|
|
381
392
|
* reach everything the bridge actually uses.
|
|
382
393
|
*/
|
|
383
394
|
export declare function renderRuntimeSettings(): string;
|
|
395
|
+
/**
|
|
396
|
+
* Build settings a feature adds to the app target. WorkoutKit is compiled in
|
|
397
|
+
* only for `enableScheduledWorkouts`: CraftScheduledWorkouts.swift imports it
|
|
398
|
+
* behind the `CRAFT_SCHEDULED_WORKOUTS` condition, so an app that does not
|
|
399
|
+
* schedule workouts does not link it. `$(inherited)` keeps Debug's `DEBUG`.
|
|
400
|
+
*/
|
|
401
|
+
export declare function renderFeatureSettings(config: Pick<CraftConfig, 'enableScheduledWorkouts'>): string;
|
|
384
402
|
export declare function renderPrivacyManifest(config: CraftConfig): string;
|
|
385
403
|
/**
|
|
386
404
|
* Whether a PNG carries an alpha channel, from its header: colour type 4
|
package/dist/ios/src/index.js
CHANGED
|
@@ -349,6 +349,7 @@ var DEFAULT_CONFIG = {
|
|
|
349
349
|
enableMLKit: false,
|
|
350
350
|
enableAppleMusic: false,
|
|
351
351
|
enableHeartRate: false,
|
|
352
|
+
enableScheduledWorkouts: false,
|
|
352
353
|
iosVersion: "16.0",
|
|
353
354
|
watchosVersion: "9.0",
|
|
354
355
|
teamId: "",
|
|
@@ -583,6 +584,14 @@ function renderRuntimeSettings() {
|
|
|
583
584
|
].join(`
|
|
584
585
|
`);
|
|
585
586
|
}
|
|
587
|
+
function renderFeatureSettings(config) {
|
|
588
|
+
const conditions = [];
|
|
589
|
+
if (config.enableScheduledWorkouts)
|
|
590
|
+
conditions.push("CRAFT_SCHEDULED_WORKOUTS");
|
|
591
|
+
if (!conditions.length)
|
|
592
|
+
return "";
|
|
593
|
+
return ` SWIFT_ACTIVE_COMPILATION_CONDITIONS: "$(inherited) ${conditions.join(" ")}"`;
|
|
594
|
+
}
|
|
586
595
|
function renderPrivacyManifest(config) {
|
|
587
596
|
const privacy = config.privacy ?? {};
|
|
588
597
|
const collected = privacy.collectedDataTypes ?? [];
|
|
@@ -944,6 +953,7 @@ function writeShell(output, config) {
|
|
|
944
953
|
cpSync(join2(TEMPLATES_DIR, "CraftHealth.swift"), join2(output, "Sources", "CraftHealth.swift"));
|
|
945
954
|
cpSync(join2(TEMPLATES_DIR, "CraftMusic.swift"), join2(output, "Sources", "CraftMusic.swift"));
|
|
946
955
|
cpSync(join2(TEMPLATES_DIR, "CraftHeartRate.swift"), join2(output, "Sources", "CraftHeartRate.swift"));
|
|
956
|
+
cpSync(join2(TEMPLATES_DIR, "CraftScheduledWorkouts.swift"), join2(output, "Sources", "CraftScheduledWorkouts.swift"));
|
|
947
957
|
cpSync(join2(TEMPLATES_DIR, "CraftNativeMutation.swift"), join2(output, "Sources", "CraftNativeMutation.swift"));
|
|
948
958
|
cpSync(join2(TEMPLATES_DIR, "CraftNativeUI.swift"), join2(output, "Sources", "CraftNativeUI.swift"));
|
|
949
959
|
cpSync(join2(TEMPLATES_DIR, "CraftNativeServices.swift"), join2(output, "Sources", "CraftNativeServices.swift"));
|
|
@@ -1065,7 +1075,7 @@ async function init(options) {
|
|
|
1065
1075
|
} else {
|
|
1066
1076
|
rmSync2(join2(output, "Runtime"), { recursive: true, force: true });
|
|
1067
1077
|
}
|
|
1068
|
-
const projectYml = projectYmlTemplate.replace(/\{\{CRAFT_RUNTIME_SETTINGS\}\}/g, hasRuntime ? renderRuntimeSettings() : "").replace(/\{\{APP_NAME\}\}/g, name).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{BUNDLE_ID_PREFIX\}\}/g, bundleIdPrefix).replace(/\{\{VERSION\}\}/g, config.version || "1.0.0").replace(/\{\{BUILD_NUMBER\}\}/g, config.buildNumber || "1").replace(/\{\{IOS_VERSION\}\}/g, config.iosVersion || "15.0").replace(/\{\{DEVICE_FAMILIES\}\}/g, renderDeviceFamilies(config)).replace(/\{\{TEAM_ID\}\}/g, teamId || "").replace(/\{\{NATIVE_DEPENDENCIES\}\}/g, nativeDependencies.length ? ` dependencies:
|
|
1078
|
+
const projectYml = projectYmlTemplate.replace(/\{\{CRAFT_RUNTIME_SETTINGS\}\}/g, hasRuntime ? renderRuntimeSettings() : "").replace(/\{\{FEATURE_SETTINGS\}\}/g, renderFeatureSettings(config)).replace(/\{\{APP_NAME\}\}/g, name).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{BUNDLE_ID_PREFIX\}\}/g, bundleIdPrefix).replace(/\{\{VERSION\}\}/g, config.version || "1.0.0").replace(/\{\{BUILD_NUMBER\}\}/g, config.buildNumber || "1").replace(/\{\{IOS_VERSION\}\}/g, config.iosVersion || "15.0").replace(/\{\{DEVICE_FAMILIES\}\}/g, renderDeviceFamilies(config)).replace(/\{\{TEAM_ID\}\}/g, teamId || "").replace(/\{\{NATIVE_DEPENDENCIES\}\}/g, nativeDependencies.length ? ` dependencies:
|
|
1069
1079
|
${nativeDependencies.join(`
|
|
1070
1080
|
`)}` : "").replace(/\{\{NATIVE_TARGETS\}\}/g, nativeTargets.join(`
|
|
1071
1081
|
`));
|
|
@@ -1298,6 +1308,7 @@ export {
|
|
|
1298
1308
|
renderOrientations,
|
|
1299
1309
|
renderOfflinePage,
|
|
1300
1310
|
renderLaunchScreen,
|
|
1311
|
+
renderFeatureSettings,
|
|
1301
1312
|
renderEntitlements,
|
|
1302
1313
|
renderDeviceFamilies,
|
|
1303
1314
|
renderBackgroundTaskIdentifiers,
|
|
@@ -738,6 +738,18 @@ final class CraftChrome: ObservableObject {
|
|
|
738
738
|
emit("craftTabSelect", ["id": tab.id])
|
|
739
739
|
}
|
|
740
740
|
|
|
741
|
+
/// A completed horizontal swipe selects the adjacent tab. The shell owns
|
|
742
|
+
/// this path so it works over both native screens and the shared WebView.
|
|
743
|
+
/// The gesture recognizer yields before calling this for a screen-owned
|
|
744
|
+
/// horizontal scroll or custom pager.
|
|
745
|
+
func swipe(to id: String) {
|
|
746
|
+
guard id != selected, tabs.contains(where: { $0.id == id }) else { return }
|
|
747
|
+
withAnimation(Self.motion(.spring(response: 0.32, dampingFraction: 0.86))) { selected = id }
|
|
748
|
+
UISelectionFeedbackGenerator().selectionChanged()
|
|
749
|
+
guard CraftHybrid.shared.tabTapped(id, reselect: false) else { return }
|
|
750
|
+
emit("craftTabSelect", ["id": id])
|
|
751
|
+
}
|
|
752
|
+
|
|
741
753
|
/// The animation, or none with Reduce Motion on: the selection jumps to
|
|
742
754
|
/// its tab rather than springing there, as UIKit's own controls do.
|
|
743
755
|
static func motion(_ animation: Animation) -> Animation? {
|
|
@@ -1212,6 +1224,9 @@ struct CraftConfig: Codable {
|
|
|
1212
1224
|
/// A Bluetooth heart-rate strap (or a watch broadcasting its heart rate)
|
|
1213
1225
|
/// through `craft.heartRate` (CraftHeartRate.swift).
|
|
1214
1226
|
var enableHeartRate: Bool = false
|
|
1227
|
+
/// Structured workouts scheduled onto the paired Apple Watch through
|
|
1228
|
+
/// `craft.scheduledWorkouts` (CraftScheduledWorkouts.swift, WorkoutKit).
|
|
1229
|
+
var enableScheduledWorkouts: Bool = false
|
|
1215
1230
|
var devServerURL: String? = nil
|
|
1216
1231
|
var trustedOrigins: [String] = []
|
|
1217
1232
|
/// The longest the launch splash stays up when the page neither says it is
|
|
@@ -2335,6 +2350,22 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
2335
2350
|
}
|
|
2336
2351
|
}
|
|
2337
2352
|
}
|
|
2353
|
+
// MARK: - Scheduled workouts (CraftScheduledWorkouts.swift)
|
|
2354
|
+
// Gated on enableScheduledWorkouts. One action for every method,
|
|
2355
|
+
// as a native screen's ScheduledWorkouts module calls them;
|
|
2356
|
+
// asking to schedule has its own, which waits on the person.
|
|
2357
|
+
case "scheduledWorkouts", "scheduledWorkoutsRequestAuthorization":
|
|
2358
|
+
if !config.enableScheduledWorkouts {
|
|
2359
|
+
rejectCallback(callbackId, error: "Scheduled workouts are disabled", code: "CAPABILITY_DISABLED")
|
|
2360
|
+
} else {
|
|
2361
|
+
let method = action == "scheduledWorkouts" ? body["method"] as? String ?? "" : "requestAuthorization"
|
|
2362
|
+
CraftScheduledWorkouts.shared.perform(method: method, args: [body["options"] as? [String: Any] ?? [:]]) { [weak self] result in
|
|
2363
|
+
switch result {
|
|
2364
|
+
case .success(let value): self?.resolveCallback(callbackId, result: value)
|
|
2365
|
+
case .failure(let failure): self?.rejectCallback(callbackId, error: failure.message, code: failure.code)
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2338
2369
|
// MARK: - Orientation Lock
|
|
2339
2370
|
case "lockOrientation":
|
|
2340
2371
|
if config.enableOrientationLock {
|
|
@@ -2495,6 +2526,21 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
2495
2526
|
} else {
|
|
2496
2527
|
rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
|
|
2497
2528
|
}
|
|
2529
|
+
case "saveHealthData":
|
|
2530
|
+
if config.enableHealthKit {
|
|
2531
|
+
guard let dataType = body["type"] as? String,
|
|
2532
|
+
let value = (body["value"] as? NSNumber)?.doubleValue else {
|
|
2533
|
+
rejectCallback(callbackId, error: "saveHealthData was called without a type and numeric value", code: "INVALID_ARGUMENT")
|
|
2534
|
+
break
|
|
2535
|
+
}
|
|
2536
|
+
CraftHealth.shared.saveData(
|
|
2537
|
+
type: dataType,
|
|
2538
|
+
value: value,
|
|
2539
|
+
options: body
|
|
2540
|
+
) { [weak self] in self?.answer(callbackId, $0) }
|
|
2541
|
+
} else {
|
|
2542
|
+
rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
|
|
2543
|
+
}
|
|
2498
2544
|
case "saveHealthWorkout":
|
|
2499
2545
|
if config.enableHealthKit {
|
|
2500
2546
|
saveHealthWorkout(body: body, callbackId: callbackId)
|
|
@@ -3337,6 +3383,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
3337
3383
|
brightness: true,
|
|
3338
3384
|
music: \(config.enableAppleMusic),
|
|
3339
3385
|
heartRate: \(config.enableHeartRate),
|
|
3386
|
+
scheduledWorkouts: \(config.enableScheduledWorkouts),
|
|
3340
3387
|
speech: true,
|
|
3341
3388
|
network: true,
|
|
3342
3389
|
deviceInfo: true,
|
|
@@ -4877,6 +4924,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
4877
4924
|
requestHealthAuthorization: true,
|
|
4878
4925
|
musicRequestAuthorization: true,
|
|
4879
4926
|
heartRateRequestAuthorization: true,
|
|
4927
|
+
scheduledWorkoutsRequestAuthorization: true,
|
|
4880
4928
|
requestPermission: true,
|
|
4881
4929
|
signInWithApple: true,
|
|
4882
4930
|
dialogAlert: true,
|
|
@@ -5056,6 +5104,10 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
5056
5104
|
options = options || {};
|
|
5057
5105
|
return craft._invoke('getHealthData', {type: type, startDate: options.startDate, endDate: options.endDate});
|
|
5058
5106
|
},
|
|
5107
|
+
saveData: function(type, value, options) {
|
|
5108
|
+
options = options || {};
|
|
5109
|
+
return craft._invoke('saveHealthData', {type: type, value: value, startDate: options.startDate, endDate: options.endDate});
|
|
5110
|
+
},
|
|
5059
5111
|
saveWorkout: function(workout) { return craft._invoke('saveHealthWorkout', workout || {}); },
|
|
5060
5112
|
getWorkouts: function(options) {
|
|
5061
5113
|
options = options || {};
|
|
@@ -5123,6 +5175,22 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
5123
5175
|
readRecording: function(options) { return heartRate('readRecording', {since: options && typeof options.since === 'number' ? options.since : null}); }
|
|
5124
5176
|
};
|
|
5125
5177
|
})();
|
|
5178
|
+
// Structured workouts on the paired Apple Watch (WorkoutKit,
|
|
5179
|
+
// iOS 17+): each appears in its Workout app on its day.
|
|
5180
|
+
// schedule(plan) answers {id} or rejects naming what WorkoutKit
|
|
5181
|
+
// refused; list() answers [{id, date, hour, minute, complete}].
|
|
5182
|
+
(function() {
|
|
5183
|
+
function scheduled(method, options) { return craft._invoke('scheduledWorkouts', {method: method, options: options || {}}); }
|
|
5184
|
+
craft.scheduledWorkouts = {
|
|
5185
|
+
isSupported: function() { return scheduled('isSupported'); },
|
|
5186
|
+
authorization: function() { return scheduled('authorization'); },
|
|
5187
|
+
requestAuthorization: function() { return craft._invoke('scheduledWorkoutsRequestAuthorization'); },
|
|
5188
|
+
list: function() { return scheduled('list'); },
|
|
5189
|
+
schedule: function(plan) { return scheduled('schedule', plan); },
|
|
5190
|
+
remove: function(id) { return scheduled('remove', {id: typeof id === 'string' ? id : id && id.id}); },
|
|
5191
|
+
removeAll: function() { return scheduled('removeAll'); }
|
|
5192
|
+
};
|
|
5193
|
+
})();
|
|
5126
5194
|
craft.liveActivity = {
|
|
5127
5195
|
start: function(options) { return craft._invoke('startLiveActivity', options || {}); },
|
|
5128
5196
|
update: function(idOrState, state) {
|
|
@@ -32,6 +32,13 @@ final class CraftHealth {
|
|
|
32
32
|
case "getData":
|
|
33
33
|
guard let type = args.first as? String else { return completion(.failure(CraftHealthError(message: "getData needs a type", code: "INVALID_ARGUMENT"))) }
|
|
34
34
|
getData(type: type, startDate: options(1)["startDate"] as? Double, endDate: options(1)["endDate"] as? Double, completion: completion)
|
|
35
|
+
case "saveData":
|
|
36
|
+
guard let type = args.first as? String,
|
|
37
|
+
args.count > 1,
|
|
38
|
+
let value = (args[1] as? NSNumber)?.doubleValue else {
|
|
39
|
+
return completion(.failure(CraftHealthError(message: "saveData needs a type and numeric value", code: "INVALID_ARGUMENT")))
|
|
40
|
+
}
|
|
41
|
+
saveData(type: type, value: value, options: options(2), completion: completion)
|
|
35
42
|
case "saveWorkout":
|
|
36
43
|
saveWorkout(body: options(0), completion: completion)
|
|
37
44
|
case "getWorkouts":
|
|
@@ -120,12 +127,15 @@ final class CraftHealth {
|
|
|
120
127
|
}
|
|
121
128
|
case "dietaryEnergy", "dietaryProtein", "dietaryCarbohydrates", "dietaryFat":
|
|
122
129
|
// What a food log (MyFitnessPal, Lose It!, Cronometer...)
|
|
123
|
-
// writes to Apple Health
|
|
130
|
+
// writes to Apple Health. Request the write grant too unless
|
|
131
|
+
// the caller explicitly asked for a read-only authorization.
|
|
124
132
|
if let dietaryType = Self.healthQuantity(type)?.type {
|
|
125
133
|
readTypes.insert(dietaryType)
|
|
134
|
+
if !readOnly { shareTypes.insert(dietaryType) }
|
|
126
135
|
}
|
|
127
136
|
default:
|
|
128
|
-
|
|
137
|
+
completion(.failure(CraftHealthError(message: "Unknown health data type: \(type)", code: "INVALID_ARGUMENT")))
|
|
138
|
+
return
|
|
129
139
|
}
|
|
130
140
|
}
|
|
131
141
|
|
|
@@ -179,6 +189,42 @@ final class CraftHealth {
|
|
|
179
189
|
healthStore.execute(query)
|
|
180
190
|
}
|
|
181
191
|
|
|
192
|
+
/// Save one quantity sample, primarily a food-log entry. Dates are epoch
|
|
193
|
+
/// milliseconds; an omitted range records an instantaneous sample now.
|
|
194
|
+
func saveData(type: String, value: Double, options: [String: Any], completion: @escaping Completion) {
|
|
195
|
+
guard let healthStore = healthStore else {
|
|
196
|
+
completion(.failure(CraftHealthError(message: "HealthKit not available")))
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
guard value.isFinite, value >= 0 else {
|
|
200
|
+
completion(.failure(CraftHealthError(message: "saveData needs a finite, non-negative value", code: "INVALID_ARGUMENT")))
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
guard let spec = Self.healthQuantity(type), let qType = spec.type else {
|
|
204
|
+
completion(.failure(CraftHealthError(message: "Unknown health data type: \(type)", code: "INVALID_ARGUMENT")))
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
let startValue = options["startDate"] as? Double ?? Date().timeIntervalSince1970 * 1000
|
|
208
|
+
let endValue = options["endDate"] as? Double ?? startValue
|
|
209
|
+
guard startValue.isFinite, endValue.isFinite, endValue >= startValue else {
|
|
210
|
+
completion(.failure(CraftHealthError(message: "saveData needs a valid date range", code: "INVALID_ARGUMENT")))
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
let sample = HKQuantitySample(
|
|
214
|
+
type: qType,
|
|
215
|
+
quantity: HKQuantity(unit: spec.unit, doubleValue: value),
|
|
216
|
+
start: Date(timeIntervalSince1970: startValue / 1000),
|
|
217
|
+
end: Date(timeIntervalSince1970: endValue / 1000)
|
|
218
|
+
)
|
|
219
|
+
healthStore.save(sample) { success, error in
|
|
220
|
+
if success {
|
|
221
|
+
completion(.success(["id": sample.uuid.uuidString]))
|
|
222
|
+
} else {
|
|
223
|
+
completion(.failure(CraftHealthError(message: error?.localizedDescription ?? "Health data could not be saved")))
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
182
228
|
func saveWorkout(body: [String: Any], completion: @escaping Completion) {
|
|
183
229
|
guard let healthStore = healthStore else {
|
|
184
230
|
completion(.failure(CraftHealthError(message: "HealthKit not available")))
|
|
@@ -1004,6 +1004,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1004
1004
|
private var openingWeb = false
|
|
1005
1005
|
private var firstFrameLogged = false
|
|
1006
1006
|
private var tabBarHeight: CGFloat = 0
|
|
1007
|
+
private var tabSwipeRecognizers: [UISwipeGestureRecognizer] = []
|
|
1007
1008
|
|
|
1008
1009
|
init(config: CraftConfig) {
|
|
1009
1010
|
self.config = config
|
|
@@ -1033,6 +1034,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1033
1034
|
let tabs = hybrid.tabIds
|
|
1034
1035
|
let start = CraftHybridRoutes.tab(owning: hybrid.startPath, among: tabs) ?? tabs.first ?? ""
|
|
1035
1036
|
show(tab: start, rootPath: hybrid.startPath)
|
|
1037
|
+
installTabSwipeGestures()
|
|
1036
1038
|
if hybrid.hasWeb && hybrid.startsNative {
|
|
1037
1039
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in self?.attachWeb() }
|
|
1038
1040
|
}
|
|
@@ -1043,6 +1045,42 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1043
1045
|
|
|
1044
1046
|
private var webAttached = false
|
|
1045
1047
|
|
|
1048
|
+
private func installTabSwipeGestures() {
|
|
1049
|
+
guard tabSwipeRecognizers.isEmpty else { return }
|
|
1050
|
+
for direction in [UISwipeGestureRecognizer.Direction.left, .right] {
|
|
1051
|
+
let recognizer = UISwipeGestureRecognizer(target: self, action: #selector(tabSwipeRecognized(_:)))
|
|
1052
|
+
recognizer.direction = direction
|
|
1053
|
+
recognizer.delegate = self
|
|
1054
|
+
recognizer.cancelsTouchesInView = false
|
|
1055
|
+
recognizer.delaysTouchesBegan = false
|
|
1056
|
+
view.addGestureRecognizer(recognizer)
|
|
1057
|
+
tabSwipeRecognizers.append(recognizer)
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
@objc private func tabSwipeRecognized(_ recognizer: UISwipeGestureRecognizer) {
|
|
1062
|
+
let tabs = hybrid.tabIds
|
|
1063
|
+
guard let index = tabs.firstIndex(of: currentTab), tabs.count > 1 else { return }
|
|
1064
|
+
let next = recognizer.direction == .left ? index + 1 : index - 1
|
|
1065
|
+
guard tabs.indices.contains(next) else { return }
|
|
1066
|
+
CraftChrome.shared.swipe(to: tabs[next])
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
private func horizontalScrollAt(_ point: CGPoint, in root: UIView) -> Bool {
|
|
1070
|
+
let local = root.convert(point, from: view)
|
|
1071
|
+
guard root.bounds.contains(local), let hit = root.hitTest(local, with: nil) else { return false }
|
|
1072
|
+
var candidate: UIView? = hit
|
|
1073
|
+
while let current = candidate {
|
|
1074
|
+
if let scroll = current as? UIScrollView,
|
|
1075
|
+
scroll.isScrollEnabled,
|
|
1076
|
+
scroll.alwaysBounceHorizontal || scroll.contentSize.width > scroll.bounds.width + 1 {
|
|
1077
|
+
return true
|
|
1078
|
+
}
|
|
1079
|
+
candidate = current.superview
|
|
1080
|
+
}
|
|
1081
|
+
return false
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1046
1084
|
/// The web view joins the screen, behind the native screens, and loads.
|
|
1047
1085
|
private func attachWeb() {
|
|
1048
1086
|
guard let webHost, !webAttached else { return }
|
|
@@ -1481,8 +1519,19 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1481
1519
|
}
|
|
1482
1520
|
|
|
1483
1521
|
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
1522
|
+
if tabSwipeRecognizers.contains(where: { $0 === gestureRecognizer }) {
|
|
1523
|
+
guard hybrid.tabIds.count > 1, let top = visibleStack?.topViewController else { return false }
|
|
1524
|
+
if let native = top as? CraftNativeScreenController,
|
|
1525
|
+
!native.tabSwipeEnabled || !native.contentSwipeBack { return false }
|
|
1526
|
+
return !horizontalScrollAt(gestureRecognizer.location(in: view), in: top.view)
|
|
1527
|
+
}
|
|
1484
1528
|
guard let nav = visibleStack, isPopRecognizer(gestureRecognizer),
|
|
1485
1529
|
nav.viewControllers.count > 1, nav.transitionCoordinator == nil else { return false }
|
|
1530
|
+
if let native = nav.topViewController as? CraftNativeScreenController,
|
|
1531
|
+
gestureRecognizer !== nav.interactivePopGestureRecognizer,
|
|
1532
|
+
!native.contentSwipeBack {
|
|
1533
|
+
return false
|
|
1534
|
+
}
|
|
1486
1535
|
if let native = nav.topViewController as? CraftNativeScreenController,
|
|
1487
1536
|
gestureRecognizer !== nav.interactivePopGestureRecognizer,
|
|
1488
1537
|
nativeScreenContainsHorizontalScroll(native) {
|