diff --git a/CONTEXT.md b/CONTEXT.md index 19dbda3d6..71482254e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -240,6 +240,10 @@ _Avoid_: fast-forward, playback rate, time scale A user-controlled app preference that affects app behavior across boards unless explicitly scoped elsewhere. _Avoid_: Option, config +**Settings Drawer**: +The edge drawer behind the top-right button on the main screen: the **Vescape Account**, the app's live self-status (backup, version, storage), the few settings worth one tap, and one link to Advanced settings for everything else. Its button wears whatever inside it needs attention — a required update, or a running backup with its progress — the way the Social button wears an active **Group Ride**. +_Avoid_: settings menu, settings popover, quick settings + **Board Setting**: A rider-adjustable preference or soft state scoped to one **Board**, stored schemalessly per Board (key-value). Distinct from Board identity and probe-confirmed facts (name, **Board Link**), which are structured Board fields. Examples: battery configuration, **Alert Preset** levels, **Board Top Speed**. _Avoid_: Board config, per-board App Setting diff --git a/src/app/settings.tsx b/src/app/settings.tsx index 86e98158d..f84c1b8a3 100644 --- a/src/app/settings.tsx +++ b/src/app/settings.tsx @@ -96,28 +96,28 @@ export default function SettingsScreen() { router.push(routes.settingsConnection)} /> router.push(routes.settingsLiveTelemetry)} /> router.push(routes.settingsDiagnostics)} /> router.push(routes.settingsMap)} @@ -131,7 +131,7 @@ export default function SettingsScreen() { router.push(routes.settingsWatch)} @@ -145,21 +145,21 @@ export default function SettingsScreen() { router.push(routes.settingsPrivacyZones)} /> router.push(routes.settingsFilters)} /> router.push(routes.settingsGraphs)} @@ -171,14 +171,14 @@ export default function SettingsScreen() { router.push(routes.settingsDev)} /> router.push(routes.settingsAbout)} diff --git a/src/app/settings/components/base.tsx b/src/app/settings/components/base.tsx index 8ea70356b..b28395dd4 100644 --- a/src/app/settings/components/base.tsx +++ b/src/app/settings/components/base.tsx @@ -9,8 +9,10 @@ import { withTiming, } from 'react-native-reanimated' import { + ArrowFatLinesUpIcon, ArrowLeftIcon, ArrowRightIcon, + ArrowsClockwiseIcon, CubeIcon, FadersIcon, GearSixIcon, @@ -35,6 +37,19 @@ function IconButtonShowcase() { const [loading, setLoading] = useState(false) const [disabled, setDisabled] = useState(false) const [dot, setDot] = useState(true) + const [takeover, setTakeover] = useState('backup') + const [progress, setProgress] = useState('40%') + + const activeTakeover = + takeover === 'none' + ? null + : takeover === 'update' + ? { icon: ArrowFatLinesUpIcon, accent: theme.settingsIcon.update } + : { + icon: ArrowsClockwiseIcon, + accent: theme.settingsIcon.sync, + progress: progress === 'none' ? undefined : Number.parseInt(progress, 10) / 100, + } return ( + + } > @@ -80,6 +107,27 @@ function IconButtonShowcase() { disabled={disabled} /> + + {}} + loading={loading} + disabled={disabled} + /> + {}} + loading={loading} + disabled={disabled} + /> + { + const inset = RING_STROKE / 2 + const p = Skia.Path.Make() + p.addArc({ x: inset, y: inset, width: dim - RING_STROKE, height: dim - RING_STROKE }, -90, 360) + return p + }, [dim]) + const end = Math.min(1, Math.max(0, progress)) + + return ( + + + + + ) +} + +/** + * An alternate identity the button wears while something important is happening behind it — the + * Social button becoming a live Group Ride, the Settings gear becoming an available update or a + * running backup. The resting icon stays the component's own; a takeover only overrides. + */ +export interface IconButtonTakeover { + /** Replaces the resting icon. Omit to keep it and only recolor. */ + icon?: Icon + /** Replaces the icon and border color. */ + accent?: string + /** 0–1 determinate ring drawn around the button. Omit for indeterminate work. */ + progress?: number +} interface IconButtonProps { icon: Icon onPress: () => void + /** Alternate icon/accent/progress for an active background state. Null when resting. */ + takeover?: IconButtonTakeover | null onLongPress?: () => void size?: keyof typeof SIZES disabled?: boolean @@ -38,8 +81,9 @@ interface IconButtonProps { } export function IconButton({ - icon: Icon, + icon: RestingIcon, onPress, + takeover, onLongPress, size = 'sm', disabled = false, @@ -54,12 +98,16 @@ export function IconButton({ const isDisabled = disabled || loading const dim = SIZES[size] const iconSize = ICON_SIZES[size] + const Icon = takeover?.icon ?? RestingIcon + // A takeover outranks `accent`: it is the state the Rider needs to see right now. + const activeAccent = takeover?.accent ?? accent const iconColor = destructive ? theme.status.error.text - : (accent ?? theme.palette.slate.textSecondary) + : (activeAccent ?? theme.palette.slate.textSecondary) const borderColor = destructive ? theme.status.error.border - : (accent ?? theme.palette.slate.border) + : (activeAccent ?? theme.palette.slate.border) + const progress = takeover?.progress const pulse = useSharedValue(0) useEffect(() => { @@ -97,6 +145,9 @@ export function IconButton({ ) : ( )} + {progress != null && !loading ? ( + + ) : null} {dot && !loading ? ( { test('hides ReFloat idle quantization', () => { @@ -29,3 +29,21 @@ describe('fmtTimeAgo', () => { expect(fmtTimeAgo(now + 60_000, now)).toBe('now') }) }) + +describe('fmtCompactCount', () => { + test('keeps small counts exact and abbreviates larger ones', () => { + expect(fmtCompactCount(0)).toBe('0') + expect(fmtCompactCount(999)).toBe('999') + expect(fmtCompactCount(1000)).toBe('1.0k') + expect(fmtCompactCount(1240)).toBe('1.2k') + expect(fmtCompactCount(12_400)).toBe('12k') + expect(fmtCompactCount(100_000)).toBe('100k') + expect(fmtCompactCount(999_999)).toBe('1.0M') + expect(fmtCompactCount(2_500_000)).toBe('2.5M') + }) + + test('never renders a negative or fractional count', () => { + expect(fmtCompactCount(-5)).toBe('0') + expect(fmtCompactCount(3.7)).toBe('4') + }) +}) diff --git a/src/helpers/format.ts b/src/helpers/format.ts index a8fbed081..850a48a49 100644 --- a/src/helpers/format.ts +++ b/src/helpers/format.ts @@ -56,6 +56,21 @@ export function fmtTimeAgo(atMs: number, nowMs = Date.now()): string { return `${Math.floor(diffH / 24)}d ago` } +/** + * Abbreviate a count so it stays narrow in a fixed-width slot: 999 → "999", 1240 → "1.2k", + * 100000 → "100k". Backup backlogs run to six figures and must not widen the tile that shows them. + */ +export function fmtCompactCount(value: number): string { + const n = Math.max(0, Math.round(value)) + if (n < 1000) return String(n) + // Round before picking the suffix: 999_999 rounds to 1000k, which belongs in the next unit. + const k = n / 1000 + const roundedK = k < 10 ? Number(k.toFixed(1)) : Math.round(k) + if (roundedK < 1000) return `${k < 10 ? k.toFixed(1) : roundedK}k` + const m = n / 1_000_000 + return `${m < 10 ? m.toFixed(1) : Math.round(m)}M` +} + /** Format bytes to human-readable string (B, KB, MB). */ export function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B` diff --git a/src/modules/board/components/EditBoardSettings.tsx b/src/modules/board/components/EditBoardSettings.tsx index 587e29531..e6e6b8d8d 100644 --- a/src/modules/board/components/EditBoardSettings.tsx +++ b/src/modules/board/components/EditBoardSettings.tsx @@ -85,7 +85,7 @@ export function EditBoardSettings({ void } -export function SocialSheet({ accountWidget, onNavigate }: SocialSheetProps) { +export function SocialSheet({ onNavigate }: SocialSheetProps) { return ( - {accountWidget} void +} + +/** + * The Vescape Account as one compact pill: sign in, or who you are signed in as. + * + * Credential provisioning is part of the identity, not a separate widget — a failed Clerk → + * Device Token exchange turns the pill into a retry button, because until it succeeds the account + * buys the Rider nothing. + */ +export function AccountPill({ onNavigate }: AccountPillProps) { + const router = useRouter() + const { isLoaded, isSignedIn, user } = useUser() + const deviceAuthStatus = useDeviceAuthStore((s) => s.status) + const retryDeviceAuth = useDeviceAuthStore((s) => s.retry) + + const navigate = (route: typeof routes.signIn | typeof routes.account) => { + onNavigate() + router.push(route) + } + + if (!isLoaded) { + return ( + + + Checking account… + + ) + } + + if (!isSignedIn) { + return ( + navigate(routes.signIn)}> + + Sign in + + + ) + } + + if (deviceAuthStatus === 'failed') { + return ( + + + + Account not connected — retry + + + ) + } + + const name = user.fullName ?? user.primaryEmailAddress?.emailAddress ?? 'Vescape rider' + + return ( + navigate(routes.account)}> + {user.imageUrl ? ( + + ) : ( + + )} + + {name} + + {deviceAuthStatus === 'provisioning' ? ( + + ) : ( + + )} + + ) +} + +function Pill({ + tone, + onPress, + children, +}: { + tone?: string + onPress: () => void + children: React.ReactNode +}) { + return ( + [ + styles.pill, + tone ? { borderColor: tone } : null, + pressed && { opacity: theme.interaction.pressedOpacity }, + ]} + onPress={onPress} + testID="account-pill" + > + {children} + + ) +} + +const styles = StyleSheet.create({ + pill: { + alignSelf: 'center', + flexDirection: 'row', + alignItems: 'center', + gap: 8, + maxWidth: '90%', + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 999, + borderWidth: 1, + borderColor: theme.palette.slate.border, + backgroundColor: theme.palette.slate.surfaceDeep, + }, + avatar: { + width: 22, + height: 22, + borderRadius: 11, + }, + label: { + flexShrink: 1, + color: theme.palette.slate.textPrimary, + fontSize: 13, + fontWeight: '700', + }, +}) diff --git a/src/modules/profile/components/AccountWidget.tsx b/src/modules/profile/components/AccountWidget.tsx deleted file mode 100644 index 44c3436a8..000000000 --- a/src/modules/profile/components/AccountWidget.tsx +++ /dev/null @@ -1,185 +0,0 @@ -import { useUser } from '@clerk/expo' -import { Image } from 'expo-image' -import { useRouter } from 'expo-router' -import { ActivityIndicator, StyleSheet, View } from 'react-native' -import { UserCircleIcon, WarningCircleIcon } from 'phosphor-react-native' - -import { Button } from '@/components/base/Button' -import { Text } from '@/components/base/Text' -import { LinkWidget } from '@/components/widgets/LinkWidget' -import { widgetSurface } from '@/components/widgets/widgetSurface' -import { theme } from '@/constants/theme' -import { useDeviceAuthStore } from '@/modules/profile/store/deviceAuthStore' -import { routes } from '@/navigation/routes' - -interface AccountWidgetProps { - onNavigate: () => void -} - -export function AccountWidget({ onNavigate }: AccountWidgetProps) { - const router = useRouter() - const { isLoaded, isSignedIn, user } = useUser() - const deviceAuthStatus = useDeviceAuthStore((state) => state.status) - const deviceAuthError = useDeviceAuthStore((state) => state.error) - const retryDeviceAuth = useDeviceAuthStore((state) => state.retry) - - const navigate = (route: typeof routes.signIn | typeof routes.account) => { - onNavigate() - router.push(route) - } - - if (!isLoaded) { - return ( - - - Checking your Vescape account… - - ) - } - - if (!isSignedIn) { - return ( - navigate(routes.signIn)} - /> - ) - } - - return ( - - - {user.imageUrl ? ( - - ) : ( - - - - )} - - - Vescape account - - {user.fullName ?? user.primaryEmailAddress?.emailAddress ?? 'Vescape rider'} - - {user.fullName && user.primaryEmailAddress?.emailAddress ? ( - - {user.primaryEmailAddress.emailAddress} - - ) : null} - - -