From b806fe53e2a6ec95e5c3b1f3344d1889caca7104 Mon Sep 17 00:00:00 2001 From: Kacper Kozak Date: Fri, 7 Aug 2026 20:55:38 +0200 Subject: [PATCH 1/5] Add Settings Drawer with a status-aware trigger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The top-right gear opens an edge drawer instead of pushing /settings: wordmark, Vescape Account (moved off the Social sheet), a status strip for backup, app version and storage, the four settings worth one tap, and one link to Advanced. IconButton gains a takeover — alternate icon, accent and an optional determinate progress ring — so the Settings button can wear a required update or a running backup the way Social wears an active Group Ride. Backup enters the UI through a single BackupSlot seam wired to #276 when it lands. Settings icon accents move into theme.settingsIcon so a destination keeps one color everywhere: Connection is now the pairing purple, Live telemetry the speed blue. Database moves out of Dev tools into the drawer. --- CONTEXT.md | 4 + src/app/settings.tsx | 20 +- src/app/settings/components/base.tsx | 48 +++ src/app/settings/dev.tsx | 16 +- src/components/base/IconButton.tsx | 64 +++- src/constants/theme.ts | 26 ++ src/helpers/format.test.ts | 19 +- src/helpers/format.ts | 15 + .../board/components/EditBoardSettings.tsx | 2 +- .../group-ride/components/SocialSheet.tsx | 6 +- src/modules/profile/hooks/useBackupSlot.ts | 16 + src/modules/profile/lib/backupSlot.ts | 22 ++ src/screens/main/overlays/SettingsSheet.tsx | 322 ++++++++++++++++++ src/screens/main/overlays/TopBar.tsx | 60 +++- .../main/overlays/settingsTrigger.test.ts | 63 ++++ src/screens/main/overlays/settingsTrigger.ts | 67 ++++ 16 files changed, 718 insertions(+), 52 deletions(-) create mode 100644 src/modules/profile/hooks/useBackupSlot.ts create mode 100644 src/modules/profile/lib/backupSlot.ts create mode 100644 src/screens/main/overlays/SettingsSheet.tsx create mode 100644 src/screens/main/overlays/settingsTrigger.test.ts create mode 100644 src/screens/main/overlays/settingsTrigger.ts 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,20 @@ 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(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..1a0dddee9 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) + if (n < 1_000_000) { + const k = n / 1000 + return `${k < 10 ? k.toFixed(1) : Math.round(k)}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 +} + +/** + * Contents of the Settings Drawer: who you are, what the app is doing for you right now (backup, + * update, storage), the settings worth one tap, and one door to everything else. + */ +export function SettingsSheet({ backup, onNavigate }: SettingsSheetProps) { + const db = useSettingsDatabaseOps() + const appStatus = useAppStatusStore((s) => s.status) + const availableUpdate = selectAvailableUpdate(appStatus) + + const go = (route: Href) => { + onNavigate() + router.push(route) + } + + return ( + + + + + + + + + go(routes.signIn)} /> + + go(routes.settingsReleaseNotes)} + accessibilityLabel="Release notes" + /> + + go(routes.settingsDatabase)} + accessibilityLabel="Database and storage" + /> + + + {availableUpdate ? ( +