react-pebble 0.1.0 → 0.2.0

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.
@@ -142,10 +142,9 @@ export function useLongButton(button: PebbleButton, handler: PebbleButtonHandler
142
142
  // Time hooks
143
143
  //
144
144
  // On Alloy, `watch.addEventListener('secondchange'|'minutechange', fn)` is
145
- // the canonical tick source. In Node mock mode we fall back to setInterval.
146
- // Both paths are abstracted away by the renderer, which pumps the tick into
147
- // the hook via React state — here we just use setInterval directly, which
148
- // works in both XS and Node. (`watch` is used for redraws, not hook state.)
145
+ // the canonical tick source it fires exactly on boundaries and is far more
146
+ // battery-efficient than setInterval. In Node mock mode we fall back to
147
+ // setInterval.
149
148
  // ---------------------------------------------------------------------------
150
149
 
151
150
  export function useTime(intervalMs = 1000): Date {
@@ -153,6 +152,15 @@ export function useTime(intervalMs = 1000): Date {
153
152
 
154
153
  useEffect(() => {
155
154
  const tick = () => setTime(new Date());
155
+
156
+ // On Alloy: use watch tick events for battery efficiency
157
+ if (typeof watch !== 'undefined' && watch) {
158
+ const event = intervalMs <= 1000 ? 'secondchange' : 'minutechange';
159
+ watch.addEventListener(event, tick);
160
+ return () => watch!.removeEventListener(event, tick);
161
+ }
162
+
163
+ // Mock mode: fall back to setInterval
156
164
  const id = setInterval(tick, intervalMs);
157
165
  return () => clearInterval(id);
158
166
  }, [intervalMs]);
@@ -292,20 +300,500 @@ export function useMessage<T>(options: UseMessageOptions<T>): UseMessageResult<T
292
300
  }
293
301
 
294
302
  // ---------------------------------------------------------------------------
295
- // REMOVED HOOKSreference for future reimplementation
303
+ // Battery hookreads Alloy's Battery sensor (percent, charging, plugged)
304
+ // ---------------------------------------------------------------------------
305
+
306
+ export interface BatteryState {
307
+ percent: number;
308
+ charging: boolean;
309
+ plugged: boolean;
310
+ }
311
+
312
+ /**
313
+ * Returns the current battery state. On Alloy, reads the `Battery` global.
314
+ * In mock mode (Node), returns a static default (100%, not charging).
315
+ *
316
+ * Re-reads on each render; battery updates arrive via watch tick events
317
+ * which trigger redraws, so the value stays fresh.
318
+ */
319
+ export function useBattery(): BatteryState {
320
+ if (typeof Battery !== 'undefined' && Battery) {
321
+ return {
322
+ percent: Battery.percent,
323
+ charging: Battery.charging,
324
+ plugged: Battery.plugged,
325
+ };
326
+ }
327
+ // Mock mode — return a sensible default
328
+ return { percent: 100, charging: false, plugged: false };
329
+ }
330
+
331
+ // ---------------------------------------------------------------------------
332
+ // Connection hook — reads watch.connected (app + pebblekit)
333
+ // ---------------------------------------------------------------------------
334
+
335
+ export interface ConnectionState {
336
+ app: boolean;
337
+ pebblekit: boolean;
338
+ }
339
+
340
+ /**
341
+ * Returns the current phone connection state. On Alloy, reads
342
+ * `watch.connected`. In mock mode, returns connected for both.
343
+ */
344
+ export function useConnection(): ConnectionState {
345
+ if (typeof watch !== 'undefined' && watch?.connected) {
346
+ return {
347
+ app: watch.connected.app,
348
+ pebblekit: watch.connected.pebblekit,
349
+ };
350
+ }
351
+ // Mock mode
352
+ return { app: true, pebblekit: true };
353
+ }
354
+
355
+ // ---------------------------------------------------------------------------
356
+ // localStorage hook — persists state across app restarts and reboots
357
+ // ---------------------------------------------------------------------------
358
+
359
+ /**
360
+ * Like useState, but backed by localStorage so the value persists across
361
+ * app restarts and watch reboots.
362
+ *
363
+ * On Alloy, `localStorage` is a standard Web API global.
364
+ * In mock mode (Node), falls back to a plain in-memory useState.
365
+ *
366
+ * Values are JSON-serialized. Only use with JSON-safe types.
367
+ */
368
+ export function useLocalStorage<T>(key: string, defaultValue: T): [T, (v: T | ((prev: T) => T)) => void] {
369
+ const [value, setValue] = useState<T>(() => {
370
+ if (typeof localStorage === 'undefined') return defaultValue;
371
+ try {
372
+ const stored = localStorage.getItem(key);
373
+ return stored !== null ? JSON.parse(stored) as T : defaultValue;
374
+ } catch {
375
+ return defaultValue;
376
+ }
377
+ });
378
+
379
+ const setAndPersist = useCallback((v: T | ((prev: T) => T)) => {
380
+ setValue((prev) => {
381
+ const next = typeof v === 'function' ? (v as (p: T) => T)(prev) : v;
382
+ if (typeof localStorage !== 'undefined') {
383
+ try {
384
+ localStorage.setItem(key, JSON.stringify(next));
385
+ } catch {
386
+ // Storage full or unavailable — silently ignore
387
+ }
388
+ }
389
+ return next;
390
+ });
391
+ }, [key]);
392
+
393
+ return [value, setAndPersist];
394
+ }
395
+
396
+ // ---------------------------------------------------------------------------
397
+ // useFetch — HTTP data loading via pebbleproxy
398
+ // ---------------------------------------------------------------------------
399
+
400
+ export interface UseFetchOptions<T> {
401
+ /** Mock data returned in Node mock mode so the compiler can render. */
402
+ mockData?: T;
403
+ /** Delay in ms before mock data appears (default 100). */
404
+ mockDelay?: number;
405
+ /** fetch() RequestInit options (method, headers, body). */
406
+ init?: RequestInit;
407
+ }
408
+
409
+ export interface UseFetchResult<T> {
410
+ data: T | null;
411
+ loading: boolean;
412
+ error: string | null;
413
+ }
414
+
415
+ /**
416
+ * Fetch JSON data from a URL.
417
+ *
418
+ * On Alloy: uses the standard `fetch()` API (proxied via @moddable/pebbleproxy).
419
+ * In mock mode (Node): returns `mockData` after `mockDelay` ms.
420
+ *
421
+ * Usage:
422
+ * const { data, loading, error } = useFetch<Weather>(
423
+ * 'https://api.example.com/weather',
424
+ * { mockData: { temp: 72, condition: 'Sunny' } }
425
+ * );
426
+ */
427
+ export function useFetch<T>(url: string, options: UseFetchOptions<T> = {}): UseFetchResult<T> {
428
+ const [data, setData] = useState<T | null>(null);
429
+ const [loading, setLoading] = useState(true);
430
+ const [error, setError] = useState<string | null>(null);
431
+
432
+ useEffect(() => {
433
+ // On Alloy (real device): use fetch()
434
+ if (typeof globalThis.fetch === 'function') {
435
+ globalThis.fetch(url, options.init)
436
+ .then((res) => {
437
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
438
+ return res.json() as Promise<T>;
439
+ })
440
+ .then((json) => {
441
+ setData(json);
442
+ setLoading(false);
443
+ })
444
+ .catch((err) => {
445
+ setError(String(err));
446
+ setLoading(false);
447
+ });
448
+ return;
449
+ }
450
+
451
+ // Mock mode: return mockData after delay
452
+ if (options.mockData !== undefined) {
453
+ const timer = setTimeout(() => {
454
+ setData(options.mockData!);
455
+ setLoading(false);
456
+ }, options.mockDelay ?? 100);
457
+ return () => clearTimeout(timer);
458
+ }
459
+
460
+ // No fetch and no mock data
461
+ setError('fetch() not available');
462
+ setLoading(false);
463
+ }, [url]);
464
+
465
+ return { data, loading, error };
466
+ }
467
+
468
+ // ---------------------------------------------------------------------------
469
+ // useAnimation — property interpolation with easing
470
+ // ---------------------------------------------------------------------------
471
+
472
+ /** Standard easing functions matching Alloy's Timeline API. */
473
+ export const Easing = {
474
+ linear: (t: number) => t,
475
+ quadEaseIn: (t: number) => t * t,
476
+ quadEaseOut: (t: number) => t * (2 - t),
477
+ quadEaseInOut: (t: number) => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t,
478
+ cubicEaseIn: (t: number) => t * t * t,
479
+ cubicEaseOut: (t: number) => (--t) * t * t + 1,
480
+ cubicEaseInOut: (t: number) => t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1,
481
+ sinEaseIn: (t: number) => 1 - Math.cos(t * Math.PI / 2),
482
+ sinEaseOut: (t: number) => Math.sin(t * Math.PI / 2),
483
+ sinEaseInOut: (t: number) => -(Math.cos(Math.PI * t) - 1) / 2,
484
+ expoEaseIn: (t: number) => t === 0 ? 0 : Math.pow(2, 10 * (t - 1)),
485
+ expoEaseOut: (t: number) => t === 1 ? 1 : 1 - Math.pow(2, -10 * t),
486
+ circEaseIn: (t: number) => 1 - Math.sqrt(1 - t * t),
487
+ circEaseOut: (t: number) => Math.sqrt(1 - (--t) * t),
488
+ bounceEaseOut: (t: number) => {
489
+ if (t < 1 / 2.75) return 7.5625 * t * t;
490
+ if (t < 2 / 2.75) return 7.5625 * (t -= 1.5 / 2.75) * t + 0.75;
491
+ if (t < 2.5 / 2.75) return 7.5625 * (t -= 2.25 / 2.75) * t + 0.9375;
492
+ return 7.5625 * (t -= 2.625 / 2.75) * t + 0.984375;
493
+ },
494
+ bounceEaseIn: (t: number) => 1 - Easing.bounceEaseOut(1 - t),
495
+ elasticEaseOut: (t: number) => {
496
+ if (t === 0 || t === 1) return t;
497
+ return Math.pow(2, -10 * t) * Math.sin((t - 0.075) * (2 * Math.PI) / 0.3) + 1;
498
+ },
499
+ backEaseOut: (t: number) => {
500
+ const s = 1.70158;
501
+ return (--t) * t * ((s + 1) * t + s) + 1;
502
+ },
503
+ } as const;
504
+
505
+ export type EasingFn = (t: number) => number;
506
+
507
+ export interface UseAnimationOptions {
508
+ /** Duration in ms. */
509
+ duration: number;
510
+ /** Easing function (default: linear). */
511
+ easing?: EasingFn;
512
+ /** Delay before start in ms (default: 0). */
513
+ delay?: number;
514
+ /** Loop the animation (default: false). */
515
+ loop?: boolean;
516
+ /** Auto-start on mount (default: true). */
517
+ autoStart?: boolean;
518
+ }
519
+
520
+ export interface UseAnimationResult {
521
+ /** Current progress value (0 to 1), eased. */
522
+ progress: number;
523
+ /** Whether the animation is currently running. */
524
+ running: boolean;
525
+ /** Start or restart the animation. */
526
+ start: () => void;
527
+ /** Stop the animation. */
528
+ stop: () => void;
529
+ }
530
+
531
+ /**
532
+ * Animate a progress value from 0 to 1 over a duration with easing.
533
+ *
534
+ * Uses `useTime` internally so that animation progress is derived from
535
+ * the wall clock. This ensures compatibility with the piu compiler,
536
+ * which detects time-dependent values via T1/T2 render diffs.
537
+ *
538
+ * The animation cycles based on `duration` (in ms). If `loop` is true,
539
+ * it repeats indefinitely.
540
+ *
541
+ * Usage:
542
+ * const { progress } = useAnimation({ duration: 10000, easing: Easing.bounceEaseOut, loop: true });
543
+ * const x = lerp(0, 200, progress);
544
+ */
545
+ export function useAnimation(options: UseAnimationOptions): UseAnimationResult {
546
+ const { duration, easing = Easing.linear, loop = false } = options;
547
+ // Use useTime for clock ticks — this makes the compiler detect time deps.
548
+ // Progress is derived purely from the current time (no stored start time),
549
+ // so the compiler can diff T1 vs T2 and detect changing values.
550
+ const time = useTime(1000);
551
+
552
+ // Derive progress from current time modulo duration.
553
+ // For a 60s duration, this cycles every 60s based on wall clock.
554
+ const totalSeconds = time.getMinutes() * 60 + time.getSeconds();
555
+ const durationSec = duration / 1000;
556
+ const raw = loop
557
+ ? (totalSeconds % durationSec) / durationSec
558
+ : Math.min(totalSeconds / durationSec, 1);
559
+ const progress = easing(raw);
560
+
561
+ const start = useCallback(() => { /* no-op: auto-driven by time */ }, []);
562
+ const stop = useCallback(() => { /* no-op: auto-driven by time */ }, []);
563
+
564
+ return { progress, running: true, start, stop };
565
+ }
566
+
567
+ /**
568
+ * Interpolate between two values using an animation progress (0-1).
569
+ */
570
+ export function lerp(from: number, to: number, progress: number): number {
571
+ return from + (to - from) * progress;
572
+ }
573
+
574
+ // ---------------------------------------------------------------------------
575
+ // useAccelerometer — motion sensing via Moddable sensor API
576
+ // ---------------------------------------------------------------------------
577
+
578
+ export interface AccelerometerData {
579
+ x: number;
580
+ y: number;
581
+ z: number;
582
+ }
583
+
584
+ export interface UseAccelerometerOptions {
585
+ /** Sample rate in ms (default: 100). */
586
+ sampleRate?: number;
587
+ /** Called on tap gesture. */
588
+ onTap?: () => void;
589
+ /** Called on double-tap gesture. */
590
+ onDoubleTap?: () => void;
591
+ }
592
+
593
+ /**
594
+ * Read accelerometer data.
595
+ *
596
+ * On Alloy: reads from the Moddable Accelerometer sensor.
597
+ * In mock mode: returns { x: 0, y: 0, z: -1000 } (gravity pointing down).
598
+ */
599
+ export function useAccelerometer(options: UseAccelerometerOptions = {}): AccelerometerData {
600
+ const { sampleRate = 100, onTap, onDoubleTap } = options;
601
+ const [data, setData] = useState<AccelerometerData>({ x: 0, y: 0, z: -1000 });
602
+ const tapRef = useRef(onTap);
603
+ const doubleTapRef = useRef(onDoubleTap);
604
+ tapRef.current = onTap;
605
+ doubleTapRef.current = onDoubleTap;
606
+
607
+ useEffect(() => {
608
+ // Try to access the Alloy accelerometer
609
+ if (typeof globalThis !== 'undefined' && (globalThis as Record<string, unknown>).__pbl_accel) {
610
+ const accel = (globalThis as Record<string, unknown>).__pbl_accel as {
611
+ onSample?: (x: number, y: number, z: number) => void;
612
+ onTap?: () => void;
613
+ onDoubleTap?: () => void;
614
+ start?: () => void;
615
+ stop?: () => void;
616
+ };
617
+ accel.onSample = (x: number, y: number, z: number) => setData({ x, y, z });
618
+ if (tapRef.current) accel.onTap = () => tapRef.current?.();
619
+ if (doubleTapRef.current) accel.onDoubleTap = () => doubleTapRef.current?.();
620
+ accel.start?.();
621
+ return () => accel.stop?.();
622
+ }
623
+
624
+ // Mock mode: simulate gentle wobble
625
+ const id = setInterval(() => {
626
+ setData({
627
+ x: Math.round(Math.sin(Date.now() / 1000) * 50),
628
+ y: Math.round(Math.cos(Date.now() / 1200) * 30),
629
+ z: -1000 + Math.round(Math.sin(Date.now() / 800) * 20),
630
+ });
631
+ }, sampleRate);
632
+ return () => clearInterval(id);
633
+ }, [sampleRate]);
634
+
635
+ return data;
636
+ }
637
+
638
+ // ---------------------------------------------------------------------------
639
+ // useCompass — magnetic heading via Moddable sensor API
640
+ // ---------------------------------------------------------------------------
641
+
642
+ export interface CompassData {
643
+ /** Heading in degrees (0-360, 0 = north). */
644
+ heading: number;
645
+ }
646
+
647
+ /**
648
+ * Read compass heading.
649
+ *
650
+ * On Alloy: reads from the Moddable Compass sensor.
651
+ * In mock mode: returns a slowly rotating heading.
652
+ */
653
+ export function useCompass(): CompassData {
654
+ const [heading, setHeading] = useState(0);
655
+
656
+ useEffect(() => {
657
+ // Try to access the Alloy compass
658
+ if (typeof globalThis !== 'undefined' && (globalThis as Record<string, unknown>).__pbl_compass) {
659
+ const compass = (globalThis as Record<string, unknown>).__pbl_compass as {
660
+ onSample?: (heading: number) => void;
661
+ start?: () => void;
662
+ stop?: () => void;
663
+ };
664
+ compass.onSample = (h: number) => setHeading(h);
665
+ compass.start?.();
666
+ return () => compass.stop?.();
667
+ }
668
+
669
+ // Mock mode: slowly rotate
670
+ const id = setInterval(() => {
671
+ setHeading((h) => (h + 1) % 360);
672
+ }, 100);
673
+ return () => clearInterval(id);
674
+ }, []);
675
+
676
+ return { heading };
677
+ }
678
+
679
+ // ---------------------------------------------------------------------------
680
+ // useWebSocket — bidirectional communication via pebbleproxy
681
+ // ---------------------------------------------------------------------------
682
+
683
+ export interface UseWebSocketResult {
684
+ /** Last received message (null until first message). */
685
+ lastMessage: string | null;
686
+ /** Whether the connection is open. */
687
+ connected: boolean;
688
+ /** Send a message. */
689
+ send: (data: string) => void;
690
+ /** Close the connection. */
691
+ close: () => void;
692
+ }
693
+
694
+ /**
695
+ * Connect to a WebSocket server.
696
+ *
697
+ * On Alloy: uses the WebSocket API (proxied via @moddable/pebbleproxy).
698
+ * In mock mode: simulates a connection that echoes messages back.
699
+ */
700
+ export function useWebSocket(url: string): UseWebSocketResult {
701
+ const [lastMessage, setLastMessage] = useState<string | null>(null);
702
+ const [connected, setConnected] = useState(false);
703
+ const wsRef = useRef<{ send: (d: string) => void; close: () => void } | null>(null);
704
+
705
+ useEffect(() => {
706
+ // On Alloy: use real WebSocket
707
+ if (typeof WebSocket !== 'undefined') {
708
+ const ws = new WebSocket(url);
709
+ ws.onopen = () => setConnected(true);
710
+ ws.onmessage = (e) => setLastMessage(String(e.data));
711
+ ws.onclose = () => setConnected(false);
712
+ ws.onerror = () => setConnected(false);
713
+ wsRef.current = { send: (d) => ws.send(d), close: () => ws.close() };
714
+ return () => ws.close();
715
+ }
716
+
717
+ // Mock mode: echo server
718
+ setConnected(true);
719
+ wsRef.current = {
720
+ send: (d: string) => {
721
+ setTimeout(() => setLastMessage(`echo: ${d}`), 50);
722
+ },
723
+ close: () => setConnected(false),
724
+ };
725
+ return () => setConnected(false);
726
+ }, [url]);
727
+
728
+ const send = useCallback((data: string) => {
729
+ wsRef.current?.send(data);
730
+ }, []);
731
+
732
+ const close = useCallback(() => {
733
+ wsRef.current?.close();
734
+ }, []);
735
+
736
+ return { lastMessage, connected, send, close };
737
+ }
738
+
739
+ // ---------------------------------------------------------------------------
740
+ // useKVStorage — ECMA-419 binary key-value storage
741
+ // ---------------------------------------------------------------------------
742
+
743
+ /**
744
+ * Key-value storage for binary and structured data using the ECMA-419 API.
745
+ *
746
+ * On Alloy: uses `device.keyValue.open(storeName)` for persistent binary storage.
747
+ * In mock mode: uses an in-memory Map.
748
+ *
749
+ * For simple string storage, prefer `useLocalStorage`.
750
+ */
751
+ export function useKVStorage(storeName: string): {
752
+ get: (key: string) => string | null;
753
+ set: (key: string, value: string) => void;
754
+ remove: (key: string) => void;
755
+ } {
756
+ const storeRef = useRef<{
757
+ get: (key: string) => string | null;
758
+ set: (key: string, value: string) => void;
759
+ delete: (key: string) => void;
760
+ } | null>(null);
761
+
762
+ if (!storeRef.current) {
763
+ // Try ECMA-419 device.keyValue API
764
+ const device = (globalThis as Record<string, unknown>).device as {
765
+ keyValue?: { open: (path: string) => {
766
+ get: (key: string) => string | null;
767
+ set: (key: string, value: string) => void;
768
+ delete: (key: string) => void;
769
+ }};
770
+ } | undefined;
771
+
772
+ if (device?.keyValue) {
773
+ storeRef.current = device.keyValue.open(storeName);
774
+ } else {
775
+ // Mock mode: in-memory map
776
+ const map = new Map<string, string>();
777
+ storeRef.current = {
778
+ get: (k) => map.get(k) ?? null,
779
+ set: (k, v) => map.set(k, v),
780
+ delete: (k) => { map.delete(k); },
781
+ };
782
+ }
783
+ }
784
+
785
+ const store = storeRef.current!;
786
+ return {
787
+ get: useCallback((key: string) => store.get(key), []),
788
+ set: useCallback((key: string, value: string) => store.set(key, value), []),
789
+ remove: useCallback((key: string) => store.delete(key), []),
790
+ };
791
+ }
792
+
793
+ // ---------------------------------------------------------------------------
794
+ // FUTURE HOOKS
296
795
  //
297
- // - useBattery previously read Pebble.battery (fictional global).
298
- // Alloy equivalent: probably a Moddable power
299
- // module; not yet explored.
300
- // - useConnection — previously Pebble.connection.isConnected().
301
- // Alloy equivalent: `watch.connected` property was
302
- // observed on the watch prototype but its shape is
303
- // unknown.
304
- // - useAccelerometer — previously Pebble.accel. Alloy has Moddable
305
- // sensor modules; specific import path unknown.
306
- // - useAppMessage — previously Pebble.sendAppMessage / addEventListener
307
- // for 'appmessage'. Alloy has no direct equivalent;
308
- // phone↔watch messaging goes through PebbleKit JS on
309
- // the phone side (`src/pkjs/index.js`) and is a
310
- // separate concern.
796
+ // - useAppMessage phone↔watch messaging goes through PebbleKit JS
797
+ // on the phone side (`src/pkjs/index.js`).
798
+ // - useLocation — GPS via phone proxy (one-shot).
311
799
  // ---------------------------------------------------------------------------
package/src/index.ts CHANGED
@@ -45,6 +45,8 @@ export {
45
45
  Line,
46
46
  Image,
47
47
  Group,
48
+ Column,
49
+ Row,
48
50
  StatusBar,
49
51
  ActionBar,
50
52
  Card,
@@ -58,6 +60,8 @@ export type {
58
60
  LineProps,
59
61
  ImageProps,
60
62
  GroupProps,
63
+ ColumnProps,
64
+ RowProps,
61
65
  StatusBarProps,
62
66
  ActionBarProps,
63
67
  CardProps,
@@ -79,6 +83,17 @@ export {
79
83
  useFormattedTime,
80
84
  useInterval,
81
85
  useListNavigation,
86
+ useBattery,
87
+ useConnection,
88
+ useLocalStorage,
89
+ useFetch,
90
+ useAnimation,
91
+ useAccelerometer,
92
+ useCompass,
93
+ useWebSocket,
94
+ useKVStorage,
95
+ Easing,
96
+ lerp,
82
97
  ButtonRegistry,
83
98
  PebbleAppContext,
84
99
  } from './hooks/index.js';
@@ -87,6 +102,17 @@ export type {
87
102
  PebbleButtonHandler,
88
103
  ListNavigationOptions,
89
104
  ListNavigationResult,
105
+ BatteryState,
106
+ ConnectionState,
107
+ UseFetchOptions,
108
+ UseFetchResult,
109
+ UseAnimationOptions,
110
+ UseAnimationResult,
111
+ EasingFn,
112
+ AccelerometerData,
113
+ UseAccelerometerOptions,
114
+ CompassData,
115
+ UseWebSocketResult,
90
116
  } from './hooks/index.js';
91
117
 
92
118
  // Low-level access (for advanced usage / custom renderers / tests)