@expokit/cli 0.0.0-stage → 0.3.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.
- package/LICENSE +21 -0
- package/README.md +46 -2
- package/bin/expo-kit.js +1266 -0
- package/bin/lib.mjs +343 -0
- package/bin/mcp.mjs +345 -0
- package/package.json +49 -4
- package/registry/_docs/accordion.json +115 -0
- package/registry/_docs/action-sheet.json +138 -0
- package/registry/_docs/alert.json +155 -0
- package/registry/_docs/avatar.json +227 -0
- package/registry/_docs/badge.json +148 -0
- package/registry/_docs/bottom-nav.json +143 -0
- package/registry/_docs/bottom-sheet.json +186 -0
- package/registry/_docs/bullet.json +27 -0
- package/registry/_docs/button-3d.json +138 -0
- package/registry/_docs/button.json +131 -0
- package/registry/_docs/calendar.json +198 -0
- package/registry/_docs/card-stack.json +124 -0
- package/registry/_docs/card.json +131 -0
- package/registry/_docs/carousel.json +122 -0
- package/registry/_docs/chat.json +184 -0
- package/registry/_docs/checkbox.json +109 -0
- package/registry/_docs/checkout.json +105 -0
- package/registry/_docs/chip-group.json +132 -0
- package/registry/_docs/color-swatch.json +82 -0
- package/registry/_docs/confetti.json +102 -0
- package/registry/_docs/coupon.json +192 -0
- package/registry/_docs/cube-pager.json +100 -0
- package/registry/_docs/dashboard.json +123 -0
- package/registry/_docs/date-picker.json +269 -0
- package/registry/_docs/dialog.json +186 -0
- package/registry/_docs/drawer.json +199 -0
- package/registry/_docs/fab.json +144 -0
- package/registry/_docs/flip-card.json +127 -0
- package/registry/_docs/floating-input.json +211 -0
- package/registry/_docs/gift-card.json +124 -0
- package/registry/_docs/glass-button.json +142 -0
- package/registry/_docs/glass-nav-bar.json +102 -0
- package/registry/_docs/glass-search-bar.json +114 -0
- package/registry/_docs/glass-segmented.json +94 -0
- package/registry/_docs/glass-sheet.json +136 -0
- package/registry/_docs/glass-tab-bar.json +100 -0
- package/registry/_docs/glass.json +94 -0
- package/registry/_docs/inbox.json +75 -0
- package/registry/_docs/isometric-bar.json +75 -0
- package/registry/_docs/kit.json +14 -0
- package/registry/_docs/kpi-card.json +14 -0
- package/registry/_docs/like-button.json +92 -0
- package/registry/_docs/liquidglass-tab.json +30 -0
- package/registry/_docs/list-item.json +216 -0
- package/registry/_docs/loyalty-card.json +124 -0
- package/registry/_docs/morph-button.json +128 -0
- package/registry/_docs/number-ticker.json +112 -0
- package/registry/_docs/offers.json +81 -0
- package/registry/_docs/onboarding.json +69 -0
- package/registry/_docs/otp-input.json +158 -0
- package/registry/_docs/pagination-dots.json +108 -0
- package/registry/_docs/parallax-layers.json +104 -0
- package/registry/_docs/parallax-scroll.json +210 -0
- package/registry/_docs/password-input.json +112 -0
- package/registry/_docs/payment-card.json +169 -0
- package/registry/_docs/pixel-bar.json +69 -0
- package/registry/_docs/polar-area.json +81 -0
- package/registry/_docs/profile.json +87 -0
- package/registry/_docs/progress-steps.json +116 -0
- package/registry/_docs/progress.json +152 -0
- package/registry/_docs/radio-group.json +112 -0
- package/registry/_docs/rating.json +114 -0
- package/registry/_docs/ring-carousel.json +122 -0
- package/registry/_docs/scratch-card.json +110 -0
- package/registry/_docs/search-bar.json +160 -0
- package/registry/_docs/search-results.json +123 -0
- package/registry/_docs/segmented-control.json +123 -0
- package/registry/_docs/select.json +277 -0
- package/registry/_docs/settings.json +75 -0
- package/registry/_docs/shimmer-button.json +98 -0
- package/registry/_docs/sign-in.json +201 -0
- package/registry/_docs/skeleton.json +162 -0
- package/registry/_docs/slide-to-confirm.json +108 -0
- package/registry/_docs/slider.json +177 -0
- package/registry/_docs/spin-wheel.json +138 -0
- package/registry/_docs/spinner.json +121 -0
- package/registry/_docs/stateful-button.json +184 -0
- package/registry/_docs/stepper.json +184 -0
- package/registry/_docs/swipe-deck.json +118 -0
- package/registry/_docs/swipeable-row.json +95 -0
- package/registry/_docs/switch.json +124 -0
- package/registry/_docs/tabs.json +129 -0
- package/registry/_docs/tag-input.json +148 -0
- package/registry/_docs/text-animation.json +141 -0
- package/registry/_docs/textarea.json +191 -0
- package/registry/_docs/ticket-card.json +194 -0
- package/registry/_docs/ticket-shape.json +14 -0
- package/registry/_docs/tilt-card.json +157 -0
- package/registry/_docs/time-picker.json +269 -0
- package/registry/_docs/timeline-chart.json +33 -0
- package/registry/_docs/timeline.json +172 -0
- package/registry/_docs/toast.json +185 -0
- package/registry/_docs/treemap.json +33 -0
- package/registry/_docs/waterfall.json +39 -0
- package/registry/_docs/wheel-list.json +122 -0
- package/registry/accordion/files/src/components/ui/accordion.tsx +233 -0
- package/registry/accordion/files/src/lib/kit.tsx +219 -0
- package/registry/accordion.json +17 -0
- package/registry/action-sheet/files/src/components/ui/action-sheet.tsx +580 -0
- package/registry/action-sheet/files/src/lib/kit.tsx +219 -0
- package/registry/action-sheet.json +18 -0
- package/registry/alert/files/src/components/ui/alert.tsx +654 -0
- package/registry/alert/files/src/components/ui/button.tsx +274 -0
- package/registry/alert/files/src/lib/kit.tsx +219 -0
- package/registry/alert.json +20 -0
- package/registry/avatar/files/src/components/ui/avatar.tsx +790 -0
- package/registry/avatar/files/src/lib/kit.tsx +219 -0
- package/registry/avatar.json +18 -0
- package/registry/badge/files/src/components/ui/badge.tsx +626 -0
- package/registry/badge/files/src/lib/kit.tsx +219 -0
- package/registry/badge.json +17 -0
- package/registry/bottom-nav/files/src/components/ui/bottom-nav.tsx +1050 -0
- package/registry/bottom-nav/files/src/lib/kit.tsx +219 -0
- package/registry/bottom-nav.json +18 -0
- package/registry/bottom-sheet/files/src/components/ui/bottom-sheet.tsx +585 -0
- package/registry/bottom-sheet/files/src/lib/kit.tsx +219 -0
- package/registry/bottom-sheet.json +17 -0
- package/registry/bullet.json +14 -0
- package/registry/button/files/src/components/ui/button.tsx +274 -0
- package/registry/button/files/src/lib/kit.tsx +219 -0
- package/registry/button-3d/files/src/components/ui/button-3d.tsx +212 -0
- package/registry/button-3d/files/src/lib/kit.tsx +219 -0
- package/registry/button-3d.json +18 -0
- package/registry/button.json +18 -0
- package/registry/calendar/files/src/components/ui/calendar.tsx +822 -0
- package/registry/calendar/files/src/lib/kit.tsx +219 -0
- package/registry/calendar.json +17 -0
- package/registry/card/files/src/components/ui/card.tsx +300 -0
- package/registry/card/files/src/lib/kit.tsx +219 -0
- package/registry/card-stack.json +15 -0
- package/registry/card.json +17 -0
- package/registry/carousel.json +15 -0
- package/registry/chat.json +18 -0
- package/registry/checkbox/files/src/components/ui/checkbox.tsx +150 -0
- package/registry/checkbox/files/src/lib/kit.tsx +219 -0
- package/registry/checkbox.json +17 -0
- package/registry/checkout.json +26 -0
- package/registry/chip-group/files/src/components/ui/chip-group.tsx +269 -0
- package/registry/chip-group/files/src/lib/kit.tsx +219 -0
- package/registry/chip-group.json +17 -0
- package/registry/color-swatch.json +17 -0
- package/registry/confetti/files/src/components/ui/confetti.tsx +222 -0
- package/registry/confetti/files/src/lib/kit.tsx +219 -0
- package/registry/confetti.json +15 -0
- package/registry/coupon/files/src/components/ui/coupon.tsx +698 -0
- package/registry/coupon/files/src/components/ui/flip-card.tsx +205 -0
- package/registry/coupon/files/src/components/ui/tilt-card.tsx +635 -0
- package/registry/coupon/files/src/lib/kit.tsx +219 -0
- package/registry/coupon/files/src/lib/ticket-shape.tsx +543 -0
- package/registry/coupon.json +23 -0
- package/registry/cube-pager.json +15 -0
- package/registry/dashboard.json +32 -0
- package/registry/date-picker/files/src/components/ui/calendar.tsx +822 -0
- package/registry/date-picker/files/src/components/ui/date-picker.tsx +796 -0
- package/registry/date-picker/files/src/lib/kit.tsx +219 -0
- package/registry/date-picker.json +19 -0
- package/registry/dialog/files/src/components/ui/dialog.tsx +759 -0
- package/registry/dialog/files/src/lib/kit.tsx +219 -0
- package/registry/dialog.json +18 -0
- package/registry/drawer/files/src/components/ui/drawer.tsx +1135 -0
- package/registry/drawer/files/src/lib/kit.tsx +219 -0
- package/registry/drawer.json +18 -0
- package/registry/fab.json +18 -0
- package/registry/flip-card/files/src/components/ui/flip-card.tsx +205 -0
- package/registry/flip-card/files/src/lib/kit.tsx +219 -0
- package/registry/flip-card.json +15 -0
- package/registry/floating-input/files/src/components/ui/floating-input.tsx +528 -0
- package/registry/floating-input/files/src/lib/kit.tsx +219 -0
- package/registry/floating-input.json +17 -0
- package/registry/gift-card.json +21 -0
- package/registry/glass-button.json +22 -0
- package/registry/glass-nav-bar.json +21 -0
- package/registry/glass-search-bar.json +21 -0
- package/registry/glass-segmented.json +22 -0
- package/registry/glass-sheet.json +22 -0
- package/registry/glass-tab-bar.json +22 -0
- package/registry/glass.json +19 -0
- package/registry/inbox.json +30 -0
- package/registry/isometric-bar.json +14 -0
- package/registry/kit/files/src/lib/kit.tsx +219 -0
- package/registry/kit.json +11 -0
- package/registry/kpi-card/files/src/components/charts/kpi-card.tsx +45 -0
- package/registry/kpi-card.json +14 -0
- package/registry/like-button.json +18 -0
- package/registry/liquidglass-tab/files/src/app/(tabs)/_layout.tsx +97 -0
- package/registry/liquidglass-tab/files/src/app/(tabs)/alerts.tsx +5 -0
- package/registry/liquidglass-tab/files/src/app/(tabs)/explore.tsx +5 -0
- package/registry/liquidglass-tab/files/src/app/(tabs)/index.tsx +5 -0
- package/registry/liquidglass-tab/files/src/app/(tabs)/profile.tsx +5 -0
- package/registry/liquidglass-tab/files/src/app/_layout.tsx +33 -0
- package/registry/liquidglass-tab/files/src/components/GlassTabBar.tsx +259 -0
- package/registry/liquidglass-tab/files/src/components/TabScreen.tsx +122 -0
- package/registry/liquidglass-tab/files/src/lib/tab-bar.ts +61 -0
- package/registry/liquidglass-tab/files/src/lib/tab-icons.ts +130 -0
- package/registry/liquidglass-tab/files/src/lib/tabs.ts +108 -0
- package/registry/liquidglass-tab.json +23 -0
- package/registry/list-item/files/src/components/ui/list-item.tsx +508 -0
- package/registry/list-item/files/src/lib/kit.tsx +219 -0
- package/registry/list-item.json +17 -0
- package/registry/loyalty-card.json +23 -0
- package/registry/morph-button.json +18 -0
- package/registry/number-ticker.json +15 -0
- package/registry/offers.json +24 -0
- package/registry/onboarding.json +17 -0
- package/registry/otp-input/files/src/components/ui/otp-input.tsx +380 -0
- package/registry/otp-input/files/src/lib/kit.tsx +219 -0
- package/registry/otp-input.json +17 -0
- package/registry/pagination-dots/files/src/components/ui/pagination-dots.tsx +262 -0
- package/registry/pagination-dots/files/src/lib/kit.tsx +219 -0
- package/registry/pagination-dots.json +15 -0
- package/registry/parallax-layers.json +15 -0
- package/registry/parallax-scroll.json +17 -0
- package/registry/password-input/files/src/components/ui/floating-input.tsx +528 -0
- package/registry/password-input/files/src/components/ui/password-input.tsx +271 -0
- package/registry/password-input/files/src/lib/kit.tsx +219 -0
- package/registry/password-input.json +19 -0
- package/registry/payment-card/files/src/components/ui/flip-card.tsx +205 -0
- package/registry/payment-card/files/src/components/ui/payment-card.tsx +385 -0
- package/registry/payment-card/files/src/components/ui/tilt-card.tsx +635 -0
- package/registry/payment-card/files/src/lib/kit.tsx +219 -0
- package/registry/payment-card.json +21 -0
- package/registry/pixel-bar.json +14 -0
- package/registry/polar-area.json +14 -0
- package/registry/profile.json +17 -0
- package/registry/progress/files/src/components/ui/progress.tsx +883 -0
- package/registry/progress/files/src/lib/kit.tsx +219 -0
- package/registry/progress-steps/files/src/components/ui/progress-steps.tsx +559 -0
- package/registry/progress-steps/files/src/lib/kit.tsx +219 -0
- package/registry/progress-steps.json +18 -0
- package/registry/progress.json +17 -0
- package/registry/radio-group/files/src/components/ui/radio-group.tsx +327 -0
- package/registry/radio-group/files/src/lib/kit.tsx +219 -0
- package/registry/radio-group.json +18 -0
- package/registry/rating.json +18 -0
- package/registry/ring-carousel.json +15 -0
- package/registry/scratch-card.json +18 -0
- package/registry/search-bar/files/src/components/ui/search-bar.tsx +530 -0
- package/registry/search-bar/files/src/lib/kit.tsx +219 -0
- package/registry/search-bar.json +17 -0
- package/registry/search-results.json +30 -0
- package/registry/segmented-control/files/src/components/ui/segmented-control.tsx +341 -0
- package/registry/segmented-control/files/src/lib/kit.tsx +219 -0
- package/registry/segmented-control.json +18 -0
- package/registry/select/files/src/components/ui/select.tsx +1398 -0
- package/registry/select/files/src/lib/kit.tsx +219 -0
- package/registry/select.json +17 -0
- package/registry/settings.json +26 -0
- package/registry/shimmer-button.json +17 -0
- package/registry/sign-in.json +26 -0
- package/registry/skeleton/files/src/components/ui/skeleton.tsx +799 -0
- package/registry/skeleton/files/src/lib/kit.tsx +219 -0
- package/registry/skeleton.json +17 -0
- package/registry/slide-to-confirm.json +18 -0
- package/registry/slider/files/src/components/ui/slider.tsx +716 -0
- package/registry/slider/files/src/lib/kit.tsx +219 -0
- package/registry/slider.json +17 -0
- package/registry/spin-wheel.json +18 -0
- package/registry/spinner/files/src/components/ui/spinner.tsx +748 -0
- package/registry/spinner/files/src/lib/kit.tsx +219 -0
- package/registry/spinner.json +17 -0
- package/registry/stateful-button/files/src/components/ui/stateful-button.tsx +532 -0
- package/registry/stateful-button/files/src/lib/kit.tsx +219 -0
- package/registry/stateful-button.json +18 -0
- package/registry/stepper/files/src/components/ui/stepper.tsx +646 -0
- package/registry/stepper/files/src/lib/kit.tsx +219 -0
- package/registry/stepper.json +17 -0
- package/registry/swipe-deck.json +15 -0
- package/registry/swipeable-row.json +15 -0
- package/registry/switch/files/src/components/ui/switch.tsx +344 -0
- package/registry/switch/files/src/lib/kit.tsx +219 -0
- package/registry/switch.json +18 -0
- package/registry/tabs/files/src/components/ui/tabs.tsx +539 -0
- package/registry/tabs/files/src/lib/kit.tsx +219 -0
- package/registry/tabs.json +17 -0
- package/registry/tag-input/files/src/components/ui/tag-input.tsx +720 -0
- package/registry/tag-input/files/src/lib/kit.tsx +219 -0
- package/registry/tag-input.json +17 -0
- package/registry/text-animation/files/src/components/ui/text-animation.tsx +650 -0
- package/registry/text-animation/files/src/lib/kit.tsx +219 -0
- package/registry/text-animation.json +15 -0
- package/registry/textarea/files/src/components/ui/textarea.tsx +553 -0
- package/registry/textarea/files/src/lib/kit.tsx +219 -0
- package/registry/textarea.json +17 -0
- package/registry/ticket-card.json +24 -0
- package/registry/ticket-shape/files/src/lib/kit.tsx +219 -0
- package/registry/ticket-shape/files/src/lib/ticket-shape.tsx +543 -0
- package/registry/ticket-shape.json +17 -0
- package/registry/tilt-card/files/src/components/ui/tilt-card.tsx +635 -0
- package/registry/tilt-card/files/src/lib/kit.tsx +219 -0
- package/registry/tilt-card.json +17 -0
- package/registry/time-picker/files/src/components/ui/time-picker.tsx +1159 -0
- package/registry/time-picker/files/src/lib/kit.tsx +219 -0
- package/registry/time-picker.json +17 -0
- package/registry/timeline/files/src/components/ui/avatar.tsx +790 -0
- package/registry/timeline/files/src/components/ui/timeline.tsx +1059 -0
- package/registry/timeline/files/src/lib/kit.tsx +219 -0
- package/registry/timeline-chart.json +14 -0
- package/registry/timeline.json +20 -0
- package/registry/toast/files/src/components/ui/button.tsx +274 -0
- package/registry/toast/files/src/components/ui/toast.tsx +1284 -0
- package/registry/toast/files/src/lib/kit.tsx +219 -0
- package/registry/toast.json +20 -0
- package/registry/treemap.json +14 -0
- package/registry/waterfall.json +14 -0
- package/registry/wheel-list.json +17 -0
- package/schema/components.schema.json +13 -0
|
@@ -0,0 +1,1059 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
AccessibilityInfo,
|
|
4
|
+
Animated,
|
|
5
|
+
Easing,
|
|
6
|
+
Platform,
|
|
7
|
+
Pressable,
|
|
8
|
+
StyleSheet,
|
|
9
|
+
Text,
|
|
10
|
+
View,
|
|
11
|
+
type LayoutChangeEvent,
|
|
12
|
+
type StyleProp,
|
|
13
|
+
type ViewStyle,
|
|
14
|
+
} from 'react-native';
|
|
15
|
+
import Svg, { Circle, Defs, Line, LinearGradient, Path, Rect, Stop } from 'react-native-svg';
|
|
16
|
+
|
|
17
|
+
import { Avatar } from '@/components/ui/avatar';
|
|
18
|
+
import { alpha, isTextNode, motion, readableOn, useKit } from '@/lib/kit';
|
|
19
|
+
|
|
20
|
+
export type TimelineStatus = 'done' | 'current' | 'upcoming' | 'error';
|
|
21
|
+
export type TimelineVariant = 'default' | 'compact' | 'card' | 'scroll';
|
|
22
|
+
|
|
23
|
+
export type TimelineIconName =
|
|
24
|
+
| 'cart'
|
|
25
|
+
| 'package'
|
|
26
|
+
| 'truck'
|
|
27
|
+
| 'home'
|
|
28
|
+
| 'rocket'
|
|
29
|
+
| 'bug'
|
|
30
|
+
| 'star'
|
|
31
|
+
| 'chat'
|
|
32
|
+
| 'tag'
|
|
33
|
+
| 'user';
|
|
34
|
+
|
|
35
|
+
export interface TimelineIconProps {
|
|
36
|
+
color: string;
|
|
37
|
+
size: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type TimelineImageSource = string | { uri: string; aspectRatio?: number };
|
|
41
|
+
|
|
42
|
+
export interface TimelineLink {
|
|
43
|
+
label: string;
|
|
44
|
+
onPress: () => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface TimelineItem {
|
|
48
|
+
key?: string;
|
|
49
|
+
title: string;
|
|
50
|
+
description?: string;
|
|
51
|
+
/** Timestamp or meta, e.g. "Oct 12 · 2:14 PM" or "2h". */
|
|
52
|
+
time?: string;
|
|
53
|
+
/** Default 'done'. */
|
|
54
|
+
status?: TimelineStatus;
|
|
55
|
+
/** Icon inside a larger dot. */
|
|
56
|
+
icon?: TimelineIconName | ((props: TimelineIconProps) => React.ReactNode);
|
|
57
|
+
/** Photo (initials fallback) instead of a dot. */
|
|
58
|
+
avatar?: { src?: string; name: string };
|
|
59
|
+
/** A photo under the text — a URL, or `{ uri, aspectRatio }` (default 16:9). */
|
|
60
|
+
image?: TimelineImageSource;
|
|
61
|
+
/** Several photos in a 1-, 2- or 3-up grid (more than three show a "+N" tile). Combined with `image`. */
|
|
62
|
+
images?: TimelineImageSource[];
|
|
63
|
+
/** Small accent chips under the text. */
|
|
64
|
+
tags?: string[];
|
|
65
|
+
/** Tappable accent links under the text. */
|
|
66
|
+
links?: TimelineLink[];
|
|
67
|
+
/** Anything under the description: a card, a quote, tags, buttons. */
|
|
68
|
+
content?: React.ReactNode;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface TimelineProps {
|
|
72
|
+
items: TimelineItem[];
|
|
73
|
+
/**
|
|
74
|
+
* 'default' roomy rows · 'compact' small dots, one-line rows · 'card' each event on a card ·
|
|
75
|
+
* 'scroll' renders its own scroll view and fills the connector as you scroll (give it a height).
|
|
76
|
+
*/
|
|
77
|
+
variant?: TimelineVariant;
|
|
78
|
+
/** Colour of done / current dots and the filled line (default: theme accent). */
|
|
79
|
+
accent?: string;
|
|
80
|
+
/** Draw a check in done dots (default true). */
|
|
81
|
+
checks?: boolean;
|
|
82
|
+
/** Entrance + connector fill, or the scroll-driven fill for 'scroll' (default true; off with reduce motion). */
|
|
83
|
+
animated?: boolean;
|
|
84
|
+
/** Colour behind the rail — fills hollow dots and the mini status badges (default: theme background). */
|
|
85
|
+
surface?: string;
|
|
86
|
+
/** 'scroll' only: rendered above the timeline inside the scroll view. */
|
|
87
|
+
header?: React.ReactNode;
|
|
88
|
+
/** 'scroll' only: rendered below the timeline inside the scroll view. */
|
|
89
|
+
footer?: React.ReactNode;
|
|
90
|
+
/** 'scroll' only: style for the scroll view's content container (default `{ paddingVertical: 12 }`). */
|
|
91
|
+
contentContainerStyle?: StyleProp<ViewStyle>;
|
|
92
|
+
/** Root style. For 'scroll' this is the scroll view, so set a `height` (or `flex: 1` on a screen). */
|
|
93
|
+
style?: StyleProp<ViewStyle>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const ENTER_STAGGER = 70;
|
|
97
|
+
const FILL_START = 160;
|
|
98
|
+
/** Where the fill point sits in the viewport at the top of the scroll (it slides to the bottom by the end). */
|
|
99
|
+
const SCROLL_ANCHOR = 0.55;
|
|
100
|
+
/** The next node starts pulsing when the fill point is this fraction of the viewport away. */
|
|
101
|
+
const NEAR = 0.3;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Vertical timeline for order tracking, activity feeds and release history. Items rise in one
|
|
105
|
+
* by one, then the connector fills down through every reached step: done dots draw their check
|
|
106
|
+
* as the line arrives, the current dot starts to pulse, upcoming dots stay hollow on a dotted
|
|
107
|
+
* track, and failed steps draw a cross. `variant="scroll"` renders its own scroll view and ties
|
|
108
|
+
* the fill to the scroll position instead.
|
|
109
|
+
*/
|
|
110
|
+
export function Timeline(props: TimelineProps) {
|
|
111
|
+
return props.variant === 'scroll' ? <ScrollTimeline {...props} /> : <StaticTimeline {...props} />;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function StaticTimeline({
|
|
115
|
+
items,
|
|
116
|
+
variant = 'default',
|
|
117
|
+
accent,
|
|
118
|
+
checks = true,
|
|
119
|
+
animated = true,
|
|
120
|
+
surface,
|
|
121
|
+
style,
|
|
122
|
+
}: TimelineProps) {
|
|
123
|
+
const { colors } = useKit();
|
|
124
|
+
const reduceMotion = useReduceMotion();
|
|
125
|
+
const tint = accent ?? colors.accent;
|
|
126
|
+
const bg = surface ?? colors.background;
|
|
127
|
+
const still = !animated || reduceMotion;
|
|
128
|
+
const segs = Math.max(1, items.length - 1);
|
|
129
|
+
const fillMs = Math.round(Math.min(280, 1400 / segs));
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<View accessibilityRole="list" style={style}>
|
|
133
|
+
{items.map((item, i) => {
|
|
134
|
+
const next = items[i + 1];
|
|
135
|
+
const status = item.status ?? 'done';
|
|
136
|
+
return (
|
|
137
|
+
<Row
|
|
138
|
+
key={item.key ?? `${item.title}-${i}`}
|
|
139
|
+
item={item}
|
|
140
|
+
status={status}
|
|
141
|
+
next={next ? (next.status ?? 'done') : null}
|
|
142
|
+
last={!next}
|
|
143
|
+
tint={tint}
|
|
144
|
+
surface={bg}
|
|
145
|
+
variant={variant}
|
|
146
|
+
checks={checks}
|
|
147
|
+
still={still}
|
|
148
|
+
enterDelay={i * ENTER_STAGGER}
|
|
149
|
+
arriveDelay={FILL_START + i * fillMs}
|
|
150
|
+
fillMs={fillMs}
|
|
151
|
+
/>
|
|
152
|
+
);
|
|
153
|
+
})}
|
|
154
|
+
</View>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Scroll-driven timeline. One continuous rail sits behind the rows; its accent fill is a native
|
|
160
|
+
* scaleY driven by `scrollY`, interpolated against the node centres measured with onLayout.
|
|
161
|
+
* The fill point starts at 55% of the viewport and slides to the bottom edge as the scroll
|
|
162
|
+
* reaches its end, so the last node is always reachable without padding. Nodes light up (and
|
|
163
|
+
* draw their check) when the fill reaches them; the next one pulses as the fill gets close.
|
|
164
|
+
* The fill runs top-down through the leading steps that are not 'upcoming'.
|
|
165
|
+
*/
|
|
166
|
+
function ScrollTimeline({
|
|
167
|
+
items,
|
|
168
|
+
accent,
|
|
169
|
+
checks = true,
|
|
170
|
+
animated = true,
|
|
171
|
+
surface,
|
|
172
|
+
header,
|
|
173
|
+
footer,
|
|
174
|
+
contentContainerStyle,
|
|
175
|
+
style,
|
|
176
|
+
}: TimelineProps) {
|
|
177
|
+
const { colors, dark } = useKit();
|
|
178
|
+
const reduceMotion = useReduceMotion();
|
|
179
|
+
const tint = accent ?? colors.accent;
|
|
180
|
+
const bg = surface ?? colors.background;
|
|
181
|
+
const still = !animated || reduceMotion;
|
|
182
|
+
const n = items.length;
|
|
183
|
+
const rail = 34;
|
|
184
|
+
|
|
185
|
+
const scrollY = useRef(new Animated.Value(0)).current;
|
|
186
|
+
const lastY = useRef(0);
|
|
187
|
+
const [viewH, setViewH] = useState(0);
|
|
188
|
+
const [contentH, setContentH] = useState(0);
|
|
189
|
+
const [listY, setListY] = useState(0);
|
|
190
|
+
const [nodes, setNodes] = useState<number[]>([]);
|
|
191
|
+
|
|
192
|
+
const onNode = useCallback((i: number, y: number) => {
|
|
193
|
+
setNodes((prev) => {
|
|
194
|
+
if (Math.abs((prev[i] ?? -1) - y) < 0.5) return prev;
|
|
195
|
+
const nextNodes = prev.slice();
|
|
196
|
+
nextNodes[i] = y;
|
|
197
|
+
return nextNodes;
|
|
198
|
+
});
|
|
199
|
+
}, []);
|
|
200
|
+
|
|
201
|
+
// Fill runs through the leading steps that have been reached.
|
|
202
|
+
let reach = items.findIndex((it) => (it.status ?? 'done') === 'upcoming') - 1;
|
|
203
|
+
if (reach < -1) reach = n - 1;
|
|
204
|
+
|
|
205
|
+
const measured = n > 0 && viewH > 0 && contentH > 0 && items.every((_, i) => nodes[i] != null);
|
|
206
|
+
const maxScroll = contentH - viewH;
|
|
207
|
+
const scrolls = measured && maxScroll > 1;
|
|
208
|
+
const frozen = still || (measured && !scrolls);
|
|
209
|
+
|
|
210
|
+
// Scroll offset at which the fill point (s·k + anchor) reaches each node.
|
|
211
|
+
const triggers = useMemo(() => {
|
|
212
|
+
if (!scrolls) return [] as number[];
|
|
213
|
+
const anchor = viewH * SCROLL_ANCHOR;
|
|
214
|
+
const k = 1 + (viewH - anchor) / maxScroll;
|
|
215
|
+
return items.map((_, i) => (listY + nodes[i] - anchor) / k);
|
|
216
|
+
}, [scrolls, viewH, maxScroll, listY, nodes, items]);
|
|
217
|
+
|
|
218
|
+
// progress = lit * 2 + (near ? 1 : 0), one number so React can skip identical updates.
|
|
219
|
+
const [progress, setProgress] = useState(0);
|
|
220
|
+
useEffect(() => {
|
|
221
|
+
if (frozen || !triggers.length) return;
|
|
222
|
+
const update = (y: number) => {
|
|
223
|
+
lastY.current = y;
|
|
224
|
+
let lit = 0;
|
|
225
|
+
while (lit <= reach && triggers[lit] <= y) lit++;
|
|
226
|
+
const near = lit <= reach && y >= triggers[lit] - viewH * NEAR;
|
|
227
|
+
setProgress(lit * 2 + (near ? 1 : 0));
|
|
228
|
+
};
|
|
229
|
+
update(lastY.current);
|
|
230
|
+
const id = scrollY.addListener(({ value }) => update(value));
|
|
231
|
+
return () => scrollY.removeListener(id);
|
|
232
|
+
}, [frozen, triggers, reach, viewH, scrollY]);
|
|
233
|
+
|
|
234
|
+
const lit = frozen ? n : measured ? progress >> 1 : 0;
|
|
235
|
+
const near = !frozen && (progress & 1) === 1;
|
|
236
|
+
|
|
237
|
+
const onScroll = useMemo(
|
|
238
|
+
() => Animated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], { useNativeDriver: true }),
|
|
239
|
+
[scrollY],
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
const top = nodes[0] ?? 0;
|
|
243
|
+
const fillH = reach > 0 && measured ? nodes[reach] - top : 0;
|
|
244
|
+
const dottedFrom = Math.max(0, reach);
|
|
245
|
+
const dottedH = measured && reach < n - 1 ? nodes[n - 1] - nodes[dottedFrom] : 0;
|
|
246
|
+
|
|
247
|
+
let fillStyle: Animated.WithAnimatedObject<ViewStyle> | null = null;
|
|
248
|
+
if (fillH > 0) {
|
|
249
|
+
if (frozen) {
|
|
250
|
+
fillStyle = {};
|
|
251
|
+
} else if (triggers.length) {
|
|
252
|
+
const t0 = triggers[0];
|
|
253
|
+
const t1 = Math.max(triggers[reach], t0 + 1);
|
|
254
|
+
const p = scrollY.interpolate({ inputRange: [t0, t1], outputRange: [0.0001, 1], extrapolate: 'clamp' });
|
|
255
|
+
fillStyle = {
|
|
256
|
+
transform: [
|
|
257
|
+
{ translateY: p.interpolate({ inputRange: [0, 1], outputRange: [-fillH / 2, 0] }) },
|
|
258
|
+
{ scaleY: p },
|
|
259
|
+
],
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return (
|
|
265
|
+
<Animated.ScrollView
|
|
266
|
+
style={style}
|
|
267
|
+
contentContainerStyle={[styles.scrollContent, contentContainerStyle]}
|
|
268
|
+
scrollEventThrottle={16}
|
|
269
|
+
onScroll={onScroll}
|
|
270
|
+
nestedScrollEnabled
|
|
271
|
+
onLayout={(e) => setViewH(e.nativeEvent.layout.height)}
|
|
272
|
+
onContentSizeChange={(_, h) => setContentH(h)}
|
|
273
|
+
>
|
|
274
|
+
{header}
|
|
275
|
+
<View accessibilityRole="list" onLayout={(e) => setListY(e.nativeEvent.layout.y)}>
|
|
276
|
+
{fillH > 0 ? (
|
|
277
|
+
<View
|
|
278
|
+
pointerEvents="none"
|
|
279
|
+
style={[styles.track, { left: rail / 2 - 1, top, height: fillH, backgroundColor: alpha(colors.foreground, dark ? 0.1 : 0.07) }]}
|
|
280
|
+
/>
|
|
281
|
+
) : null}
|
|
282
|
+
{dottedH > 0 ? (
|
|
283
|
+
<Svg
|
|
284
|
+
width={2}
|
|
285
|
+
height={dottedH}
|
|
286
|
+
pointerEvents="none"
|
|
287
|
+
style={[styles.track, { left: rail / 2 - 1, top: nodes[dottedFrom] }]}
|
|
288
|
+
>
|
|
289
|
+
<Line
|
|
290
|
+
x1={1}
|
|
291
|
+
y1={1}
|
|
292
|
+
x2={1}
|
|
293
|
+
y2={dottedH - 1}
|
|
294
|
+
stroke={alpha(colors.foreground, dark ? 0.28 : 0.22)}
|
|
295
|
+
strokeWidth={2}
|
|
296
|
+
strokeLinecap="round"
|
|
297
|
+
strokeDasharray={[0.1, 6]}
|
|
298
|
+
/>
|
|
299
|
+
</Svg>
|
|
300
|
+
) : null}
|
|
301
|
+
{fillStyle ? (
|
|
302
|
+
<Animated.View
|
|
303
|
+
pointerEvents="none"
|
|
304
|
+
style={[styles.track, { left: rail / 2 - 1, top, height: fillH, backgroundColor: tint }, fillStyle]}
|
|
305
|
+
/>
|
|
306
|
+
) : null}
|
|
307
|
+
{items.map((item, i) => {
|
|
308
|
+
const status = item.status ?? 'done';
|
|
309
|
+
const reachable = i <= reach;
|
|
310
|
+
const isLit = !reachable || i < lit;
|
|
311
|
+
const dotStatus: TimelineStatus = isLit ? status : i === lit && near ? 'current' : 'upcoming';
|
|
312
|
+
return (
|
|
313
|
+
<Row
|
|
314
|
+
key={item.key ?? `${item.title}-${i}`}
|
|
315
|
+
item={item}
|
|
316
|
+
status={status}
|
|
317
|
+
dotStatus={dotStatus}
|
|
318
|
+
lit={reachable && isLit}
|
|
319
|
+
next={null}
|
|
320
|
+
last={i === n - 1}
|
|
321
|
+
tint={tint}
|
|
322
|
+
surface={bg}
|
|
323
|
+
variant="scroll"
|
|
324
|
+
checks={checks}
|
|
325
|
+
still={still}
|
|
326
|
+
enterDelay={0}
|
|
327
|
+
arriveDelay={0}
|
|
328
|
+
fillMs={0}
|
|
329
|
+
onNode={(y) => onNode(i, y)}
|
|
330
|
+
/>
|
|
331
|
+
);
|
|
332
|
+
})}
|
|
333
|
+
</View>
|
|
334
|
+
{footer}
|
|
335
|
+
</Animated.ScrollView>
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function statusLabel(s: TimelineStatus) {
|
|
340
|
+
return s === 'done' ? 'completed' : s === 'current' ? 'in progress' : s === 'error' ? 'failed' : 'upcoming';
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
interface RowProps {
|
|
344
|
+
item: TimelineItem;
|
|
345
|
+
status: TimelineStatus;
|
|
346
|
+
next: TimelineStatus | null;
|
|
347
|
+
last: boolean;
|
|
348
|
+
tint: string;
|
|
349
|
+
surface: string;
|
|
350
|
+
variant: TimelineVariant;
|
|
351
|
+
checks: boolean;
|
|
352
|
+
still: boolean;
|
|
353
|
+
enterDelay: number;
|
|
354
|
+
/** When the connector reaches this row's dot. */
|
|
355
|
+
arriveDelay: number;
|
|
356
|
+
fillMs: number;
|
|
357
|
+
/** 'scroll': what the dot shows right now (it lights up when the fill reaches it). */
|
|
358
|
+
dotStatus?: TimelineStatus;
|
|
359
|
+
/** 'scroll': the fill has reached this dot — pops it. */
|
|
360
|
+
lit?: boolean;
|
|
361
|
+
/** 'scroll': reports the dot centre's y inside the list. */
|
|
362
|
+
onNode?: (y: number) => void;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function Row({
|
|
366
|
+
item,
|
|
367
|
+
status,
|
|
368
|
+
next,
|
|
369
|
+
last,
|
|
370
|
+
tint,
|
|
371
|
+
surface,
|
|
372
|
+
variant,
|
|
373
|
+
checks,
|
|
374
|
+
still,
|
|
375
|
+
enterDelay,
|
|
376
|
+
arriveDelay,
|
|
377
|
+
fillMs,
|
|
378
|
+
dotStatus,
|
|
379
|
+
lit,
|
|
380
|
+
onNode,
|
|
381
|
+
}: RowProps) {
|
|
382
|
+
const { colors, radius, dark } = useKit();
|
|
383
|
+
const compact = variant === 'compact';
|
|
384
|
+
const card = variant === 'card';
|
|
385
|
+
const scroll = variant === 'scroll';
|
|
386
|
+
const rich = !!(item.icon || item.avatar);
|
|
387
|
+
const dot = compact ? (rich ? 24 : 16) : rich ? 34 : 24;
|
|
388
|
+
const rail = compact ? 24 : 34;
|
|
389
|
+
const titleSize = compact ? 14.5 : 15.5;
|
|
390
|
+
const titleLine = compact ? 20 : 21;
|
|
391
|
+
const cardPad = 14;
|
|
392
|
+
const dotTop = Math.max(0, (card ? cardPad : 0) + (titleLine - dot) / 2);
|
|
393
|
+
const textTop = card ? 0 : Math.max(0, (dot - titleLine) / 2);
|
|
394
|
+
const gapBelow = compact ? 14 : card ? 12 : scroll ? 32 : 26;
|
|
395
|
+
const entrance = !still && !scroll;
|
|
396
|
+
|
|
397
|
+
const enter = useRef(new Animated.Value(entrance ? 0 : 1)).current;
|
|
398
|
+
const fill = useRef(new Animated.Value(still ? 1 : 0)).current;
|
|
399
|
+
const pop = useRef(new Animated.Value(1)).current;
|
|
400
|
+
const wasLit = useRef(!!lit);
|
|
401
|
+
const [lineH, setLineH] = useState(0);
|
|
402
|
+
|
|
403
|
+
// A segment is filled only when both ends have been reached (works for newest-first lists too).
|
|
404
|
+
const reached = next !== null && next !== 'upcoming' && status !== 'upcoming';
|
|
405
|
+
|
|
406
|
+
useEffect(() => {
|
|
407
|
+
if (!entrance) {
|
|
408
|
+
enter.setValue(1);
|
|
409
|
+
fill.setValue(1);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
enter.setValue(0);
|
|
413
|
+
fill.setValue(0);
|
|
414
|
+
const a = Animated.timing(enter, {
|
|
415
|
+
toValue: 1,
|
|
416
|
+
duration: 420,
|
|
417
|
+
delay: enterDelay,
|
|
418
|
+
easing: Easing.out(Easing.cubic),
|
|
419
|
+
useNativeDriver: true,
|
|
420
|
+
});
|
|
421
|
+
const b = Animated.timing(fill, {
|
|
422
|
+
toValue: 1,
|
|
423
|
+
duration: fillMs,
|
|
424
|
+
delay: arriveDelay + 40,
|
|
425
|
+
easing: Easing.inOut(Easing.quad),
|
|
426
|
+
useNativeDriver: true,
|
|
427
|
+
});
|
|
428
|
+
a.start();
|
|
429
|
+
if (reached) b.start();
|
|
430
|
+
return () => {
|
|
431
|
+
a.stop();
|
|
432
|
+
b.stop();
|
|
433
|
+
};
|
|
434
|
+
}, [entrance, enter, fill, enterDelay, arriveDelay, fillMs, reached]);
|
|
435
|
+
|
|
436
|
+
// Scroll: a small pop when the fill reaches this dot.
|
|
437
|
+
useEffect(() => {
|
|
438
|
+
if (lit && !wasLit.current && !still) {
|
|
439
|
+
pop.setValue(0.6);
|
|
440
|
+
Animated.spring(pop, { toValue: 1, ...motion.bouncy, useNativeDriver: true }).start();
|
|
441
|
+
}
|
|
442
|
+
wasLit.current = !!lit;
|
|
443
|
+
}, [lit, still, pop]);
|
|
444
|
+
|
|
445
|
+
const muted = status === 'upcoming';
|
|
446
|
+
const current = status === 'current';
|
|
447
|
+
const titleColor = muted ? colors.mutedForeground : status === 'error' ? colors.danger : colors.foreground;
|
|
448
|
+
const hairline = alpha(colors.foreground, dark ? 0.1 : 0.06);
|
|
449
|
+
const photos = [...(item.image ? [item.image] : []), ...(item.images ?? [])];
|
|
450
|
+
const hasLinks = !!item.links?.length;
|
|
451
|
+
|
|
452
|
+
const body = (
|
|
453
|
+
<>
|
|
454
|
+
<View style={[styles.titleRow, compact && styles.compactRow]}>
|
|
455
|
+
<Text
|
|
456
|
+
numberOfLines={compact ? 1 : 2}
|
|
457
|
+
style={[styles.title, { fontSize: titleSize, lineHeight: titleLine, color: titleColor }]}
|
|
458
|
+
>
|
|
459
|
+
{item.title}
|
|
460
|
+
</Text>
|
|
461
|
+
{item.time ? (
|
|
462
|
+
<Text
|
|
463
|
+
numberOfLines={1}
|
|
464
|
+
style={[styles.time, { lineHeight: titleLine, color: current ? tint : colors.mutedForeground }]}
|
|
465
|
+
>
|
|
466
|
+
{item.time}
|
|
467
|
+
</Text>
|
|
468
|
+
) : null}
|
|
469
|
+
</View>
|
|
470
|
+
{item.description && !compact ? (
|
|
471
|
+
<Text style={[styles.desc, { color: colors.mutedForeground }]}>{item.description}</Text>
|
|
472
|
+
) : null}
|
|
473
|
+
{photos.length ? <TimelineGallery images={photos} dim={muted} /> : null}
|
|
474
|
+
{item.tags?.length || hasLinks ? (
|
|
475
|
+
<View style={styles.chips}>
|
|
476
|
+
{item.tags?.map((t) => (
|
|
477
|
+
<View
|
|
478
|
+
key={`t-${t}`}
|
|
479
|
+
style={[styles.tag, { borderRadius: radius.capsule(22), backgroundColor: alpha(tint, dark ? 0.2 : 0.1) }]}
|
|
480
|
+
>
|
|
481
|
+
<Text style={[styles.tagText, { color: tint }]}>{t}</Text>
|
|
482
|
+
</View>
|
|
483
|
+
))}
|
|
484
|
+
{item.links?.map((l) => (
|
|
485
|
+
<Pressable
|
|
486
|
+
key={`l-${l.label}`}
|
|
487
|
+
accessibilityRole="link"
|
|
488
|
+
accessibilityLabel={l.label}
|
|
489
|
+
onPress={l.onPress}
|
|
490
|
+
hitSlop={12}
|
|
491
|
+
style={({ pressed }) => [styles.link, { opacity: pressed ? 0.55 : 1 }]}
|
|
492
|
+
>
|
|
493
|
+
<Text style={[styles.linkText, { color: tint }]}>{l.label} →</Text>
|
|
494
|
+
</Pressable>
|
|
495
|
+
))}
|
|
496
|
+
</View>
|
|
497
|
+
) : null}
|
|
498
|
+
{item.content ? (
|
|
499
|
+
<View style={styles.content}>
|
|
500
|
+
{isTextNode(item.content) ? <Text style={{ color: colors.foreground }}>{item.content}</Text> : item.content}
|
|
501
|
+
</View>
|
|
502
|
+
) : null}
|
|
503
|
+
</>
|
|
504
|
+
);
|
|
505
|
+
|
|
506
|
+
const cardShadow: ViewStyle =
|
|
507
|
+
Platform.OS === 'android'
|
|
508
|
+
? { elevation: current ? 3 : 1 }
|
|
509
|
+
: {
|
|
510
|
+
shadowColor: '#000',
|
|
511
|
+
shadowOffset: { width: 0, height: current ? 6 : 3 },
|
|
512
|
+
shadowOpacity: dark ? 0.35 : current ? 0.08 : 0.04,
|
|
513
|
+
shadowRadius: current ? 16 : 10,
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
const dotWrap = scroll
|
|
517
|
+
? { transform: [{ scale: pop }] }
|
|
518
|
+
: {
|
|
519
|
+
opacity: enter,
|
|
520
|
+
transform: [{ scale: enter.interpolate({ inputRange: [0, 1], outputRange: [0.4, 1] }) }],
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
return (
|
|
524
|
+
<View
|
|
525
|
+
accessible={!hasLinks}
|
|
526
|
+
accessibilityLabel={[item.title, item.time, item.description, statusLabel(status)].filter(Boolean).join(', ')}
|
|
527
|
+
style={styles.row}
|
|
528
|
+
onLayout={onNode ? (e: LayoutChangeEvent) => onNode(e.nativeEvent.layout.y + dotTop + dot / 2) : undefined}
|
|
529
|
+
>
|
|
530
|
+
<View style={[styles.rail, { width: rail }]}>
|
|
531
|
+
<View style={{ height: dotTop }} />
|
|
532
|
+
<Animated.View style={dotWrap}>
|
|
533
|
+
<Dot
|
|
534
|
+
item={item}
|
|
535
|
+
status={dotStatus ?? status}
|
|
536
|
+
size={dot}
|
|
537
|
+
tint={tint}
|
|
538
|
+
surface={surface}
|
|
539
|
+
checks={checks}
|
|
540
|
+
still={still}
|
|
541
|
+
delay={arriveDelay}
|
|
542
|
+
/>
|
|
543
|
+
</Animated.View>
|
|
544
|
+
{next !== null ? (
|
|
545
|
+
<View
|
|
546
|
+
style={[styles.line, { marginTop: compact ? 3 : 5, marginBottom: (compact ? 3 : 5) - dotTop }]}
|
|
547
|
+
onLayout={(e) => setLineH(Math.round(e.nativeEvent.layout.height))}
|
|
548
|
+
>
|
|
549
|
+
{lineH > 0 ? (
|
|
550
|
+
<>
|
|
551
|
+
<Svg width={2} height={lineH} style={StyleSheet.absoluteFill}>
|
|
552
|
+
{reached ? (
|
|
553
|
+
<Rect width={2} height={lineH} rx={1} fill={alpha(colors.foreground, dark ? 0.1 : 0.07)} />
|
|
554
|
+
) : (
|
|
555
|
+
<Line
|
|
556
|
+
x1={1}
|
|
557
|
+
y1={1}
|
|
558
|
+
x2={1}
|
|
559
|
+
y2={lineH - 1}
|
|
560
|
+
stroke={alpha(colors.foreground, dark ? 0.28 : 0.22)}
|
|
561
|
+
strokeWidth={2}
|
|
562
|
+
strokeLinecap="round"
|
|
563
|
+
strokeDasharray={[0.1, 6]}
|
|
564
|
+
/>
|
|
565
|
+
)}
|
|
566
|
+
</Svg>
|
|
567
|
+
{reached ? (
|
|
568
|
+
<Animated.View
|
|
569
|
+
pointerEvents="none"
|
|
570
|
+
style={{
|
|
571
|
+
position: 'absolute',
|
|
572
|
+
left: 0,
|
|
573
|
+
top: 0,
|
|
574
|
+
width: 2,
|
|
575
|
+
height: lineH,
|
|
576
|
+
borderRadius: 1,
|
|
577
|
+
backgroundColor: tint,
|
|
578
|
+
transform: [
|
|
579
|
+
{ translateY: fill.interpolate({ inputRange: [0, 1], outputRange: [-lineH / 2, 0] }) },
|
|
580
|
+
{ scaleY: fill.interpolate({ inputRange: [0, 1], outputRange: [0.001, 1] }) },
|
|
581
|
+
],
|
|
582
|
+
}}
|
|
583
|
+
/>
|
|
584
|
+
) : null}
|
|
585
|
+
</>
|
|
586
|
+
) : null}
|
|
587
|
+
</View>
|
|
588
|
+
) : null}
|
|
589
|
+
</View>
|
|
590
|
+
|
|
591
|
+
<Animated.View
|
|
592
|
+
style={[
|
|
593
|
+
styles.bodyWrap,
|
|
594
|
+
{
|
|
595
|
+
paddingBottom: last ? 0 : gapBelow,
|
|
596
|
+
paddingTop: textTop,
|
|
597
|
+
opacity: enter,
|
|
598
|
+
transform: [{ translateY: enter.interpolate({ inputRange: [0, 1], outputRange: [12, 0] }) }],
|
|
599
|
+
},
|
|
600
|
+
]}
|
|
601
|
+
>
|
|
602
|
+
{card ? (
|
|
603
|
+
<View
|
|
604
|
+
style={[
|
|
605
|
+
styles.card,
|
|
606
|
+
cardShadow,
|
|
607
|
+
{
|
|
608
|
+
padding: cardPad,
|
|
609
|
+
borderRadius: radius.lg,
|
|
610
|
+
backgroundColor: colors.card,
|
|
611
|
+
borderColor: current ? alpha(tint, dark ? 0.45 : 0.32) : status === 'error' ? alpha(colors.danger, 0.3) : hairline,
|
|
612
|
+
},
|
|
613
|
+
]}
|
|
614
|
+
>
|
|
615
|
+
{current ? (
|
|
616
|
+
<View
|
|
617
|
+
pointerEvents="none"
|
|
618
|
+
style={[StyleSheet.absoluteFill, { borderRadius: radius.lg, backgroundColor: alpha(tint, dark ? 0.08 : 0.045) }]}
|
|
619
|
+
/>
|
|
620
|
+
) : null}
|
|
621
|
+
{body}
|
|
622
|
+
</View>
|
|
623
|
+
) : (
|
|
624
|
+
body
|
|
625
|
+
)}
|
|
626
|
+
</Animated.View>
|
|
627
|
+
</View>
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const AnimatedPath = Animated.createAnimatedComponent(Path);
|
|
632
|
+
const CHECK = 'M7.2 12.4l3.2 3.2 6.4-6.6';
|
|
633
|
+
const CHECK_LEN = 15;
|
|
634
|
+
const CROSS_A = 'M8.6 8.6l6.8 6.8';
|
|
635
|
+
const CROSS_B = 'M15.4 8.6l-6.8 6.8';
|
|
636
|
+
const CROSS_LEN = 10;
|
|
637
|
+
|
|
638
|
+
/** Check or cross that draws itself (JS-driven dash offset on its own Svg). */
|
|
639
|
+
function DrawnMark({ kind, color, size, draw }: { kind: 'check' | 'cross'; color: string; size: number; draw: Animated.Value }) {
|
|
640
|
+
const s = { stroke: color, strokeWidth: 2.6, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const, fill: 'none' };
|
|
641
|
+
return (
|
|
642
|
+
<Svg width={size} height={size} viewBox="0 0 24 24">
|
|
643
|
+
{kind === 'check' ? (
|
|
644
|
+
<AnimatedPath
|
|
645
|
+
d={CHECK}
|
|
646
|
+
{...s}
|
|
647
|
+
strokeDasharray={[CHECK_LEN, CHECK_LEN]}
|
|
648
|
+
strokeDashoffset={draw.interpolate({ inputRange: [0, 1], outputRange: [CHECK_LEN, 0] })}
|
|
649
|
+
/>
|
|
650
|
+
) : (
|
|
651
|
+
<>
|
|
652
|
+
<AnimatedPath
|
|
653
|
+
d={CROSS_A}
|
|
654
|
+
{...s}
|
|
655
|
+
strokeDasharray={[CROSS_LEN, CROSS_LEN]}
|
|
656
|
+
strokeDashoffset={draw.interpolate({ inputRange: [0, 0.5], outputRange: [CROSS_LEN, 0], extrapolate: 'clamp' })}
|
|
657
|
+
/>
|
|
658
|
+
<AnimatedPath
|
|
659
|
+
d={CROSS_B}
|
|
660
|
+
{...s}
|
|
661
|
+
strokeDasharray={[CROSS_LEN, CROSS_LEN]}
|
|
662
|
+
strokeDashoffset={draw.interpolate({ inputRange: [0.5, 1], outputRange: [CROSS_LEN, 0], extrapolate: 'clamp' })}
|
|
663
|
+
/>
|
|
664
|
+
</>
|
|
665
|
+
)}
|
|
666
|
+
</Svg>
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function Dot({
|
|
671
|
+
item,
|
|
672
|
+
status,
|
|
673
|
+
size,
|
|
674
|
+
tint,
|
|
675
|
+
surface,
|
|
676
|
+
checks,
|
|
677
|
+
still,
|
|
678
|
+
delay,
|
|
679
|
+
}: {
|
|
680
|
+
item: TimelineItem;
|
|
681
|
+
status: TimelineStatus;
|
|
682
|
+
size: number;
|
|
683
|
+
tint: string;
|
|
684
|
+
surface: string;
|
|
685
|
+
checks: boolean;
|
|
686
|
+
still: boolean;
|
|
687
|
+
delay: number;
|
|
688
|
+
}) {
|
|
689
|
+
const { colors, radius, dark } = useKit();
|
|
690
|
+
const uid = useId().replace(/[^a-zA-Z0-9]/g, '');
|
|
691
|
+
const draw = useRef(new Animated.Value(still ? 1 : 0)).current; // JS: dash offsets
|
|
692
|
+
const pulse = useRef(new Animated.Value(0)).current; // native: halo
|
|
693
|
+
const current = status === 'current';
|
|
694
|
+
const marked = status === 'error' || (status === 'done' && checks);
|
|
695
|
+
|
|
696
|
+
useEffect(() => {
|
|
697
|
+
if (still || !marked) {
|
|
698
|
+
draw.setValue(1);
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
draw.setValue(0);
|
|
702
|
+
const a = Animated.timing(draw, {
|
|
703
|
+
toValue: 1,
|
|
704
|
+
duration: status === 'error' ? 420 : 340,
|
|
705
|
+
delay,
|
|
706
|
+
easing: Easing.out(Easing.cubic),
|
|
707
|
+
useNativeDriver: false,
|
|
708
|
+
});
|
|
709
|
+
a.start();
|
|
710
|
+
return () => a.stop();
|
|
711
|
+
}, [still, marked, status, delay, draw]);
|
|
712
|
+
|
|
713
|
+
useEffect(() => {
|
|
714
|
+
if (!current || still) {
|
|
715
|
+
pulse.setValue(0);
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
const loop = Animated.loop(
|
|
719
|
+
Animated.sequence([
|
|
720
|
+
Animated.timing(pulse, { toValue: 1, duration: 1500, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
|
|
721
|
+
Animated.timing(pulse, { toValue: 0, duration: 0, useNativeDriver: true }),
|
|
722
|
+
Animated.delay(500),
|
|
723
|
+
]),
|
|
724
|
+
);
|
|
725
|
+
const t = setTimeout(() => loop.start(), delay);
|
|
726
|
+
return () => {
|
|
727
|
+
clearTimeout(t);
|
|
728
|
+
loop.stop();
|
|
729
|
+
};
|
|
730
|
+
}, [current, still, delay, pulse]);
|
|
731
|
+
|
|
732
|
+
const tone = status === 'error' ? colors.danger : tint;
|
|
733
|
+
const hollow = alpha(colors.foreground, dark ? 0.24 : 0.2);
|
|
734
|
+
const hasIcon = !!item.icon && !item.avatar;
|
|
735
|
+
const glyph = Math.round(size * 0.5);
|
|
736
|
+
|
|
737
|
+
const halo = current ? (
|
|
738
|
+
<Animated.View
|
|
739
|
+
pointerEvents="none"
|
|
740
|
+
style={[
|
|
741
|
+
StyleSheet.absoluteFill,
|
|
742
|
+
{
|
|
743
|
+
borderRadius: radius.full,
|
|
744
|
+
backgroundColor: alpha(tint, 0.35),
|
|
745
|
+
opacity: pulse.interpolate({ inputRange: [0, 1], outputRange: [0.9, 0] }),
|
|
746
|
+
transform: [{ scale: pulse.interpolate({ inputRange: [0, 1], outputRange: [1, 2.1] }) }],
|
|
747
|
+
},
|
|
748
|
+
]}
|
|
749
|
+
/>
|
|
750
|
+
) : null;
|
|
751
|
+
|
|
752
|
+
// Opaque disc so a rail running behind the dot never shows through tinted / dimmed fills.
|
|
753
|
+
const base = <View style={[StyleSheet.absoluteFill, { borderRadius: radius.full, backgroundColor: surface }]} />;
|
|
754
|
+
|
|
755
|
+
// Small check / cross badge on icon dots (avatars only flag failures — a feed is mostly 'done').
|
|
756
|
+
const miniSize = Math.max(12, Math.round(size * 0.42));
|
|
757
|
+
const mini =
|
|
758
|
+
(hasIcon && marked) || (item.avatar && status === 'error') ? (
|
|
759
|
+
<View
|
|
760
|
+
style={[
|
|
761
|
+
styles.mini,
|
|
762
|
+
{
|
|
763
|
+
width: miniSize + 4,
|
|
764
|
+
height: miniSize + 4,
|
|
765
|
+
right: -4,
|
|
766
|
+
bottom: -4,
|
|
767
|
+
borderRadius: radius.full,
|
|
768
|
+
backgroundColor: surface,
|
|
769
|
+
},
|
|
770
|
+
]}
|
|
771
|
+
>
|
|
772
|
+
<View
|
|
773
|
+
style={{
|
|
774
|
+
width: miniSize,
|
|
775
|
+
height: miniSize,
|
|
776
|
+
borderRadius: radius.full,
|
|
777
|
+
backgroundColor: tone,
|
|
778
|
+
alignItems: 'center',
|
|
779
|
+
justifyContent: 'center',
|
|
780
|
+
}}
|
|
781
|
+
>
|
|
782
|
+
<DrawnMark kind={status === 'error' ? 'cross' : 'check'} color={readableOn(tone)} size={miniSize} draw={draw} />
|
|
783
|
+
</View>
|
|
784
|
+
</View>
|
|
785
|
+
) : null;
|
|
786
|
+
|
|
787
|
+
if (item.avatar) {
|
|
788
|
+
return (
|
|
789
|
+
<View style={{ width: size, height: size }}>
|
|
790
|
+
{halo}
|
|
791
|
+
{base}
|
|
792
|
+
<View style={{ opacity: status === 'upcoming' ? 0.55 : 1 }}>
|
|
793
|
+
<Avatar src={item.avatar.src} name={item.avatar.name} size={size} surface={surface} />
|
|
794
|
+
</View>
|
|
795
|
+
{mini}
|
|
796
|
+
</View>
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
if (hasIcon) {
|
|
801
|
+
const solid = current;
|
|
802
|
+
const fg = solid ? readableOn(tint) : status === 'upcoming' ? colors.mutedForeground : tone;
|
|
803
|
+
const icon =
|
|
804
|
+
typeof item.icon === 'function' ? item.icon({ color: fg, size: glyph }) : <TimelineIcon name={item.icon as TimelineIconName} color={fg} size={glyph} />;
|
|
805
|
+
return (
|
|
806
|
+
<View style={{ width: size, height: size }}>
|
|
807
|
+
{halo}
|
|
808
|
+
{base}
|
|
809
|
+
<View
|
|
810
|
+
style={[
|
|
811
|
+
styles.dot,
|
|
812
|
+
{
|
|
813
|
+
width: size,
|
|
814
|
+
height: size,
|
|
815
|
+
borderRadius: radius.full,
|
|
816
|
+
backgroundColor: solid
|
|
817
|
+
? tint
|
|
818
|
+
: status === 'upcoming'
|
|
819
|
+
? surface
|
|
820
|
+
: alpha(tone, dark ? 0.2 : 0.12),
|
|
821
|
+
borderWidth: status === 'upcoming' ? 1.5 : 0,
|
|
822
|
+
borderColor: hollow,
|
|
823
|
+
},
|
|
824
|
+
solid ? glow(tint) : null,
|
|
825
|
+
]}
|
|
826
|
+
>
|
|
827
|
+
{solid ? <Sheen id={`sh${uid}`} size={size} /> : null}
|
|
828
|
+
{icon}
|
|
829
|
+
</View>
|
|
830
|
+
{mini}
|
|
831
|
+
</View>
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// Plain dots.
|
|
836
|
+
const filled = status === 'done' || status === 'error';
|
|
837
|
+
const small = size < 16;
|
|
838
|
+
return (
|
|
839
|
+
<View style={{ width: size, height: size }}>
|
|
840
|
+
{halo}
|
|
841
|
+
<View
|
|
842
|
+
style={[
|
|
843
|
+
styles.dot,
|
|
844
|
+
{
|
|
845
|
+
width: size,
|
|
846
|
+
height: size,
|
|
847
|
+
borderRadius: radius.full,
|
|
848
|
+
backgroundColor: filled ? tone : surface,
|
|
849
|
+
borderWidth: filled ? 0 : current ? 2 : 1.5,
|
|
850
|
+
borderColor: current ? tint : hollow,
|
|
851
|
+
},
|
|
852
|
+
filled ? glow(tone) : null,
|
|
853
|
+
]}
|
|
854
|
+
>
|
|
855
|
+
{filled ? <Sheen id={`sh${uid}`} size={size} /> : null}
|
|
856
|
+
{filled && marked && !small ? (
|
|
857
|
+
<DrawnMark kind={status === 'error' ? 'cross' : 'check'} color={readableOn(tone)} size={Math.round(size * 0.8)} draw={draw} />
|
|
858
|
+
) : null}
|
|
859
|
+
{current ? (
|
|
860
|
+
<View style={{ width: size * 0.4, height: size * 0.4, borderRadius: radius.full, backgroundColor: tint }} />
|
|
861
|
+
) : null}
|
|
862
|
+
</View>
|
|
863
|
+
</View>
|
|
864
|
+
);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/** Very soft white top highlight on solid dots (neutral, ≤ 0.12). */
|
|
868
|
+
function Sheen({ id, size }: { id: string; size: number }) {
|
|
869
|
+
return (
|
|
870
|
+
<Svg width={size} height={size} style={StyleSheet.absoluteFill} pointerEvents="none">
|
|
871
|
+
<Defs>
|
|
872
|
+
<LinearGradient id={id} x1="0" y1="0" x2="0" y2="1">
|
|
873
|
+
<Stop offset="0" stopColor="#ffffff" stopOpacity={0.12} />
|
|
874
|
+
<Stop offset="0.6" stopColor="#ffffff" stopOpacity={0} />
|
|
875
|
+
</LinearGradient>
|
|
876
|
+
</Defs>
|
|
877
|
+
<Circle cx={size / 2} cy={size / 2} r={size / 2} fill={`url(#${id})`} />
|
|
878
|
+
</Svg>
|
|
879
|
+
);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function glow(color: string): ViewStyle {
|
|
883
|
+
return Platform.OS === 'ios'
|
|
884
|
+
? { shadowColor: color, shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.3, shadowRadius: 5 }
|
|
885
|
+
: Platform.OS === 'android'
|
|
886
|
+
? { elevation: 2 }
|
|
887
|
+
: {};
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function srcOf(image: TimelineImageSource) {
|
|
891
|
+
return typeof image === 'string' ? { uri: image, ratio: 16 / 9 } : { uri: image.uri, ratio: image.aspectRatio ?? 16 / 9 };
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/** One photo at its aspect ratio, two side by side, or one large + two stacked (+N on the last tile). */
|
|
895
|
+
function TimelineGallery({ images, dim }: { images: TimelineImageSource[]; dim: boolean }) {
|
|
896
|
+
const { colors, radius, dark } = useKit();
|
|
897
|
+
const shown = images.slice(0, 3).map(srcOf);
|
|
898
|
+
const extra = images.length - shown.length;
|
|
899
|
+
const frame = [
|
|
900
|
+
styles.gallery,
|
|
901
|
+
{ borderRadius: radius.md, borderColor: alpha(colors.foreground, dark ? 0.1 : 0.06), opacity: dim ? 0.6 : 1 },
|
|
902
|
+
];
|
|
903
|
+
const tile = (i: number, flex: ViewStyle) => (
|
|
904
|
+
<Photo key={i} uri={shown[i].uri} delay={i * 90} style={[flex, { borderRadius: radius.scale(4) }]} more={i === 2 && extra > 0 ? extra : 0} />
|
|
905
|
+
);
|
|
906
|
+
|
|
907
|
+
if (shown.length === 1) {
|
|
908
|
+
return <View style={[frame, { aspectRatio: shown[0].ratio }]}>{tile(0, styles.fill)}</View>;
|
|
909
|
+
}
|
|
910
|
+
if (shown.length === 2) {
|
|
911
|
+
return (
|
|
912
|
+
<View style={[frame, styles.grid, { aspectRatio: 2 }]}>
|
|
913
|
+
{tile(0, styles.flex)}
|
|
914
|
+
{tile(1, styles.flex)}
|
|
915
|
+
</View>
|
|
916
|
+
);
|
|
917
|
+
}
|
|
918
|
+
return (
|
|
919
|
+
<View style={[frame, styles.grid, { aspectRatio: 3 / 2 }]}>
|
|
920
|
+
{tile(0, { flex: 2 })}
|
|
921
|
+
<View style={[styles.flex, styles.stack]}>
|
|
922
|
+
{tile(1, styles.flex)}
|
|
923
|
+
{tile(2, styles.flex)}
|
|
924
|
+
</View>
|
|
925
|
+
</View>
|
|
926
|
+
);
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function Photo({ uri, delay, style, more }: { uri: string; delay: number; style: StyleProp<ViewStyle>; more: number }) {
|
|
930
|
+
const { colors } = useKit();
|
|
931
|
+
const shown = useRef(new Animated.Value(0)).current;
|
|
932
|
+
return (
|
|
933
|
+
<View style={[styles.photo, { backgroundColor: colors.muted }, style]}>
|
|
934
|
+
<Animated.Image
|
|
935
|
+
source={{ uri }}
|
|
936
|
+
accessibilityIgnoresInvertColors
|
|
937
|
+
onLoad={() => Animated.timing(shown, { toValue: 1, duration: 320, delay, useNativeDriver: true }).start()}
|
|
938
|
+
style={[StyleSheet.absoluteFill, { opacity: shown }]}
|
|
939
|
+
/>
|
|
940
|
+
{more > 0 ? (
|
|
941
|
+
<View style={[StyleSheet.absoluteFill, styles.more]}>
|
|
942
|
+
<Text style={styles.moreText}>+{more}</Text>
|
|
943
|
+
</View>
|
|
944
|
+
) : null}
|
|
945
|
+
</View>
|
|
946
|
+
);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
function useReduceMotion() {
|
|
950
|
+
const [reduce, setReduce] = useState(false);
|
|
951
|
+
useEffect(() => {
|
|
952
|
+
let alive = true;
|
|
953
|
+
AccessibilityInfo.isReduceMotionEnabled()
|
|
954
|
+
.then((v) => alive && setReduce(v))
|
|
955
|
+
.catch(() => {});
|
|
956
|
+
const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduce);
|
|
957
|
+
return () => {
|
|
958
|
+
alive = false;
|
|
959
|
+
sub.remove();
|
|
960
|
+
};
|
|
961
|
+
}, []);
|
|
962
|
+
return reduce;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/** Built-in 24×24 line icons with a 1.8 stroke. */
|
|
966
|
+
export function TimelineIcon({ name, color, size = 18 }: { name: TimelineIconName; color: string; size?: number }) {
|
|
967
|
+
const p = { stroke: color, strokeWidth: 1.8, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const, fill: 'none' };
|
|
968
|
+
const icons: Record<TimelineIconName, React.ReactNode> = {
|
|
969
|
+
cart: (
|
|
970
|
+
<>
|
|
971
|
+
<Path d="M3 4h2.5l2.2 11h10.8l2-8H6.4" {...p} />
|
|
972
|
+
<Circle cx={9.5} cy={19.5} r={1.4} {...p} />
|
|
973
|
+
<Circle cx={17} cy={19.5} r={1.4} {...p} />
|
|
974
|
+
</>
|
|
975
|
+
),
|
|
976
|
+
package: (
|
|
977
|
+
<>
|
|
978
|
+
<Path d="M12 3l8 4.5v9L12 21l-8-4.5v-9z" {...p} />
|
|
979
|
+
<Path d="M4 7.5l8 4.5 8-4.5M12 12v9M8 5.3l8 4.4" {...p} />
|
|
980
|
+
</>
|
|
981
|
+
),
|
|
982
|
+
truck: (
|
|
983
|
+
<>
|
|
984
|
+
<Path d="M3 6h11v10H3zM14 9h4l3 3.5V16h-7" {...p} />
|
|
985
|
+
<Circle cx={7} cy={17.5} r={1.8} {...p} />
|
|
986
|
+
<Circle cx={17.5} cy={17.5} r={1.8} {...p} />
|
|
987
|
+
</>
|
|
988
|
+
),
|
|
989
|
+
home: (
|
|
990
|
+
<>
|
|
991
|
+
<Path d="M4 11l8-7 8 7v9H4z" {...p} />
|
|
992
|
+
<Path d="M10 20v-5h4v5" {...p} />
|
|
993
|
+
</>
|
|
994
|
+
),
|
|
995
|
+
rocket: (
|
|
996
|
+
<>
|
|
997
|
+
<Path d="M14 4c3 0 6 3 6 6l-6.5 6.5-6-6z" {...p} />
|
|
998
|
+
<Path d="M7.5 10.5L4 11l3-4h4M13.5 16.5L13 20l4-3v-4" {...p} />
|
|
999
|
+
<Circle cx={15} cy={9} r={1.5} {...p} />
|
|
1000
|
+
</>
|
|
1001
|
+
),
|
|
1002
|
+
bug: (
|
|
1003
|
+
<>
|
|
1004
|
+
<Rect x={7} y={8} width={10} height={12} rx={5} {...p} />
|
|
1005
|
+
<Path d="M9 8a3 3 0 0 1 6 0M12 12v8M3 13h4M17 13h4M4 8l3 2M20 8l-3 2M4 19l3-2M20 19l-3-2" {...p} />
|
|
1006
|
+
</>
|
|
1007
|
+
),
|
|
1008
|
+
star: <Path d="M12 3.5l2.6 5.4 5.9.8-4.3 4.1 1 5.8L12 16.8l-5.2 2.8 1-5.8-4.3-4.1 5.9-.8z" {...p} />,
|
|
1009
|
+
chat: <Path d="M4 5h16v11H9l-5 4z" {...p} />,
|
|
1010
|
+
tag: (
|
|
1011
|
+
<>
|
|
1012
|
+
<Path d="M3.5 12.5V4h8.5l8.5 8.5-8.5 8.5z" {...p} />
|
|
1013
|
+
<Circle cx={8} cy={8.5} r={1.3} {...p} />
|
|
1014
|
+
</>
|
|
1015
|
+
),
|
|
1016
|
+
user: (
|
|
1017
|
+
<>
|
|
1018
|
+
<Circle cx={12} cy={8} r={4} {...p} />
|
|
1019
|
+
<Path d="M4 20.5c1.2-3.6 4.3-5.5 8-5.5s6.8 1.9 8 5.5" {...p} />
|
|
1020
|
+
</>
|
|
1021
|
+
),
|
|
1022
|
+
};
|
|
1023
|
+
return (
|
|
1024
|
+
<Svg width={size} height={size} viewBox="0 0 24 24">
|
|
1025
|
+
{icons[name]}
|
|
1026
|
+
</Svg>
|
|
1027
|
+
);
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
const styles = StyleSheet.create({
|
|
1031
|
+
row: { flexDirection: 'row', gap: 14 },
|
|
1032
|
+
rail: { alignItems: 'center' },
|
|
1033
|
+
line: { width: 2, flex: 1, minHeight: 8 },
|
|
1034
|
+
track: { position: 'absolute', width: 2, borderRadius: 1 },
|
|
1035
|
+
scrollContent: { paddingVertical: 12 },
|
|
1036
|
+
dot: { alignItems: 'center', justifyContent: 'center' },
|
|
1037
|
+
mini: { position: 'absolute', alignItems: 'center', justifyContent: 'center' },
|
|
1038
|
+
bodyWrap: { flex: 1, minWidth: 0 },
|
|
1039
|
+
card: { borderWidth: 1 },
|
|
1040
|
+
titleRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 10 },
|
|
1041
|
+
compactRow: { alignItems: 'center' },
|
|
1042
|
+
title: { flex: 1, fontWeight: '400', letterSpacing: -0.2 },
|
|
1043
|
+
time: { fontSize: 12.5, fontWeight: '400', fontVariant: ['tabular-nums'] },
|
|
1044
|
+
desc: { fontSize: 14, fontWeight: '400', lineHeight: 20, marginTop: 3 },
|
|
1045
|
+
gallery: { marginTop: 12, width: '100%', overflow: 'hidden', borderWidth: 1 },
|
|
1046
|
+
grid: { flexDirection: 'row', gap: 3 },
|
|
1047
|
+
stack: { gap: 3 },
|
|
1048
|
+
flex: { flex: 1 },
|
|
1049
|
+
fill: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 },
|
|
1050
|
+
photo: { overflow: 'hidden' },
|
|
1051
|
+
more: { alignItems: 'center', justifyContent: 'center', backgroundColor: 'rgba(0,0,0,0.42)' },
|
|
1052
|
+
moreText: { color: '#ffffff', fontSize: 18, letterSpacing: -0.3 },
|
|
1053
|
+
chips: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', gap: 6, marginTop: 10 },
|
|
1054
|
+
tag: { paddingHorizontal: 9, paddingVertical: 3 },
|
|
1055
|
+
tagText: { fontSize: 12 },
|
|
1056
|
+
link: { paddingVertical: 3, marginRight: 6 },
|
|
1057
|
+
linkText: { fontSize: 13.5 },
|
|
1058
|
+
content: { marginTop: 12 },
|
|
1059
|
+
});
|