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:
@@ -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',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user