feat: seasonal race palette (Giro/Tour/Vuelta) + mobile picker

- 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
This commit is contained in:
Davide Scaini
2026-04-25 15:41:20 +02:00
parent 5330b7b489
commit dfe5307ab4
6 changed files with 111 additions and 50 deletions
+3 -1
View File
@@ -1,12 +1,14 @@
import { Tabs } from 'expo-router';
import { useTheme } from '@/theme';
export default function TabLayout() {
const theme = useTheme();
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarStyle: { backgroundColor: '#18181b', borderTopColor: '#27272a' },
tabBarActiveTintColor: '#60a5fa',
tabBarActiveTintColor: theme.accent,
tabBarInactiveTintColor: '#71717a',
}}
>
+4 -2
View File
@@ -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<ImportState>({ status: 'idle' });
async function pickFile() {
@@ -143,7 +145,7 @@ export default function ImportScreen() {
<Text style={styles.body}>
Import a FIT, GPX, or TCX file extracted on your device, nothing uploaded.
You can also import a pre-extracted BAS <Text style={styles.code}>.json</Text> file directly.
You can also import a pre-extracted BAS <Text style={[styles.code, { color: theme.accent }]}>.json</Text> file directly.
</Text>
<Pressable
@@ -157,7 +159,7 @@ export default function ImportScreen() {
{state.status === 'loading' && (
<View style={styles.statusBox}>
<Text style={styles.statusMsg}>{state.msg}</Text>
<Text style={[styles.statusMsg, { color: theme.accent }]}>{state.msg}</Text>
<Text style={styles.statusHint}>
First run downloads ~35 MB (Python runtime + packages). Subsequent runs are instant.
</Text>
+12 -11
View File
@@ -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<string | null>(null);
@@ -86,10 +88,10 @@ export default function FeedScreen() {
<>
<Text style={styles.header}>Feed</Text>
<Pressable
style={[styles.syncButton, syncing && styles.syncButtonDisabled]}
style={[styles.syncButton, { backgroundColor: theme.dim }, syncing && styles.syncButtonDisabled]}
onPress={syncing ? undefined : doSync}
>
<Text style={styles.syncText}>{syncing ? 'Syncing…' : '↓ Sync'}</Text>
<Text style={[styles.syncText, { color: theme.accent }]}>{syncing ? 'Syncing…' : '↓ Sync'}</Text>
</Pressable>
</>
)}
@@ -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 (
<Pressable
style={[styles.card, checked && styles.cardSelected]}
style={[styles.card, checked && { borderColor: theme.accent }]}
onPress={handlePress}
onLongPress={onLongPress}
>
<View style={styles.cardTop}>
<View style={styles.cardLeft}>
{selecting && (
<View style={[styles.checkbox, checked && styles.checkboxChecked]}>
<View style={[styles.checkbox, checked && { backgroundColor: theme.accent, borderColor: theme.accent }]}>
{checked && <Text style={styles.checkmark}></Text>}
</View>
)}
@@ -188,7 +191,7 @@ function ActivityCard({
<View style={styles.cardMeta}>
<Text style={styles.cardDate}>{date}</Text>
{activity.origin === 'remote'
? <Text style={styles.remoteBadge}>cloud</Text>
? <Text style={[styles.remoteBadge, { color: theme.accent, borderColor: theme.accent }]}>cloud</Text>
: !activity.synced_at && <Text style={styles.localBadge}>local</Text>
}
</View>
@@ -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,
+43 -25
View File
@@ -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<PaletteKey>(storedPalette);
const [password, setPassword] = useState('');
const [connecting, setConnecting] = useState(false);
@@ -181,16 +185,10 @@ export default function SettingsScreen() {
<Section title="Sync">
<Text style={styles.subLabel}>Download</Text>
<View style={styles.modeRow}>
<ModeButton
label="Summaries only"
active={syncMode === 'summaries'}
onPress={() => { setSyncMode('summaries'); setSetting(db, 'sync_mode', 'summaries'); }}
/>
<ModeButton
label="Full data"
active={syncMode === 'full'}
onPress={() => { setSyncMode('full'); setSetting(db, 'sync_mode', 'full'); }}
/>
<ModeButton label="Summaries only" active={syncMode === 'summaries'} accent={theme.accent} dim={theme.dim}
onPress={() => { setSyncMode('summaries'); setSetting(db, 'sync_mode', 'summaries'); }} />
<ModeButton label="Full data" active={syncMode === 'full'} accent={theme.accent} dim={theme.dim}
onPress={() => { setSyncMode('full'); setSetting(db, 'sync_mode', 'full'); }} />
</View>
<Text style={styles.hint}>
{syncMode === 'full'
@@ -199,16 +197,10 @@ export default function SettingsScreen() {
</Text>
<Text style={[styles.subLabel, { borderTopWidth: 1, borderTopColor: '#27272a', paddingTop: 12 }]}>Upload</Text>
<View style={styles.modeRow}>
<ModeButton
label="Off"
active={!syncUpload}
onPress={() => { setSyncUpload(false); setSetting(db, 'sync_upload', 'false'); }}
/>
<ModeButton
label="Upload local activities"
active={syncUpload}
onPress={() => { setSyncUpload(true); setSetting(db, 'sync_upload', 'true'); }}
/>
<ModeButton label="Off" active={!syncUpload} accent={theme.accent} dim={theme.dim}
onPress={() => { setSyncUpload(false); setSetting(db, 'sync_upload', 'false'); }} />
<ModeButton label="Upload local activities" active={syncUpload} accent={theme.accent} dim={theme.dim}
onPress={() => { setSyncUpload(true); setSetting(db, 'sync_upload', 'true'); }} />
</View>
<Text style={styles.hint}>
{syncUpload
@@ -217,6 +209,32 @@ export default function SettingsScreen() {
</Text>
</Section>
<Section title="Palette">
<Text style={[styles.hint, { paddingBottom: 0 }]}>
Auto-switches to race colours during Giro, Tour, and Vuelta. Override here for testing.
</Text>
<View style={styles.modeRow}>
{(['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 (
<ModeButton
key={key}
label={label}
active={palette === key}
accent={keyAccent}
dim={keyDim}
onPress={() => {
setPalette(key);
setSetting(db, 'palette_override', key);
}}
/>
);
})}
</View>
</Section>
<Section title="Data">
<Pressable
style={[styles.resetButton, resetArmed && styles.resetButtonArmed]}
@@ -274,13 +292,15 @@ function Field({
);
}
function ModeButton({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) {
function ModeButton({ label, active, accent, dim, onPress }: {
label: string; active: boolean; accent: string; dim: string; onPress: () => void;
}) {
return (
<Pressable
style={[styles.modeButton, active && styles.modeButtonActive]}
style={[styles.modeButton, active && { backgroundColor: dim, borderColor: accent }]}
onPress={onPress}
>
<Text style={[styles.modeButtonText, active && styles.modeButtonTextActive]}>{label}</Text>
<Text style={[styles.modeButtonText, active && { color: accent }]}>{label}</Text>
</Pressable>
);
}
@@ -339,9 +359,7 @@ const styles = StyleSheet.create({
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' },
resetButton: {
margin: 12, paddingVertical: 10, alignItems: 'center',
borderRadius: 8, borderWidth: 1, borderColor: '#3f3f46',
+11 -9
View File
@@ -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() {
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<View style={styles.topBar}>
<Pressable style={styles.backButton} onPress={() => router.back()}>
<Text style={styles.backText}> Back</Text>
<Text style={[styles.backText, { color: theme.accent }]}> Back</Text>
</Pressable>
<Pressable style={styles.deleteButton} onPress={confirmDelete}>
<Text style={styles.deleteText}>Delete</Text>
@@ -128,7 +130,7 @@ export default function ActivityScreen() {
<Text style={styles.date}>{date}</Text>
{/* Map */}
<RouteMap geojson={geojson} loading={loadingMap} />
<RouteMap geojson={geojson} loading={loadingMap} accent={theme.accent} />
{/* Stats grid */}
<View style={styles.grid}>
@@ -142,7 +144,7 @@ export default function ActivityScreen() {
</View>
{/* Metric charts */}
<MetricCharts timeseries={timeseries} loading={loadingChart} />
<MetricCharts timeseries={timeseries} loading={loadingChart} accent={theme.accent} />
{/* Meta */}
<View style={styles.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 (
<View style={styles.mapPlaceholder}>
<ActivityIndicator color="#60a5fa" />
<ActivityIndicator color={accent} />
</View>
);
}
@@ -175,7 +177,7 @@ function RouteMap({ geojson, loading }: { geojson: object | null; loading: boole
<Layer
type="line"
id="route-line"
paint={{ 'line-color': '#60a5fa', 'line-width': 3 }}
paint={{ 'line-color': accent, 'line-width': 3 }}
layout={{ 'line-cap': 'round', 'line-join': 'round' }}
/>
</GeoJSONSource>
@@ -237,13 +239,13 @@ const TAB_META: Record<TabKey, { label: string; unit: string; color: string; dec
power: { label: 'Power', unit: 'W', color: '#facc15', decimals: 0 },
};
function MetricCharts({ timeseries, loading }: { timeseries: Timeseries | null; loading: boolean }) {
function MetricCharts({ timeseries, loading, accent }: { timeseries: Timeseries | null; loading: boolean; accent: string }) {
const [activeTab, setActiveTab] = useState<TabKey>('elevation');
if (loading) {
return (
<View style={styles.chartPlaceholder}>
<ActivityIndicator color="#60a5fa" />
<ActivityIndicator color={accent} />
</View>
);
}
@@ -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 },
+36
View File
@@ -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<string, { accent: string; dim: string; label: string }>;
export type Theme = (typeof PALETTES)[keyof typeof PALETTES];
// Race windows [month 0-indexed, day inclusive] — update each year
const RACES: Array<{ key: Exclude<PaletteKey, 'auto' | 'default'>; 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<PaletteKey, 'auto'> {
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;
}