craft-native 0.0.254 → 0.0.272
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 +22 -0
- package/dist/android/src/index.d.ts +4 -2
- package/dist/android/src/index.js +65 -4
- package/dist/android/templates/AndroidManifest.xml.template +1 -0
- package/dist/android/templates/CraftHealthServices.kt.template +132 -0
- package/dist/android/templates/CraftHealthServicesService.kt.template +217 -0
- package/dist/android/templates/CraftHealthServicesStub.kt.template +16 -0
- package/dist/android/templates/CraftNativeHealth.kt.template +115 -0
- package/dist/android/templates/CraftNativeHealthStub.kt.template +1 -0
- package/dist/android/templates/CraftNativeMutation.kt.template +2 -0
- package/dist/android/templates/CraftNativeServices.kt.template +31 -8
- package/dist/android/templates/CraftNativeShell.kt.template +5 -0
- package/dist/android/templates/MainActivityNative.kt.template +343 -33
- package/dist/android/templates/build.gradle.kts.app.template +5 -0
- package/dist/api/mobile.d.ts +9 -1
- package/dist/browser/lifecycle.d.ts +7 -0
- package/dist/browser.js +126 -24
- package/dist/cli.js +1 -1
- package/dist/components/index.d.ts +19 -10
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/templates/CraftApp.swift +55 -7
- package/dist/ios/templates/CraftHealth.swift +38 -0
- package/dist/ios/templates/CraftHybrid.swift +50 -7
- package/dist/ios/templates/CraftNativeActions.swift +109 -22
- package/dist/ios/templates/CraftNativeFlatList.swift +187 -18
- package/dist/ios/templates/CraftNativeMutation.swift +3 -1
- package/dist/ios/templates/CraftNativeScreen.swift +466 -24
- package/dist/macos/templates/CraftNativeMutation.swift +3 -1
- package/dist/mobile.js +1 -1
- package/package.json +1 -1
package/dist/api/mobile.d.ts
CHANGED
|
@@ -1227,6 +1227,11 @@ export interface NativeTabBarOptions {
|
|
|
1227
1227
|
tintDark?: string;
|
|
1228
1228
|
/** Whether Craft's horizontal tab-switch gesture may begin over the page. Defaults to true. */
|
|
1229
1229
|
swipeEnabled?: boolean;
|
|
1230
|
+
/** The selected tab highlight. Omit to keep the default capsule. */
|
|
1231
|
+
selection?: {
|
|
1232
|
+
/** Continuous corner radius in points. The highlight height is the upper bound. */
|
|
1233
|
+
cornerRadius?: number;
|
|
1234
|
+
};
|
|
1230
1235
|
}
|
|
1231
1236
|
/**
|
|
1232
1237
|
* The phone's own tab bar, floating over the page: Liquid Glass on iOS 26 and
|
|
@@ -1698,10 +1703,13 @@ export interface HealthLiveWorkoutResult {
|
|
|
1698
1703
|
}
|
|
1699
1704
|
export interface HealthLiveWorkoutEvent {
|
|
1700
1705
|
id: string;
|
|
1701
|
-
event: 'state' | 'error';
|
|
1706
|
+
event: 'state' | 'metrics' | 'error';
|
|
1702
1707
|
state?: 'notStarted' | 'prepared' | 'running' | 'paused' | 'stopped' | 'ended' | 'unknown';
|
|
1703
1708
|
/** Epoch milliseconds for a state transition. */
|
|
1704
1709
|
date?: number;
|
|
1710
|
+
heartRateBpm?: number;
|
|
1711
|
+
activeEnergyCalories?: number;
|
|
1712
|
+
distanceMeters?: number;
|
|
1705
1713
|
message?: string;
|
|
1706
1714
|
}
|
|
1707
1715
|
/** The health reads and writes a host may or may not have, depending on the app build it runs in. */
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type BrowserAppState = 'active' | 'background';
|
|
2
|
+
/** Browser lifecycle shares the native APP_STATE contract, including bfcache navigation. */
|
|
3
|
+
export declare function createBrowserLifecycle(page: Pick<Document, 'hidden' | 'addEventListener' | 'removeEventListener'>, host: Pick<Window, 'addEventListener' | 'removeEventListener'>, changed: (state: BrowserAppState) => void): {
|
|
4
|
+
getState: () => BrowserAppState;
|
|
5
|
+
onStateChange(callback: (state: BrowserAppState) => void): () => void;
|
|
6
|
+
dispose(): void;
|
|
7
|
+
};
|
package/dist/browser.js
CHANGED
|
@@ -1,3 +1,50 @@
|
|
|
1
|
+
// src/browser/lifecycle.ts
|
|
2
|
+
function createBrowserLifecycle(page, host, changed) {
|
|
3
|
+
let state = page.hidden ? "background" : "active";
|
|
4
|
+
const listeners = new Set;
|
|
5
|
+
let disposed = false;
|
|
6
|
+
const set = (next) => {
|
|
7
|
+
if (disposed || state === next)
|
|
8
|
+
return;
|
|
9
|
+
state = next;
|
|
10
|
+
changed(state);
|
|
11
|
+
for (const listener of [...listeners]) {
|
|
12
|
+
try {
|
|
13
|
+
listener(state);
|
|
14
|
+
} catch (error) {
|
|
15
|
+
console.error("craft.lifecycle listener failed", error);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
const visibility = () => set(page.hidden ? "background" : "active");
|
|
20
|
+
const hide = () => set("background");
|
|
21
|
+
page.addEventListener("visibilitychange", visibility);
|
|
22
|
+
host.addEventListener("pagehide", hide);
|
|
23
|
+
host.addEventListener("pageshow", visibility);
|
|
24
|
+
return {
|
|
25
|
+
getState: () => state,
|
|
26
|
+
onStateChange(callback) {
|
|
27
|
+
if (typeof callback !== "function")
|
|
28
|
+
throw new TypeError("lifecycle.onStateChange needs a function");
|
|
29
|
+
if (disposed)
|
|
30
|
+
return () => {};
|
|
31
|
+
listeners.add(callback);
|
|
32
|
+
return () => {
|
|
33
|
+
listeners.delete(callback);
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
dispose() {
|
|
37
|
+
if (disposed)
|
|
38
|
+
return;
|
|
39
|
+
disposed = true;
|
|
40
|
+
page.removeEventListener("visibilitychange", visibility);
|
|
41
|
+
host.removeEventListener("pagehide", hide);
|
|
42
|
+
host.removeEventListener("pageshow", visibility);
|
|
43
|
+
listeners.clear();
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
1
48
|
// src/bridge/ids.ts
|
|
2
49
|
function secureUUID() {
|
|
3
50
|
const c = globalThis.crypto;
|
|
@@ -377,6 +424,8 @@ async function createBrowserPlatform(options = {}) {
|
|
|
377
424
|
const files = new Map;
|
|
378
425
|
const mediaURLs = new Map;
|
|
379
426
|
const sounds = new Map;
|
|
427
|
+
const soundRequests = new Map;
|
|
428
|
+
const soundCleanup = new Map;
|
|
380
429
|
const fades = new Map;
|
|
381
430
|
const cancelFade = (sound) => {
|
|
382
431
|
const fade = fades.get(sound);
|
|
@@ -414,11 +463,12 @@ async function createBrowserPlatform(options = {}) {
|
|
|
414
463
|
if (!sound)
|
|
415
464
|
return;
|
|
416
465
|
await fade(sound, 0, sound.paused ? 0 : milliseconds);
|
|
417
|
-
|
|
418
|
-
|
|
466
|
+
soundCleanup.get(sound)?.();
|
|
467
|
+
soundCleanup.delete(sound);
|
|
419
468
|
sound.pause();
|
|
420
469
|
sound.removeAttribute("src");
|
|
421
|
-
sounds.
|
|
470
|
+
if (sounds.get(id) === sound)
|
|
471
|
+
sounds.delete(id);
|
|
422
472
|
};
|
|
423
473
|
const lifetime = new AbortController;
|
|
424
474
|
let account = new AbortController;
|
|
@@ -454,6 +504,7 @@ async function createBrowserPlatform(options = {}) {
|
|
|
454
504
|
const resetAccount = async () => {
|
|
455
505
|
const previousCache = `${namespace}:media:${mediaScope}`;
|
|
456
506
|
account.abort();
|
|
507
|
+
soundRequests.clear();
|
|
457
508
|
account = new AbortController;
|
|
458
509
|
for (const path of files.keys())
|
|
459
510
|
URL.revokeObjectURL(path);
|
|
@@ -462,10 +513,12 @@ async function createBrowserPlatform(options = {}) {
|
|
|
462
513
|
files.clear();
|
|
463
514
|
mediaURLs.clear();
|
|
464
515
|
for (const audio of sounds.values()) {
|
|
516
|
+
soundCleanup.get(audio)?.();
|
|
465
517
|
cancelFade(audio);
|
|
466
518
|
audio.pause();
|
|
467
519
|
audio.src = "";
|
|
468
520
|
}
|
|
521
|
+
soundCleanup.clear();
|
|
469
522
|
sounds.clear();
|
|
470
523
|
await persistence.accountChanged();
|
|
471
524
|
mediaScope = persistence.scope();
|
|
@@ -480,7 +533,10 @@ async function createBrowserPlatform(options = {}) {
|
|
|
480
533
|
});
|
|
481
534
|
};
|
|
482
535
|
window.addEventListener("storage", storageEvent);
|
|
483
|
-
const
|
|
536
|
+
const lifecycle = typeof document !== "undefined" ? createBrowserLifecycle(document, window, (state) => emit("APP_STATE", { state })) : null;
|
|
537
|
+
const capabilityNames = ["storage", "secureStorage", "dialog", "browser", "linking", "files", "share", "device"];
|
|
538
|
+
if (lifecycle)
|
|
539
|
+
capabilityNames.push("lifecycle");
|
|
484
540
|
if (persistence.atomicStorage)
|
|
485
541
|
capabilityNames.push("storageCompareAndSet", "storageSetIfAbsent");
|
|
486
542
|
if (typeof globalThis.crypto?.randomUUID === "function" || typeof globalThis.crypto?.getRandomValues === "function")
|
|
@@ -500,6 +556,7 @@ async function createBrowserPlatform(options = {}) {
|
|
|
500
556
|
const platform = {
|
|
501
557
|
craft: {
|
|
502
558
|
...persistence.craft,
|
|
559
|
+
...lifecycle ? { lifecycle: { getState: lifecycle.getState, onStateChange: lifecycle.onStateChange, onChange: lifecycle.onStateChange } } : {},
|
|
503
560
|
session: {
|
|
504
561
|
key: sessionTokenKey,
|
|
505
562
|
token: () => persistence.craft.secureStorage.getSync(sessionTokenKey),
|
|
@@ -755,29 +812,74 @@ async function createBrowserPlatform(options = {}) {
|
|
|
755
812
|
const id = String(args[0]);
|
|
756
813
|
const sound = sounds.get(id);
|
|
757
814
|
if (method === "play") {
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
const
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
audio.onended = () => {
|
|
765
|
-
sounds.delete(id);
|
|
766
|
-
emit("SOUND_EVENT", { id, event: "ended" });
|
|
815
|
+
const request = {};
|
|
816
|
+
soundRequests.set(id, request);
|
|
817
|
+
const current = () => {
|
|
818
|
+
signal.throwIfAborted();
|
|
819
|
+
if (soundRequests.get(id) !== request)
|
|
820
|
+
throw new DOMException("Playback replaced", "AbortError");
|
|
767
821
|
};
|
|
768
|
-
audio
|
|
769
|
-
|
|
770
|
-
|
|
822
|
+
let audio;
|
|
823
|
+
const release = () => {
|
|
824
|
+
if (!audio)
|
|
825
|
+
return;
|
|
826
|
+
signal.removeEventListener("abort", release);
|
|
827
|
+
soundCleanup.delete(audio);
|
|
828
|
+
cancelFade(audio);
|
|
829
|
+
audio.pause();
|
|
830
|
+
audio.removeAttribute("src");
|
|
831
|
+
if (sounds.get(id) === audio)
|
|
832
|
+
sounds.delete(id);
|
|
771
833
|
};
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
834
|
+
try {
|
|
835
|
+
await stopSound(id);
|
|
836
|
+
current();
|
|
837
|
+
const url = await platform.resolveMedia(String(args[1]));
|
|
838
|
+
current();
|
|
839
|
+
audio = new Audio(url);
|
|
840
|
+
const settings = object(args[2]);
|
|
841
|
+
audio.loop = Boolean(settings.loop);
|
|
842
|
+
const volume = Math.max(0, Math.min(1, Number(settings.volume ?? 1)));
|
|
843
|
+
audio.volume = Number(settings.fadeIn) > 0 ? 0 : volume;
|
|
844
|
+
audio.onended = () => {
|
|
845
|
+
release();
|
|
846
|
+
if (soundRequests.get(id) === request) {
|
|
847
|
+
soundRequests.delete(id);
|
|
848
|
+
emit("SOUND_EVENT", { id, event: "ended" });
|
|
849
|
+
}
|
|
850
|
+
};
|
|
851
|
+
audio.onerror = () => {
|
|
852
|
+
const message = audio?.error?.message;
|
|
853
|
+
release();
|
|
854
|
+
if (soundRequests.get(id) === request) {
|
|
855
|
+
soundRequests.delete(id);
|
|
856
|
+
emit("SOUND_EVENT", { id, event: "error", message });
|
|
857
|
+
}
|
|
858
|
+
};
|
|
859
|
+
sounds.set(id, audio);
|
|
860
|
+
soundCleanup.set(audio, release);
|
|
861
|
+
signal.addEventListener("abort", release, { once: true });
|
|
862
|
+
await audio.play();
|
|
863
|
+
current();
|
|
864
|
+
fade(audio, volume, Number(settings.fadeIn ?? 0));
|
|
865
|
+
return true;
|
|
866
|
+
} catch (error) {
|
|
867
|
+
release();
|
|
868
|
+
if (soundRequests.get(id) === request)
|
|
869
|
+
soundRequests.delete(id);
|
|
870
|
+
throw error;
|
|
871
|
+
}
|
|
776
872
|
}
|
|
777
873
|
if (method === "stopAll") {
|
|
874
|
+
soundRequests.clear();
|
|
778
875
|
await Promise.all([...sounds.keys()].map((key) => stopSound(key, Number(first.fadeOut ?? 0))));
|
|
779
876
|
return true;
|
|
780
877
|
}
|
|
878
|
+
if (method === "stop") {
|
|
879
|
+
soundRequests.delete(id);
|
|
880
|
+
await stopSound(id, Number(object(args[1]).fadeOut ?? 0));
|
|
881
|
+
return Boolean(sound);
|
|
882
|
+
}
|
|
781
883
|
if (!sound)
|
|
782
884
|
return false;
|
|
783
885
|
if (method === "pause") {
|
|
@@ -788,10 +890,6 @@ async function createBrowserPlatform(options = {}) {
|
|
|
788
890
|
await sound.play();
|
|
789
891
|
return true;
|
|
790
892
|
}
|
|
791
|
-
if (method === "stop") {
|
|
792
|
-
await stopSound(id, Number(object(args[1]).fadeOut ?? 0));
|
|
793
|
-
return true;
|
|
794
|
-
}
|
|
795
893
|
if (method === "setVolume") {
|
|
796
894
|
await fade(sound, Number(args[1]), Number(object(args[2]).fade ?? 0));
|
|
797
895
|
return true;
|
|
@@ -873,6 +971,8 @@ async function createBrowserPlatform(options = {}) {
|
|
|
873
971
|
if (disposed)
|
|
874
972
|
return;
|
|
875
973
|
disposed = true;
|
|
974
|
+
soundRequests.clear();
|
|
975
|
+
lifecycle?.dispose();
|
|
876
976
|
window.removeEventListener("storage", storageEvent);
|
|
877
977
|
lifetime.abort();
|
|
878
978
|
account.abort();
|
|
@@ -883,10 +983,12 @@ async function createBrowserPlatform(options = {}) {
|
|
|
883
983
|
files.clear();
|
|
884
984
|
mediaURLs.clear();
|
|
885
985
|
for (const audio of sounds.values()) {
|
|
986
|
+
soundCleanup.get(audio)?.();
|
|
886
987
|
cancelFade(audio);
|
|
887
988
|
audio.pause();
|
|
888
989
|
audio.src = "";
|
|
889
990
|
}
|
|
991
|
+
soundCleanup.clear();
|
|
890
992
|
sounds.clear();
|
|
891
993
|
wakeLock?.release();
|
|
892
994
|
listeners.clear();
|
package/dist/cli.js
CHANGED
|
@@ -2842,7 +2842,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
|
|
|
2842
2842
|
`);
|
|
2843
2843
|
}
|
|
2844
2844
|
// package.json
|
|
2845
|
-
var version = "0.0.
|
|
2845
|
+
var version = "0.0.272";
|
|
2846
2846
|
|
|
2847
2847
|
// bin/cli.ts
|
|
2848
2848
|
var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
|
|
@@ -38,6 +38,8 @@ export interface FlexStyle {
|
|
|
38
38
|
flexDirection?: 'row' | 'column' | 'row-reverse' | 'column-reverse';
|
|
39
39
|
/** Flex wrap */
|
|
40
40
|
flexWrap?: 'nowrap' | 'wrap' | 'wrap-reverse';
|
|
41
|
+
/** Flex/grid sibling order; equal values retain source order */
|
|
42
|
+
order?: number;
|
|
41
43
|
/** Justify content (main axis) */
|
|
42
44
|
justifyContent?: 'flex-start' | 'flex-end' | 'center' | 'space-between' | 'space-around' | 'space-evenly';
|
|
43
45
|
/** Align items (cross axis); baseline aligns text-bearing children by their first baseline */
|
|
@@ -309,6 +311,8 @@ export interface BaseProps {
|
|
|
309
311
|
accessibilityState?: AccessibilityState;
|
|
310
312
|
/** Accessibility role */
|
|
311
313
|
accessibilityRole?: 'none' | 'button' | 'link' | 'image' | 'text' | 'header' | 'search' | 'adjustable' | 'menu' | 'summary' | 'alert' | 'checkbox' | 'combobox' | 'menuitem' | 'progressbar' | 'radio' | 'tablist' | 'radiogroup' | 'list' | 'menubar' | 'spinbutton' | 'switch' | 'tab' | 'timer' | 'toolbar' | 'scrollbar';
|
|
314
|
+
/** Native list chrome slot for keyed FlatList/SectionList children. */
|
|
315
|
+
listRole?: 'header' | 'item' | 'separator' | 'empty' | 'footer';
|
|
312
316
|
/** Matches a view across an animated state change. */
|
|
313
317
|
transitionID?: string;
|
|
314
318
|
/** Children elements */
|
|
@@ -454,8 +458,14 @@ export interface VideoProps extends BaseProps {
|
|
|
454
458
|
autoPlay?: boolean;
|
|
455
459
|
loop?: boolean;
|
|
456
460
|
muted?: boolean;
|
|
461
|
+
/** Controlled playback position in seconds. */
|
|
462
|
+
currentTime?: number;
|
|
457
463
|
paused?: boolean;
|
|
458
464
|
resizeMode?: 'contain' | 'cover' | 'stretch';
|
|
465
|
+
/** The source began loading. */
|
|
466
|
+
onLoadStart?: () => void;
|
|
467
|
+
/** The source became ready for playback. */
|
|
468
|
+
onLoadEnd?: () => void;
|
|
459
469
|
/**
|
|
460
470
|
* Carries on in a floating window (Picture in Picture) when the app goes to
|
|
461
471
|
* the background or another app opens while it plays, and comes back into
|
|
@@ -848,16 +858,7 @@ export interface ActivityIndicatorProps extends BaseProps {
|
|
|
848
858
|
* FlatList component props.
|
|
849
859
|
*/
|
|
850
860
|
export interface FlatListProps<T> extends ScrollViewBaseProps {
|
|
851
|
-
/**
|
|
852
|
-
data: T[];
|
|
853
|
-
/** Render item function */
|
|
854
|
-
renderItem: (info: {
|
|
855
|
-
item: T;
|
|
856
|
-
index: number;
|
|
857
|
-
}) => any;
|
|
858
|
-
/** Key extractor function */
|
|
859
|
-
keyExtractor?: (item: T, index: number) => string;
|
|
860
|
-
/** Number of columns */
|
|
861
|
+
/** Number of columns in the keyed child tree. */
|
|
861
862
|
numColumns?: number;
|
|
862
863
|
/** Render rows in reverse order along the scroll axis. */
|
|
863
864
|
inverted?: boolean;
|
|
@@ -870,6 +871,10 @@ export interface FlatListProps<T> extends ScrollViewBaseProps {
|
|
|
870
871
|
/** Refreshing state */
|
|
871
872
|
refreshing?: boolean;
|
|
872
873
|
}
|
|
874
|
+
/**
|
|
875
|
+
* @deprecated Grouped SectionList data is deferred until the versioned native
|
|
876
|
+
* list-data protocol exists. Use keyed child rows with `listRole` instead.
|
|
877
|
+
*/
|
|
873
878
|
export interface SectionListSection<T> {
|
|
874
879
|
data: T[];
|
|
875
880
|
key?: string;
|
|
@@ -881,6 +886,10 @@ export interface ModalProps extends BaseProps {
|
|
|
881
886
|
style?: StyleValue<ViewStyle>;
|
|
882
887
|
visible?: boolean;
|
|
883
888
|
transparent?: boolean;
|
|
889
|
+
/** Keep the popup interactive without blocking the screen behind it. */
|
|
890
|
+
nonModal?: boolean;
|
|
891
|
+
/** When non-modal, consume an outside tap and call onRequestClose. */
|
|
892
|
+
dismissOnOutsideTap?: boolean;
|
|
884
893
|
animationType?: 'none' | 'fade' | 'slide';
|
|
885
894
|
onShow?: () => void;
|
|
886
895
|
onDismiss?: () => void;
|
package/dist/index.cjs
CHANGED
package/dist/index.js
CHANGED
|
@@ -686,6 +686,9 @@ final class CraftChrome: ObservableObject {
|
|
|
686
686
|
/// Whether a hybrid page lets the shell claim horizontal swipes for tabs.
|
|
687
687
|
/// Native screens use their own per-screen `tabSwipeEnabled` option.
|
|
688
688
|
private(set) var tabSwipeEnabled = true
|
|
689
|
+
/// The selected tab highlight's corner radius. Nil preserves the default
|
|
690
|
+
/// capsule, while a finite non-negative value opts into a rounded rect.
|
|
691
|
+
private(set) var tabSelectionCornerRadius: CGFloat?
|
|
689
692
|
|
|
690
693
|
weak var webView: WKWebView?
|
|
691
694
|
/// What the tab bar covers at the bottom of the screen, in points.
|
|
@@ -794,10 +797,21 @@ final class CraftChrome: ObservableObject {
|
|
|
794
797
|
tabAccessory = next
|
|
795
798
|
}
|
|
796
799
|
|
|
797
|
-
/// A
|
|
798
|
-
|
|
800
|
+
/// A screen going away gives its accessory up without taking it off the
|
|
801
|
+
/// bar (a retained or prepared screen cannot clear another's): the screen coming in shows it until it says what it wants. Taken
|
|
802
|
+
/// off at once, the pill stretched across the bar and back while the
|
|
803
|
+
/// next screen (Messages, opened from the accessory; a tab opened for the
|
|
804
|
+
/// first time) started up and set the same accessory again.
|
|
805
|
+
func releaseTabAccessory(ownedBy owner: AnyObject) {
|
|
799
806
|
guard tabAccessoryOwner === owner else { return }
|
|
800
|
-
|
|
807
|
+
tabAccessoryOwner = nil
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/// Takes off an accessory nobody claims any more: the screen now showing
|
|
811
|
+
/// drew without asking for one, or is a page that never does.
|
|
812
|
+
func clearUnclaimedTabAccessory() {
|
|
813
|
+
guard tabAccessoryOwner == nil, tabAccessory != nil else { return }
|
|
814
|
+
tabAccessory = nil
|
|
801
815
|
}
|
|
802
816
|
|
|
803
817
|
func setHiddenByScreen(_ hidden: Bool) {
|
|
@@ -823,6 +837,7 @@ final class CraftChrome: ObservableObject {
|
|
|
823
837
|
switch body["type"] as? String {
|
|
824
838
|
case "tabBar":
|
|
825
839
|
pendingHide?.cancel()
|
|
840
|
+
tabSelectionCornerRadius = Self.selectionCornerRadius(body["selection"])
|
|
826
841
|
tabs = (body["tabs"] as? [[String: Any]] ?? []).compactMap { item in
|
|
827
842
|
guard let id = item["id"] as? String, let title = item["title"] as? String else { return nil }
|
|
828
843
|
let badge = (item["badge"] as? String).flatMap { $0.isEmpty ? nil : $0 }
|
|
@@ -872,6 +887,15 @@ final class CraftChrome: ObservableObject {
|
|
|
872
887
|
}
|
|
873
888
|
}
|
|
874
889
|
|
|
890
|
+
private static func selectionCornerRadius(_ value: Any?) -> CGFloat? {
|
|
891
|
+
guard let selection = value as? [String: Any],
|
|
892
|
+
let raw = selection["cornerRadius"] as? NSNumber,
|
|
893
|
+
raw.doubleValue.isFinite,
|
|
894
|
+
raw.doubleValue >= 0,
|
|
895
|
+
raw.doubleValue <= Double(CGFloat.greatestFiniteMagnitude) else { return nil }
|
|
896
|
+
return CGFloat(raw.doubleValue)
|
|
897
|
+
}
|
|
898
|
+
|
|
875
899
|
/// A tap on a tab: the page navigates, and says which tab is current.
|
|
876
900
|
///
|
|
877
901
|
/// No haptic. UITabBar plays none, and a tab bar that ticks on every tap is
|
|
@@ -1175,7 +1199,7 @@ struct CraftTabBarView: View {
|
|
|
1175
1199
|
}
|
|
1176
1200
|
.foregroundStyle(isSelected ? selectedColor : Color.primary)
|
|
1177
1201
|
.frame(maxWidth: .infinity, minHeight: 54)
|
|
1178
|
-
.contentShape(
|
|
1202
|
+
.contentShape(CraftTabSelectionShape(cornerRadius: chrome.tabSelectionCornerRadius))
|
|
1179
1203
|
}
|
|
1180
1204
|
.buttonStyle(.plain)
|
|
1181
1205
|
.accessibilityShowsLargeContentViewer {
|
|
@@ -1188,7 +1212,11 @@ struct CraftTabBarView: View {
|
|
|
1188
1212
|
}
|
|
1189
1213
|
}
|
|
1190
1214
|
.background {
|
|
1191
|
-
CraftTabSelectionIndicator(
|
|
1215
|
+
CraftTabSelectionIndicator(
|
|
1216
|
+
selected: chrome.tabs.firstIndex(where: { $0.id == chrome.selected }),
|
|
1217
|
+
count: chrome.tabs.count,
|
|
1218
|
+
cornerRadius: chrome.tabSelectionCornerRadius
|
|
1219
|
+
)
|
|
1192
1220
|
}
|
|
1193
1221
|
}
|
|
1194
1222
|
|
|
@@ -1230,18 +1258,33 @@ struct CraftTabBarView: View {
|
|
|
1230
1258
|
}
|
|
1231
1259
|
}
|
|
1232
1260
|
|
|
1261
|
+
private struct CraftTabSelectionShape: Shape {
|
|
1262
|
+
let cornerRadius: CGFloat?
|
|
1263
|
+
|
|
1264
|
+
func path(in rect: CGRect) -> Path {
|
|
1265
|
+
if let cornerRadius {
|
|
1266
|
+
return RoundedRectangle(
|
|
1267
|
+
cornerRadius: min(cornerRadius, min(rect.width, rect.height) / 2),
|
|
1268
|
+
style: .continuous
|
|
1269
|
+
).path(in: rect)
|
|
1270
|
+
}
|
|
1271
|
+
return Capsule().path(in: rect)
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1233
1275
|
private struct CraftTabSelectionIndicator: View {
|
|
1234
1276
|
@ObservedObject private var progress = CraftChrome.shared.tabSwipeProgress
|
|
1235
1277
|
let selected: Int?
|
|
1236
1278
|
let count: Int
|
|
1279
|
+
let cornerRadius: CGFloat?
|
|
1237
1280
|
|
|
1238
1281
|
var body: some View {
|
|
1239
1282
|
GeometryReader { geometry in
|
|
1240
1283
|
if let position = progress.position ?? selected.map({ CGFloat($0) }) {
|
|
1241
1284
|
let width = geometry.size.width / CGFloat(max(1, count))
|
|
1242
|
-
|
|
1243
|
-
.fill(Color.primary.opacity(0.09))
|
|
1285
|
+
CraftTabSelectionShape(cornerRadius: cornerRadius)
|
|
1244
1286
|
.frame(width: width, height: geometry.size.height)
|
|
1287
|
+
.foregroundStyle(Color.primary.opacity(0.09))
|
|
1245
1288
|
.offset(x: position * width)
|
|
1246
1289
|
}
|
|
1247
1290
|
}
|
|
@@ -6315,6 +6358,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6315
6358
|
|
|
6316
6359
|
SecItemDelete(query as CFDictionary)
|
|
6317
6360
|
let status = SecItemAdd(query as CFDictionary, nil)
|
|
6361
|
+
CraftKeychain.forget(account: key)
|
|
6318
6362
|
return status == errSecSuccess
|
|
6319
6363
|
}
|
|
6320
6364
|
|
|
@@ -6341,12 +6385,14 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
6341
6385
|
kSecAttrAccount as String: key
|
|
6342
6386
|
]
|
|
6343
6387
|
let status = SecItemDelete(query as CFDictionary)
|
|
6388
|
+
CraftKeychain.forget(account: key)
|
|
6344
6389
|
return status == errSecSuccess || status == errSecItemNotFound
|
|
6345
6390
|
}
|
|
6346
6391
|
|
|
6347
6392
|
private func secureClear() -> Bool {
|
|
6348
6393
|
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword]
|
|
6349
6394
|
let status = SecItemDelete(query as CFDictionary)
|
|
6395
|
+
CraftKeychain.forget(account: nil)
|
|
6350
6396
|
return status == errSecSuccess || status == errSecItemNotFound
|
|
6351
6397
|
}
|
|
6352
6398
|
|
|
@@ -8227,6 +8273,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
8227
8273
|
SecItemDelete(query as CFDictionary)
|
|
8228
8274
|
|
|
8229
8275
|
let status = SecItemAdd(query as CFDictionary, nil)
|
|
8276
|
+
CraftKeychain.forget(account: key)
|
|
8230
8277
|
if status == errSecSuccess {
|
|
8231
8278
|
resolveCallback(callbackId, result: ["success": true, "key": key])
|
|
8232
8279
|
} else {
|
|
@@ -8269,6 +8316,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
8269
8316
|
}
|
|
8270
8317
|
|
|
8271
8318
|
let status = SecItemDelete(query as CFDictionary)
|
|
8319
|
+
CraftKeychain.forget(account: key)
|
|
8272
8320
|
if status == errSecSuccess || status == errSecItemNotFound {
|
|
8273
8321
|
resolveCallback(callbackId, result: ["success": true, "key": key])
|
|
8274
8322
|
} else {
|
|
@@ -43,6 +43,19 @@ private final class CraftHealthWorkoutDelegate: NSObject, HKWorkoutSessionDelega
|
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
@available(iOS 26.0, *)
|
|
47
|
+
private final class CraftHealthWorkoutBuilderDelegate: NSObject, HKLiveWorkoutBuilderDelegate {
|
|
48
|
+
weak var owner: CraftHealth?
|
|
49
|
+
|
|
50
|
+
init(owner: CraftHealth) { self.owner = owner }
|
|
51
|
+
|
|
52
|
+
func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didCollectDataOf collectedTypes: Set<HKSampleType>) {
|
|
53
|
+
owner?.emitLiveWorkoutMetrics(workoutBuilder)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {}
|
|
57
|
+
}
|
|
58
|
+
|
|
46
59
|
/// Apple Health for the whole app: the web page's bridge (`craft.health` in
|
|
47
60
|
/// the hybrid's web view, CraftApp.swift) and native screens alike
|
|
48
61
|
/// (`Health` through CraftNativeActions), so a screen drawn natively reads
|
|
@@ -837,12 +850,15 @@ final class CraftHealth {
|
|
|
837
850
|
let session = try HKWorkoutSession(healthStore: healthStore, configuration: configuration)
|
|
838
851
|
let builder = session.associatedWorkoutBuilder()
|
|
839
852
|
let delegate = CraftHealthWorkoutDelegate(id: { [weak self] in self?.liveWorkoutID ?? "" }) { [weak self] detail in self?.sendWorkout(detail) }
|
|
853
|
+
let metricsDelegate = CraftHealthWorkoutBuilderDelegate(owner: self)
|
|
840
854
|
session.delegate = delegate
|
|
855
|
+
builder.delegate = metricsDelegate
|
|
841
856
|
builder.shouldCollectWorkoutEvents = true
|
|
842
857
|
builder.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore, workoutConfiguration: configuration)
|
|
843
858
|
liveWorkoutSession = session
|
|
844
859
|
liveWorkoutBuilder = builder
|
|
845
860
|
liveWorkoutDelegate = delegate
|
|
861
|
+
liveWorkoutBuilderDelegate = metricsDelegate
|
|
846
862
|
liveWorkoutID = UUID().uuidString.lowercased()
|
|
847
863
|
liveWorkoutType = type
|
|
848
864
|
liveWorkoutStart = Date()
|
|
@@ -923,11 +939,32 @@ final class CraftHealth {
|
|
|
923
939
|
liveWorkoutSession = nil
|
|
924
940
|
liveWorkoutBuilder = nil
|
|
925
941
|
liveWorkoutDelegate = nil
|
|
942
|
+
liveWorkoutBuilderDelegate = nil
|
|
926
943
|
liveWorkoutID = nil
|
|
927
944
|
liveWorkoutType = nil
|
|
928
945
|
liveWorkoutStart = nil
|
|
929
946
|
}
|
|
930
947
|
|
|
948
|
+
@available(iOS 26.0, *)
|
|
949
|
+
fileprivate func emitLiveWorkoutMetrics(_ builder: HKLiveWorkoutBuilder) {
|
|
950
|
+
guard let id = liveWorkoutID else { return }
|
|
951
|
+
var detail: [String: Any] = ["id": id, "event": "metrics", "date": Date().timeIntervalSince1970 * 1000]
|
|
952
|
+
let bpm = HKUnit.count().unitDivided(by: .minute())
|
|
953
|
+
if let type = HKQuantityType.quantityType(forIdentifier: .heartRate),
|
|
954
|
+
let value = builder.statistics(for: type)?.mostRecentQuantity()?.doubleValue(for: bpm), value.isFinite {
|
|
955
|
+
detail["heartRateBpm"] = value
|
|
956
|
+
}
|
|
957
|
+
if let type = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned),
|
|
958
|
+
let value = builder.statistics(for: type)?.sumQuantity()?.doubleValue(for: .kilocalorie()), value.isFinite {
|
|
959
|
+
detail["activeEnergyCalories"] = value
|
|
960
|
+
}
|
|
961
|
+
if let type = HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning),
|
|
962
|
+
let value = builder.statistics(for: type)?.sumQuantity()?.doubleValue(for: .meter()), value.isFinite {
|
|
963
|
+
detail["distanceMeters"] = value
|
|
964
|
+
}
|
|
965
|
+
onMain { [weak self] in self?.sendWorkout(detail) }
|
|
966
|
+
}
|
|
967
|
+
|
|
931
968
|
private static func workoutActivity(_ type: String) -> HKWorkoutActivityType? {
|
|
932
969
|
switch type {
|
|
933
970
|
case "running": return .running
|
|
@@ -965,6 +1002,7 @@ final class CraftHealth {
|
|
|
965
1002
|
private var liveWorkoutSession: AnyObject?
|
|
966
1003
|
private var liveWorkoutBuilder: AnyObject?
|
|
967
1004
|
private var liveWorkoutDelegate: AnyObject?
|
|
1005
|
+
private var liveWorkoutBuilderDelegate: AnyObject?
|
|
968
1006
|
private var liveWorkoutID: String?
|
|
969
1007
|
private var liveWorkoutType: String?
|
|
970
1008
|
private var liveWorkoutStart: Date?
|