Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 10 additions & 10 deletions src/app/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,28 +96,28 @@ export default function SettingsScreen() {
<SettingsCard>
<SettingsRow
icon={BluetoothConnectedIcon}
iconColor={theme.palette.cyan.color}
iconColor={theme.settingsIcon.connection}
label="Connection"
hint="Auto start, auto connect, and sounds"
onPress={() => router.push(routes.settingsConnection)}
/>
<SettingsRow
icon={GaugeIcon}
iconColor={theme.palette.green.color}
iconColor={theme.settingsIcon.liveTelemetry}
label="Live telemetry"
hint="Graphs, update rate, and battery smoothing"
onPress={() => router.push(routes.settingsLiveTelemetry)}
/>
<SettingsRow
icon={WarningIcon}
iconColor={theme.status.warning.color}
iconColor={theme.settingsIcon.diagnostics}
label="Diagnostics"
hint="Board warnings and health checks"
onPress={() => router.push(routes.settingsDiagnostics)}
/>
<SettingsRow
icon={MapTrifoldIcon}
iconColor={theme.palette.sky.color}
iconColor={theme.settingsIcon.map}
label="Map"
hint="Map appearance and satellite imagery"
onPress={() => router.push(routes.settingsMap)}
Expand All @@ -131,7 +131,7 @@ export default function SettingsScreen() {
<SettingsCard>
<SettingsRow
icon={WatchIcon}
iconColor={theme.palette.amber.color}
iconColor={theme.settingsIcon.watch}
label="Watch Mirror"
hint="Auto open and telemetry push rate"
onPress={() => router.push(routes.settingsWatch)}
Expand All @@ -145,21 +145,21 @@ export default function SettingsScreen() {
<SettingsCard>
<SettingsRow
icon={MapPinIcon}
iconColor={theme.palette.green.color}
iconColor={theme.settingsIcon.privacyZones}
label="Privacy zones"
hint="Skip recording near saved places"
onPress={() => router.push(routes.settingsPrivacyZones)}
/>
<SettingsRow
icon={FadersIcon}
iconColor={theme.palette.purple.color}
iconColor={theme.settingsIcon.filters}
label="Filters"
hint="Ride data filtering and free-spin detection"
onPress={() => router.push(routes.settingsFilters)}
/>
<SettingsRow
icon={ChartLineUpIcon}
iconColor={theme.palette.cyan.color}
iconColor={theme.settingsIcon.graphs}
label="Graphs"
hint="Hot gradients and color ramps"
onPress={() => router.push(routes.settingsGraphs)}
Expand All @@ -171,14 +171,14 @@ export default function SettingsScreen() {
<SettingsCard>
<SettingsRow
icon={CodeIcon}
iconColor={theme.palette.yellow.color}
iconColor={theme.settingsIcon.dev}
label="Dev tools"
hint="Diagnostics and local verification"
onPress={() => router.push(routes.settingsDev)}
/>
<SettingsRow
icon={InfoIcon}
iconColor={theme.palette.cyan.color}
iconColor={theme.settingsIcon.about}
label="About us"
hint="The people who built this app"
onPress={() => router.push(routes.settingsAbout)}
Expand Down
48 changes: 48 additions & 0 deletions src/app/settings/components/base.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ import {
withTiming,
} from 'react-native-reanimated'
import {
ArrowFatLinesUpIcon,
ArrowLeftIcon,
ArrowRightIcon,
ArrowsClockwiseIcon,
CubeIcon,
FadersIcon,
GearSixIcon,
Expand All @@ -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 (
<ShowcaseCard
Expand All @@ -44,6 +59,18 @@ function IconButtonShowcase() {
<ToggleRow label="loading" value={loading} onToggle={setLoading} />
<ToggleRow label="disabled" value={disabled} onToggle={setDisabled} />
<ToggleRow label="dot" value={dot} onToggle={setDot} />
<ChipRow
label="takeover"
options={['none', 'update', 'backup']}
selected={takeover}
onSelect={setTakeover}
/>
<ChipRow
label="progress"
options={['none', '10%', '40%', '85%']}
selected={progress}
onSelect={setProgress}
/>
</>
}
>
Expand Down Expand Up @@ -80,6 +107,27 @@ function IconButtonShowcase() {
disabled={disabled}
/>
</View>
<View style={{ gap: 8, alignItems: 'center' }}>
<IconButton
icon={GearSixIcon}
takeover={activeTakeover}
dot={dot ? theme.status.upgrade.color : undefined}
accessibilityLabel="Settings"
onPress={() => {}}
loading={loading}
disabled={disabled}
/>
<IconButton
icon={GearSixIcon}
size="lg"
takeover={activeTakeover}
dot={dot ? theme.status.upgrade.color : undefined}
accessibilityLabel="Settings"
onPress={() => {}}
loading={loading}
disabled={disabled}
/>
</View>
<View style={{ gap: 8, alignItems: 'center' }}>
<IconButton
icon={UsersThreeIcon}
Expand Down
16 changes: 1 addition & 15 deletions src/app/settings/dev.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,7 @@
import { ScrollView, StyleSheet } from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { router } from 'expo-router'
import {
RecordIcon,
CompassIcon,
SwatchesIcon,
ToolboxIcon,
CodeIcon,
DatabaseIcon,
} from 'phosphor-react-native'
import { RecordIcon, CompassIcon, SwatchesIcon, ToolboxIcon, CodeIcon } from 'phosphor-react-native'

import { routes } from '@/navigation/routes'
import { SettingsCard } from '@/components/settings/SettingsCard'
Expand Down Expand Up @@ -38,13 +31,6 @@ const devPages = [
icon: CompassIcon,
iconColor: theme.palette.sky.color,
},
{
label: 'Database',
hint: 'Back up, restore, and rebuild history',
route: routes.settingsDatabase,
icon: DatabaseIcon,
iconColor: theme.status.warning.color,
},
{
label: 'Camera playground',
hint: 'Tune the spring camera engine against fake GPS',
Expand Down
64 changes: 60 additions & 4 deletions src/components/base/IconButton.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect } from 'react'
import { useEffect, useMemo } from 'react'
import { Canvas, Path, Skia } from '@shopify/react-native-skia'
import type { Icon } from 'phosphor-react-native'
import {
ActivityIndicator,
Expand All @@ -19,10 +20,52 @@ import { interaction, theme } from '@/constants/theme'

const SIZES = { sm: 38, md: 50, lg: 54 } as const
const ICON_SIZES = { sm: 18, md: 21, lg: 22 } as const
const RING_STROKE = 2.5

/** Determinate ring hugging the button's border, filling clockwise from the top. */
function ProgressRing({ dim, color, progress }: { dim: number; color: string; progress: number }) {
const path = useMemo(() => {
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 (
<Canvas pointerEvents="none" style={[styles.ring, { width: dim, height: dim }]}>
<Path path={path} style="stroke" strokeWidth={RING_STROKE} color={theme.alpha(color, 0.3)} />
<Path
path={path}
style="stroke"
strokeWidth={RING_STROKE}
strokeCap="round"
color={color}
end={end}
/>
</Canvas>
)
}

/**
* 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
Expand All @@ -38,8 +81,9 @@ interface IconButtonProps {
}

export function IconButton({
icon: Icon,
icon: RestingIcon,
onPress,
takeover,
onLongPress,
size = 'sm',
disabled = false,
Expand All @@ -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(() => {
Expand Down Expand Up @@ -97,6 +145,9 @@ export function IconButton({
) : (
<Icon size={iconSize} color={iconColor} weight="bold" />
)}
{progress != null && !loading ? (
<ProgressRing dim={dim} color={iconColor} progress={progress} />
) : null}
{dot && !loading ? (
<Animated.View
pointerEvents="none"
Expand All @@ -117,6 +168,11 @@ const styles = StyleSheet.create({
disabled: {
opacity: 0.35,
},
ring: {
position: 'absolute',
top: 0,
left: 0,
},
dot: {
position: 'absolute',
top: -1,
Expand Down
26 changes: 26 additions & 0 deletions src/constants/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,31 @@ export const status = {
/** Tune Profile actions and entry points. */
export const tune = palette.purple

/**
* Icon accent per settings destination. A destination keeps one color wherever it is offered — the
* Settings Drawer, the settings screens and any future entry point all read the same token, so
* recoloring a destination is a single edit here.
*/
export const settingsIcon = {
account: palette.cyan.color,
sync: palette.cyan.color,
update: status.upgrade.color,
database: status.warning.color,
/** Board Link and BLE connection share the "pairing" purple. */
link: palette.purple.color,
connection: palette.purple.color,
liveTelemetry: telemetry.speed,
diagnostics: status.warning.color,
map: palette.sky.color,
watch: palette.amber.color,
privacyZones: palette.green.color,
filters: palette.purple.color,
graphs: palette.cyan.color,
advanced: palette.slate.light,
dev: palette.yellow.color,
about: palette.cyan.color,
} as const

/** Banner callouts — flat row, accent icon + neutral text. */
export const banner = {
info: { icon: status.info.color },
Expand Down Expand Up @@ -263,6 +288,7 @@ export const theme = {
map,
status,
tune,
settingsIcon,
alpha,
banner,
weather,
Expand Down
20 changes: 19 additions & 1 deletion src/helpers/format.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, test } from 'bun:test'

import { dutyPercent, fmtDutyPercent, fmtTimeAgo } from './format'
import { dutyPercent, fmtCompactCount, fmtDutyPercent, fmtTimeAgo } from './format'

describe('dutyPercent', () => {
test('hides ReFloat idle quantization', () => {
Expand Down Expand Up @@ -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')
})
})
Loading
Loading