@expokit/cli 0.4.2 → 0.4.4

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.
Files changed (102) hide show
  1. package/README.md +6 -2
  2. package/bin/expo-kit.js +26 -3
  3. package/bin/lib.mjs +17 -1
  4. package/package.json +1 -1
  5. package/registry/_docs/flip-calendar.json +106 -0
  6. package/registry/_docs/paywall.json +144 -0
  7. package/registry/_docs/ruler-time-picker.json +147 -0
  8. package/registry/accordion/files/src/lib/kit.tsx +102 -4
  9. package/registry/action-sheet/files/src/components/ui/action-sheet.tsx +8 -11
  10. package/registry/action-sheet/files/src/lib/kit.tsx +102 -4
  11. package/registry/alert/files/src/components/ui/alert.tsx +1 -1
  12. package/registry/alert/files/src/components/ui/button.tsx +5 -18
  13. package/registry/alert/files/src/lib/kit.tsx +102 -4
  14. package/registry/avatar/files/src/components/ui/avatar.tsx +5 -8
  15. package/registry/avatar/files/src/lib/kit.tsx +102 -4
  16. package/registry/badge/files/src/components/ui/badge.tsx +2 -2
  17. package/registry/badge/files/src/lib/kit.tsx +102 -4
  18. package/registry/bottom-nav/files/src/components/ui/bottom-nav.tsx +11 -17
  19. package/registry/bottom-nav/files/src/lib/kit.tsx +102 -4
  20. package/registry/bottom-sheet/files/src/components/ui/bottom-sheet.tsx +7 -14
  21. package/registry/bottom-sheet/files/src/lib/kit.tsx +102 -4
  22. package/registry/button/files/src/components/ui/button.tsx +5 -18
  23. package/registry/button/files/src/lib/kit.tsx +102 -4
  24. package/registry/button-3d/files/src/components/ui/button-3d.tsx +4 -7
  25. package/registry/button-3d/files/src/lib/kit.tsx +102 -4
  26. package/registry/calendar/files/src/components/ui/calendar.tsx +3 -3
  27. package/registry/calendar/files/src/lib/kit.tsx +102 -4
  28. package/registry/card/files/src/components/ui/card.tsx +4 -3
  29. package/registry/card/files/src/lib/kit.tsx +102 -4
  30. package/registry/checkbox/files/src/lib/kit.tsx +102 -4
  31. package/registry/chip-group/files/src/components/ui/chip-group.tsx +2 -2
  32. package/registry/chip-group/files/src/lib/kit.tsx +102 -4
  33. package/registry/confetti/files/src/lib/kit.tsx +102 -4
  34. package/registry/coupon/files/src/components/ui/coupon.tsx +5 -10
  35. package/registry/coupon/files/src/components/ui/flip-card.tsx +3 -9
  36. package/registry/coupon/files/src/components/ui/tilt-card.tsx +4 -10
  37. package/registry/coupon/files/src/lib/kit.tsx +102 -4
  38. package/registry/date-picker/files/src/components/ui/calendar.tsx +3 -3
  39. package/registry/date-picker/files/src/components/ui/date-picker.tsx +10 -16
  40. package/registry/date-picker/files/src/lib/kit.tsx +102 -4
  41. package/registry/dialog/files/src/components/ui/dialog.tsx +6 -12
  42. package/registry/dialog/files/src/lib/kit.tsx +102 -4
  43. package/registry/drawer/files/src/components/ui/drawer.tsx +3 -19
  44. package/registry/drawer/files/src/lib/kit.tsx +102 -4
  45. package/registry/flip-calendar/files/src/components/ui/flip-calendar.tsx +626 -0
  46. package/registry/flip-calendar/files/src/lib/kit.tsx +320 -0
  47. package/registry/flip-calendar.json +17 -0
  48. package/registry/flip-card/files/src/components/ui/flip-card.tsx +3 -9
  49. package/registry/flip-card/files/src/lib/kit.tsx +102 -4
  50. package/registry/floating-input/files/src/lib/kit.tsx +102 -4
  51. package/registry/grain/files/src/lib/kit.tsx +102 -4
  52. package/registry/kit/files/src/lib/kit.tsx +102 -4
  53. package/registry/liquidglass-tab/files/src/components/GlassTabBar.tsx +6 -7
  54. package/registry/liquidglass-tab/files/src/components/TabScreen.tsx +31 -17
  55. package/registry/list-item/files/src/components/ui/list-item.tsx +1 -1
  56. package/registry/list-item/files/src/lib/kit.tsx +102 -4
  57. package/registry/otp-input/files/src/components/ui/otp-input.tsx +1 -1
  58. package/registry/otp-input/files/src/lib/kit.tsx +102 -4
  59. package/registry/password-input/files/src/lib/kit.tsx +102 -4
  60. package/registry/payment-card/files/src/components/ui/flip-card.tsx +3 -9
  61. package/registry/payment-card/files/src/components/ui/payment-card.tsx +9 -3
  62. package/registry/payment-card/files/src/components/ui/tilt-card.tsx +4 -10
  63. package/registry/payment-card/files/src/lib/kit.tsx +102 -4
  64. package/registry/paywall.json +22 -0
  65. package/registry/progress/files/src/components/ui/progress.tsx +2 -0
  66. package/registry/progress/files/src/lib/kit.tsx +102 -4
  67. package/registry/progress-steps/files/src/components/ui/progress-steps.tsx +1 -1
  68. package/registry/progress-steps/files/src/lib/kit.tsx +102 -4
  69. package/registry/radio-group/files/src/lib/kit.tsx +102 -4
  70. package/registry/ruler-time-picker/files/src/components/ui/ruler-time-picker.tsx +816 -0
  71. package/registry/ruler-time-picker/files/src/lib/kit.tsx +320 -0
  72. package/registry/ruler-time-picker.json +19 -0
  73. package/registry/search-bar/files/src/components/ui/search-bar.tsx +3 -7
  74. package/registry/search-bar/files/src/lib/kit.tsx +102 -4
  75. package/registry/segmented-control/files/src/components/ui/segmented-control.tsx +3 -10
  76. package/registry/segmented-control/files/src/lib/kit.tsx +102 -4
  77. package/registry/select/files/src/components/ui/select.tsx +16 -18
  78. package/registry/select/files/src/lib/kit.tsx +102 -4
  79. package/registry/skeleton/files/src/lib/kit.tsx +102 -4
  80. package/registry/slider/files/src/components/ui/slider.tsx +4 -13
  81. package/registry/slider/files/src/lib/kit.tsx +102 -4
  82. package/registry/spinner/files/src/lib/kit.tsx +102 -4
  83. package/registry/stateful-button/files/src/lib/kit.tsx +102 -4
  84. package/registry/stepper/files/src/components/ui/stepper.tsx +2 -2
  85. package/registry/stepper/files/src/lib/kit.tsx +102 -4
  86. package/registry/switch/files/src/components/ui/switch.tsx +2 -5
  87. package/registry/switch/files/src/lib/kit.tsx +102 -4
  88. package/registry/tabs/files/src/components/ui/tabs.tsx +2 -2
  89. package/registry/tabs/files/src/lib/kit.tsx +102 -4
  90. package/registry/text-animation/files/src/lib/kit.tsx +102 -4
  91. package/registry/textarea/files/src/lib/kit.tsx +102 -4
  92. package/registry/ticket-shape/files/src/lib/kit.tsx +102 -4
  93. package/registry/tilt-card/files/src/components/ui/tilt-card.tsx +4 -10
  94. package/registry/tilt-card/files/src/lib/kit.tsx +102 -4
  95. package/registry/time-picker/files/src/components/ui/time-picker.tsx +16 -14
  96. package/registry/time-picker/files/src/lib/kit.tsx +102 -4
  97. package/registry/timeline/files/src/components/ui/avatar.tsx +5 -8
  98. package/registry/timeline/files/src/components/ui/timeline.tsx +5 -16
  99. package/registry/timeline/files/src/lib/kit.tsx +102 -4
  100. package/registry/toast/files/src/components/ui/button.tsx +5 -18
  101. package/registry/toast/files/src/components/ui/toast.tsx +2 -2
  102. package/registry/toast/files/src/lib/kit.tsx +102 -4
