@expokit/cli 0.0.0-stage → 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.
Files changed (302) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +32 -2
  3. package/bin/expo-kit.js +1204 -0
  4. package/bin/lib.mjs +343 -0
  5. package/bin/mcp.mjs +338 -0
  6. package/package.json +44 -4
  7. package/registry/_docs/accordion.json +99 -0
  8. package/registry/_docs/action-sheet.json +110 -0
  9. package/registry/_docs/alert.json +127 -0
  10. package/registry/_docs/avatar.json +203 -0
  11. package/registry/_docs/badge.json +117 -0
  12. package/registry/_docs/bottom-nav.json +119 -0
  13. package/registry/_docs/bottom-sheet.json +158 -0
  14. package/registry/_docs/bullet.json +24 -0
  15. package/registry/_docs/button-3d.json +122 -0
  16. package/registry/_docs/button.json +115 -0
  17. package/registry/_docs/calendar.json +182 -0
  18. package/registry/_docs/card-stack.json +108 -0
  19. package/registry/_docs/card.json +111 -0
  20. package/registry/_docs/carousel.json +102 -0
  21. package/registry/_docs/checkbox.json +93 -0
  22. package/registry/_docs/checkout.json +102 -0
  23. package/registry/_docs/chip-group.json +116 -0
  24. package/registry/_docs/color-swatch.json +66 -0
  25. package/registry/_docs/confetti.json +82 -0
  26. package/registry/_docs/coupon.json +176 -0
  27. package/registry/_docs/cube-pager.json +84 -0
  28. package/registry/_docs/dashboard.json +120 -0
  29. package/registry/_docs/date-picker.json +249 -0
  30. package/registry/_docs/dialog.json +162 -0
  31. package/registry/_docs/drawer.json +171 -0
  32. package/registry/_docs/fab.json +120 -0
  33. package/registry/_docs/flip-card.json +111 -0
  34. package/registry/_docs/floating-input.json +191 -0
  35. package/registry/_docs/gift-card.json +108 -0
  36. package/registry/_docs/glass-button.json +118 -0
  37. package/registry/_docs/glass-nav-bar.json +78 -0
  38. package/registry/_docs/glass-search-bar.json +90 -0
  39. package/registry/_docs/glass-segmented.json +66 -0
  40. package/registry/_docs/glass-sheet.json +108 -0
  41. package/registry/_docs/glass-tab-bar.json +76 -0
  42. package/registry/_docs/glass.json +78 -0
  43. package/registry/_docs/inbox.json +72 -0
  44. package/registry/_docs/isometric-bar.json +72 -0
  45. package/registry/_docs/kit.json +11 -0
  46. package/registry/_docs/kpi-card.json +11 -0
  47. package/registry/_docs/like-button.json +72 -0
  48. package/registry/_docs/liquidglass-tab.json +27 -0
  49. package/registry/_docs/list-item.json +200 -0
  50. package/registry/_docs/loyalty-card.json +108 -0
  51. package/registry/_docs/morph-button.json +108 -0
  52. package/registry/_docs/number-ticker.json +96 -0
  53. package/registry/_docs/offers.json +78 -0
  54. package/registry/_docs/onboarding.json +66 -0
  55. package/registry/_docs/otp-input.json +134 -0
  56. package/registry/_docs/pagination-dots.json +88 -0
  57. package/registry/_docs/parallax-layers.json +84 -0
  58. package/registry/_docs/parallax-scroll.json +186 -0
  59. package/registry/_docs/password-input.json +92 -0
  60. package/registry/_docs/payment-card.json +153 -0
  61. package/registry/_docs/pixel-bar.json +66 -0
  62. package/registry/_docs/polar-area.json +78 -0
  63. package/registry/_docs/profile.json +84 -0
  64. package/registry/_docs/progress-steps.json +92 -0
  65. package/registry/_docs/progress.json +132 -0
  66. package/registry/_docs/radio-group.json +92 -0
  67. package/registry/_docs/rating.json +90 -0
  68. package/registry/_docs/ring-carousel.json +102 -0
  69. package/registry/_docs/scratch-card.json +90 -0
  70. package/registry/_docs/search-bar.json +140 -0
  71. package/registry/_docs/search-results.json +120 -0
  72. package/registry/_docs/segmented-control.json +103 -0
  73. package/registry/_docs/select.json +252 -0
  74. package/registry/_docs/settings.json +72 -0
  75. package/registry/_docs/shimmer-button.json +78 -0
  76. package/registry/_docs/sign-in.json +198 -0
  77. package/registry/_docs/skeleton.json +142 -0
  78. package/registry/_docs/slide-to-confirm.json +84 -0
  79. package/registry/_docs/slider.json +157 -0
  80. package/registry/_docs/spin-wheel.json +114 -0
  81. package/registry/_docs/spinner.json +91 -0
  82. package/registry/_docs/stepper.json +168 -0
  83. package/registry/_docs/swipe-deck.json +102 -0
  84. package/registry/_docs/swipeable-row.json +72 -0
  85. package/registry/_docs/switch.json +104 -0
  86. package/registry/_docs/tabs.json +105 -0
  87. package/registry/_docs/tag-input.json +128 -0
  88. package/registry/_docs/textarea.json +175 -0
  89. package/registry/_docs/ticket-card.json +174 -0
  90. package/registry/_docs/ticket-shape.json +11 -0
  91. package/registry/_docs/tilt-card.json +141 -0
  92. package/registry/_docs/time-picker.json +253 -0
  93. package/registry/_docs/timeline-chart.json +30 -0
  94. package/registry/_docs/timeline.json +156 -0
  95. package/registry/_docs/toast.json +161 -0
  96. package/registry/_docs/treemap.json +30 -0
  97. package/registry/_docs/waterfall.json +36 -0
  98. package/registry/_docs/wheel-list.json +102 -0
  99. package/registry/accordion/files/src/components/ui/accordion.tsx +233 -0
  100. package/registry/accordion/files/src/lib/kit.tsx +219 -0
  101. package/registry/accordion.json +17 -0
  102. package/registry/action-sheet/files/src/components/ui/action-sheet.tsx +580 -0
  103. package/registry/action-sheet/files/src/lib/kit.tsx +219 -0
  104. package/registry/action-sheet.json +18 -0
  105. package/registry/alert/files/src/components/ui/alert.tsx +654 -0
  106. package/registry/alert/files/src/components/ui/button.tsx +274 -0
  107. package/registry/alert/files/src/lib/kit.tsx +219 -0
  108. package/registry/alert.json +20 -0
  109. package/registry/avatar/files/src/components/ui/avatar.tsx +790 -0
  110. package/registry/avatar/files/src/lib/kit.tsx +219 -0
  111. package/registry/avatar.json +18 -0
  112. package/registry/badge/files/src/components/ui/badge.tsx +316 -0
  113. package/registry/badge/files/src/lib/kit.tsx +219 -0
  114. package/registry/badge.json +17 -0
  115. package/registry/bottom-nav/files/src/components/ui/bottom-nav.tsx +1050 -0
  116. package/registry/bottom-nav/files/src/lib/kit.tsx +219 -0
  117. package/registry/bottom-nav.json +18 -0
  118. package/registry/bottom-sheet/files/src/components/ui/bottom-sheet.tsx +585 -0
  119. package/registry/bottom-sheet/files/src/lib/kit.tsx +219 -0
  120. package/registry/bottom-sheet.json +17 -0
  121. package/registry/bullet.json +14 -0
  122. package/registry/button/files/src/components/ui/button.tsx +274 -0
  123. package/registry/button/files/src/lib/kit.tsx +219 -0
  124. package/registry/button-3d/files/src/components/ui/button-3d.tsx +212 -0
  125. package/registry/button-3d/files/src/lib/kit.tsx +219 -0
  126. package/registry/button-3d.json +18 -0
  127. package/registry/button.json +18 -0
  128. package/registry/calendar/files/src/components/ui/calendar.tsx +822 -0
  129. package/registry/calendar/files/src/lib/kit.tsx +219 -0
  130. package/registry/calendar.json +17 -0
  131. package/registry/card/files/src/components/ui/card.tsx +300 -0
  132. package/registry/card/files/src/lib/kit.tsx +219 -0
  133. package/registry/card-stack.json +15 -0
  134. package/registry/card.json +17 -0
  135. package/registry/carousel.json +15 -0
  136. package/registry/checkbox/files/src/components/ui/checkbox.tsx +150 -0
  137. package/registry/checkbox/files/src/lib/kit.tsx +219 -0
  138. package/registry/checkbox.json +17 -0
  139. package/registry/checkout.json +26 -0
  140. package/registry/chip-group/files/src/components/ui/chip-group.tsx +269 -0
  141. package/registry/chip-group/files/src/lib/kit.tsx +219 -0
  142. package/registry/chip-group.json +17 -0
  143. package/registry/color-swatch.json +17 -0
  144. package/registry/confetti/files/src/components/ui/confetti.tsx +222 -0
  145. package/registry/confetti/files/src/lib/kit.tsx +219 -0
  146. package/registry/confetti.json +15 -0
  147. package/registry/coupon/files/src/components/ui/coupon.tsx +698 -0
  148. package/registry/coupon/files/src/components/ui/flip-card.tsx +205 -0
  149. package/registry/coupon/files/src/components/ui/tilt-card.tsx +635 -0
  150. package/registry/coupon/files/src/lib/kit.tsx +219 -0
  151. package/registry/coupon/files/src/lib/ticket-shape.tsx +543 -0
  152. package/registry/coupon.json +23 -0
  153. package/registry/cube-pager.json +15 -0
  154. package/registry/dashboard.json +32 -0
  155. package/registry/date-picker/files/src/components/ui/calendar.tsx +822 -0
  156. package/registry/date-picker/files/src/components/ui/date-picker.tsx +796 -0
  157. package/registry/date-picker/files/src/lib/kit.tsx +219 -0
  158. package/registry/date-picker.json +19 -0
  159. package/registry/dialog/files/src/components/ui/dialog.tsx +759 -0
  160. package/registry/dialog/files/src/lib/kit.tsx +219 -0
  161. package/registry/dialog.json +18 -0
  162. package/registry/drawer/files/src/components/ui/drawer.tsx +1135 -0
  163. package/registry/drawer/files/src/lib/kit.tsx +219 -0
  164. package/registry/drawer.json +18 -0
  165. package/registry/fab.json +18 -0
  166. package/registry/flip-card/files/src/components/ui/flip-card.tsx +205 -0
  167. package/registry/flip-card/files/src/lib/kit.tsx +219 -0
  168. package/registry/flip-card.json +15 -0
  169. package/registry/floating-input/files/src/components/ui/floating-input.tsx +528 -0
  170. package/registry/floating-input/files/src/lib/kit.tsx +219 -0
  171. package/registry/floating-input.json +17 -0
  172. package/registry/gift-card.json +21 -0
  173. package/registry/glass-button.json +22 -0
  174. package/registry/glass-nav-bar.json +21 -0
  175. package/registry/glass-search-bar.json +21 -0
  176. package/registry/glass-segmented.json +22 -0
  177. package/registry/glass-sheet.json +22 -0
  178. package/registry/glass-tab-bar.json +22 -0
  179. package/registry/glass.json +19 -0
  180. package/registry/inbox.json +30 -0
  181. package/registry/isometric-bar.json +14 -0
  182. package/registry/kit/files/src/lib/kit.tsx +219 -0
  183. package/registry/kit.json +11 -0
  184. package/registry/kpi-card/files/src/components/charts/kpi-card.tsx +45 -0
  185. package/registry/kpi-card.json +14 -0
  186. package/registry/like-button.json +18 -0
  187. package/registry/liquidglass-tab/files/src/app/(tabs)/_layout.tsx +97 -0
  188. package/registry/liquidglass-tab/files/src/app/(tabs)/alerts.tsx +5 -0
  189. package/registry/liquidglass-tab/files/src/app/(tabs)/explore.tsx +5 -0
  190. package/registry/liquidglass-tab/files/src/app/(tabs)/index.tsx +5 -0
  191. package/registry/liquidglass-tab/files/src/app/(tabs)/profile.tsx +5 -0
  192. package/registry/liquidglass-tab/files/src/app/_layout.tsx +33 -0
  193. package/registry/liquidglass-tab/files/src/components/GlassTabBar.tsx +259 -0
  194. package/registry/liquidglass-tab/files/src/components/TabScreen.tsx +122 -0
  195. package/registry/liquidglass-tab/files/src/lib/tab-bar.ts +61 -0
  196. package/registry/liquidglass-tab/files/src/lib/tab-icons.ts +130 -0
  197. package/registry/liquidglass-tab/files/src/lib/tabs.ts +108 -0
  198. package/registry/liquidglass-tab.json +23 -0
  199. package/registry/list-item/files/src/components/ui/list-item.tsx +508 -0
  200. package/registry/list-item/files/src/lib/kit.tsx +219 -0
  201. package/registry/list-item.json +17 -0
  202. package/registry/loyalty-card.json +23 -0
  203. package/registry/morph-button.json +18 -0
  204. package/registry/number-ticker.json +15 -0
  205. package/registry/offers.json +24 -0
  206. package/registry/onboarding.json +17 -0
  207. package/registry/otp-input/files/src/components/ui/otp-input.tsx +380 -0
  208. package/registry/otp-input/files/src/lib/kit.tsx +219 -0
  209. package/registry/otp-input.json +17 -0
  210. package/registry/pagination-dots/files/src/components/ui/pagination-dots.tsx +262 -0
  211. package/registry/pagination-dots/files/src/lib/kit.tsx +219 -0
  212. package/registry/pagination-dots.json +15 -0
  213. package/registry/parallax-layers.json +15 -0
  214. package/registry/parallax-scroll.json +17 -0
  215. package/registry/password-input/files/src/components/ui/floating-input.tsx +528 -0
  216. package/registry/password-input/files/src/components/ui/password-input.tsx +271 -0
  217. package/registry/password-input/files/src/lib/kit.tsx +219 -0
  218. package/registry/password-input.json +19 -0
  219. package/registry/payment-card/files/src/components/ui/flip-card.tsx +205 -0
  220. package/registry/payment-card/files/src/components/ui/payment-card.tsx +385 -0
  221. package/registry/payment-card/files/src/components/ui/tilt-card.tsx +635 -0
  222. package/registry/payment-card/files/src/lib/kit.tsx +219 -0
  223. package/registry/payment-card.json +21 -0
  224. package/registry/pixel-bar.json +14 -0
  225. package/registry/polar-area.json +14 -0
  226. package/registry/profile.json +17 -0
  227. package/registry/progress/files/src/components/ui/progress.tsx +883 -0
  228. package/registry/progress/files/src/lib/kit.tsx +219 -0
  229. package/registry/progress-steps/files/src/components/ui/progress-steps.tsx +559 -0
  230. package/registry/progress-steps/files/src/lib/kit.tsx +219 -0
  231. package/registry/progress-steps.json +18 -0
  232. package/registry/progress.json +17 -0
  233. package/registry/radio-group/files/src/components/ui/radio-group.tsx +327 -0
  234. package/registry/radio-group/files/src/lib/kit.tsx +219 -0
  235. package/registry/radio-group.json +18 -0
  236. package/registry/rating.json +18 -0
  237. package/registry/ring-carousel.json +15 -0
  238. package/registry/scratch-card.json +18 -0
  239. package/registry/search-bar/files/src/components/ui/search-bar.tsx +530 -0
  240. package/registry/search-bar/files/src/lib/kit.tsx +219 -0
  241. package/registry/search-bar.json +17 -0
  242. package/registry/search-results.json +30 -0
  243. package/registry/segmented-control/files/src/components/ui/segmented-control.tsx +341 -0
  244. package/registry/segmented-control/files/src/lib/kit.tsx +219 -0
  245. package/registry/segmented-control.json +18 -0
  246. package/registry/select/files/src/components/ui/select.tsx +1398 -0
  247. package/registry/select/files/src/lib/kit.tsx +219 -0
  248. package/registry/select.json +17 -0
  249. package/registry/settings.json +26 -0
  250. package/registry/shimmer-button.json +17 -0
  251. package/registry/sign-in.json +26 -0
  252. package/registry/skeleton/files/src/components/ui/skeleton.tsx +799 -0
  253. package/registry/skeleton/files/src/lib/kit.tsx +219 -0
  254. package/registry/skeleton.json +17 -0
  255. package/registry/slide-to-confirm.json +18 -0
  256. package/registry/slider/files/src/components/ui/slider.tsx +716 -0
  257. package/registry/slider/files/src/lib/kit.tsx +219 -0
  258. package/registry/slider.json +17 -0
  259. package/registry/spin-wheel.json +18 -0
  260. package/registry/spinner/files/src/components/ui/spinner.tsx +335 -0
  261. package/registry/spinner/files/src/lib/kit.tsx +219 -0
  262. package/registry/spinner.json +17 -0
  263. package/registry/stepper/files/src/components/ui/stepper.tsx +646 -0
  264. package/registry/stepper/files/src/lib/kit.tsx +219 -0
  265. package/registry/stepper.json +17 -0
  266. package/registry/swipe-deck.json +15 -0
  267. package/registry/swipeable-row.json +15 -0
  268. package/registry/switch/files/src/components/ui/switch.tsx +344 -0
  269. package/registry/switch/files/src/lib/kit.tsx +219 -0
  270. package/registry/switch.json +18 -0
  271. package/registry/tabs/files/src/components/ui/tabs.tsx +539 -0
  272. package/registry/tabs/files/src/lib/kit.tsx +219 -0
  273. package/registry/tabs.json +17 -0
  274. package/registry/tag-input/files/src/components/ui/tag-input.tsx +720 -0
  275. package/registry/tag-input/files/src/lib/kit.tsx +219 -0
  276. package/registry/tag-input.json +17 -0
  277. package/registry/textarea/files/src/components/ui/textarea.tsx +553 -0
  278. package/registry/textarea/files/src/lib/kit.tsx +219 -0
  279. package/registry/textarea.json +17 -0
  280. package/registry/ticket-card.json +24 -0
  281. package/registry/ticket-shape/files/src/lib/kit.tsx +219 -0
  282. package/registry/ticket-shape/files/src/lib/ticket-shape.tsx +543 -0
  283. package/registry/ticket-shape.json +17 -0
  284. package/registry/tilt-card/files/src/components/ui/tilt-card.tsx +635 -0
  285. package/registry/tilt-card/files/src/lib/kit.tsx +219 -0
  286. package/registry/tilt-card.json +17 -0
  287. package/registry/time-picker/files/src/components/ui/time-picker.tsx +1159 -0
  288. package/registry/time-picker/files/src/lib/kit.tsx +219 -0
  289. package/registry/time-picker.json +17 -0
  290. package/registry/timeline/files/src/components/ui/avatar.tsx +790 -0
  291. package/registry/timeline/files/src/components/ui/timeline.tsx +1059 -0
  292. package/registry/timeline/files/src/lib/kit.tsx +219 -0
  293. package/registry/timeline-chart.json +14 -0
  294. package/registry/timeline.json +20 -0
  295. package/registry/toast/files/src/components/ui/button.tsx +274 -0
  296. package/registry/toast/files/src/components/ui/toast.tsx +1284 -0
  297. package/registry/toast/files/src/lib/kit.tsx +219 -0
  298. package/registry/toast.json +20 -0
  299. package/registry/treemap.json +14 -0
  300. package/registry/waterfall.json +14 -0
  301. package/registry/wheel-list.json +17 -0
  302. package/schema/components.schema.json +13 -0
