From dfe5307ab4c64f201f6deb5e69b22b145f03ed06 Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Sat, 25 Apr 2026 15:41:20 +0200 Subject: [PATCH] feat: seasonal race palette (Giro/Tour/Vuelta) + mobile picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - theme.ts: useTheme() hook with race calendar (May–Sep windows), auto-detects Giro/Tour/Vuelta by date; stores override in SQLite - All screens (feed, import, activity, tab bar) now use accent/dim from useTheme() instead of hardcoded #60a5fa - Settings: Palette section with Auto/Default/Giro/Tour/Vuelta buttons to override the auto-detected palette for testing --- mobile/app/(tabs)/_layout.tsx | 4 +- mobile/app/(tabs)/import.tsx | 6 ++- mobile/app/(tabs)/index.tsx | 23 +++++------ mobile/app/(tabs)/settings.tsx | 72 +++++++++++++++++++++------------- mobile/app/activity/[id].tsx | 20 +++++----- mobile/theme.ts | 36 +++++++++++++++++ 6 files changed, 111 insertions(+), 50 deletions(-) create mode 100644 mobile/theme.ts diff --git a/mobile/app/(tabs)/_layout.tsx b/mobile/app/(tabs)/_layout.tsx index bb1e62f..c95b97d 100644 --- a/mobile/app/(tabs)/_layout.tsx +++ b/mobile/app/(tabs)/_layout.tsx @@ -1,12 +1,14 @@ import { Tabs } from 'expo-router'; +import { useTheme } from '@/theme'; export default function TabLayout() { + const theme = useTheme(); return ( diff --git a/mobile/app/(tabs)/import.tsx b/mobile/app/(tabs)/import.tsx index 879bdc4..deb58cd 100644 --- a/mobile/app/(tabs)/import.tsx +++ b/mobile/app/(tabs)/import.tsx @@ -6,6 +6,7 @@ import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { insertActivity } from '@/db/queries'; import { PyodideWebView } from '@/extraction/PyodideWebView'; import { extractFile } from '@/extraction/extractActivity'; +import { useTheme } from '@/theme'; const FIT_EXTENSIONS = ['.fit', '.fit.gz']; const OTHER_EXTENSIONS = ['.gpx', '.tcx', '.gpx.gz', '.tcx.gz']; @@ -19,6 +20,7 @@ type ImportState = export default function ImportScreen() { const db = useSQLiteContext(); + const theme = useTheme(); const [state, setState] = useState({ status: 'idle' }); async function pickFile() { @@ -143,7 +145,7 @@ export default function ImportScreen() { Import a FIT, GPX, or TCX file — extracted on your device, nothing uploaded. - You can also import a pre-extracted BAS .json file directly. + You can also import a pre-extracted BAS .json file directly. - {state.msg} + {state.msg} First run downloads ~35 MB (Python runtime + packages). Subsequent runs are instant. diff --git a/mobile/app/(tabs)/index.tsx b/mobile/app/(tabs)/index.tsx index 11650af..0f4335c 100644 --- a/mobile/app/(tabs)/index.tsx +++ b/mobile/app/(tabs)/index.tsx @@ -5,9 +5,11 @@ import { useCallback, useState } from 'react'; import { Alert, FlatList, Pressable, RefreshControl, StyleSheet, Text, View } from 'react-native'; import { deleteActivities, useActivities, type ActivitySummary } from '@/db/queries'; import { syncFeed } from '@/db/sync'; +import { useTheme } from '@/theme'; export default function FeedScreen() { const db = useSQLiteContext(); + const theme = useTheme(); const activities = useActivities(); const [syncing, setSyncing] = useState(false); const [syncMsg, setSyncMsg] = useState(null); @@ -86,10 +88,10 @@ export default function FeedScreen() { <> Feed - {syncing ? 'Syncing…' : '↓ Sync'} + {syncing ? 'Syncing…' : '↓ Sync'} )} @@ -156,6 +158,7 @@ function ActivityCard({ onLongPress: () => void; }) { const router = useRouter(); + const theme = useTheme(); const km = activity.distance_m != null ? (activity.distance_m / 1000).toFixed(1) : null; const elev = activity.elevation_gain_m != null ? Math.round(activity.elevation_gain_m) : null; const date = new Date(activity.started_at).toLocaleDateString(undefined, { @@ -172,14 +175,14 @@ function ActivityCard({ return ( {selecting && ( - + {checked && } )} @@ -188,7 +191,7 @@ function ActivityCard({ {date} {activity.origin === 'remote' - ? cloud + ? cloud : !activity.synced_at && local } @@ -226,11 +229,11 @@ const styles = StyleSheet.create({ }, header: { color: '#fff', fontSize: 22, fontWeight: '700' }, syncButton: { - backgroundColor: '#1e3a5f', borderRadius: 8, + borderRadius: 8, paddingHorizontal: 14, paddingVertical: 7, }, syncButtonDisabled: { opacity: 0.5 }, - syncText: { color: '#60a5fa', fontSize: 13, fontWeight: '600' }, + syncText: { fontSize: 13, fontWeight: '600' }, cancelButton: { backgroundColor: '#27272a', borderRadius: 8, paddingHorizontal: 14, paddingVertical: 7, @@ -245,15 +248,14 @@ const styles = StyleSheet.create({ backgroundColor: '#18181b', borderRadius: 12, padding: 16, borderWidth: 1, borderColor: '#27272a', }, - cardSelected: { borderColor: '#60a5fa' }, cardTop: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 6 }, cardLeft: { flexDirection: 'row', alignItems: 'center', gap: 10 }, sportIcon: { fontSize: 20 }, cardMeta: { flexDirection: 'row', alignItems: 'center', gap: 8 }, cardDate: { color: '#71717a', fontSize: 12 }, remoteBadge: { - color: '#60a5fa', fontSize: 10, borderWidth: 1, - borderColor: '#1e3a5f', borderRadius: 4, paddingHorizontal: 4, + fontSize: 10, borderWidth: 1, + borderRadius: 4, paddingHorizontal: 4, }, localBadge: { color: '#a1a1aa', fontSize: 10, borderWidth: 1, @@ -268,7 +270,6 @@ const styles = StyleSheet.create({ width: 20, height: 20, borderRadius: 4, borderWidth: 1.5, borderColor: '#52525b', alignItems: 'center', justifyContent: 'center', }, - checkboxChecked: { backgroundColor: '#60a5fa', borderColor: '#60a5fa' }, checkmark: { color: '#fff', fontSize: 12, fontWeight: '700' }, empty: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32, diff --git a/mobile/app/(tabs)/settings.tsx b/mobile/app/(tabs)/settings.tsx index 448fd03..7432553 100644 --- a/mobile/app/(tabs)/settings.tsx +++ b/mobile/app/(tabs)/settings.tsx @@ -5,6 +5,7 @@ import { Text, TextInput, View, } from 'react-native'; import { deleteRemoteActivities, getSetting, setSetting, useSetting } from '@/db/queries'; +import { PALETTES, type PaletteKey, useTheme } from '@/theme'; export default function SettingsScreen() { const db = useSQLiteContext(); @@ -22,6 +23,9 @@ export default function SettingsScreen() { const [syncMode, setSyncMode] = useState(storedSyncMode); const [syncUpload, setSyncUpload] = useState(storedSyncUpload); const [saved, setSaved] = useState(false); + const theme = useTheme(); + const storedPalette = (useSetting('palette_override') ?? 'auto') as PaletteKey; + const [palette, setPalette] = useState(storedPalette); const [password, setPassword] = useState(''); const [connecting, setConnecting] = useState(false); @@ -181,16 +185,10 @@ export default function SettingsScreen() {
Download - { setSyncMode('summaries'); setSetting(db, 'sync_mode', 'summaries'); }} - /> - { setSyncMode('full'); setSetting(db, 'sync_mode', 'full'); }} - /> + { setSyncMode('summaries'); setSetting(db, 'sync_mode', 'summaries'); }} /> + { setSyncMode('full'); setSetting(db, 'sync_mode', 'full'); }} /> {syncMode === 'full' @@ -199,16 +197,10 @@ export default function SettingsScreen() { Upload - { setSyncUpload(false); setSetting(db, 'sync_upload', 'false'); }} - /> - { setSyncUpload(true); setSetting(db, 'sync_upload', 'true'); }} - /> + { setSyncUpload(false); setSetting(db, 'sync_upload', 'false'); }} /> + { setSyncUpload(true); setSetting(db, 'sync_upload', 'true'); }} /> {syncUpload @@ -217,6 +209,32 @@ export default function SettingsScreen() {
+
+ + Auto-switches to race colours during Giro, Tour, and Vuelta. Override here for testing. + + + {(['auto', 'default', 'giro', 'tour', 'vuelta'] as PaletteKey[]).map(key => { + const label = key === 'auto' ? 'Auto' : PALETTES[key as keyof typeof PALETTES].label; + const keyAccent = key === 'auto' ? theme.accent : PALETTES[key as keyof typeof PALETTES].accent; + const keyDim = key === 'auto' ? theme.dim : PALETTES[key as keyof typeof PALETTES].dim; + return ( + { + setPalette(key); + setSetting(db, 'palette_override', key); + }} + /> + ); + })} + +
+
void }) { +function ModeButton({ label, active, accent, dim, onPress }: { + label: string; active: boolean; accent: string; dim: string; onPress: () => void; +}) { return ( - {label} + {label} ); } @@ -338,10 +358,8 @@ const styles = StyleSheet.create({ msgErr: { color: '#fca5a5', fontSize: 13, paddingHorizontal: 12, paddingBottom: 10 }, subLabel: { color: '#52525b', fontSize: 11, fontWeight: '600', letterSpacing: 0.6, paddingHorizontal: 12, paddingTop: 12, paddingBottom: 4 }, modeRow: { flexDirection: 'row', gap: 8, padding: 12 }, - modeButton: { flex: 1, paddingVertical: 9, borderRadius: 8, borderWidth: 1, borderColor: '#3f3f46', alignItems: 'center' }, - modeButtonActive: { backgroundColor: '#1e3a5f', borderColor: '#2563eb' }, - modeButtonText: { color: '#71717a', fontSize: 13, fontWeight: '500' }, - modeButtonTextActive: { color: '#60a5fa' }, + modeButton: { flex: 1, paddingVertical: 9, borderRadius: 8, borderWidth: 1, borderColor: '#3f3f46', alignItems: 'center' }, + modeButtonText: { color: '#71717a', fontSize: 13, fontWeight: '500' }, resetButton: { margin: 12, paddingVertical: 10, alignItems: 'center', borderRadius: 8, borderWidth: 1, borderColor: '#3f3f46', diff --git a/mobile/app/activity/[id].tsx b/mobile/app/activity/[id].tsx index a2b4a29..7d08574 100644 --- a/mobile/app/activity/[id].tsx +++ b/mobile/app/activity/[id].tsx @@ -6,6 +6,7 @@ import { ActivityIndicator, Alert, Modal, Pressable, ScrollView, StyleSheet, Tex import Svg, { Defs, LinearGradient, Path, Stop } from 'react-native-svg'; import { useSQLiteContext } from 'expo-sqlite'; import { deleteActivity, useActivity, useSetting } from '@/db/queries'; +import { useTheme } from '@/theme'; const MAP_STYLE = 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json'; @@ -28,6 +29,7 @@ export default function ActivityScreen() { const { id } = useLocalSearchParams<{ id: string }>(); const router = useRouter(); const db = useSQLiteContext(); + const theme = useTheme(); const row = useActivity(id); const instanceUrl = useSetting('instance_url')?.replace(/\/$/, '') ?? ''; const token = useSetting('api_token') ?? ''; @@ -116,7 +118,7 @@ export default function ActivityScreen() { router.back()}> - ← Back + ← Back Delete @@ -128,7 +130,7 @@ export default function ActivityScreen() { {date} {/* Map */} - + {/* Stats grid */} @@ -142,7 +144,7 @@ export default function ActivityScreen() { {/* Metric charts */} - + {/* Meta */} @@ -157,13 +159,13 @@ export default function ActivityScreen() { // ── Map ─────────────────────────────────────────────────────────────────────── -function RouteMap({ geojson, loading }: { geojson: object | null; loading: boolean }) { +function RouteMap({ geojson, loading, accent }: { geojson: object | null; loading: boolean; accent: string }) { const [fullscreen, setFullscreen] = useState(false); if (loading) { return ( - + ); } @@ -175,7 +177,7 @@ function RouteMap({ geojson, loading }: { geojson: object | null; loading: boole @@ -237,13 +239,13 @@ const TAB_META: Record('elevation'); if (loading) { return ( - + ); } @@ -398,7 +400,7 @@ const styles = StyleSheet.create({ notFound: { color: '#71717a', fontSize: 16 }, topBar: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingTop: 60, paddingBottom: 12 }, backButton: { paddingHorizontal: 16 }, - backText: { color: '#60a5fa', fontSize: 15 }, + backText: { fontSize: 15 }, deleteButton: { paddingHorizontal: 16 }, deleteText: { color: '#f87171', fontSize: 15 }, sport: { color: '#71717a', fontSize: 12, fontWeight: '600', letterSpacing: 0.8, paddingHorizontal: 16, marginBottom: 4 }, diff --git a/mobile/theme.ts b/mobile/theme.ts new file mode 100644 index 0000000..3516a73 --- /dev/null +++ b/mobile/theme.ts @@ -0,0 +1,36 @@ +import { useSetting } from '@/db/queries'; + +export type PaletteKey = 'auto' | 'default' | 'giro' | 'tour' | 'vuelta'; + +export const PALETTES = { + default: { accent: '#60a5fa', dim: 'rgba(96,165,250,0.15)', label: 'Default' }, + giro: { accent: '#f472b6', dim: 'rgba(244,114,182,0.15)', label: "Giro d'Italia" }, + tour: { accent: '#facc15', dim: 'rgba(250,204,21,0.15)', label: 'Tour de France' }, + vuelta: { accent: '#ef4444', dim: 'rgba(239,68,68,0.15)', label: 'Vuelta a España' }, +} as const satisfies Record; + +export type Theme = (typeof PALETTES)[keyof typeof PALETTES]; + +// Race windows [month 0-indexed, day inclusive] — update each year +const RACES: Array<{ key: Exclude; start: [number, number]; end: [number, number] }> = [ + { key: 'giro', start: [4, 8], end: [5, 1] }, // May 8 – Jun 1 + { key: 'tour', start: [5, 27], end: [6, 19] }, // Jun 27 – Jul 19 + { key: 'vuelta', start: [7, 15], end: [8, 6] }, // Aug 15 – Sep 6 +]; + +export function autoKey(): Exclude { + const now = new Date(); + const y = now.getFullYear(); + for (const r of RACES) { + const start = new Date(y, r.start[0], r.start[1]); + const end = new Date(y, r.end[0], r.end[1] + 1); + if (now >= start && now < end) return r.key; + } + return 'default'; +} + +export function useTheme(): Theme { + const override = (useSetting('palette_override') ?? 'auto') as PaletteKey; + const key = override === 'auto' ? autoKey() : override; + return PALETTES[key as keyof typeof PALETTES] ?? PALETTES.default; +}