@@ -0,0 +1,626 @@
1
+ import * as Haptics from 'expo-haptics';
2
+ import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
3
+ import {
4
+ AccessibilityInfo,
5
+ Animated,
6
+ Easing,
7
+ PanResponder,
8
+ Platform,
9
+ StyleSheet,
10
+ Text,
11
+ View,
12
+ type LayoutChangeEvent,
13
+ type StyleProp,
14
+ type ViewStyle,
15
+ } from 'react-native';
16
+
17
+ import { motion, readableOn, shadowFor, useKit } from '@/lib/kit';
18
+
19
+ export interface FlipCalendarProps {
20
+ /** Controlled date (its time of day is kept on every change). */
21
+ value?: Date;
22
+ /** Initial date when uncontrolled (default today). */
23
+ defaultValue?: Date;
24
+ /** Called when a page is flipped over (next day) or flipped back (previous day). */
25
+ onChange?: (date: Date) => void;
26
+ /** Earliest day; flipping back past it only nudges the page. */
27
+ minDate?: Date;
28
+ /** Latest day; flipping forward past it only lifts the page a little. */
29
+ maxDate?: Date;
30
+ /** BCP 47 tag for weekday and month names (default: device). */
31
+ locale?: string;
32
+ /** Month band and Sunday colour (default theme accent). */
33
+ accent?: string;
34
+ /** Widest the pad grows (default 300). */
35
+ maxWidth?: number;
36
+ /** A tick when a page passes upright and a soft thud when it lands (default true). */
37
+ haptics?: boolean;
38
+ accessibilityLabel?: string;
39
+ style?: StyleProp<ViewStyle>;
40
+ }
41
+
42
+ /** A calendar day without a time: `{ y, m: 0–11, d: 1–31 }`. */
43
+ export interface Day {
44
+ y: number;
45
+ m: number;
46
+ d: number;
47
+ }
48
+
49
+ const MS_DAY = 86400000;
50
+ // Where the pages hang from: the board behind them, and how far down the first page starts.
51
+ const BOARD_H = 26;
52
+ const PAGE_TOP = 16;
53
+ // A page turning on its own (after you let go, or in a fling) eases out like paper settling.
54
+ const TURN = { duration: 420, easing: Easing.bezier(0.32, 0.72, 0, 1), useNativeDriver: true };
55
+ const QUICK = { duration: 240, easing: Easing.bezier(0.32, 0.72, 0, 1), useNativeDriver: true };
56
+ const MAX_CHAIN = 6;
57
+
58
+ export const dayOf = (date: Date): Day => ({ y: date.getFullYear(), m: date.getMonth(), d: date.getDate() });
59
+ const utc = (t: Day) => Date.UTC(t.y, t.m, t.d);
60
+ const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n));
61
+
62
+ /** The day `n` days after `base` (negative for before). */
63
+ export function addDays(base: Day, n: number): Day {
64
+ const t = new Date(utc(base) + n * MS_DAY);
65
+ return { y: t.getUTCFullYear(), m: t.getUTCMonth(), d: t.getUTCDate() };
66
+ }
67
+
68
+ /** Whole days from `a` to `b`, immune to daylight-saving shifts. */
69
+ export function daysBetween(a: Day, b: Day) {
70
+ return Math.round((utc(b) - utc(a)) / MS_DAY);
71
+ }
72
+
73
+ /** 1 for 1 January … 365 / 366 for 31 December. */
74
+ export function dayOfYear(t: Day) {
75
+ return daysBetween({ y: t.y, m: 0, d: 1 }, t) + 1;
76
+ }
77
+
78
+ /** ISO-8601 week number (weeks start Monday; week 1 holds the year's first Thursday). */
79
+ export function isoWeek(t: Day) {
80
+ const wd = (new Date(utc(t)).getUTCDay() + 6) % 7; // Monday = 0
81
+ return Math.floor((dayOfYear(addDays(t, 3 - wd)) - 1) / 7) + 1;
82
+ }
83
+
84
+ /**
85
+ * Where a point of a turning page is. The page hangs from its hinge (y = 0) down to length L; it is
86
+ * turned as far as `p` (0 = lying flat, 1 = turned over the top). Paper doesn't hinge stiffly: the
87
+ * part above the fold line stays flat on the pad, the paper rolls round a cylinder of radius R
88
+ * toward you, and the rest comes back up over the top with its back showing. `s` is the distance
89
+ * along the paper from the hinge; returns y (down the pad) and z (toward you).
90
+ */
91
+ export function curlPoint(s: number, p: number, L: number, R: number) {
92
+ const fold = L * (1 - p);
93
+ if (s <= fold) return { y: s, z: 0 };
94
+ const d = s - fold;
95
+ if (d <= Math.PI * R) {
96
+ const a = d / R;
97
+ return { y: fold + R * Math.sin(a), z: R * (1 - Math.cos(a)) };
98
+ }
99
+ return { y: fold - (d - Math.PI * R), z: 2 * R };
100
+ }
101
+
102
+ /**
103
+ * How far the page must be turned so the point `s` along the paper sits at `y` (where the finger
104
+ * is). A point only ever moves up as the page turns, so this is a simple bisection.
105
+ */
106
+ export function progressUnderFinger(s: number, y: number, L: number, R: number) {
107
+ if (y >= curlPoint(s, 0, L, R).y) return 0;
108
+ if (y <= curlPoint(s, 1, L, R).y) return 1;
109
+ let lo = 0;
110
+ let hi = 1;
111
+ for (let i = 0; i < 24; i++) {
112
+ const mid = (lo + hi) / 2;
113
+ if (curlPoint(s, mid, L, R).y > y) lo = mid;
114
+ else hi = mid;
115
+ }
116
+ return (lo + hi) / 2;
117
+ }
118
+
119
+ const yearLength = (y: number) => daysBetween({ y, m: 0, d: 1 }, { y: y + 1, m: 0, d: 1 });
120
+
121
+ type Names = { days: string[]; months: string[] };
122
+ const nameCache = new Map<string, Names>();
123
+ const EN: Names = {
124
+ days: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
125
+ months: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'],
126
+ };
127
+
128
+ /** Weekday and month names for a locale, falling back to English where Intl isn't available. */
129
+ function namesFor(locale?: string): Names {
130
+ const key = locale ?? '';
131
+ const hit = nameCache.get(key);
132
+ if (hit) return hit;
133
+ let names = EN;
134
+ try {
135
+ const wd = new Intl.DateTimeFormat(locale, { weekday: 'long', timeZone: 'UTC' });
136
+ const mo = new Intl.DateTimeFormat(locale, { month: 'long', timeZone: 'UTC' });
137
+ names = {
138
+ days: EN.days.map((_, i) => wd.format(new Date(Date.UTC(2023, 0, 1 + i)))), // 1 Jan 2023 was a Sunday
139
+ months: EN.months.map((_, i) => mo.format(new Date(Date.UTC(2023, i, 15)))),
140
+ };
141
+ } catch {
142
+ // Keep the English names.
143
+ }
144
+ nameCache.set(key, names);
145
+ return names;
146
+ }
147
+
148
+ interface PageValues {
149
+ /** 0 = lying on the pad, 180 = flipped over the top (hidden behind the pad). */
150
+ flip: Animated.Value;
151
+ /** A small downward nudge when there's no earlier page to bring back. */
152
+ nudge: Animated.Value;
153
+ }
154
+
155
+ /**
156
+ * A top-bound desk calendar as a date picker. Lift a page and it turns up over the rings toward
157
+ * you, its back showing as it passes upright, and the next day is underneath; pull down and
158
+ * yesterday's page swings back over the top onto the pad. The page follows your finger the whole
159
+ * way, a flick finishes the turn, and a hard flick turns several days.
160
+ */
161
+ export function FlipCalendar({
162
+ value,
163
+ defaultValue,
164
+ onChange,
165
+ minDate,
166
+ maxDate,
167
+ locale,
168
+ accent,
169
+ maxWidth = 300,
170
+ haptics = true,
171
+ accessibilityLabel,
172
+ style,
173
+ }: FlipCalendarProps) {
174
+ const { colors, radius, dark } = useKit();
175
+ const reduce = useReduceMotion();
176
+ const tint = accent ?? colors.accent;
177
+
178
+ const start = useRef(value ?? defaultValue ?? new Date()).current;
179
+ const clock = useRef({ h: start.getHours(), min: start.getMinutes() });
180
+ const [day, setDay] = useState(() => dayOf(start));
181
+
182
+ // Animated values per page, keyed by its date. A page keeps its angle when its role changes, so a
183
+ // page turned over (180) is already yesterday's page, and the one under it is already lying flat.
184
+ const pages = useRef(new Map<number, PageValues>());
185
+ const valuesFor = (t: Day, flipped: boolean) => {
186
+ const k = utc(t);
187
+ let v = pages.current.get(k);
188
+ if (!v) pages.current.set(k, (v = { flip: new Animated.Value(flipped ? 180 : 0), nudge: new Animated.Value(0) }));
189
+ return v;
190
+ };
191
+
192
+ const minKey = minDate ? utc(dayOf(minDate)) : -Infinity;
193
+ const maxKey = maxDate ? utc(dayOf(maxDate)) : Infinity;
194
+ const live = useRef({ day, onChange, haptics, reduce, minKey, maxKey });
195
+ live.current = { day, onChange, haptics, reduce, minKey, maxKey };
196
+ const canNext = () => utc(addDays(live.current.day, 1)) <= live.current.maxKey;
197
+ const canPrev = () => utc(addDays(live.current.day, -1)) >= live.current.minKey;
198
+
199
+ const busy = useRef(false);
200
+ const chain = useRef(0);
201
+ const pending = useRef(false);
202
+
203
+ const commit = (delta: 1 | -1) => {
204
+ const next = addDays(live.current.day, delta);
205
+ live.current.day = next;
206
+ pending.current = true;
207
+ setDay(next);
208
+ if (live.current.haptics) Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Soft).catch(() => {});
209
+ live.current.onChange?.(new Date(next.y, next.m, next.d, clock.current.h, clock.current.min));
210
+ };
211
+
212
+ /** Turns the top page over the rings (to the next day). */
213
+ function flipOver(quick = false) {
214
+ if (busy.current || !canNext()) return;
215
+ busy.current = true;
216
+ const v = valuesFor(live.current.day, false);
217
+ if (live.current.reduce) {
218
+ v.flip.setValue(180);
219
+ return commit(1);
220
+ }
221
+ Animated.timing(v.flip, { toValue: 180, ...(quick ? QUICK : TURN) }).start(({ finished }) => {
222
+ if (finished) commit(1);
223
+ else busy.current = false;
224
+ });
225
+ }
226
+
227
+ /** Brings yesterday's page back over the rings onto the pad. */
228
+ function flipBack(quick = false) {
229
+ if (busy.current || !canPrev()) return;
230
+ busy.current = true;
231
+ const v = valuesFor(addDays(live.current.day, -1), true);
232
+ if (live.current.reduce) {
233
+ v.flip.setValue(0);
234
+ return commit(-1);
235
+ }
236
+ Animated.timing(v.flip, { toValue: 0, ...(quick ? QUICK : TURN) }).start(({ finished }) => {
237
+ if (finished) commit(-1);
238
+ else busy.current = false;
239
+ });
240
+ }
241
+ const actions = useRef({ flipOver, flipBack });
242
+ actions.current = { flipOver, flipBack };
243
+
244
+ // After a page change: forget pages that left the pad, then keep turning if a fling asked.
245
+ useLayoutEffect(() => {
246
+ if (!pending.current) return;
247
+ pending.current = false;
248
+ const keep = new Set([-1, 0, 1].map((n) => utc(addDays(day, n))));
249
+ for (const k of pages.current.keys()) if (!keep.has(k)) pages.current.delete(k);
250
+ busy.current = false;
251
+ if (chain.current !== 0) {
252
+ const forward = chain.current > 0;
253
+ chain.current -= forward ? 1 : -1;
254
+ if (forward ? canNext() : canPrev()) {
255
+ if (forward) flipOver(true);
256
+ else flipBack(true);
257
+ } else chain.current = 0;
258
+ }
259
+ // eslint-disable-next-line react-hooks/exhaustive-deps
260
+ }, [day]);
261
+
262
+ // A controlled value that isn't our own echo jumps straight there.
263
+ const external = value ? utc(dayOf(value)) : null;
264
+ useEffect(() => {
265
+ if (external === null || external === utc(live.current.day) || !value) return;
266
+ clock.current = { h: value.getHours(), min: value.getMinutes() };
267
+ chain.current = 0;
268
+ busy.current = false;
269
+ pages.current.clear();
270
+ setDay(dayOf(value));
271
+ // eslint-disable-next-line react-hooks/exhaustive-deps
272
+ }, [external]);
273
+
274
+ const [width, setWidth] = useState(0);
275
+ const W = Math.min(width, maxWidth);
276
+ const H = Math.round(W * 1.08);
277
+
278
+ // The page a drag turns, where it was grabbed, and whether it has passed upright.
279
+ // The direction is only known once the finger has moved (the drag is claimed before that), so the
280
+ // page is picked on the first move.
281
+ const grip = useRef<{ started: boolean; y0: number; y: number; page: PageValues | null; back: boolean; r: number; over: boolean }>({
282
+ started: false,
283
+ y0: 0,
284
+ y: 0,
285
+ page: null,
286
+ back: false,
287
+ r: 1,
288
+ over: false,
289
+ });
290
+ const pan = useMemo(() => {
291
+ const g0 = grip.current;
292
+ const spring = (v: Animated.Value, to: number) => Animated.spring(v, { toValue: to, useNativeDriver: true, ...motion.snappy }).start();
293
+ return PanResponder.create({
294
+ onMoveShouldSetPanResponder: (_, g) => !busy.current && Math.abs(g.dy) > 6 && Math.abs(g.dy) > Math.abs(g.dx),
295
+ onPanResponderTerminationRequest: () => false,
296
+ onPanResponderGrant: (e, g) => {
297
+ chain.current = 0;
298
+ g0.started = false;
299
+ g0.page = null;
300
+ // Where the touch came down, in the pad.
301
+ g0.y0 = e.nativeEvent.locationY - g.dy;
302
+ },
303
+ onPanResponderMove: (_, g) => {
304
+ if (!g0.started) {
305
+ if (Math.abs(g.dy) < 3) return;
306
+ g0.started = true;
307
+ // Lifting turns today's page; pulling down brings yesterday's back (which starts over the top).
308
+ g0.back = g.dy > 0;
309
+ g0.over = g0.back;
310
+ g0.page = g0.back ? (canPrev() ? valuesFor(addDays(live.current.day, -1), true) : null) : valuesFor(live.current.day, false);
311
+ // The point of the paper under the finger: where you grabbed today's page (at least halfway
312
+ // down, so a grab near the rings still turns it), or near the free end of yesterday's.
313
+ g0.r = g0.back ? H * 0.9 : clamp(g0.y0 - PAGE_TOP, H * 0.55, H);
314
+ g0.y = curlPoint(g0.r, g0.back ? 1 : 0, H, curlRadius(H)).y;
315
+ }
316
+ const top = valuesFor(live.current.day, false);
317
+ if (!g0.page) {
318
+ // Nothing earlier to bring back: today's page only gives a little.
319
+ top.nudge.setValue(Math.max(0, g.dy) * 0.18);
320
+ return;
321
+ }
322
+ const angle = progressUnderFinger(g0.r, g0.y + g.dy, H, curlRadius(H)) * 180;
323
+ // At the last day the page only lifts a little.
324
+ g0.page.flip.setValue(!g0.back && !canNext() ? Math.min(angle * 0.25, 24) : angle);
325
+ const over = angle > 90;
326
+ if (over !== g0.over) {
327
+ g0.over = over;
328
+ if (live.current.haptics) Haptics.selectionAsync().catch(() => {});
329
+ }
330
+ },
331
+ onPanResponderRelease: (_, g) => {
332
+ const top = valuesFor(live.current.day, false);
333
+ spring(top.nudge, 0);
334
+ const page = g0.page;
335
+ if (!page) return;
336
+ if (g0.back) {
337
+ // Past upright, or flicked down: it lands; a hard flick brings back a few more.
338
+ if (!g0.over || g.vy > 0.5) {
339
+ chain.current = -clamp(Math.floor((g.vy - 1.2) * 3), 0, MAX_CHAIN);
340
+ actions.current.flipBack();
341
+ } else spring(page.flip, 180);
342
+ } else if (canNext() && (g0.over || g.vy < -0.5)) {
343
+ chain.current = clamp(Math.floor((-g.vy - 1.2) * 3), 0, MAX_CHAIN);
344
+ actions.current.flipOver();
345
+ } else spring(page.flip, 0);
346
+ },
347
+ onPanResponderTerminate: () => {
348
+ spring(valuesFor(live.current.day, false).nudge, 0);
349
+ if (g0.page) spring(g0.page.flip, g0.back ? 180 : 0);
350
+ },
351
+ });
352
+ }, [H]);
353
+
354
+ const names = namesFor(locale);
355
+ const spoken = (t: Day) => `${names.days[new Date(utc(t)).getUTCDay()]}, ${t.d} ${names.months[t.m]} ${t.y}`;
356
+ const prevDay = addDays(day, -1);
357
+ const nextDay = addDays(day, 1);
358
+ const corner = radius.scale(14);
359
+ const pageProps = { W, H, tint, names, colors, corner };
360
+ const top = valuesFor(day, false);
361
+
362
+ return (
363
+ <View
364
+ onLayout={(e: LayoutChangeEvent) => setWidth(Math.round(e.nativeEvent.layout.width))}
365
+ accessible
366
+ accessibilityRole="adjustable"
367
+ accessibilityLabel={accessibilityLabel ?? 'Date'}
368
+ accessibilityValue={{ text: spoken(day) }}
369
+ accessibilityHint="Swipe up to turn to the next day, down for the previous one."
370
+ accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
371
+ onAccessibilityAction={(e) => (e.nativeEvent.actionName === 'increment' ? flipOver() : flipBack())}
372
+ style={[styles.root, style]}
373
+ >
374
+ {W > 0 ? (
375
+ <View style={[styles.pad, styles.grab, { width: W, height: H + PAGE_TOP + 14 }]} {...pan.panHandlers}>
376
+ {/* The board the pages hang from, and page edges stacked under them for thickness. */}
377
+ <View style={[styles.board, { backgroundColor: colors.foreground, borderRadius: radius.scale(8) }]} />
378
+ {[2, 1].map((n) => (
379
+ <View
380
+ key={n}
381
+ style={[
382
+ styles.edge,
383
+ { top: PAGE_TOP + n * 5, height: H, left: n * 4, right: n * 4, backgroundColor: colors.card, borderColor: colors.border, borderRadius: corner },
384
+ n === 2 && shadowFor('md', dark),
385
+ ]}
386
+ />
387
+ ))}
388
+ {/* Pages sit in their own 3D layer (iOS slices 3D siblings otherwise). */}
389
+ <View collapsable={false} style={[StyleSheet.absoluteFill, { top: PAGE_TOP }]}>
390
+ <Page key={utc(nextDay)} day={nextDay} values={valuesFor(nextDay, false)} under shadowFrom={top.flip} {...pageProps} />
391
+ <Animated.View style={{ transform: [{ translateY: top.nudge }] }}>
392
+ <Page key={utc(day)} day={day} values={top} {...pageProps} />
393
+ </Animated.View>
394
+ {canPrev() ? <Page key={utc(prevDay)} day={prevDay} values={valuesFor(prevDay, true)} {...pageProps} /> : null}
395
+ </View>
396
+ <Rings width={W} color={colors.mutedForeground} hole={colors.foreground} />
397
+ </View>
398
+ ) : null}
399
+ </View>
400
+ );
401
+ }
402
+
403
+ interface PageProps {
404
+ day: Day;
405
+ values: PageValues;
406
+ /** The next day, lying under today's page: it doesn't turn, it only darkens under the lifted page. */
407
+ under?: boolean;
408
+ /** Today's page angle, for the shadow it casts on the page underneath. */
409
+ shadowFrom?: Animated.Value;
410
+ W: number;
411
+ H: number;
412
+ tint: string;
413
+ names: Names;
414
+ colors: ReturnType<typeof useKit>['colors'];
415
+ corner: number;
416
+ }
417
+
418
+ // The page is cut into thin strips across its height; each one is placed and tilted where the curl
419
+ // model puts that slice of paper, so the page bends instead of swinging stiffly.
420
+ const STRIPS = 26;
421
+ // Samples of the turn (every 3°), precomputed per strip and handed to native-driver interpolations.
422
+ const SAMPLES = 61;
423
+ const PERSPECTIVE = 900;
424
+ const curlRadius = (H: number) => Math.min(48, H * 0.16);
425
+
426
+ type StripTrack = { input: number[]; y: number[]; rot: string[]; sx: number[]; front: number[]; frontShade: number[]; backShade: number[] };
427
+
428
+ /** Per strip, the translate / tilt / width / shading at every sampled angle. */
429
+ function stripTracks(H: number): StripTrack[] {
430
+ const h = H / STRIPS;
431
+ const R = curlRadius(H);
432
+ return Array.from({ length: STRIPS }, (_, i) => {
433
+ const t: StripTrack = { input: [], y: [], rot: [], sx: [], front: [], frontShade: [], backShade: [] };
434
+ for (let k = 0; k < SAMPLES; k++) {
435
+ const p = k / (SAMPLES - 1);
436
+ const a = curlPoint(i * h, p, H, R);
437
+ const b = curlPoint((i + 1) * h, p, H, R);
438
+ const theta = Math.atan2(b.z - a.z, b.y - a.y); // 0 = flat, π/2 = facing up, π = turned over
439
+ const c = Math.cos(theta);
440
+ t.input.push(p * 180);
441
+ // The strip turns about its centre, so its centre goes to the middle of its slice of paper;
442
+ // squeezed neighbours then meet edge to edge whatever the transform origin.
443
+ t.y.push((a.y + b.y) / 2 - (i * h + h / 2));
444
+ t.rot.push(`${(theta * 180) / Math.PI}deg`);
445
+ // Closer to you looks a touch wider.
446
+ t.sx.push(PERSPECTIVE / (PERSPECTIVE - (a.z + b.z) / 2));
447
+ t.front.push(theta < Math.PI / 2 ? 1 : 0);
448
+ // Lit from the front: paper turning away darkens; the back brightens as it comes round.
449
+ t.frontShade.push(Math.max(0, 1 - c) * 0.45);
450
+ t.backShade.push(Math.max(0, 1 + c) * 0.4 + 0.04);
451
+ }
452
+ return t;
453
+ });
454
+ }
455
+
456
+ /**
457
+ * One calendar page hanging from the rings. At rest it's flat; turning, it bends like paper: strips
458
+ * near the fold roll toward you and darken or catch the light as they turn, and past the fold the
459
+ * plain back comes up over the top.
460
+ */
461
+ const Page = React.memo(function Page({ day, values, under, shadowFrom, W, H, tint, names, colors, corner }: PageProps) {
462
+ const { flip } = values;
463
+ const h = H / STRIPS;
464
+ const strips = useMemo(() => {
465
+ if (under) return null;
466
+ return stripTracks(H).map((t) => {
467
+ const at = (out: number[] | string[]) => flip.interpolate({ inputRange: t.input, outputRange: out as number[], extrapolate: 'clamp' });
468
+ return {
469
+ // No per-strip perspective: each strip is already placed on the curve, and a perspective per
470
+ // strip would project neighbours differently and open gaps between them.
471
+ transform: [{ translateY: at(t.y) }, { rotateX: at(t.rot) }, { scaleX: at(t.sx) }],
472
+ front: at(t.front),
473
+ back: at(t.front.map((f) => 1 - f)),
474
+ frontShade: at(t.frontShade),
475
+ backShade: at(t.backShade),
476
+ };
477
+ });
478
+ }, [flip, under, H]);
479
+ // Going over the top, the page fades out (it hangs behind the pad); fully over it also collapses,
480
+ // since a faded view's shadow can still draw a faint outline above the pad.
481
+ const gone = useMemo(
482
+ () => ({
483
+ opacity: flip.interpolate({ inputRange: [0, 150, 176], outputRange: [1, 1, 0], extrapolate: 'clamp' }),
484
+ transform: [{ scale: flip.interpolate({ inputRange: [0, 179, 180], outputRange: [1, 1, 0.001], extrapolate: 'clamp' }) }],
485
+ }),
486
+ [flip],
487
+ );
488
+
489
+ const sheet = { width: W, height: H, backgroundColor: colors.card, borderColor: colors.border, borderRadius: corner };
490
+ const face = <Face day={day} W={W} tint={tint} names={names} colors={colors} sheet={sheet} />;
491
+
492
+ if (under || !strips) {
493
+ return (
494
+ <View pointerEvents="none" style={[styles.page, { width: W, height: H }]}>
495
+ {face}
496
+ {shadowFrom ? (
497
+ <Animated.View
498
+ style={[
499
+ StyleSheet.absoluteFill,
500
+ {
501
+ borderRadius: corner,
502
+ backgroundColor: colors.overlay,
503
+ opacity: shadowFrom.interpolate({ inputRange: [0, 30, 120, 180], outputRange: [0, 0.22, 0.1, 0], extrapolate: 'clamp' }),
504
+ },
505
+ ]}
506
+ />
507
+ ) : null}
508
+ </View>
509
+ );
510
+ }
511
+
512
+ return (
513
+ <Animated.View pointerEvents="none" style={[styles.page, { width: W, height: H }, gone]}>
514
+ {strips.map((st, i) => (
515
+ // Each tilted strip sits in its own flat layer: iOS intersects sibling layers that have 3D
516
+ // transforms, which would slice the strips into bands; flat wrappers just stack in order.
517
+ <View key={i} collapsable={false} pointerEvents="none" style={StyleSheet.absoluteFill}>
518
+ <Animated.View style={[styles.strip, { top: i * h - 0.5, width: W, height: h + 1, transform: st.transform }]}>
519
+ {/* The front: the whole page, shifted so this strip shows its own slice. */}
520
+ <Animated.View style={[StyleSheet.absoluteFill, styles.clip, { opacity: st.front }]}>
521
+ <View style={{ position: 'absolute', top: -i * h + 0.5, left: 0 }}>{face}</View>
522
+ <Animated.View style={[StyleSheet.absoluteFill, { backgroundColor: colors.overlay, opacity: st.frontShade }]} />
523
+ </Animated.View>
524
+ {/* The back: plain paper. */}
525
+ <Animated.View style={[StyleSheet.absoluteFill, styles.clip, { opacity: st.back, backgroundColor: colors.card }]}>
526
+ <Animated.View style={[StyleSheet.absoluteFill, { backgroundColor: colors.overlay, opacity: st.backShade }]} />
527
+ </Animated.View>
528
+ </Animated.View>
529
+ </View>
530
+ ))}
531
+ </Animated.View>
532
+ );
533
+ });
534
+
535
+ interface FaceProps {
536
+ day: Day;
537
+ W: number;
538
+ tint: string;
539
+ names: Names;
540
+ colors: ReturnType<typeof useKit>['colors'];
541
+ sheet: ViewStyle;
542
+ }
543
+
544
+ /** The printed side of a page: perforation, month band, the day, weekday and a footer. */
545
+ const Face = React.memo(function Face({ day, W, tint, names, colors, sheet }: FaceProps) {
546
+ const wd = new Date(utc(day)).getUTCDay();
547
+ const ink = wd === 0 ? tint : colors.foreground;
548
+ const band = readableOn(tint);
549
+ const left = yearLength(day.y) - dayOfYear(day);
550
+ return (
551
+ <View style={[styles.face, sheet]}>
552
+ <View style={[styles.perforation, { borderBottomColor: colors.border }]} />
553
+ <View style={[styles.band, { backgroundColor: tint }]}>
554
+ <Text numberOfLines={1} style={[styles.month, { color: band }]}>{names.months[day.m].toUpperCase()}</Text>
555
+ <Text style={[styles.year, { color: band }]}>{day.y}</Text>
556
+ </View>
557
+ <View style={styles.body}>
558
+ <Text numberOfLines={1} adjustsFontSizeToFit style={[styles.day, { color: ink, fontSize: W * 0.5, lineHeight: W * 0.56 }]}>
559
+ {day.d}
560
+ </Text>
561
+ <Text numberOfLines={1} style={[styles.weekday, { color: ink }]}>{names.days[wd].toUpperCase()}</Text>
562
+ </View>
563
+ <View style={[styles.footer, { borderTopColor: colors.border }]}>
564
+ <Text style={[styles.meta, { color: colors.mutedForeground }]}>Day {dayOfYear(day)} · Week {isoWeek(day)}</Text>
565
+ <Text style={[styles.meta, { color: colors.mutedForeground }]}>{left} left</Text>
566
+ </View>
567
+ </View>
568
+ );
569
+ });
570
+
571
+ /** Wire loops through the page tops, drawn over the pages so a turning page passes behind them. */
572
+ const Rings = React.memo(function Rings({ width, color, hole }: { width: number; color: string; hole: string }) {
573
+ const n = Math.max(3, Math.round(width / 60));
574
+ return (
575
+ <View pointerEvents="none" style={[styles.rings, { width }]}>
576
+ {Array.from({ length: n }, (_, i) => (
577
+ <View key={i} style={styles.ringSlot}>
578
+ <View style={[styles.ringHole, { backgroundColor: hole }]} />
579
+ <View style={[styles.ring, { borderColor: color }]} />
580
+ </View>
581
+ ))}
582
+ </View>
583
+ );
584
+ });
585
+
586
+ function useReduceMotion() {
587
+ const [reduce, setReduce] = useState(false);
588
+ useEffect(() => {
589
+ let alive = true;
590
+ AccessibilityInfo.isReduceMotionEnabled()
591
+ .then((r) => alive && setReduce(r))
592
+ .catch(() => {});
593
+ const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
594
+ return () => {
595
+ alive = false;
596
+ sub.remove();
597
+ };
598
+ }, []);
599
+ return reduce;
600
+ }
601
+
602
+ const styles = StyleSheet.create({
603
+ root: { alignSelf: 'stretch', alignItems: 'center' },
604
+ grab: Platform.OS === 'web' ? ({ userSelect: 'none', cursor: 'grab', touchAction: 'pan-x' } as object) : {},
605
+ pad: {},
606
+ board: { position: 'absolute', top: 0, left: 0, right: 0, height: BOARD_H },
607
+ edge: { position: 'absolute', borderWidth: StyleSheet.hairlineWidth },
608
+ page: { position: 'absolute', top: 0, left: 0 },
609
+ face: { position: 'absolute', top: 0, left: 0, borderWidth: StyleSheet.hairlineWidth, overflow: 'hidden', backfaceVisibility: 'hidden' },
610
+ // One dotted line instead of dozens of dots: every strip repeats the page, so it stays light.
611
+ perforation: { height: 12, marginHorizontal: 6, borderBottomWidth: 2, borderStyle: 'dotted' },
612
+ strip: { position: 'absolute', left: 0 },
613
+ clip: { overflow: 'hidden' },
614
+ band: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 16, paddingVertical: 9 },
615
+ month: { fontSize: 15, fontWeight: '800', letterSpacing: 2.5, flexShrink: 1 },
616
+ year: { fontSize: 14, fontWeight: '600', fontVariant: ['tabular-nums'], opacity: 0.85 },
617
+ body: { flex: 1, alignItems: 'center', justifyContent: 'center' },
618
+ day: { fontWeight: '800', fontVariant: ['tabular-nums'], letterSpacing: -2, includeFontPadding: false, textAlign: 'center' },
619
+ weekday: { fontSize: 15, fontWeight: '700', letterSpacing: 4, marginTop: 2 },
620
+ footer: { flexDirection: 'row', justifyContent: 'space-between', paddingHorizontal: 14, paddingVertical: 9, borderTopWidth: StyleSheet.hairlineWidth },
621
+ meta: { fontSize: 11.5, fontWeight: '500', fontVariant: ['tabular-nums'] },
622
+ rings: { position: 'absolute', top: 6, flexDirection: 'row', justifyContent: 'space-evenly' },
623
+ ringSlot: { width: 12, height: 26, alignItems: 'center' },
624
+ ringHole: { position: 'absolute', top: 5, width: 9, height: 9, borderRadius: 4.5 },
625
+ ring: { position: 'absolute', top: 0, width: 7, height: 24, borderRadius: 3.5, borderWidth: 2 },
626
+ });