@@ -0,0 +1,1159 @@
1
+ import React, { memo, useEffect, useMemo, useRef, useState } from 'react';
2
+ import {
3
+ Animated,
4
+ Easing,
5
+ Modal,
6
+ PanResponder,
7
+ Pressable,
8
+ ScrollView,
9
+ StyleSheet,
10
+ Text,
11
+ View,
12
+ useWindowDimensions,
13
+ type NativeScrollEvent,
14
+ type NativeSyntheticEvent,
15
+ type StyleProp,
16
+ type TextStyle,
17
+ type ViewStyle,
18
+ } from 'react-native';
19
+ import Svg, { Circle, Line, Path } from 'react-native-svg';
20
+
21
+ import { alpha, motion, readableOn, 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 TimeValue or a `'HH:mm'` string such as `'09:30'` or `'17:00'`. */
29
+ export type TimeInput = TimeValue | string;
30
+ export type TimePickerVariant = 'wheel' | 'chips' | 'clock';
31
+ export type TimePickerPresentation = 'inline' | 'sheet';
32
+ export type TimePickerSize = 'sm' | 'md' | 'lg';
33
+
34
+ export interface TimePickerProps {
35
+ value?: TimeValue | null;
36
+ defaultValue?: TimeValue | null;
37
+ onChange?: (time: TimeValue) => void;
38
+ /** 'wheel' — iOS-style 3D drums · 'chips' — a grid of bookable slots · 'clock' — a Material 3-style analog dial. */
39
+ variant?: TimePickerVariant;
40
+ /** 'inline' renders the picker in place · 'sheet' renders a field that opens it in a bottom sheet. */
41
+ presentation?: TimePickerPresentation;
42
+ /** 12-hour clock with an AM / PM column (default: from the locale). */
43
+ hour12?: boolean;
44
+ /** Minute step: wheel / clock default 1, chips default 30. The clock labels every 5 minutes and snaps to this step. */
45
+ minuteInterval?: number;
46
+ /** Locale used to pick the default clock (12h / 24h). */
47
+ locale?: string;
48
+ /** Chips: first slot (default '09:00'). */
49
+ startTime?: TimeInput;
50
+ /** Chips: closing time — the last slot starts one interval before it (default '17:00'). */
51
+ endTime?: TimeInput;
52
+ /** Chips: explicit slot list (overrides start / end / interval). */
53
+ slots?: TimeInput[];
54
+ /** Chips: slots that are taken — struck through and not selectable. */
55
+ bookedTimes?: TimeInput[];
56
+ /** Chips: slots that are unavailable for any other reason. */
57
+ disabledTimes?: TimeInput[];
58
+ /** Chips: columns in the grid. */
59
+ columns?: number;
60
+ /** Chips: split slots under Morning / Afternoon / Evening headings. */
61
+ groupByPeriod?: boolean;
62
+ /** Sheet: field label (floats onto the border once filled). */
63
+ label?: string;
64
+ /** Sheet: `false` puts the label above the field as static text (no notch) and shows `placeholder` inside. */
65
+ floatingLabel?: boolean;
66
+ /** Sheet: heading (default: label). */
67
+ title?: string;
68
+ placeholder?: string;
69
+ /** Sheet: field height and type size. */
70
+ size?: TimePickerSize;
71
+ /** Sheet: stage the choice behind Cancel / Set (default: true for wheel and clock, false for chips). */
72
+ confirm?: boolean;
73
+ error?: string;
74
+ success?: boolean | string;
75
+ helper?: string;
76
+ disabled?: boolean;
77
+ /** Selection, focus and halo colour (default: theme accent). */
78
+ accent?: string;
79
+ /** Background behind the field — the floating label cuts its notch with this colour. */
80
+ surface?: string;
81
+ /** Root: the wheel / chip grid inline, or the field wrapper (the one that shakes) as a sheet. */
82
+ style?: StyleProp<ViewStyle>;
83
+ /**
84
+ * Sheet: the trigger box — background, border width / colour, radius, height, padding. Applied
85
+ * after the computed style, so it wins. A `borderColor` replaces the resting border only — open
86
+ * (accent), error and success colours still take over. A numeric `height` / `borderRadius` also
87
+ * re-centres the label and resizes the focus halo.
88
+ */
89
+ fieldStyle?: StyleProp<ViewStyle>;
90
+ /** Sheet: the formatted time text in the trigger. */
91
+ inputStyle?: StyleProp<TextStyle>;
92
+ /**
93
+ * Sheet: the label, floating or above the field. Its `color` is the resting colour; open (accent),
94
+ * error and success colours still take over. A `fontSize` sets the resting size and the label
95
+ * floats at the same ratio; the notch follows the rendered label width.
96
+ */
97
+ labelStyle?: StyleProp<TextStyle>;
98
+ /** Sheet: helper / error / success text. `color` applies to the helper; error and success keep their tone colour. */
99
+ messageStyle?: StyleProp<TextStyle>;
100
+ /** Sheet: colour of the placeholder in the trigger (default: theme mutedForeground). */
101
+ placeholderTextColor?: string;
102
+ /** Sheet: clock icon colour at rest (it turns accent while open). */
103
+ iconColor?: string;
104
+ /** Sheet: the sheet / dialog panel — background, border, radius, padding, shadow. Don't pass `transform` or `opacity`: they animate. */
105
+ menuStyle?: StyleProp<ViewStyle>;
106
+ }
107
+
108
+ const ITEM = 44;
109
+ const VISIBLE = 5;
110
+ const PAD = ITEM * Math.floor(VISIBLE / 2);
111
+ const SIZES: Record<TimePickerSize, { h: number; font: number; pad: number; icon: number }> = {
112
+ sm: { h: 46, font: 15, pad: 12, icon: 17 },
113
+ md: { h: 56, font: 16, pad: 16, icon: 19 },
114
+ lg: { h: 66, font: 17.5, pad: 18, icon: 21 },
115
+ };
116
+ const FLOAT_SCALE = 0.78;
117
+ /** Font size of the static label used when `floatingLabel` is false. */
118
+ const LABEL_ABOVE_FONT: Record<TimePickerSize, number> = { sm: 13, md: 14, lg: 14.5 };
119
+
120
+ /* ───────────────────────────── helpers ───────────────────────────── */
121
+
122
+ const pad2 = (n: number) => (n < 10 ? '0' : '') + n;
123
+
124
+ /** Parses `'9:30'`, `'09:30'` or a TimeValue. */
125
+ export function parseTime(t: TimeInput): TimeValue {
126
+ if (typeof t !== 'string') return t;
127
+ const [h, m] = t.split(':').map((x) => parseInt(x, 10));
128
+ return { hours: (h || 0) % 24, minutes: (m || 0) % 60 };
129
+ }
130
+ const toMin = (t: TimeValue) => t.hours * 60 + t.minutes;
131
+ const fromMin = (n: number): TimeValue => ({ hours: Math.floor(n / 60) % 24, minutes: n % 60 });
132
+
133
+ /** True when the locale writes times with AM / PM. */
134
+ export function uses12Hour(locale?: string) {
135
+ try {
136
+ return new Intl.DateTimeFormat(locale, { hour: 'numeric' }).resolvedOptions().hour12 ?? true;
137
+ } catch {
138
+ return true;
139
+ }
140
+ }
141
+
142
+ /** '9:30 AM' or '09:30'. */
143
+ export function formatTime(t: TimeValue, hour12 = true) {
144
+ if (!hour12) return `${pad2(t.hours)}:${pad2(t.minutes)}`;
145
+ const h = t.hours % 12 || 12;
146
+ return `${h}:${pad2(t.minutes)} ${t.hours < 12 ? 'AM' : 'PM'}`;
147
+ }
148
+
149
+ /* ───────────────────────────── TimePicker ───────────────────────────── */
150
+
151
+ /**
152
+ * Time input in three styles: iOS-style wheel drums whose rows tilt, shrink and fade as they
153
+ * roll past the selection band (scroll-driven, native), a grid of bookable time chips with
154
+ * booked / disabled slots, or a Material 3-style clock face you drag the hand around
155
+ * (`variant="clock"`). Render it inline or as a field that opens it in a drag-to-dismiss sheet.
156
+ */
157
+ export function TimePicker({
158
+ value,
159
+ defaultValue = null,
160
+ onChange,
161
+ variant = 'wheel',
162
+ presentation = 'inline',
163
+ hour12,
164
+ minuteInterval,
165
+ locale,
166
+ ...rest
167
+ }: TimePickerProps) {
168
+ const [inner, setInner] = useState<TimeValue | null>(defaultValue);
169
+ const current = value !== undefined ? value : inner;
170
+ const h12 = useMemo(() => hour12 ?? uses12Hour(locale), [hour12, locale]);
171
+ const step = Math.max(1, Math.min(60, minuteInterval ?? (variant === 'chips' ? 30 : 1)));
172
+ const commit = (t: TimeValue) => {
173
+ if (value === undefined) setInner(t);
174
+ onChange?.(t);
175
+ };
176
+
177
+ if (presentation === 'sheet') {
178
+ return <TimeField {...rest} variant={variant} hour12={h12} step={step} current={current} commit={commit} />;
179
+ }
180
+ if (variant === 'chips') return <SlotGrid {...rest} hour12={h12} step={step} value={current} onPick={commit} />;
181
+ if (variant === 'clock') return <Clock hour12={h12} step={step} value={current} onChange={commit} accent={rest.accent} style={rest.style} />;
182
+ return <Wheel hour12={h12} step={step} value={current} onChange={commit} accent={rest.accent} style={rest.style} />;
183
+ }
184
+
185
+ /* ───────────────────────────── wheel ───────────────────────────── */
186
+
187
+ interface WheelProps {
188
+ hour12: boolean;
189
+ step: number;
190
+ value: TimeValue | null;
191
+ onChange: (t: TimeValue) => void;
192
+ accent?: string;
193
+ style?: StyleProp<ViewStyle>;
194
+ }
195
+
196
+ function Wheel({ hour12, step, value, onChange, accent, style }: WheelProps) {
197
+ const { colors, radius, font, dark } = useKit();
198
+ const tint = accent ?? colors.accent;
199
+ const t = value ?? { hours: 9, minutes: 0 };
200
+ const hours = useMemo(() => (hour12 ? ['12', ...Array.from({ length: 11 }, (_, i) => String(i + 1))] : Array.from({ length: 24 }, (_, i) => pad2(i))), [hour12]);
201
+ const minutes = useMemo(() => Array.from({ length: Math.ceil(60 / step) }, (_, i) => pad2(i * step)), [step]);
202
+ const periods = useMemo(() => ['AM', 'PM'], []);
203
+
204
+ const hIdx = hour12 ? t.hours % 12 : t.hours;
205
+ const mIdx = Math.min(minutes.length - 1, Math.round(t.minutes / step));
206
+ const pIdx = t.hours >= 12 ? 1 : 0;
207
+
208
+ // Keep the latest value for column callbacks that fire after a scroll settles.
209
+ const latest = useRef(t);
210
+ latest.current = t;
211
+ const set = (patch: Partial<{ h: number; m: number; p: number }>) => {
212
+ const cur = latest.current;
213
+ const curP = cur.hours >= 12 ? 1 : 0;
214
+ const h = patch.h ?? (hour12 ? cur.hours % 12 : cur.hours);
215
+ const p = patch.p ?? curP;
216
+ const m = patch.m !== undefined ? patch.m * step : Math.min(minutes.length - 1, Math.round(cur.minutes / step)) * step;
217
+ const next = { hours: hour12 ? h + p * 12 : h, minutes: m };
218
+ latest.current = next;
219
+ onChange(next);
220
+ };
221
+
222
+ return (
223
+ <View style={[styles.wheel, { height: ITEM * VISIBLE }, style]}>
224
+ {/* The selection band: an inset, softly tinted capsule behind the middle row. */}
225
+ <View
226
+ pointerEvents="none"
227
+ style={[styles.band, { top: PAD, height: ITEM, borderRadius: radius.scale(12), backgroundColor: alpha(tint, dark ? 0.16 : 0.11) }]}
228
+ />
229
+ <View style={styles.wheelGroup}>
230
+ <WheelColumn items={hours} index={hIdx} onIndexChange={(h) => set({ h })} label="Hour" width={64} align="right" />
231
+ <View pointerEvents="none" style={styles.colon}>
232
+ <Text style={[styles.colonText, font.family ? { fontFamily: font.family } : null, { color: colors.mutedForeground }]}>:</Text>
233
+ </View>
234
+ <WheelColumn items={minutes} index={mIdx} onIndexChange={(m) => set({ m })} label="Minute" width={64} align="left" />
235
+ {hour12 ? (
236
+ <WheelColumn items={periods} index={pIdx} onIndexChange={(p) => set({ p })} label="AM or PM" width={60} align="center" small style={styles.periodCol} />
237
+ ) : null}
238
+ </View>
239
+ </View>
240
+ );
241
+ }
242
+
243
+ interface WheelColumnProps {
244
+ items: string[];
245
+ index: number;
246
+ onIndexChange: (i: number) => void;
247
+ label: string;
248
+ width: number;
249
+ align: 'left' | 'right' | 'center';
250
+ small?: boolean;
251
+ style?: StyleProp<ViewStyle>;
252
+ }
253
+
254
+ const WheelColumn = memo(function WheelColumn({ items, index, onIndexChange, label, width, align, small, style }: WheelColumnProps) {
255
+ const { colors, font } = useKit();
256
+ const ff = font.family ? { fontFamily: font.family } : null;
257
+ const scrollY = useRef(new Animated.Value(index * ITEM)).current; // native, driven by the scroll view
258
+ const ref = useRef<ScrollView>(null);
259
+ const settled = useRef(index);
260
+ const dragging = useRef(false);
261
+ const lastY = useRef(index * ITEM);
262
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
263
+ const cb = useRef(onIndexChange);
264
+ cb.current = onIndexChange;
265
+
266
+ useEffect(() => {
267
+ if (index !== settled.current && !dragging.current) {
268
+ settled.current = index;
269
+ ref.current?.scrollTo({ y: index * ITEM, animated: true });
270
+ }
271
+ }, [index]);
272
+ useEffect(() => () => {
273
+ if (timer.current) clearTimeout(timer.current);
274
+ }, []);
275
+
276
+ const settle = () => {
277
+ const y = lastY.current;
278
+ const i = Math.max(0, Math.min(items.length - 1, Math.round(y / ITEM)));
279
+ if (Math.abs(y - i * ITEM) > 0.5) ref.current?.scrollTo({ y: i * ITEM, animated: true });
280
+ if (i !== settled.current) {
281
+ settled.current = i;
282
+ cb.current(i);
283
+ }
284
+ };
285
+ const schedule = () => {
286
+ if (timer.current) clearTimeout(timer.current);
287
+ timer.current = setTimeout(() => !dragging.current && settle(), 130);
288
+ };
289
+ const goTo = (i: number) => {
290
+ const n = Math.max(0, Math.min(items.length - 1, i));
291
+ lastY.current = n * ITEM;
292
+ ref.current?.scrollTo({ y: n * ITEM, animated: true });
293
+ schedule();
294
+ };
295
+
296
+ const onScroll = useMemo(
297
+ () =>
298
+ Animated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], {
299
+ useNativeDriver: true,
300
+ listener: (e: NativeSyntheticEvent<NativeScrollEvent>) => {
301
+ lastY.current = e.nativeEvent.contentOffset.y;
302
+ schedule();
303
+ },
304
+ }),
305
+ // eslint-disable-next-line react-hooks/exhaustive-deps
306
+ [scrollY]
307
+ );
308
+
309
+ const size = small ? styles.itemSmall : null;
310
+ return (
311
+ <View
312
+ accessible
313
+ accessibilityRole="adjustable"
314
+ accessibilityLabel={label}
315
+ accessibilityValue={{ text: items[index] }}
316
+ accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
317
+ onAccessibilityAction={(e) => goTo(settled.current + (e.nativeEvent.actionName === 'increment' ? 1 : -1))}
318
+ style={[{ width, height: ITEM * VISIBLE }, style]}
319
+ >
320
+ <Animated.ScrollView
321
+ ref={ref}
322
+ showsVerticalScrollIndicator={false}
323
+ snapToInterval={ITEM}
324
+ decelerationRate="fast"
325
+ scrollEventThrottle={16}
326
+ contentOffset={{ x: 0, y: index * ITEM }}
327
+ onLayout={() => ref.current?.scrollTo({ y: settled.current * ITEM, animated: false })}
328
+ onScroll={onScroll}
329
+ onScrollBeginDrag={() => {
330
+ dragging.current = true;
331
+ }}
332
+ onScrollEndDrag={() => {
333
+ dragging.current = false;
334
+ schedule();
335
+ }}
336
+ contentContainerStyle={{ paddingVertical: PAD }}
337
+ >
338
+ {items.map((item, i) => {
339
+ const c = i * ITEM;
340
+ const input = [c - 3 * ITEM, c - 2 * ITEM, c - ITEM, c, c + ITEM, c + 2 * ITEM, c + 3 * ITEM];
341
+ return (
342
+ <Pressable key={item + i} onPress={() => goTo(i)} accessible={false}>
343
+ <Animated.View
344
+ style={[
345
+ styles.item,
346
+ {
347
+ opacity: scrollY.interpolate({ inputRange: input, outputRange: [0.14, 0.28, 0.55, 1, 0.55, 0.28, 0.14], extrapolate: 'clamp' }),
348
+ transform: [
349
+ { perspective: 700 },
350
+ {
351
+ rotateX: scrollY.interpolate({
352
+ inputRange: input,
353
+ outputRange: ['-62deg', '-42deg', '-21deg', '0deg', '21deg', '42deg', '62deg'],
354
+ extrapolate: 'clamp',
355
+ }),
356
+ },
357
+ // 22 → 20pt for neighbours, smaller still towards the rim.
358
+ { scale: scrollY.interpolate({ inputRange: input, outputRange: [0.8, 0.85, 0.91, 1, 0.91, 0.85, 0.8], extrapolate: 'clamp' }) },
359
+ ],
360
+ },
361
+ ]}
362
+ >
363
+ <Text numberOfLines={1} style={[styles.itemText, size, ff, { color: colors.foreground, textAlign: align }]}>
364
+ {item}
365
+ </Text>
366
+ </Animated.View>
367
+ </Pressable>
368
+ );
369
+ })}
370
+ </Animated.ScrollView>
371
+ </View>
372
+ );
373
+ });
374
+
375
+ /* ───────────────────────────── clock ───────────────────────────── */
376
+
377
+ type ClockMode = 'hour' | 'minute';
378
+
379
+ interface ClockProps {
380
+ hour12: boolean;
381
+ step: number;
382
+ value: TimeValue | null;
383
+ onChange: (t: TimeValue) => void;
384
+ accent?: string;
385
+ style?: StyleProp<ViewStyle>;
386
+ }
387
+
388
+ const DIAL_MAX = 280;
389
+ const KNOB = 22; // selector radius → a 44pt circle
390
+
391
+ const HOUR_OUTER = ['12', ...Array.from({ length: 11 }, (_, i) => String(i + 1))];
392
+ const HOUR_INNER = ['00', ...Array.from({ length: 11 }, (_, i) => String(i + 13))];
393
+ const MINUTE_LABELS = Array.from({ length: 12 }, (_, i) => pad2(i * 5));
394
+
395
+ /** Clockwise degrees from 12 o'clock for the hand in the given mode. */
396
+ const handAngle = (t: TimeValue, mode: ClockMode) => (mode === 'hour' ? (t.hours % 12) * 30 : t.minutes * 6);
397
+
398
+ /**
399
+ * Material 3-style analog dial: a large hh : mm header whose segments switch the dial between
400
+ * hours and minutes, an AM / PM toggle, and a face you drag anywhere on. The hand rotates on the
401
+ * native driver and snaps to hours, then auto-advances to minutes when you let go.
402
+ */
403
+ function Clock({ hour12, step, value, onChange, accent, style }: ClockProps) {
404
+ const { colors, radius, font, dark } = useKit();
405
+ const ff = font.family ? { fontFamily: font.family } : null;
406
+ const tint = accent ?? colors.accent;
407
+ const ink = readableOn(tint);
408
+ const t = value ?? { hours: 9, minutes: 0 };
409
+ const pm = t.hours >= 12;
410
+
411
+ const [mode, setMode] = useState<ClockMode>('hour');
412
+ const [width, setWidth] = useState(0);
413
+ const size = Math.min(width || DIAL_MAX, DIAL_MAX);
414
+ const narrow = width > 0 && width < 300;
415
+ const c = size / 2;
416
+ const outer = c - 28;
417
+ const inner = outer - 40;
418
+ const innerHour = !hour12 && (t.hours === 0 || t.hours > 12);
419
+ const handR = mode === 'hour' && innerHour ? inner : outer;
420
+
421
+ // Hand rotation in degrees (native). Kept continuous so 55 → 00 turns forward, not all the way back.
422
+ const rot = useRef(new Animated.Value(handAngle(t, 'hour'))).current;
423
+ const angle = useRef(handAngle(t, 'hour'));
424
+ const modeAnim = useRef(new Animated.Value(0)).current; // native: number crossfade
425
+ useEffect(() => {
426
+ const target = handAngle(t, mode);
427
+ const d = ((((target - angle.current) % 360) + 540) % 360) - 180;
428
+ angle.current += d;
429
+ Animated.spring(rot, { toValue: angle.current, useNativeDriver: true, ...motion.snappy }).start();
430
+ }, [t.hours, t.minutes, mode, rot]); // eslint-disable-line react-hooks/exhaustive-deps
431
+ useEffect(() => {
432
+ Animated.timing(modeAnim, { toValue: mode === 'minute' ? 1 : 0, duration: motion.normal - 20, easing: Easing.out(Easing.cubic), useNativeDriver: true }).start();
433
+ }, [mode, modeAnim]);
434
+
435
+ // Latest values for the pan responder, which is created once.
436
+ const live = useRef({ t, mode, hour12, step, onChange, c, outer, inner, touched: value !== null });
437
+ live.current = { t, mode, hour12, step, onChange, c, outer, inner, touched: value !== null };
438
+ const origin = useRef({ x: 0, y: 0 });
439
+
440
+ const pan = useMemo(() => {
441
+ const pick = (x: number, y: number) => {
442
+ const L = live.current;
443
+ const dx = x - L.c;
444
+ const dy = y - L.c;
445
+ let deg = (Math.atan2(dx, -dy) * 180) / Math.PI;
446
+ if (deg < 0) deg += 360;
447
+ const cur = L.t;
448
+ let next: TimeValue;
449
+ if (L.mode === 'hour') {
450
+ const i = Math.round(deg / 30) % 12;
451
+ if (L.hour12) next = { hours: i + (cur.hours >= 12 ? 12 : 0), minutes: cur.minutes };
452
+ else {
453
+ const innerRing = Math.hypot(dx, dy) < (L.outer + L.inner) / 2;
454
+ next = { hours: innerRing ? (i === 0 ? 0 : i + 12) : i === 0 ? 12 : i, minutes: cur.minutes };
455
+ }
456
+ } else {
457
+ next = { hours: cur.hours, minutes: (Math.round(deg / 6 / L.step) * L.step) % 60 };
458
+ }
459
+ if (next.hours !== cur.hours || next.minutes !== cur.minutes || !L.touched) {
460
+ L.t = next;
461
+ L.touched = true;
462
+ L.onChange(next);
463
+ }
464
+ };
465
+ return PanResponder.create({
466
+ onStartShouldSetPanResponder: () => true,
467
+ onMoveShouldSetPanResponder: () => true,
468
+ onPanResponderTerminationRequest: () => false,
469
+ onPanResponderGrant: (e) => {
470
+ origin.current = { x: e.nativeEvent.locationX, y: e.nativeEvent.locationY };
471
+ pick(origin.current.x, origin.current.y);
472
+ },
473
+ onPanResponderMove: (_, g) => pick(origin.current.x + g.dx, origin.current.y + g.dy),
474
+ onPanResponderRelease: () => {
475
+ if (live.current.mode === 'hour') setMode('minute');
476
+ },
477
+ });
478
+ }, []);
479
+
480
+ const adjust = (dir: 1 | -1) => {
481
+ if (mode === 'hour') {
482
+ const hours = hour12 ? ((((t.hours % 12) + dir) % 12) + 12) % 12 + (pm ? 12 : 0) : (t.hours + dir + 24) % 24;
483
+ onChange({ hours, minutes: t.minutes });
484
+ } else {
485
+ onChange({ hours: t.hours, minutes: (((t.minutes + dir * step) % 60) + 60) % 60 });
486
+ }
487
+ };
488
+ const setPeriod = (toPm: boolean) => {
489
+ if (toPm !== pm || value === null) onChange({ hours: (t.hours % 12) + (toPm ? 12 : 0), minutes: t.minutes });
490
+ };
491
+
492
+ const hourText = pad2(hour12 ? t.hours % 12 || 12 : t.hours);
493
+ const knobLabel =
494
+ mode === 'hour' ? (hour12 ? String(t.hours % 12 || 12) : innerHour ? pad2(t.hours) : String(t.hours)) : t.minutes % 5 === 0 ? pad2(t.minutes) : '';
495
+ const segW = narrow ? 76 : 92;
496
+ const segH = narrow ? 64 : 76;
497
+ const segFont = narrow ? 40 : 48;
498
+ const rotate = rot.interpolate({ inputRange: [-360, 360], outputRange: ['-360deg', '360deg'] });
499
+ const counter = rot.interpolate({ inputRange: [-360, 360], outputRange: ['360deg', '-360deg'] });
500
+
501
+ const ring = (labels: string[], r: number, small?: boolean) =>
502
+ labels.map((l, i) => {
503
+ const a = (i * Math.PI) / 6;
504
+ return (
505
+ <View key={l} style={[styles.num, { left: c + r * Math.sin(a) - KNOB, top: c - r * Math.cos(a) - KNOB }]}>
506
+ <Text style={[styles.numText, small && styles.numSmall, ff, { color: small ? colors.mutedForeground : colors.foreground }]}>{l}</Text>
507
+ </View>
508
+ );
509
+ });
510
+
511
+ const segment = (m: ClockMode, text: string, label: string) => {
512
+ const on = mode === m;
513
+ return (
514
+ <Pressable
515
+ onPress={() => setMode(m)}
516
+ accessibilityRole="button"
517
+ accessibilityLabel={`${label}, ${text}`}
518
+ accessibilityState={{ selected: on }}
519
+ style={({ pressed }) => [
520
+ styles.segment,
521
+ { width: segW, height: segH, borderRadius: radius.scale(12), backgroundColor: on ? alpha(tint, dark ? 0.22 : 0.14) : colors.muted, opacity: pressed ? 0.75 : 1 },
522
+ ]}
523
+ >
524
+ <Text style={[styles.segText, ff, { fontSize: segFont, color: on ? tint : colors.foreground }]}>{text}</Text>
525
+ </Pressable>
526
+ );
527
+ };
528
+
529
+ return (
530
+ <View style={[styles.clock, style]} onLayout={(e) => setWidth(e.nativeEvent.layout.width)}>
531
+ <View style={styles.clockHead}>
532
+ {segment('hour', hourText, 'Hour')}
533
+ <Text style={[styles.clockColon, ff, { fontSize: segFont, color: colors.mutedForeground }]}>:</Text>
534
+ {segment('minute', pad2(t.minutes), 'Minute')}
535
+ {hour12 ? (
536
+ <View
537
+ accessibilityRole="radiogroup"
538
+ style={[styles.period, { height: segH, borderRadius: radius.scale(10), borderColor: colors.border }]}
539
+ >
540
+ {(['AM', 'PM'] as const).map((p, i) => {
541
+ const on = (p === 'PM') === pm;
542
+ return (
543
+ <Pressable
544
+ key={p}
545
+ onPress={() => setPeriod(p === 'PM')}
546
+ accessibilityRole="radio"
547
+ accessibilityLabel={p}
548
+ accessibilityState={{ checked: on }}
549
+ style={[
550
+ styles.periodBtn,
551
+ { backgroundColor: on ? alpha(tint, dark ? 0.22 : 0.14) : 'transparent' },
552
+ i === 1 && { borderTopWidth: StyleSheet.hairlineWidth * 2, borderTopColor: colors.border },
553
+ ]}
554
+ >
555
+ <Text style={[styles.periodText, ff, { color: on ? tint : colors.mutedForeground }]}>{p}</Text>
556
+ </Pressable>
557
+ );
558
+ })}
559
+ </View>
560
+ ) : null}
561
+ </View>
562
+
563
+ <View
564
+ {...pan.panHandlers}
565
+ accessible
566
+ accessibilityRole="adjustable"
567
+ accessibilityLabel={mode === 'hour' ? 'Hour' : 'Minute'}
568
+ accessibilityHint="Drag around the dial, or swipe up or down to adjust."
569
+ accessibilityValue={{ text: mode === 'hour' ? `${knobLabel || hourText} ${hour12 ? (pm ? 'PM' : 'AM') : 'hours'}` : `${t.minutes} minutes` }}
570
+ accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
571
+ onAccessibilityAction={(e) => adjust(e.nativeEvent.actionName === 'increment' ? 1 : -1)}
572
+ style={[styles.face, { width: size, height: size }]}
573
+ >
574
+ {/* Children ignore touches so locationX / Y stay relative to the face. */}
575
+ <View pointerEvents="none" style={StyleSheet.absoluteFill}>
576
+ <Svg width={size} height={size}>
577
+ <Circle cx={c} cy={c} r={c} fill={colors.muted} />
578
+ </Svg>
579
+ <Animated.View
580
+ style={[
581
+ StyleSheet.absoluteFill,
582
+ { opacity: modeAnim.interpolate({ inputRange: [0, 1], outputRange: [1, 0] }), transform: [{ scale: modeAnim.interpolate({ inputRange: [0, 1], outputRange: [1, 0.88] }) }] },
583
+ ]}
584
+ >
585
+ {ring(HOUR_OUTER, outer)}
586
+ {hour12 ? null : ring(HOUR_INNER, inner, true)}
587
+ </Animated.View>
588
+ <Animated.View
589
+ style={[StyleSheet.absoluteFill, { opacity: modeAnim, transform: [{ scale: modeAnim.interpolate({ inputRange: [0, 1], outputRange: [1.1, 1] }) }] }]}
590
+ >
591
+ {ring(MINUTE_LABELS, outer)}
592
+ </Animated.View>
593
+ <Animated.View style={[StyleSheet.absoluteFill, { transform: [{ rotate }] }]}>
594
+ <Svg width={size} height={size}>
595
+ <Line x1={c} y1={c} x2={c} y2={c - handR} stroke={tint} strokeWidth={2} strokeLinecap="round" />
596
+ <Circle cx={c} cy={c} r={4} fill={tint} />
597
+ <Circle cx={c} cy={c - handR} r={KNOB} fill={tint} />
598
+ {knobLabel ? null : <Circle cx={c} cy={c - handR} r={3} fill={ink} />}
599
+ </Svg>
600
+ <View style={[styles.num, { left: c - KNOB, top: c - handR - KNOB }]}>
601
+ <Animated.Text
602
+ style={[styles.numText, mode === 'hour' && innerHour && styles.numSmall, ff, { color: ink, transform: [{ rotate: counter }] }]}
603
+ >
604
+ {knobLabel}
605
+ </Animated.Text>
606
+ </View>
607
+ </Animated.View>
608
+ </View>
609
+ </View>
610
+ </View>
611
+ );
612
+ }
613
+
614
+ /* ───────────────────────────── chips ───────────────────────────── */
615
+
616
+ interface SlotGridProps extends Pick<TimePickerProps, 'startTime' | 'endTime' | 'slots' | 'bookedTimes' | 'disabledTimes' | 'columns' | 'groupByPeriod' | 'accent' | 'style'> {
617
+ hour12: boolean;
618
+ step: number;
619
+ value: TimeValue | null;
620
+ onPick: (t: TimeValue) => void;
621
+ }
622
+
623
+ function SlotGrid({
624
+ startTime = '09:00',
625
+ endTime = '17:00',
626
+ slots,
627
+ bookedTimes,
628
+ disabledTimes,
629
+ columns = 3,
630
+ groupByPeriod = false,
631
+ accent,
632
+ style,
633
+ hour12,
634
+ step,
635
+ value,
636
+ onPick,
637
+ }: SlotGridProps) {
638
+ const { colors, font } = useKit();
639
+ const ff = font.family ? { fontFamily: font.family } : null;
640
+ const tint = accent ?? colors.accent;
641
+ const list = useMemo(() => {
642
+ if (slots) return slots.map((s) => toMin(parseTime(s)));
643
+ const out: number[] = [];
644
+ for (let n = toMin(parseTime(startTime)); n < toMin(parseTime(endTime)); n += step) out.push(n);
645
+ return out;
646
+ }, [slots, startTime, endTime, step]);
647
+ const booked = useMemo(() => new Set((bookedTimes ?? []).map((t) => toMin(parseTime(t)))), [bookedTimes]);
648
+ const off = useMemo(() => new Set((disabledTimes ?? []).map((t) => toMin(parseTime(t)))), [disabledTimes]);
649
+ const selected = value ? toMin(value) : -1;
650
+
651
+ const groups = useMemo(() => {
652
+ if (!groupByPeriod) return [{ title: '', items: list }];
653
+ const g = [
654
+ { title: 'Morning', items: list.filter((n) => n < 720) },
655
+ { title: 'Afternoon', items: list.filter((n) => n >= 720 && n < 1020) },
656
+ { title: 'Evening', items: list.filter((n) => n >= 1020) },
657
+ ];
658
+ return g.filter((x) => x.items.length);
659
+ }, [list, groupByPeriod]);
660
+
661
+ return (
662
+ <View style={[styles.slots, style]} accessibilityRole="radiogroup">
663
+ {groups.map((g) => {
664
+ const free = g.items.filter((n) => !booked.has(n) && !off.has(n)).length;
665
+ return (
666
+ <View key={g.title || 'all'} style={styles.slotGroup}>
667
+ {g.title ? (
668
+ <View style={styles.groupHead}>
669
+ <Text style={[styles.groupTitle, ff, { color: colors.foreground }]}>{g.title}</Text>
670
+ <Text style={[styles.groupCount, ff, { color: colors.mutedForeground }]}>{free ? `${free} open` : 'Full'}</Text>
671
+ </View>
672
+ ) : null}
673
+ <View style={styles.grid}>
674
+ {g.items.map((n) => (
675
+ <View key={n} style={[styles.chipCell, { width: `${100 / columns}%` }]}>
676
+ <Chip
677
+ minutes={n}
678
+ label={formatTime(fromMin(n), hour12)}
679
+ selected={n === selected}
680
+ booked={booked.has(n)}
681
+ disabled={off.has(n)}
682
+ tint={tint}
683
+ onPick={onPick}
684
+ />
685
+ </View>
686
+ ))}
687
+ </View>
688
+ </View>
689
+ );
690
+ })}
691
+ </View>
692
+ );
693
+ }
694
+
695
+ interface ChipProps {
696
+ minutes: number;
697
+ label: string;
698
+ selected: boolean;
699
+ booked: boolean;
700
+ disabled: boolean;
701
+ tint: string;
702
+ onPick: (t: TimeValue) => void;
703
+ }
704
+
705
+ const Chip = memo(function Chip({ minutes, label, selected, booked, disabled, tint, onPick }: ChipProps) {
706
+ const { colors, radius, font } = useKit();
707
+ const pop = useRef(new Animated.Value(1)).current; // native: press + select spring
708
+ const on = useRef(new Animated.Value(selected ? 1 : 0)).current; // native: fill opacity
709
+ useEffect(() => {
710
+ Animated.timing(on, { toValue: selected ? 1 : 0, duration: motion.fast, useNativeDriver: true }).start();
711
+ if (selected) {
712
+ pop.setValue(0.9);
713
+ Animated.spring(pop, { toValue: 1, useNativeDriver: true, ...motion.bouncy }).start();
714
+ }
715
+ }, [selected, on, pop]);
716
+ const blocked = booked || disabled;
717
+
718
+ return (
719
+ <Pressable
720
+ onPress={() => onPick(fromMin(minutes))}
721
+ onPressIn={() => Animated.spring(pop, { toValue: 0.95, useNativeDriver: true, ...motion.snappy }).start()}
722
+ onPressOut={() => Animated.spring(pop, { toValue: 1, useNativeDriver: true, ...motion.bouncy }).start()}
723
+ disabled={blocked}
724
+ accessibilityRole="radio"
725
+ accessibilityLabel={`${label}${booked ? ', booked' : disabled ? ', unavailable' : ''}`}
726
+ accessibilityState={{ checked: selected, disabled: blocked }}
727
+ >
728
+ <Animated.View
729
+ style={[
730
+ styles.chip,
731
+ {
732
+ borderRadius: radius.md,
733
+ borderColor: blocked ? 'transparent' : colors.border,
734
+ backgroundColor: blocked ? colors.muted : 'transparent',
735
+ opacity: disabled ? 0.45 : 1,
736
+ transform: [{ scale: pop }],
737
+ },
738
+ ]}
739
+ >
740
+ <Animated.View pointerEvents="none" style={[StyleSheet.absoluteFill, { backgroundColor: tint, borderRadius: radius.md - 1, opacity: on }]} />
741
+ <Text
742
+ numberOfLines={1}
743
+ style={[
744
+ styles.chipText,
745
+ font.family ? { fontFamily: font.family } : null,
746
+ { color: selected ? readableOn(tint) : blocked ? colors.mutedForeground : colors.foreground },
747
+ booked && styles.struck,
748
+ ]}
749
+ >
750
+ {label}
751
+ </Text>
752
+ </Animated.View>
753
+ </Pressable>
754
+ );
755
+ });
756
+
757
+ /* ───────────────────────────── field + sheet ───────────────────────────── */
758
+
759
+ interface TimeFieldProps extends Omit<TimePickerProps, 'value' | 'defaultValue' | 'onChange' | 'presentation' | 'hour12' | 'minuteInterval' | 'locale'> {
760
+ hour12: boolean;
761
+ step: number;
762
+ current: TimeValue | null;
763
+ commit: (t: TimeValue) => void;
764
+ }
765
+
766
+ function TimeField({
767
+ variant = 'wheel',
768
+ hour12,
769
+ step,
770
+ current,
771
+ commit,
772
+ label = 'Time',
773
+ floatingLabel = true,
774
+ title,
775
+ placeholder = 'Select a time',
776
+ size = 'md',
777
+ confirm,
778
+ error,
779
+ success,
780
+ helper,
781
+ disabled,
782
+ accent,
783
+ surface,
784
+ style,
785
+ fieldStyle,
786
+ inputStyle,
787
+ labelStyle,
788
+ messageStyle,
789
+ placeholderTextColor,
790
+ iconColor,
791
+ menuStyle,
792
+ ...chipProps
793
+ }: TimeFieldProps) {
794
+ const { colors, radius, font } = useKit();
795
+ const ff = font.family ? { fontFamily: font.family } : null;
796
+ const tint = accent ?? colors.accent;
797
+ const bg = surface ?? colors.background;
798
+ // The user's border colour only replaces the resting one; numeric height / radius drive the layout maths.
799
+ const { borderColor: userBorder, ...userField } = StyleSheet.flatten(fieldStyle) ?? {};
800
+ const userLabelColor = StyleSheet.flatten(labelStyle)?.color as string | undefined;
801
+ const base = SIZES[size];
802
+ const sz = typeof userField.height === 'number' ? { ...base, h: userField.height } : base;
803
+ const fieldRadius =
804
+ typeof userField.borderRadius === 'number' ? userField.borderRadius : size === 'sm' ? radius.md - 2 : size === 'lg' ? radius.lg : radius.md;
805
+ const { width: winW, height: winH } = useWindowDimensions();
806
+ const wide = winW >= 640;
807
+ const staged = confirm ?? variant !== 'chips';
808
+
809
+ const [open, setOpen] = useState(false);
810
+ const [mounted, setMounted] = useState(false);
811
+ const [draft, setDraft] = useState<TimeValue | null>(current);
812
+ const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
813
+
814
+ const ok = !error && !!success;
815
+ const floating = open || !!current;
816
+ const float = useRef(new Animated.Value(floating ? 1 : 0)).current; // native
817
+ const focus = useRef(new Animated.Value(0)).current; // JS: colours
818
+ const halo = useRef(new Animated.Value(0)).current; // native
819
+ const show = useRef(new Animated.Value(0)).current; // native
820
+ const drag = useRef(new Animated.Value(0)).current; // native
821
+ const shake = useRef(new Animated.Value(0)).current; // native
822
+ const check = useRef(new Animated.Value(ok ? 1 : 0)).current; // native
823
+
824
+ useEffect(() => {
825
+ Animated.spring(float, { toValue: floating ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start();
826
+ }, [floating, float]);
827
+ useEffect(() => {
828
+ Animated.timing(focus, { toValue: open ? 1 : 0, duration: 180, easing: Easing.out(Easing.quad), useNativeDriver: false }).start();
829
+ Animated.spring(halo, { toValue: open ? 1 : 0, useNativeDriver: true, ...motion.snappy }).start();
830
+ }, [open, focus, halo]);
831
+ useEffect(() => {
832
+ Animated.spring(check, { toValue: ok ? 1 : 0, useNativeDriver: true, ...motion.bouncy }).start();
833
+ }, [ok, check]);
834
+ useEffect(() => {
835
+ if (!error) return;
836
+ shake.setValue(0);
837
+ Animated.sequence([10, -8, 6, -4, 0].map((x) => Animated.timing(shake, { toValue: x, duration: 55, useNativeDriver: true }))).start();
838
+ }, [error, shake]);
839
+ useEffect(() => {
840
+ if (open) {
841
+ drag.setValue(0);
842
+ Animated.spring(show, { toValue: 1, useNativeDriver: true, ...motion.gentle }).start();
843
+ } else if (mounted) {
844
+ Animated.timing(show, { toValue: 0, duration: motion.fast + 40, easing: Easing.in(Easing.quad), useNativeDriver: true }).start(
845
+ ({ finished }) => finished && setMounted(false)
846
+ );
847
+ }
848
+ // eslint-disable-next-line react-hooks/exhaustive-deps
849
+ }, [open]);
850
+ useEffect(() => () => {
851
+ if (closeTimer.current) clearTimeout(closeTimer.current);
852
+ }, []);
853
+
854
+ const openSheet = () => {
855
+ if (disabled) return;
856
+ setDraft(current ?? (variant === 'chips' ? null : { hours: 9, minutes: 0 }));
857
+ setMounted(true);
858
+ setOpen(true);
859
+ };
860
+ const close = () => {
861
+ if (closeTimer.current) clearTimeout(closeTimer.current);
862
+ setOpen(false);
863
+ };
864
+ const pick = (t: TimeValue) => {
865
+ setDraft(t);
866
+ if (!staged) {
867
+ commit(t);
868
+ if (closeTimer.current) clearTimeout(closeTimer.current);
869
+ closeTimer.current = setTimeout(() => setOpen(false), 240);
870
+ }
871
+ };
872
+
873
+ const pan = useMemo(
874
+ () =>
875
+ PanResponder.create({
876
+ onMoveShouldSetPanResponder: (_, g) => g.dy > 4 && Math.abs(g.dy) > Math.abs(g.dx),
877
+ onPanResponderMove: (_, g) => drag.setValue(Math.max(0, g.dy)),
878
+ onPanResponderRelease: (_, g) => {
879
+ if (g.dy > 110 || g.vy > 1.1) setOpen(false);
880
+ else Animated.spring(drag, { toValue: 0, useNativeDriver: true, ...motion.snappy }).start();
881
+ },
882
+ onPanResponderTerminate: () => Animated.spring(drag, { toValue: 0, useNativeDriver: true, ...motion.snappy }).start(),
883
+ }),
884
+ [drag]
885
+ );
886
+
887
+ const active = error ? colors.danger : tint;
888
+ const rest = error ? colors.danger : ok ? colors.success : ((userBorder as string | undefined) ?? colors.input);
889
+ const borderColor = focus.interpolate({ inputRange: [0, 1], outputRange: [rest, active] });
890
+ const labelColor = focus.interpolate({
891
+ inputRange: [0, 1],
892
+ outputRange: [error ? colors.danger : ok ? colors.success : (userLabelColor ?? colors.mutedForeground), active],
893
+ });
894
+ const topLabelColor = focus.interpolate({ inputRange: [0, 1], outputRange: [error ? colors.danger : (userLabelColor ?? colors.foreground), active] });
895
+ const text = current ? formatTime(current, hour12) : '';
896
+ const successText = typeof success === 'string' && ok ? success : undefined;
897
+ const message = error ?? successText ?? helper;
898
+ const tone = error ? 'error' : successText ? 'success' : 'helper';
899
+
900
+ return (
901
+ <Animated.View style={[{ transform: [{ translateX: shake }] }, style]}>
902
+ {floatingLabel ? null : (
903
+ <Animated.Text
904
+ numberOfLines={1}
905
+ // The field already carries the label for screen readers.
906
+ accessible={false}
907
+ importantForAccessibility="no"
908
+ accessibilityElementsHidden
909
+ style={[styles.topLabel, ff, { fontSize: LABEL_ABOVE_FONT[size] }, labelStyle, { color: topLabelColor, opacity: disabled ? 0.5 : 1 }]}
910
+ >
911
+ {label}
912
+ </Animated.Text>
913
+ )}
914
+ <Pressable
915
+ onPress={openSheet}
916
+ disabled={disabled}
917
+ accessibilityRole="button"
918
+ accessibilityLabel={label}
919
+ accessibilityValue={{ text: text || 'Not set' }}
920
+ accessibilityState={{ expanded: open, disabled: !!disabled }}
921
+ style={{ opacity: disabled ? 0.5 : 1 }}
922
+ >
923
+ <Animated.View
924
+ pointerEvents="none"
925
+ style={[
926
+ styles.halo,
927
+ {
928
+ borderRadius: fieldRadius + 4,
929
+ borderColor: alpha(active, 0.2),
930
+ opacity: halo,
931
+ transform: [{ scale: halo.interpolate({ inputRange: [0, 1], outputRange: [0.97, 1] }) }],
932
+ },
933
+ ]}
934
+ />
935
+ <Animated.View style={[styles.field, { height: sz.h, borderRadius: fieldRadius, backgroundColor: bg }, userField, { borderColor }]}>
936
+ <Text
937
+ numberOfLines={1}
938
+ style={[
939
+ styles.value,
940
+ ff,
941
+ { fontSize: sz.font, color: text ? colors.foreground : colors.mutedForeground, paddingLeft: sz.pad, paddingRight: sz.pad + 64 },
942
+ inputStyle,
943
+ !text && { color: placeholderTextColor ?? colors.mutedForeground },
944
+ ]}
945
+ >
946
+ {text || (open || !floatingLabel ? placeholder : '')}
947
+ </Text>
948
+ {floatingLabel ? (
949
+ <Animated.View
950
+ pointerEvents="none"
951
+ style={[
952
+ styles.labelWrap,
953
+ {
954
+ left: sz.pad,
955
+ height: sz.h - 3,
956
+ transform: [
957
+ { translateY: float.interpolate({ inputRange: [0, 1], outputRange: [0, -sz.h / 2] }) },
958
+ { translateX: float.interpolate({ inputRange: [0, 1], outputRange: [0, -4] }) },
959
+ { scale: float.interpolate({ inputRange: [0, 1], outputRange: [1, FLOAT_SCALE] }) },
960
+ ],
961
+ },
962
+ ]}
963
+ >
964
+ {/* The notch: the label paints the surface over the border, so it matches the rendered width. */}
965
+ <Animated.Text numberOfLines={1} style={[styles.label, ff, { fontSize: sz.font, backgroundColor: bg }, labelStyle, { color: labelColor }]}>
966
+ {label}
967
+ </Animated.Text>
968
+ </Animated.View>
969
+ ) : null}
970
+ <View pointerEvents="none" style={[styles.adorn, { right: sz.pad - 2 }]}>
971
+ <Animated.View
972
+ style={{ display: ok ? 'flex' : 'none', opacity: check, transform: [{ scale: check.interpolate({ inputRange: [0, 1], outputRange: [0.3, 1] }) }] }}
973
+ >
974
+ <Icon name="check" size={sz.icon} color={colors.success} />
975
+ </Animated.View>
976
+ <Icon name="clock" size={sz.icon} color={open ? active : (iconColor ?? colors.mutedForeground)} />
977
+ </View>
978
+ </Animated.View>
979
+ </Pressable>
980
+ {message ? <FieldMessage key={tone + message} text={message} tone={tone} style={messageStyle} /> : null}
981
+
982
+ <Modal visible={mounted} transparent animationType="none" statusBarTranslucent onRequestClose={close}>
983
+ <Animated.View style={[StyleSheet.absoluteFill, { backgroundColor: colors.overlay, opacity: show }]}>
984
+ <Pressable style={StyleSheet.absoluteFill} onPress={close} accessibilityLabel="Close" accessibilityRole="button" />
985
+ </Animated.View>
986
+ <View style={[styles.sheetHost, wide && styles.centered]} pointerEvents="box-none">
987
+ <Animated.View
988
+ accessibilityViewIsModal
989
+ style={[
990
+ styles.sheet,
991
+ { backgroundColor: colors.popover, borderColor: colors.border, maxHeight: winH * 0.9 },
992
+ wide
993
+ ? { width: Math.min(420, winW - 48), borderRadius: radius.xl, paddingBottom: 14 }
994
+ : { alignSelf: 'stretch', borderTopLeftRadius: radius.xl + 4, borderTopRightRadius: radius.xl + 4, paddingBottom: 30 },
995
+ menuStyle,
996
+ wide
997
+ ? {
998
+ opacity: show,
999
+ transform: [
1000
+ { scale: show.interpolate({ inputRange: [0, 1], outputRange: [0.94, 1] }) },
1001
+ { translateY: Animated.add(show.interpolate({ inputRange: [0, 1], outputRange: [16, 0] }), drag) },
1002
+ ],
1003
+ }
1004
+ : { transform: [{ translateY: Animated.add(show.interpolate({ inputRange: [0, 1], outputRange: [winH * 0.7, 0] }), drag) }] },
1005
+ ]}
1006
+ >
1007
+ <View {...pan.panHandlers} style={styles.sheetHeader}>
1008
+ {!wide ? <View style={[styles.grabber, { backgroundColor: colors.border }]} /> : null}
1009
+ <Text style={[styles.title, ff, { color: colors.foreground }]}>{title ?? label}</Text>
1010
+ {variant === 'clock' ? null : (
1011
+ <Text style={[styles.preview, ff, { color: draft ? tint : colors.mutedForeground }]}>{draft ? formatTime(draft, hour12) : placeholder}</Text>
1012
+ )}
1013
+ </View>
1014
+ <ScrollView style={styles.sheetBody} contentContainerStyle={styles.sheetContent} bounces={false} scrollEnabled={variant === 'chips'}>
1015
+ {variant === 'chips' ? (
1016
+ <SlotGrid {...chipProps} accent={tint} hour12={hour12} step={step} value={draft} onPick={pick} />
1017
+ ) : variant === 'clock' ? (
1018
+ <Clock hour12={hour12} step={step} value={draft} onChange={pick} accent={tint} />
1019
+ ) : (
1020
+ <Wheel hour12={hour12} step={step} value={draft} onChange={pick} accent={tint} />
1021
+ )}
1022
+ </ScrollView>
1023
+ {staged ? (
1024
+ <View style={[styles.footer, { borderTopColor: colors.border }]}>
1025
+ <Pressable onPress={close} accessibilityRole="button" style={({ pressed }) => [styles.footBtn, { borderRadius: radius.md, opacity: pressed ? 0.6 : 1 }]}>
1026
+ <Text style={[styles.footText, ff, { color: colors.foreground }]}>Cancel</Text>
1027
+ </Pressable>
1028
+ <Pressable
1029
+ onPress={() => {
1030
+ if (draft) commit(draft);
1031
+ close();
1032
+ }}
1033
+ disabled={!draft}
1034
+ accessibilityRole="button"
1035
+ style={({ pressed }) => [
1036
+ styles.footBtn,
1037
+ styles.footMain,
1038
+ { backgroundColor: tint, borderRadius: radius.md, opacity: !draft ? 0.4 : pressed ? 0.85 : 1 },
1039
+ ]}
1040
+ >
1041
+ <Text style={[styles.footText, ff, { color: readableOn(tint) }]}>{draft ? `Set ${formatTime(draft, hour12)}` : 'Done'}</Text>
1042
+ </Pressable>
1043
+ </View>
1044
+ ) : null}
1045
+ </Animated.View>
1046
+ </View>
1047
+ </Modal>
1048
+ </Animated.View>
1049
+ );
1050
+ }
1051
+
1052
+ /** Helper / error / success line that slides in; error and success carry an icon. */
1053
+ function FieldMessage({ text, tone, style }: { text: string; tone: 'error' | 'success' | 'helper'; style?: StyleProp<TextStyle> }) {
1054
+ const { colors, font } = useKit();
1055
+ const enter = useRef(new Animated.Value(tone === 'helper' ? 1 : 0)).current; // native
1056
+ useEffect(() => {
1057
+ Animated.spring(enter, { toValue: 1, useNativeDriver: true, ...motion.snappy }).start();
1058
+ }, [enter]);
1059
+ const color = tone === 'error' ? colors.danger : tone === 'success' ? colors.success : colors.mutedForeground;
1060
+ return (
1061
+ <Animated.View
1062
+ accessibilityLiveRegion="polite"
1063
+ style={[styles.message, { opacity: enter, transform: [{ translateY: enter.interpolate({ inputRange: [0, 1], outputRange: [4, 0] }) }] }]}
1064
+ >
1065
+ {tone !== 'helper' ? <Icon name={tone === 'error' ? 'alert' : 'checkCircle'} size={14} color={color} /> : null}
1066
+ <Text style={[styles.helper, font.family ? { fontFamily: font.family } : null, { color }, style, tone !== 'helper' && { color }]}>{text}</Text>
1067
+ </Animated.View>
1068
+ );
1069
+ }
1070
+
1071
+ function Icon({ name, size, color }: { name: 'clock' | 'check' | 'checkCircle' | 'alert'; size: number; color: string }) {
1072
+ const p = { stroke: color, strokeWidth: 1.8, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const, fill: 'none' };
1073
+ return (
1074
+ <Svg width={size} height={size} viewBox="0 0 24 24">
1075
+ {name === 'clock' ? (
1076
+ <>
1077
+ <Circle cx={12} cy={12} r={8.75} {...p} />
1078
+ <Path d="M12 7.5V12l3 2" {...p} />
1079
+ </>
1080
+ ) : null}
1081
+ {name === 'check' ? <Path d="m5 12.5 4.5 4.5L19 7.5" {...p} strokeWidth={2.2} /> : null}
1082
+ {name === 'checkCircle' ? (
1083
+ <>
1084
+ <Circle cx={12} cy={12} r={9} {...p} />
1085
+ <Path d="m8 12.3 2.7 2.7 5.3-5.6" {...p} />
1086
+ </>
1087
+ ) : null}
1088
+ {name === 'alert' ? (
1089
+ <>
1090
+ <Circle cx={12} cy={12} r={9} {...p} />
1091
+ <Path d="M12 7.5v5.5M12 16.5v.01" {...p} strokeWidth={2.2} />
1092
+ </>
1093
+ ) : null}
1094
+ </Svg>
1095
+ );
1096
+ }
1097
+
1098
+ const styles = StyleSheet.create({
1099
+ wheel: { flexDirection: 'row', justifyContent: 'center', alignSelf: 'stretch', overflow: 'hidden' },
1100
+ band: { position: 'absolute', left: 8, right: 8 },
1101
+ wheelGroup: { flexDirection: 'row', alignItems: 'stretch' },
1102
+ colon: { width: 18, alignItems: 'center', justifyContent: 'center' },
1103
+ colonText: { fontSize: 22, fontWeight: '400', includeFontPadding: false, transform: [{ translateY: -1.5 }] },
1104
+ periodCol: { marginLeft: 10 },
1105
+ item: { height: ITEM, justifyContent: 'center', paddingHorizontal: 6 },
1106
+ itemText: { fontSize: 22, fontWeight: '400', fontVariant: ['tabular-nums'], letterSpacing: -0.2, includeFontPadding: false },
1107
+ itemSmall: { fontSize: 17, letterSpacing: 0.2 },
1108
+ clock: { alignSelf: 'stretch', alignItems: 'center', gap: 20 },
1109
+ clockHead: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center' },
1110
+ segment: { alignItems: 'center', justifyContent: 'center' },
1111
+ segText: { fontWeight: '400', fontVariant: ['tabular-nums'], letterSpacing: -0.5, includeFontPadding: false },
1112
+ clockColon: { width: 22, textAlign: 'center', fontWeight: '400', includeFontPadding: false, transform: [{ translateY: -3 }] },
1113
+ period: { width: 52, marginLeft: 12, borderWidth: StyleSheet.hairlineWidth * 2, overflow: 'hidden' },
1114
+ periodBtn: { flex: 1, alignItems: 'center', justifyContent: 'center' },
1115
+ periodText: { fontSize: 14, fontWeight: '400', letterSpacing: 0.3 },
1116
+ face: { alignSelf: 'center' },
1117
+ num: { position: 'absolute', width: KNOB * 2, height: KNOB * 2, alignItems: 'center', justifyContent: 'center' },
1118
+ numText: { fontSize: 16, fontWeight: '400', fontVariant: ['tabular-nums'], includeFontPadding: false },
1119
+ numSmall: { fontSize: 13 },
1120
+ slots: { alignSelf: 'stretch', gap: 14 },
1121
+ slotGroup: { gap: 6 },
1122
+ groupHead: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'baseline', paddingHorizontal: 2 },
1123
+ groupTitle: { fontSize: 13.5, fontWeight: '400' },
1124
+ groupCount: { fontSize: 12.5, fontWeight: '400' },
1125
+ grid: { flexDirection: 'row', flexWrap: 'wrap', marginHorizontal: -4 },
1126
+ chipCell: { padding: 4 },
1127
+ chip: { height: 44, borderWidth: 1, alignItems: 'center', justifyContent: 'center', overflow: 'hidden' },
1128
+ chipText: { fontSize: 14.5, fontWeight: '400', fontVariant: ['tabular-nums'] },
1129
+ struck: { textDecorationLine: 'line-through' },
1130
+ field: { borderWidth: 1.5, justifyContent: 'center' },
1131
+ halo: { position: 'absolute', top: -4, left: -4, right: -4, bottom: -4, borderWidth: 4 },
1132
+ value: { fontVariant: ['tabular-nums'] },
1133
+ labelWrap: { position: 'absolute', top: 0, right: 44, justifyContent: 'center', alignItems: 'flex-start', transformOrigin: 'left center' },
1134
+ label: { fontWeight: '400', paddingHorizontal: 5, marginLeft: -5 },
1135
+ topLabel: { fontWeight: '400', marginBottom: 6, marginHorizontal: 2 },
1136
+ adorn: { position: 'absolute', top: 0, bottom: 0, flexDirection: 'row', alignItems: 'center', gap: 10 },
1137
+ message: { flexDirection: 'row', alignItems: 'center', gap: 5, marginTop: 6, marginLeft: 4 },
1138
+ helper: { flex: 1, fontSize: 12.5 },
1139
+ sheetHost: { flex: 1, justifyContent: 'flex-end' },
1140
+ centered: { justifyContent: 'center', alignItems: 'center' },
1141
+ sheet: {
1142
+ borderWidth: StyleSheet.hairlineWidth,
1143
+ shadowColor: '#000',
1144
+ shadowOpacity: 0.18,
1145
+ shadowRadius: 24,
1146
+ shadowOffset: { width: 0, height: -4 },
1147
+ elevation: 16,
1148
+ },
1149
+ sheetHeader: { alignItems: 'center', paddingTop: 10, paddingBottom: 10, paddingHorizontal: 16 },
1150
+ grabber: { width: 40, height: 5, borderRadius: 3, marginBottom: 12 },
1151
+ title: { fontSize: 17, fontWeight: '400', letterSpacing: -0.2, marginTop: 4 },
1152
+ preview: { fontSize: 28, fontWeight: '400', letterSpacing: -0.8, marginTop: 4, fontVariant: ['tabular-nums'] },
1153
+ sheetBody: { flexGrow: 0, flexShrink: 1 },
1154
+ sheetContent: { paddingHorizontal: 16, paddingBottom: 12 },
1155
+ footer: { flexDirection: 'row', gap: 8, paddingTop: 12, paddingHorizontal: 16, borderTopWidth: StyleSheet.hairlineWidth },
1156
+ footBtn: { height: 46, paddingHorizontal: 20, alignItems: 'center', justifyContent: 'center' },
1157
+ footMain: { flex: 1 },
1158
+ footText: { fontSize: 15, fontWeight: '400' },
1159
+ });