@expokit/cli 0.4.3 → 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.
- package/bin/lib.mjs +3 -1
- package/package.json +1 -1
- package/registry/_docs/flip-calendar.json +106 -0
- package/registry/_docs/paywall.json +144 -0
- package/registry/_docs/ruler-time-picker.json +147 -0
- package/registry/accordion/files/src/lib/kit.tsx +102 -4
- package/registry/action-sheet/files/src/components/ui/action-sheet.tsx +8 -11
- package/registry/action-sheet/files/src/lib/kit.tsx +102 -4
- package/registry/alert/files/src/components/ui/alert.tsx +1 -1
- package/registry/alert/files/src/components/ui/button.tsx +5 -18
- package/registry/alert/files/src/lib/kit.tsx +102 -4
- package/registry/avatar/files/src/components/ui/avatar.tsx +5 -8
- package/registry/avatar/files/src/lib/kit.tsx +102 -4
- package/registry/badge/files/src/components/ui/badge.tsx +2 -2
- package/registry/badge/files/src/lib/kit.tsx +102 -4
- package/registry/bottom-nav/files/src/components/ui/bottom-nav.tsx +11 -17
- package/registry/bottom-nav/files/src/lib/kit.tsx +102 -4
- package/registry/bottom-sheet/files/src/components/ui/bottom-sheet.tsx +7 -14
- package/registry/bottom-sheet/files/src/lib/kit.tsx +102 -4
- package/registry/button/files/src/components/ui/button.tsx +5 -18
- package/registry/button/files/src/lib/kit.tsx +102 -4
- package/registry/button-3d/files/src/components/ui/button-3d.tsx +4 -7
- package/registry/button-3d/files/src/lib/kit.tsx +102 -4
- package/registry/calendar/files/src/components/ui/calendar.tsx +3 -3
- package/registry/calendar/files/src/lib/kit.tsx +102 -4
- package/registry/card/files/src/components/ui/card.tsx +4 -3
- package/registry/card/files/src/lib/kit.tsx +102 -4
- package/registry/checkbox/files/src/lib/kit.tsx +102 -4
- package/registry/chip-group/files/src/components/ui/chip-group.tsx +2 -2
- package/registry/chip-group/files/src/lib/kit.tsx +102 -4
- package/registry/confetti/files/src/lib/kit.tsx +102 -4
- package/registry/coupon/files/src/components/ui/coupon.tsx +5 -10
- package/registry/coupon/files/src/components/ui/flip-card.tsx +3 -9
- package/registry/coupon/files/src/components/ui/tilt-card.tsx +4 -10
- package/registry/coupon/files/src/lib/kit.tsx +102 -4
- package/registry/date-picker/files/src/components/ui/calendar.tsx +3 -3
- package/registry/date-picker/files/src/components/ui/date-picker.tsx +10 -16
- package/registry/date-picker/files/src/lib/kit.tsx +102 -4
- package/registry/dialog/files/src/components/ui/dialog.tsx +6 -12
- package/registry/dialog/files/src/lib/kit.tsx +102 -4
- package/registry/drawer/files/src/components/ui/drawer.tsx +3 -19
- package/registry/drawer/files/src/lib/kit.tsx +102 -4
- package/registry/flip-calendar/files/src/components/ui/flip-calendar.tsx +626 -0
- package/registry/flip-calendar/files/src/lib/kit.tsx +320 -0
- package/registry/flip-calendar.json +17 -0
- package/registry/flip-card/files/src/components/ui/flip-card.tsx +3 -9
- package/registry/flip-card/files/src/lib/kit.tsx +102 -4
- package/registry/floating-input/files/src/lib/kit.tsx +102 -4
- package/registry/grain/files/src/lib/kit.tsx +102 -4
- package/registry/kit/files/src/lib/kit.tsx +102 -4
- package/registry/liquidglass-tab/files/src/components/GlassTabBar.tsx +6 -7
- package/registry/liquidglass-tab/files/src/components/TabScreen.tsx +31 -17
- package/registry/list-item/files/src/components/ui/list-item.tsx +1 -1
- package/registry/list-item/files/src/lib/kit.tsx +102 -4
- package/registry/otp-input/files/src/components/ui/otp-input.tsx +1 -1
- package/registry/otp-input/files/src/lib/kit.tsx +102 -4
- package/registry/password-input/files/src/lib/kit.tsx +102 -4
- package/registry/payment-card/files/src/components/ui/flip-card.tsx +3 -9
- package/registry/payment-card/files/src/components/ui/payment-card.tsx +9 -3
- package/registry/payment-card/files/src/components/ui/tilt-card.tsx +4 -10
- package/registry/payment-card/files/src/lib/kit.tsx +102 -4
- package/registry/paywall.json +22 -0
- package/registry/progress/files/src/components/ui/progress.tsx +2 -0
- package/registry/progress/files/src/lib/kit.tsx +102 -4
- package/registry/progress-steps/files/src/components/ui/progress-steps.tsx +1 -1
- package/registry/progress-steps/files/src/lib/kit.tsx +102 -4
- package/registry/radio-group/files/src/lib/kit.tsx +102 -4
- package/registry/ruler-time-picker/files/src/components/ui/ruler-time-picker.tsx +816 -0
- package/registry/ruler-time-picker/files/src/lib/kit.tsx +320 -0
- package/registry/ruler-time-picker.json +19 -0
- package/registry/search-bar/files/src/components/ui/search-bar.tsx +3 -7
- package/registry/search-bar/files/src/lib/kit.tsx +102 -4
- package/registry/segmented-control/files/src/components/ui/segmented-control.tsx +3 -10
- package/registry/segmented-control/files/src/lib/kit.tsx +102 -4
- package/registry/select/files/src/components/ui/select.tsx +16 -18
- package/registry/select/files/src/lib/kit.tsx +102 -4
- package/registry/skeleton/files/src/lib/kit.tsx +102 -4
- package/registry/slider/files/src/components/ui/slider.tsx +4 -13
- package/registry/slider/files/src/lib/kit.tsx +102 -4
- package/registry/spinner/files/src/lib/kit.tsx +102 -4
- package/registry/stateful-button/files/src/lib/kit.tsx +102 -4
- package/registry/stepper/files/src/components/ui/stepper.tsx +2 -2
- package/registry/stepper/files/src/lib/kit.tsx +102 -4
- package/registry/switch/files/src/components/ui/switch.tsx +2 -5
- package/registry/switch/files/src/lib/kit.tsx +102 -4
- package/registry/tabs/files/src/components/ui/tabs.tsx +2 -2
- package/registry/tabs/files/src/lib/kit.tsx +102 -4
- package/registry/text-animation/files/src/lib/kit.tsx +102 -4
- package/registry/textarea/files/src/lib/kit.tsx +102 -4
- package/registry/ticket-shape/files/src/lib/kit.tsx +102 -4
- package/registry/tilt-card/files/src/components/ui/tilt-card.tsx +4 -10
- package/registry/tilt-card/files/src/lib/kit.tsx +102 -4
- package/registry/time-picker/files/src/components/ui/time-picker.tsx +16 -14
- package/registry/time-picker/files/src/lib/kit.tsx +102 -4
- package/registry/timeline/files/src/components/ui/avatar.tsx +5 -8
- package/registry/timeline/files/src/components/ui/timeline.tsx +5 -16
- package/registry/timeline/files/src/lib/kit.tsx +102 -4
- package/registry/toast/files/src/components/ui/button.tsx +5 -18
- package/registry/toast/files/src/components/ui/toast.tsx +2 -2
- package/registry/toast/files/src/lib/kit.tsx +102 -4
|
@@ -0,0 +1,816 @@
|
|
|
1
|
+
import { createAudioPlayer, type AudioPlayer } from 'expo-audio';
|
|
2
|
+
import { File, Paths } from 'expo-file-system';
|
|
3
|
+
import * as Haptics from 'expo-haptics';
|
|
4
|
+
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
5
|
+
import {
|
|
6
|
+
AccessibilityInfo,
|
|
7
|
+
Animated,
|
|
8
|
+
Easing,
|
|
9
|
+
PanResponder,
|
|
10
|
+
Platform,
|
|
11
|
+
StyleSheet,
|
|
12
|
+
Text,
|
|
13
|
+
View,
|
|
14
|
+
useWindowDimensions,
|
|
15
|
+
type LayoutChangeEvent,
|
|
16
|
+
type StyleProp,
|
|
17
|
+
type TextStyle,
|
|
18
|
+
type ViewStyle,
|
|
19
|
+
} from 'react-native';
|
|
20
|
+
|
|
21
|
+
import { motion, useKit } from '@/lib/kit';
|
|
22
|
+
|
|
23
|
+
/** A wall-clock time, 24-hour: `{ hours: 0–23, minutes: 0–59 }`. */
|
|
24
|
+
export interface TimeValue {
|
|
25
|
+
hours: number;
|
|
26
|
+
minutes: number;
|
|
27
|
+
}
|
|
28
|
+
/** A calendar day without a time: `{ y, m: 0–11, d: 1–31 }`. */
|
|
29
|
+
export interface Day {
|
|
30
|
+
y: number;
|
|
31
|
+
m: number;
|
|
32
|
+
d: number;
|
|
33
|
+
}
|
|
34
|
+
export type RulerTimePickerSize = 'sm' | 'md' | 'lg';
|
|
35
|
+
|
|
36
|
+
interface CommonProps {
|
|
37
|
+
/** Small line above the value, e.g. 'Reminder time:'. */
|
|
38
|
+
label?: string;
|
|
39
|
+
/** Time: 40 · 52 · 64pt. Date: 34 · 44 · 54pt (default 'md'). */
|
|
40
|
+
size?: RulerTimePickerSize;
|
|
41
|
+
/** Centre needle colour (default theme accent). */
|
|
42
|
+
accent?: string;
|
|
43
|
+
/** A light tap on every step, and a firmer one on each whole hour / new month (default true). */
|
|
44
|
+
haptics?: boolean;
|
|
45
|
+
/** A soft mechanical tick on every step, like a watch crown (default true; plays alongside other audio). */
|
|
46
|
+
sound?: boolean;
|
|
47
|
+
accessibilityLabel?: string;
|
|
48
|
+
style?: StyleProp<ViewStyle>;
|
|
49
|
+
/** The big value text (colour, weight, font family). */
|
|
50
|
+
textStyle?: StyleProp<TextStyle>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface RulerTimeProps extends CommonProps {
|
|
54
|
+
/** 'time' (default): one tick per `step` minutes. */
|
|
55
|
+
mode?: 'time';
|
|
56
|
+
/** Controlled 24-hour time. */
|
|
57
|
+
value?: TimeValue;
|
|
58
|
+
/** Initial time when uncontrolled (default 09:00). */
|
|
59
|
+
defaultValue?: TimeValue;
|
|
60
|
+
/** Called on every step the ruler passes, while you drag. */
|
|
61
|
+
onChange?: (time: TimeValue) => void;
|
|
62
|
+
/** Minutes per tick (default 5). */
|
|
63
|
+
step?: number;
|
|
64
|
+
/** 12-hour clock with a rolling am / pm (default true). */
|
|
65
|
+
hour12?: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface RulerDateProps extends CommonProps {
|
|
69
|
+
/** 'date': one tick per day, with the 1st of each month marked. */
|
|
70
|
+
mode: 'date';
|
|
71
|
+
/** Controlled date (its time of day is kept on every change). */
|
|
72
|
+
value?: Date;
|
|
73
|
+
/** Initial date when uncontrolled (default today). */
|
|
74
|
+
defaultValue?: Date;
|
|
75
|
+
/** Called on every day the ruler passes, while you drag. */
|
|
76
|
+
onChange?: (date: Date) => void;
|
|
77
|
+
/** Earliest pickable day; the ruler resists past it. */
|
|
78
|
+
minDate?: Date;
|
|
79
|
+
/** Latest pickable day; the ruler resists past it. */
|
|
80
|
+
maxDate?: Date;
|
|
81
|
+
/** 'auto' shows the year only when it isn't this year (default). */
|
|
82
|
+
showYear?: 'auto' | 'always' | 'never';
|
|
83
|
+
/** BCP 47 tag for weekday and month names (default: device). */
|
|
84
|
+
locale?: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export type RulerTimePickerProps = RulerTimeProps | RulerDateProps;
|
|
88
|
+
|
|
89
|
+
// Controls stop growing at iOS's largest standard text size (xxxL ≈ 1.35×) so they keep their shape.
|
|
90
|
+
const FONT_CAP = 1.35;
|
|
91
|
+
const DAY = 24 * 60;
|
|
92
|
+
const MS_DAY = 86400000;
|
|
93
|
+
const TIME_SIZES: Record<RulerTimePickerSize, number> = { sm: 40, md: 52, lg: 64 };
|
|
94
|
+
const DATE_SIZES: Record<RulerTimePickerSize, number> = { sm: 34, md: 44, lg: 54 };
|
|
95
|
+
// Ruler geometry: tick pitch (a time step / a day), tallest tick, the needle, the month label row.
|
|
96
|
+
// Wide enough that each 5-minute step takes a deliberate bit of finger travel.
|
|
97
|
+
const TIME_GAP = 16;
|
|
98
|
+
const DATE_GAP = 12;
|
|
99
|
+
const TICK_W = 1.5;
|
|
100
|
+
const TICK_H = 26;
|
|
101
|
+
const NEEDLE_H = 32;
|
|
102
|
+
const RULER_H = 44;
|
|
103
|
+
const LABEL_H = 18;
|
|
104
|
+
// Past a limit the ruler follows the finger at this fraction of the distance.
|
|
105
|
+
const RUBBER = 0.3;
|
|
106
|
+
const ROLL = { duration: 420, easing: Easing.bezier(0.32, 0.72, 0, 1), useNativeDriver: true };
|
|
107
|
+
|
|
108
|
+
const mod = (n: number, m: number) => ((n % m) + m) % m;
|
|
109
|
+
const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n));
|
|
110
|
+
const pad2 = (n: number) => String(n).padStart(2, '0');
|
|
111
|
+
const toMinutes = (t: TimeValue) => mod(Math.round(t.hours) * 60 + Math.round(t.minutes), DAY);
|
|
112
|
+
const fromMinutes = (m: number): TimeValue => ({ hours: Math.floor(mod(m, DAY) / 60), minutes: mod(m, DAY) % 60 });
|
|
113
|
+
|
|
114
|
+
/** The time `ticks` steps of `step` minutes away from `base` (minutes after midnight), wrapping round the day. */
|
|
115
|
+
export function stepTime(base: number, ticks: number, step: number) {
|
|
116
|
+
return mod(base + ticks * step, DAY);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** `'02:15 am'` / `'14:15'` split into the glyphs the picker rolls. */
|
|
120
|
+
export function timeGlyphs(minutes: number, hour12: boolean) {
|
|
121
|
+
const { hours, minutes: m } = fromMinutes(minutes);
|
|
122
|
+
const h = hour12 ? hours % 12 || 12 : hours;
|
|
123
|
+
const digits = pad2(h) + pad2(m);
|
|
124
|
+
return { digits: digits.split(''), period: hour12 ? (hours < 12 ? 'am' : 'pm') : null, label: `${pad2(h)}:${pad2(m)}${hour12 ? (hours < 12 ? ' AM' : ' PM') : ''}` };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export const dayOf = (date: Date): Day => ({ y: date.getFullYear(), m: date.getMonth(), d: date.getDate() });
|
|
128
|
+
const utc = (t: Day) => Date.UTC(t.y, t.m, t.d);
|
|
129
|
+
|
|
130
|
+
/** The day `n` days after `base` (negative for before); month and year lengths are handled. */
|
|
131
|
+
export function addDays(base: Day, n: number): Day {
|
|
132
|
+
const t = new Date(utc(base) + n * MS_DAY);
|
|
133
|
+
return { y: t.getUTCFullYear(), m: t.getUTCMonth(), d: t.getUTCDate() };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Whole days from `a` to `b` (negative when `b` is earlier), immune to daylight-saving shifts. */
|
|
137
|
+
export function daysBetween(a: Day, b: Day) {
|
|
138
|
+
return Math.round((utc(b) - utc(a)) / MS_DAY);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Offsets from `base`, within [from, to], that land on the 1st of a month. */
|
|
142
|
+
export function monthStarts(base: Day, from: number, to: number) {
|
|
143
|
+
const out: number[] = [];
|
|
144
|
+
let i = from - (addDays(base, from).d - 1);
|
|
145
|
+
while (i <= to) {
|
|
146
|
+
if (i >= from) out.push(i);
|
|
147
|
+
const t = addDays(base, i);
|
|
148
|
+
i += daysBetween(t, { y: t.m === 11 ? t.y + 1 : t.y, m: (t.m + 1) % 12, d: 1 });
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const EN_DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
154
|
+
const EN_DAYS_LONG = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
155
|
+
const EN_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
156
|
+
const EN_MONTHS_LONG = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
|
157
|
+
type Names = { days: string[]; daysLong: string[]; months: string[]; monthsLong: string[] };
|
|
158
|
+
const nameCache = new Map<string, Names>();
|
|
159
|
+
|
|
160
|
+
/** Weekday and month names for a locale, falling back to English where Intl isn't available. */
|
|
161
|
+
function namesFor(locale?: string): Names {
|
|
162
|
+
const key = locale ?? '';
|
|
163
|
+
const hit = nameCache.get(key);
|
|
164
|
+
if (hit) return hit;
|
|
165
|
+
let names: Names = { days: EN_DAYS, daysLong: EN_DAYS_LONG, months: EN_MONTHS, monthsLong: EN_MONTHS_LONG };
|
|
166
|
+
try {
|
|
167
|
+
const fmt = (o: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat(locale, { ...o, timeZone: 'UTC' });
|
|
168
|
+
const [wd, wdl, mo, mol] = [fmt({ weekday: 'short' }), fmt({ weekday: 'long' }), fmt({ month: 'short' }), fmt({ month: 'long' })];
|
|
169
|
+
// 2023-01-01 was a Sunday.
|
|
170
|
+
const day = (i: number) => new Date(Date.UTC(2023, 0, 1 + i));
|
|
171
|
+
const month = (i: number) => new Date(Date.UTC(2023, i, 15));
|
|
172
|
+
names = {
|
|
173
|
+
days: EN_DAYS.map((_, i) => wd.format(day(i)).replace(/\.$/, '')),
|
|
174
|
+
daysLong: EN_DAYS.map((_, i) => wdl.format(day(i))),
|
|
175
|
+
months: EN_MONTHS.map((_, i) => mo.format(month(i)).replace(/\.$/, '')),
|
|
176
|
+
monthsLong: EN_MONTHS.map((_, i) => mol.format(month(i))),
|
|
177
|
+
};
|
|
178
|
+
} catch {
|
|
179
|
+
// Keep the English names.
|
|
180
|
+
}
|
|
181
|
+
nameCache.set(key, names);
|
|
182
|
+
return names;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** `Thu, 29 Oct` (+ year) split into the pieces the picker rolls, plus a spoken label. */
|
|
186
|
+
export function dateParts(t: Day, showYear: 'auto' | 'always' | 'never' = 'auto', locale?: string, thisYear = new Date().getFullYear()) {
|
|
187
|
+
const n = namesFor(locale);
|
|
188
|
+
const wd = new Date(utc(t)).getUTCDay();
|
|
189
|
+
const year = showYear === 'always' || (showYear === 'auto' && t.y !== thisYear) ? String(t.y) : null;
|
|
190
|
+
return {
|
|
191
|
+
weekday: n.days[wd],
|
|
192
|
+
day: String(t.d).split(''),
|
|
193
|
+
month: n.months[t.m],
|
|
194
|
+
year,
|
|
195
|
+
label: `${n.daysLong[wd]}, ${t.d} ${n.monthsLong[t.m]} ${t.y}`,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* A time or date picker you scrub: drag (or fling) the tick ruler and every piece of the big value
|
|
201
|
+
* rolls to its next one with a motion blur. Ticks swell in a bell around the needle, the bump trails
|
|
202
|
+
* the drag, and it snaps to a step when you let go. `mode="date"` makes each tick a day, marks the
|
|
203
|
+
* 1st of every month and honours min / max dates with a rubber band.
|
|
204
|
+
*/
|
|
205
|
+
export function RulerTimePicker(props: RulerTimePickerProps) {
|
|
206
|
+
return props.mode === 'date' ? <DateRuler {...props} /> : <TimeRuler {...props} />;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function TimeRuler({ value, defaultValue, onChange, label, step: rawStep = 5, hour12 = true, size = 'md', accent, haptics = true, sound = true, accessibilityLabel, style, textStyle }: RulerTimeProps) {
|
|
210
|
+
const step = Math.max(1, Math.min(60, Math.round(rawStep) || 5));
|
|
211
|
+
const initial = toMinutes(value ?? defaultValue ?? { hours: 9, minutes: 0 });
|
|
212
|
+
// Minutes shown, and which way the last change went (rolls go up for later, down for earlier).
|
|
213
|
+
const [shown, setShown] = useState({ minutes: initial, dir: 1 as 1 | -1 });
|
|
214
|
+
const base = useRef(initial);
|
|
215
|
+
const live = useRef({ onChange, step, shown: initial });
|
|
216
|
+
live.current = { onChange, step, shown: shown.minutes };
|
|
217
|
+
|
|
218
|
+
const ruler = useRuler({
|
|
219
|
+
gap: TIME_GAP,
|
|
220
|
+
haptics,
|
|
221
|
+
sound,
|
|
222
|
+
firm: (i) => stepTime(base.current, i, live.current.step) % 60 === 0,
|
|
223
|
+
onStep: (i, dir) => {
|
|
224
|
+
const minutes = stepTime(base.current, i, live.current.step);
|
|
225
|
+
live.current.shown = minutes;
|
|
226
|
+
setShown({ minutes, dir });
|
|
227
|
+
live.current.onChange?.(fromMinutes(minutes));
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// A controlled value that isn't our own echo re-bases the ruler under the needle.
|
|
232
|
+
const external = value !== undefined ? toMinutes(value) : null;
|
|
233
|
+
useEffect(() => {
|
|
234
|
+
if (external === null || external === live.current.shown) return;
|
|
235
|
+
const forward = mod(external - live.current.shown, DAY) <= DAY / 2;
|
|
236
|
+
base.current = external;
|
|
237
|
+
live.current.shown = external;
|
|
238
|
+
ruler.reset();
|
|
239
|
+
setShown({ minutes: external, dir: forward ? 1 : -1 });
|
|
240
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
241
|
+
}, [external]);
|
|
242
|
+
|
|
243
|
+
const fontSize = TIME_SIZES[size];
|
|
244
|
+
const { box, glyph } = useGlyph(fontSize, textStyle);
|
|
245
|
+
const t = timeGlyphs(shown.minutes, hour12);
|
|
246
|
+
const roll = { dir: shown.dir, height: box, style: glyph, reduce: ruler.reduce };
|
|
247
|
+
|
|
248
|
+
return (
|
|
249
|
+
<Frame ruler={ruler} label={label} name={accessibilityLabel ?? label?.replace(/:\s*$/, '') ?? 'Time'} spoken={t.label} accent={accent} rulerWidth={fontSize * 5.6} style={style}>
|
|
250
|
+
<View style={[styles.row, { height: box }]}>
|
|
251
|
+
<Roll text={t.digits[0]} {...roll} />
|
|
252
|
+
<Roll text={t.digits[1]} {...roll} />
|
|
253
|
+
<Text maxFontSizeMultiplier={FONT_CAP} style={glyph}>:</Text>
|
|
254
|
+
<Roll text={t.digits[2]} {...roll} />
|
|
255
|
+
<Roll text={t.digits[3]} {...roll} />
|
|
256
|
+
{t.period ? (
|
|
257
|
+
<View style={{ marginLeft: fontSize * 0.24 }}>
|
|
258
|
+
<Roll text={t.period} {...roll} />
|
|
259
|
+
</View>
|
|
260
|
+
) : null}
|
|
261
|
+
</View>
|
|
262
|
+
</Frame>
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function DateRuler({ value, defaultValue, onChange, label, minDate, maxDate, showYear = 'auto', locale, size = 'md', accent, haptics = true, sound = true, accessibilityLabel, style, textStyle }: RulerDateProps) {
|
|
267
|
+
const start = useRef(value ?? defaultValue ?? new Date()).current;
|
|
268
|
+
// Time of day handed back with every change, so picking a date never moves a set time.
|
|
269
|
+
const clock = useRef({ h: start.getHours(), min: start.getMinutes() });
|
|
270
|
+
const [shown, setShown] = useState({ day: dayOf(start), index: 0, dir: 1 as 1 | -1 });
|
|
271
|
+
const base = useRef(dayOf(start));
|
|
272
|
+
const live = useRef({ onChange, key: utc(shown.day) });
|
|
273
|
+
live.current = { onChange, key: utc(shown.day) };
|
|
274
|
+
|
|
275
|
+
// Limits as offsets from the base day.
|
|
276
|
+
const lo = minDate ? daysBetween(base.current, dayOf(minDate)) : -Infinity;
|
|
277
|
+
const hi = maxDate ? Math.max(lo, daysBetween(base.current, dayOf(maxDate))) : Infinity;
|
|
278
|
+
|
|
279
|
+
const ruler = useRuler({
|
|
280
|
+
gap: DATE_GAP,
|
|
281
|
+
haptics,
|
|
282
|
+
lo,
|
|
283
|
+
hi,
|
|
284
|
+
sound,
|
|
285
|
+
firm: (i) => addDays(base.current, i).d === 1,
|
|
286
|
+
onStep: (i, dir) => {
|
|
287
|
+
const day = addDays(base.current, i);
|
|
288
|
+
live.current.key = utc(day);
|
|
289
|
+
setShown({ day, index: i, dir });
|
|
290
|
+
live.current.onChange?.(new Date(day.y, day.m, day.d, clock.current.h, clock.current.min));
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// A controlled value that isn't our own echo re-bases the ruler under the needle.
|
|
295
|
+
const external = value ? utc(dayOf(value)) : null;
|
|
296
|
+
useEffect(() => {
|
|
297
|
+
if (external === null || external === live.current.key || !value) return;
|
|
298
|
+
const day = dayOf(value);
|
|
299
|
+
clock.current = { h: value.getHours(), min: value.getMinutes() };
|
|
300
|
+
const dir = external > live.current.key ? 1 : -1;
|
|
301
|
+
base.current = day;
|
|
302
|
+
live.current.key = external;
|
|
303
|
+
ruler.reset();
|
|
304
|
+
setShown({ day, index: 0, dir });
|
|
305
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
306
|
+
}, [external]);
|
|
307
|
+
|
|
308
|
+
const { colors, font } = useKit();
|
|
309
|
+
const fontSize = DATE_SIZES[size];
|
|
310
|
+
const { box, glyph } = useGlyph(fontSize, textStyle);
|
|
311
|
+
const gap = fontSize * 0.24;
|
|
312
|
+
const p = dateParts(shown.day, showYear, locale);
|
|
313
|
+
const names = namesFor(locale);
|
|
314
|
+
const roll = { dir: shown.dir, height: box, style: glyph, reduce: ruler.reduce };
|
|
315
|
+
|
|
316
|
+
// Month starts near the needle, as offsets from the base day; they slide with `pos` natively.
|
|
317
|
+
const reach = ruler.width ? Math.ceil(ruler.width / 2 / DATE_GAP) + 45 : 0;
|
|
318
|
+
const months = useMemo(() => {
|
|
319
|
+
if (!reach) return [];
|
|
320
|
+
return monthStarts(base.current, shown.index - reach, shown.index + reach).map((i) => {
|
|
321
|
+
const t = addDays(base.current, i);
|
|
322
|
+
return { i, name: t.m === 0 ? `${names.months[0]} ${String(t.y).slice(2)}` : names.months[t.m] };
|
|
323
|
+
});
|
|
324
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
325
|
+
}, [shown.index, reach, names, external]);
|
|
326
|
+
|
|
327
|
+
return (
|
|
328
|
+
<Frame
|
|
329
|
+
ruler={ruler}
|
|
330
|
+
label={label}
|
|
331
|
+
name={accessibilityLabel ?? label?.replace(/:\s*$/, '') ?? 'Date'}
|
|
332
|
+
spoken={p.label}
|
|
333
|
+
accent={accent}
|
|
334
|
+
rulerWidth={fontSize * 7}
|
|
335
|
+
extra={LABEL_H}
|
|
336
|
+
marks={<MonthMarks width={ruler.width} pos={ruler.pos} months={months} tick={colors.foreground} text={colors.mutedForeground} family={font.family} />}
|
|
337
|
+
style={style}
|
|
338
|
+
>
|
|
339
|
+
<View style={[styles.row, { height: box }]}>
|
|
340
|
+
<Roll text={p.weekday} {...roll} />
|
|
341
|
+
<Text maxFontSizeMultiplier={FONT_CAP} style={glyph}>,</Text>
|
|
342
|
+
<View style={[styles.row, { marginLeft: gap }]}>
|
|
343
|
+
{p.day.map((c, i) => (
|
|
344
|
+
// Keyed from the right so the units digit stays put when the day gains a digit.
|
|
345
|
+
<Roll key={p.day.length - i} text={c} {...roll} />
|
|
346
|
+
))}
|
|
347
|
+
</View>
|
|
348
|
+
<View style={{ marginLeft: gap }}>
|
|
349
|
+
<Roll text={p.month} {...roll} />
|
|
350
|
+
</View>
|
|
351
|
+
{p.year ? (
|
|
352
|
+
<View style={{ marginLeft: gap }}>
|
|
353
|
+
<Roll text={p.year} {...roll} />
|
|
354
|
+
</View>
|
|
355
|
+
) : null}
|
|
356
|
+
</View>
|
|
357
|
+
</Frame>
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
type Ruler = ReturnType<typeof useRuler>;
|
|
362
|
+
|
|
363
|
+
interface RulerOptions {
|
|
364
|
+
/** Points per tick. */
|
|
365
|
+
gap: number;
|
|
366
|
+
haptics: boolean;
|
|
367
|
+
sound: boolean;
|
|
368
|
+
/** Lowest / highest tick (default unbounded). */
|
|
369
|
+
lo?: number;
|
|
370
|
+
hi?: number;
|
|
371
|
+
/** Ticks that get a firmer haptic (a whole hour, the 1st of a month). */
|
|
372
|
+
firm?: (i: number) => boolean;
|
|
373
|
+
/** The tick under the needle changed; `dir` is 1 for later, -1 for earlier. */
|
|
374
|
+
onStep: (i: number, dir: 1 | -1) => void;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* The ruler engine: an unbounded tick position driven by drag, fling and snap, with a trailing
|
|
379
|
+
* "lag" for the bell bump, rubber-banding past optional limits and a haptic on every tick.
|
|
380
|
+
*/
|
|
381
|
+
function useRuler({ gap, haptics, sound, lo = -Infinity, hi = Infinity, firm, onStep }: RulerOptions) {
|
|
382
|
+
const reduce = useReduceMotion();
|
|
383
|
+
const tick = useTickSound(sound);
|
|
384
|
+
const pos = useRef(new Animated.Value(0)).current;
|
|
385
|
+
const lag = useRef(new Animated.Value(0)).current;
|
|
386
|
+
const index = useRef(0);
|
|
387
|
+
const posNow = useRef(0);
|
|
388
|
+
const moving = useRef(false);
|
|
389
|
+
const flinging = useRef(false);
|
|
390
|
+
const live = useRef({ gap, haptics, lo, hi, firm, onStep, tick });
|
|
391
|
+
live.current = { gap, haptics, lo, hi, firm, onStep, tick };
|
|
392
|
+
const lagMax = gap * 2.5;
|
|
393
|
+
|
|
394
|
+
const snap = (to: number) => {
|
|
395
|
+
moving.current = true;
|
|
396
|
+
Animated.spring(pos, { toValue: to, useNativeDriver: true, ...motion.gentle }).start(({ finished }) => {
|
|
397
|
+
if (finished) moving.current = false;
|
|
398
|
+
});
|
|
399
|
+
};
|
|
400
|
+
const snapRef = useRef(snap);
|
|
401
|
+
snapRef.current = snap;
|
|
402
|
+
const settle = (v: number) => clamp(Math.round(v), live.current.lo, live.current.hi);
|
|
403
|
+
|
|
404
|
+
useEffect(() => {
|
|
405
|
+
const id = pos.addListener(({ value: v }) => {
|
|
406
|
+
posNow.current = v;
|
|
407
|
+
const { lo: min, hi: max } = live.current;
|
|
408
|
+
// A fling that runs past a limit is caught and settled on it.
|
|
409
|
+
if (flinging.current && (v < min - 0.5 || v > max + 0.5)) {
|
|
410
|
+
flinging.current = false;
|
|
411
|
+
pos.stopAnimation();
|
|
412
|
+
snapRef.current(clamp(Math.round(v), min, max));
|
|
413
|
+
}
|
|
414
|
+
const i = clamp(Math.round(v), min, max);
|
|
415
|
+
if (i === index.current) return;
|
|
416
|
+
const dir = i > index.current ? 1 : -1;
|
|
417
|
+
index.current = i;
|
|
418
|
+
const firmTick = live.current.firm?.(i) ?? false;
|
|
419
|
+
if (live.current.haptics) {
|
|
420
|
+
Haptics.impactAsync(firmTick ? Haptics.ImpactFeedbackStyle.Medium : Haptics.ImpactFeedbackStyle.Light).catch(() => {});
|
|
421
|
+
}
|
|
422
|
+
live.current.tick(firmTick);
|
|
423
|
+
live.current.onStep(i, dir);
|
|
424
|
+
});
|
|
425
|
+
return () => pos.removeListener(id);
|
|
426
|
+
}, [pos]);
|
|
427
|
+
|
|
428
|
+
// Where the ruler was when the finger came down (g.dx counts from the touch, so the ruler stays
|
|
429
|
+
// under the finger even though a sideways drag is claimed a few points in).
|
|
430
|
+
const drag = useRef(0);
|
|
431
|
+
const pan = useMemo(() => {
|
|
432
|
+
const unlag = () => Animated.spring(lag, { toValue: 0, useNativeDriver: true, ...motion.gentle }).start();
|
|
433
|
+
return PanResponder.create({
|
|
434
|
+
// A touch only claims the ruler straight away to catch a fling; otherwise a sideways drag does.
|
|
435
|
+
onStartShouldSetPanResponder: () => moving.current,
|
|
436
|
+
onMoveShouldSetPanResponder: (_, g) => Math.abs(g.dx) > 4 && Math.abs(g.dx) > Math.abs(g.dy),
|
|
437
|
+
onPanResponderTerminationRequest: () => false,
|
|
438
|
+
onPanResponderGrant: (_, g) => {
|
|
439
|
+
flinging.current = false;
|
|
440
|
+
pos.stopAnimation((v) => (posNow.current = v));
|
|
441
|
+
lag.stopAnimation();
|
|
442
|
+
moving.current = true;
|
|
443
|
+
drag.current = posNow.current + g.dx / live.current.gap;
|
|
444
|
+
},
|
|
445
|
+
onPanResponderMove: (_, g) => {
|
|
446
|
+
const { lo: min, hi: max, gap: px } = live.current;
|
|
447
|
+
const raw = drag.current - g.dx / px;
|
|
448
|
+
pos.setValue(raw < min ? min - (min - raw) * RUBBER : raw > max ? max + (raw - max) * RUBBER : raw);
|
|
449
|
+
lag.setValue(clamp(-g.vx * 14, -lagMax, lagMax));
|
|
450
|
+
},
|
|
451
|
+
onPanResponderRelease: (_, g) => {
|
|
452
|
+
unlag();
|
|
453
|
+
const { lo: min, hi: max, gap: px } = live.current;
|
|
454
|
+
const velocity = -g.vx / px; // ticks per ms
|
|
455
|
+
const outside = posNow.current < min || posNow.current > max;
|
|
456
|
+
if (outside || Math.abs(velocity) < 0.03) return snapRef.current(settle(posNow.current));
|
|
457
|
+
flinging.current = true;
|
|
458
|
+
Animated.decay(pos, { velocity, deceleration: 0.993, useNativeDriver: true }).start(({ finished }) => {
|
|
459
|
+
if (!finished || !flinging.current) return;
|
|
460
|
+
flinging.current = false;
|
|
461
|
+
pos.stopAnimation((v) => snapRef.current(settle(v)));
|
|
462
|
+
});
|
|
463
|
+
},
|
|
464
|
+
onPanResponderTerminate: () => {
|
|
465
|
+
unlag();
|
|
466
|
+
snapRef.current(settle(posNow.current));
|
|
467
|
+
},
|
|
468
|
+
});
|
|
469
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
470
|
+
}, [pos, lag]);
|
|
471
|
+
|
|
472
|
+
const [width, setWidth] = useState(0);
|
|
473
|
+
return {
|
|
474
|
+
pos,
|
|
475
|
+
lag,
|
|
476
|
+
pan,
|
|
477
|
+
reduce,
|
|
478
|
+
width,
|
|
479
|
+
gap,
|
|
480
|
+
onLayout: (e: LayoutChangeEvent) => setWidth(Math.round(e.nativeEvent.layout.width)),
|
|
481
|
+
/** One step for screen-reader increment / decrement, within the limits. */
|
|
482
|
+
nudge: (dir: 1 | -1) => {
|
|
483
|
+
flinging.current = false;
|
|
484
|
+
pos.stopAnimation((v) => snapRef.current(settle(Math.round(v) + dir)));
|
|
485
|
+
},
|
|
486
|
+
/** Puts tick 0 back under the needle without reporting a step (after a re-base). */
|
|
487
|
+
reset: () => {
|
|
488
|
+
pos.stopAnimation();
|
|
489
|
+
moving.current = false;
|
|
490
|
+
flinging.current = false;
|
|
491
|
+
index.current = 0;
|
|
492
|
+
posNow.current = 0;
|
|
493
|
+
pos.setValue(0);
|
|
494
|
+
},
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** Big-value text metrics; the style is stable so memoised rolls only re-render on their own change. */
|
|
499
|
+
function useGlyph(fontSize: number, textStyle?: StyleProp<TextStyle>) {
|
|
500
|
+
const { colors, font } = useKit();
|
|
501
|
+
// Text scales with the user's size (capped), so the roll window does too.
|
|
502
|
+
const { fontScale } = useWindowDimensions();
|
|
503
|
+
const lineHeight = Math.round(fontSize * 1.17);
|
|
504
|
+
const box = lineHeight * Math.min(fontScale, FONT_CAP);
|
|
505
|
+
const glyph = useMemo<StyleProp<TextStyle>>(
|
|
506
|
+
() => [
|
|
507
|
+
styles.glyph,
|
|
508
|
+
{ fontSize, lineHeight, height: box, color: colors.foreground, fontFamily: font.family, letterSpacing: -fontSize * 0.02 },
|
|
509
|
+
textStyle,
|
|
510
|
+
],
|
|
511
|
+
[fontSize, lineHeight, box, colors.foreground, font.family, textStyle],
|
|
512
|
+
);
|
|
513
|
+
return { box, glyph };
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
interface FrameProps {
|
|
517
|
+
ruler: Ruler;
|
|
518
|
+
label?: string;
|
|
519
|
+
name: string;
|
|
520
|
+
spoken: string;
|
|
521
|
+
accent?: string;
|
|
522
|
+
rulerWidth: number;
|
|
523
|
+
/** Extra height under the ticks (month labels). */
|
|
524
|
+
extra?: number;
|
|
525
|
+
marks?: React.ReactNode;
|
|
526
|
+
style?: StyleProp<ViewStyle>;
|
|
527
|
+
children: React.ReactNode;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** Label, the rolling value, and the ruler with its needle; the whole frame takes the drag. */
|
|
531
|
+
function Frame({ ruler, label, name, spoken, accent, rulerWidth, extra = 0, marks, style, children }: FrameProps) {
|
|
532
|
+
const { colors, radius, font } = useKit();
|
|
533
|
+
const { width } = ruler;
|
|
534
|
+
return (
|
|
535
|
+
<View
|
|
536
|
+
accessible
|
|
537
|
+
accessibilityRole="adjustable"
|
|
538
|
+
accessibilityLabel={name}
|
|
539
|
+
accessibilityValue={{ text: spoken }}
|
|
540
|
+
accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
|
|
541
|
+
onAccessibilityAction={(e) => ruler.nudge(e.nativeEvent.actionName === 'increment' ? 1 : -1)}
|
|
542
|
+
style={[styles.root, styles.grab, style]}
|
|
543
|
+
{...ruler.pan.panHandlers}
|
|
544
|
+
>
|
|
545
|
+
{label ? <Text style={[styles.label, { color: colors.foreground, fontFamily: font.family }]}>{label}</Text> : null}
|
|
546
|
+
{children}
|
|
547
|
+
<View style={[styles.ruler, { maxWidth: rulerWidth, height: RULER_H + extra }]} onLayout={ruler.onLayout}>
|
|
548
|
+
{width > 0 ? (
|
|
549
|
+
<>
|
|
550
|
+
<Ticks width={width} gap={ruler.gap} pos={ruler.pos} lag={ruler.lag} color={colors.mutedForeground} />
|
|
551
|
+
{marks}
|
|
552
|
+
</>
|
|
553
|
+
) : null}
|
|
554
|
+
<View
|
|
555
|
+
pointerEvents="none"
|
|
556
|
+
style={[styles.needle, { backgroundColor: accent ?? colors.accent, borderRadius: radius.capsule(3), left: width / 2 - 1.5 }]}
|
|
557
|
+
/>
|
|
558
|
+
</View>
|
|
559
|
+
</View>
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** Ticks fade out toward both ends of the ruler. */
|
|
564
|
+
function fade(x: Animated.AnimatedInterpolation<number> | Animated.AnimatedMultiplication<number>, half: number, gap: number) {
|
|
565
|
+
return x.interpolate({
|
|
566
|
+
inputRange: [-half, -half * 0.55, -gap * 2, 0, gap * 2, half * 0.55, half],
|
|
567
|
+
outputRange: [0, 0.35, 0.85, 1, 0.85, 0.35, 0],
|
|
568
|
+
extrapolate: 'clamp',
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
interface TicksProps {
|
|
573
|
+
width: number;
|
|
574
|
+
gap: number;
|
|
575
|
+
pos: Animated.Value;
|
|
576
|
+
lag: Animated.Value;
|
|
577
|
+
color: string;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* A fixed pool of ticks that slide by the fractional ruler position and wrap — so the ruler is
|
|
582
|
+
* endless without ever re-rendering. Height swells in a bell round the (lagging) needle; ticks
|
|
583
|
+
* fade out toward both ends. Everything runs on the native driver.
|
|
584
|
+
*/
|
|
585
|
+
const Ticks = React.memo(function Ticks({ width, gap, pos, lag, color }: TicksProps) {
|
|
586
|
+
const half = width / 2;
|
|
587
|
+
const slots = Math.ceil(half / gap) + 1;
|
|
588
|
+
const ticks = useMemo(() => {
|
|
589
|
+
const frac = Animated.modulo(pos, 1);
|
|
590
|
+
return Array.from({ length: slots * 2 + 1 }, (_, n) => {
|
|
591
|
+
const k = n - slots;
|
|
592
|
+
const x = Animated.subtract(k, frac).interpolate({ inputRange: [-1, 1], outputRange: [-gap, gap], extrapolate: 'extend' });
|
|
593
|
+
const fromBump = Animated.subtract(x, lag);
|
|
594
|
+
return {
|
|
595
|
+
k,
|
|
596
|
+
x,
|
|
597
|
+
scaleY: fromBump.interpolate({
|
|
598
|
+
inputRange: [-gap * 6, -gap * 3, -gap * 1.5, 0, gap * 1.5, gap * 3, gap * 6],
|
|
599
|
+
outputRange: [0.34, 0.46, 0.78, 1, 0.78, 0.46, 0.34],
|
|
600
|
+
extrapolate: 'clamp',
|
|
601
|
+
}),
|
|
602
|
+
opacity: fade(x, half, gap),
|
|
603
|
+
};
|
|
604
|
+
});
|
|
605
|
+
}, [pos, lag, slots, half, gap]);
|
|
606
|
+
|
|
607
|
+
return (
|
|
608
|
+
<View pointerEvents="none" style={StyleSheet.absoluteFill}>
|
|
609
|
+
{ticks.map(({ k, x, scaleY, opacity }) => (
|
|
610
|
+
<Animated.View
|
|
611
|
+
key={k}
|
|
612
|
+
style={[styles.tick, { left: half - TICK_W / 2, backgroundColor: color, opacity, transform: [{ translateX: x }, { scaleY }] }]}
|
|
613
|
+
/>
|
|
614
|
+
))}
|
|
615
|
+
</View>
|
|
616
|
+
);
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
interface MonthMarksProps {
|
|
620
|
+
width: number;
|
|
621
|
+
pos: Animated.Value;
|
|
622
|
+
months: { i: number; name: string }[];
|
|
623
|
+
tick: string;
|
|
624
|
+
text: string;
|
|
625
|
+
family?: string;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** A taller tick and the month's name under the 1st of each month, riding the ruler natively. */
|
|
629
|
+
const MonthMarks = React.memo(function MonthMarks({ width, pos, months, tick, text, family }: MonthMarksProps) {
|
|
630
|
+
const half = width / 2;
|
|
631
|
+
return (
|
|
632
|
+
<View pointerEvents="none" style={StyleSheet.absoluteFill}>
|
|
633
|
+
{months.map(({ i, name }) => {
|
|
634
|
+
const x = Animated.multiply(Animated.subtract(i, pos), DATE_GAP);
|
|
635
|
+
return (
|
|
636
|
+
<Animated.View key={i} style={[styles.mark, { left: half - 30, opacity: fade(x, half, DATE_GAP), transform: [{ translateX: x }] }]}>
|
|
637
|
+
<View style={[styles.markTick, { backgroundColor: tick }]} />
|
|
638
|
+
<Text maxFontSizeMultiplier={FONT_CAP} numberOfLines={1} style={[styles.markText, { color: text, fontFamily: family }]}>
|
|
639
|
+
{name}
|
|
640
|
+
</Text>
|
|
641
|
+
</Animated.View>
|
|
642
|
+
);
|
|
643
|
+
})}
|
|
644
|
+
</View>
|
|
645
|
+
);
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
interface RollProps {
|
|
649
|
+
text: string;
|
|
650
|
+
dir: 1 | -1;
|
|
651
|
+
height: number;
|
|
652
|
+
style: StyleProp<TextStyle>;
|
|
653
|
+
reduce: boolean;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Ghost copies stacked above and below a glyph fake a vertical motion blur while it rolls: many
|
|
657
|
+
// close, faint copies (a few pt apart) blend into a soft smear instead of visible stripes.
|
|
658
|
+
const GHOSTS: [number, number][] = Array.from({ length: 7 }, (_, i) => {
|
|
659
|
+
const d = (i + 1) * 0.045;
|
|
660
|
+
const a = 0.13 * (1 - i / 7);
|
|
661
|
+
return [
|
|
662
|
+
[-d, a],
|
|
663
|
+
[d, a],
|
|
664
|
+
] as [number, number][];
|
|
665
|
+
}).flat();
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* One piece of the value that rolls to its next one: the old one slides out, the new one in from
|
|
669
|
+
* the other side, both smeared by ghost copies that fade as the roll settles.
|
|
670
|
+
*/
|
|
671
|
+
const Roll = React.memo(function Roll({ text, dir, height, style, reduce }: RollProps) {
|
|
672
|
+
const t = useRef(new Animated.Value(1)).current;
|
|
673
|
+
const last = useRef(text);
|
|
674
|
+
const [pair, setPair] = useState({ prev: text, cur: text, dir });
|
|
675
|
+
|
|
676
|
+
useEffect(() => {
|
|
677
|
+
if (text === last.current) return;
|
|
678
|
+
setPair({ prev: last.current, cur: text, dir });
|
|
679
|
+
last.current = text;
|
|
680
|
+
t.stopAnimation();
|
|
681
|
+
if (reduce) return t.setValue(1);
|
|
682
|
+
t.setValue(0);
|
|
683
|
+
Animated.timing(t, { toValue: 1, ...ROLL }).start();
|
|
684
|
+
}, [text, dir, reduce, t]);
|
|
685
|
+
|
|
686
|
+
const blur = t.interpolate({ inputRange: [0, 0.55, 1], outputRange: [1, 0.8, 0] });
|
|
687
|
+
const sharp = t.interpolate({ inputRange: [0, 0.55, 1], outputRange: [0.08, 0.3, 1] });
|
|
688
|
+
const layer = (s: string, y: Animated.AnimatedInterpolation<number>, outgoing: boolean) => (
|
|
689
|
+
<Animated.View style={[outgoing && styles.out, { transform: [{ translateY: y }] }]}>
|
|
690
|
+
{GHOSTS.map(([dy, a], i) => (
|
|
691
|
+
<Animated.Text
|
|
692
|
+
key={i}
|
|
693
|
+
maxFontSizeMultiplier={FONT_CAP}
|
|
694
|
+
numberOfLines={1}
|
|
695
|
+
style={[style, styles.ghost, { opacity: Animated.multiply(blur, a), transform: [{ translateY: dy * height }] }]}
|
|
696
|
+
>
|
|
697
|
+
{s}
|
|
698
|
+
</Animated.Text>
|
|
699
|
+
))}
|
|
700
|
+
<Animated.Text maxFontSizeMultiplier={FONT_CAP} numberOfLines={1} style={[style, { opacity: sharp }]}>
|
|
701
|
+
{s}
|
|
702
|
+
</Animated.Text>
|
|
703
|
+
</Animated.View>
|
|
704
|
+
);
|
|
705
|
+
|
|
706
|
+
return (
|
|
707
|
+
<View style={{ height, overflow: 'hidden', alignItems: 'center' }}>
|
|
708
|
+
{layer(pair.cur, t.interpolate({ inputRange: [0, 1], outputRange: [pair.dir * height * 0.9, 0] }), false)}
|
|
709
|
+
{pair.prev !== pair.cur
|
|
710
|
+
? layer(pair.prev, t.interpolate({ inputRange: [0, 1], outputRange: [0, -pair.dir * height * 0.9] }), true)
|
|
711
|
+
: null}
|
|
712
|
+
</View>
|
|
713
|
+
);
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
// A ~20 ms tick synthesised once into the cache as a WAV (a decaying 2.6 kHz ping with a little
|
|
717
|
+
// noise), so the component ships no audio file. A few players take turns so fast scrubs overlap.
|
|
718
|
+
const TICK_RATE = 22050;
|
|
719
|
+
let tickUri: string | null = null;
|
|
720
|
+
|
|
721
|
+
function tickWav() {
|
|
722
|
+
const n = Math.round(TICK_RATE * 0.02);
|
|
723
|
+
const bytes = new Uint8Array(44 + n * 2);
|
|
724
|
+
const v = new DataView(bytes.buffer);
|
|
725
|
+
const text = (at: number, s: string) => [...s].forEach((c, i) => v.setUint8(at + i, c.charCodeAt(0)));
|
|
726
|
+
text(0, 'RIFF');
|
|
727
|
+
v.setUint32(4, 36 + n * 2, true);
|
|
728
|
+
text(8, 'WAVEfmt ');
|
|
729
|
+
v.setUint32(16, 16, true);
|
|
730
|
+
v.setUint16(20, 1, true); // PCM
|
|
731
|
+
v.setUint16(22, 1, true); // mono
|
|
732
|
+
v.setUint32(24, TICK_RATE, true);
|
|
733
|
+
v.setUint32(28, TICK_RATE * 2, true);
|
|
734
|
+
v.setUint16(32, 2, true);
|
|
735
|
+
v.setUint16(34, 16, true);
|
|
736
|
+
text(36, 'data');
|
|
737
|
+
v.setUint32(40, n * 2, true);
|
|
738
|
+
let seed = 7;
|
|
739
|
+
for (let i = 0; i < n; i++) {
|
|
740
|
+
const t = i / TICK_RATE;
|
|
741
|
+
seed = (seed * 16807) % 2147483647;
|
|
742
|
+
const noise = (seed / 2147483647) * 2 - 1;
|
|
743
|
+
const sample = (Math.sin(2 * Math.PI * 2600 * t) * 0.7 + noise * 0.3) * Math.exp(-t * 320);
|
|
744
|
+
v.setInt16(44 + i * 2, Math.round(sample * 0.55 * 32767), true);
|
|
745
|
+
}
|
|
746
|
+
return bytes;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function tickSource() {
|
|
750
|
+
if (tickUri) return tickUri;
|
|
751
|
+
const file = new File(Paths.cache, 'expokit-ruler-tick.wav');
|
|
752
|
+
if (!file.exists) file.writeSync(tickWav());
|
|
753
|
+
tickUri = file.uri;
|
|
754
|
+
return tickUri;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/** Returns `tick(firm)`: plays the tick (louder when firm), at most one every 25 ms. No-op on web or when off. */
|
|
758
|
+
function useTickSound(enabled: boolean) {
|
|
759
|
+
const pool = useRef<AudioPlayer[]>([]);
|
|
760
|
+
const state = useRef({ next: 0, last: 0 });
|
|
761
|
+
useEffect(() => {
|
|
762
|
+
if (!enabled || Platform.OS === 'web') return;
|
|
763
|
+
try {
|
|
764
|
+
const uri = tickSource();
|
|
765
|
+
pool.current = [0, 1, 2].map(() => createAudioPlayer(uri));
|
|
766
|
+
} catch {
|
|
767
|
+
pool.current = [];
|
|
768
|
+
}
|
|
769
|
+
return () => {
|
|
770
|
+
pool.current.forEach((p) => p.remove());
|
|
771
|
+
pool.current = [];
|
|
772
|
+
};
|
|
773
|
+
}, [enabled]);
|
|
774
|
+
return (firm: boolean) => {
|
|
775
|
+
const now = Date.now();
|
|
776
|
+
if (!pool.current.length || now - state.current.last < 25) return;
|
|
777
|
+
state.current.last = now;
|
|
778
|
+
const player = pool.current[state.current.next++ % pool.current.length];
|
|
779
|
+
player.volume = firm ? 0.9 : 0.5;
|
|
780
|
+
player.seekTo(0).catch(() => {});
|
|
781
|
+
player.play();
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function useReduceMotion() {
|
|
786
|
+
const [reduce, setReduce] = useState(false);
|
|
787
|
+
useEffect(() => {
|
|
788
|
+
let alive = true;
|
|
789
|
+
AccessibilityInfo.isReduceMotionEnabled()
|
|
790
|
+
.then((r) => alive && setReduce(r))
|
|
791
|
+
.catch(() => {});
|
|
792
|
+
const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
|
|
793
|
+
return () => {
|
|
794
|
+
alive = false;
|
|
795
|
+
sub.remove();
|
|
796
|
+
};
|
|
797
|
+
}, []);
|
|
798
|
+
return reduce;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
const styles = StyleSheet.create({
|
|
802
|
+
root: { alignItems: 'center', alignSelf: 'stretch', paddingVertical: 8 },
|
|
803
|
+
grab: Platform.OS === 'web' ? ({ userSelect: 'none', cursor: 'grab', touchAction: 'pan-y' } as object) : {},
|
|
804
|
+
label: { fontSize: 15, lineHeight: 20, fontWeight: '500', marginBottom: 2 },
|
|
805
|
+
row: { flexDirection: 'row', alignItems: 'flex-start' },
|
|
806
|
+
glyph: { fontWeight: '600', fontVariant: ['tabular-nums'], includeFontPadding: false, textAlign: 'center' },
|
|
807
|
+
ghost: { position: 'absolute', left: 0, right: 0, top: 0 },
|
|
808
|
+
// The outgoing piece may be wider than the incoming one (Wed → Fri): centred, it spills evenly.
|
|
809
|
+
out: { position: 'absolute', top: 0, left: -200, right: -200, alignItems: 'center' },
|
|
810
|
+
ruler: { width: '100%', marginTop: 14, overflow: 'hidden' },
|
|
811
|
+
tick: { position: 'absolute', top: (RULER_H - TICK_H) / 2, width: TICK_W, height: TICK_H, borderRadius: TICK_W / 2 },
|
|
812
|
+
needle: { position: 'absolute', top: (RULER_H - NEEDLE_H) / 2, width: 3, height: NEEDLE_H },
|
|
813
|
+
mark: { position: 'absolute', top: (RULER_H - TICK_H) / 2 - 2, width: 60, alignItems: 'center' },
|
|
814
|
+
markTick: { width: 2, height: TICK_H + 4, borderRadius: 1, opacity: 0.55 },
|
|
815
|
+
markText: { fontSize: 11, lineHeight: 14, fontWeight: '600', marginTop: (RULER_H - TICK_H) / 2, letterSpacing: 0.2 },
|
|
816
|
+
});
|