feat: ThemeContext + Settings tabs (Interface / App / Sync)
- ThemeContext: dynamic palette (Default/Giro/Tour/Vuelta), font size (small/medium/large), bold labels — all persisted to AsyncStorage - Settings: three top tabs; Interface tab has palette picker + font size pills + bold labels toggle; App tab has km notifications; Sync tab has bincio instance login + autarchive placeholder - RecordingScreen: stat labels now use theme accent colour and scale with fontSize; font weight follows boldLabels setting - All accent/accentDim usages migrated from static colors to useTheme()
This commit is contained in:
@@ -2,11 +2,11 @@ import { useEffect } from 'react';
|
|||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
import * as Notifications from 'expo-notifications';
|
import * as Notifications from 'expo-notifications';
|
||||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||||
|
import { ThemeProvider } from './src/ThemeContext';
|
||||||
import { AppNavigator } from './src/navigation/AppNavigator';
|
import { AppNavigator } from './src/navigation/AppNavigator';
|
||||||
import { requestNotificationPermissions } from './src/services/gps';
|
import { requestNotificationPermissions } from './src/services/gps';
|
||||||
import { promptBatteryOptimizationIfNeeded } from './src/services/batteryOptimization';
|
import { promptBatteryOptimizationIfNeeded } from './src/services/batteryOptimization';
|
||||||
|
|
||||||
// Show notifications even when the app is in the foreground (iOS suppresses by default)
|
|
||||||
Notifications.setNotificationHandler({
|
Notifications.setNotificationHandler({
|
||||||
handleNotification: async () => ({
|
handleNotification: async () => ({
|
||||||
shouldShowBanner: true,
|
shouldShowBanner: true,
|
||||||
@@ -24,8 +24,10 @@ export default function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
|
<ThemeProvider>
|
||||||
<StatusBar style="light" />
|
<StatusBar style="light" />
|
||||||
<AppNavigator />
|
<AppNavigator />
|
||||||
|
</ThemeProvider>
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { PALETTES, FONT_SCALE, type PaletteKey, type FontSizeKey } from './theme';
|
||||||
|
|
||||||
|
interface ThemeValue {
|
||||||
|
accent: string;
|
||||||
|
accentDim: string;
|
||||||
|
palette: PaletteKey;
|
||||||
|
setPalette: (p: PaletteKey) => void;
|
||||||
|
fontSize: FontSizeKey;
|
||||||
|
setFontSize: (s: FontSizeKey) => void;
|
||||||
|
boldLabels: boolean;
|
||||||
|
setBoldLabels: (b: boolean) => void;
|
||||||
|
scale: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeValue>({
|
||||||
|
...PALETTES.default,
|
||||||
|
palette: 'default',
|
||||||
|
setPalette: () => {},
|
||||||
|
fontSize: 'medium',
|
||||||
|
setFontSize: () => {},
|
||||||
|
boldLabels: false,
|
||||||
|
setBoldLabels: () => {},
|
||||||
|
scale: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [palette, setPaletteState] = useState<PaletteKey>('default');
|
||||||
|
const [fontSize, setFontSizeState] = useState<FontSizeKey>('medium');
|
||||||
|
const [boldLabels, setBoldLabelsState] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
const [p, f, b] = await Promise.all([
|
||||||
|
AsyncStorage.getItem('themePalette'),
|
||||||
|
AsyncStorage.getItem('themeFontSize'),
|
||||||
|
AsyncStorage.getItem('themeBoldLabels'),
|
||||||
|
]);
|
||||||
|
if (p && p in PALETTES) setPaletteState(p as PaletteKey);
|
||||||
|
if (f && f in FONT_SCALE) setFontSizeState(f as FontSizeKey);
|
||||||
|
if (b !== null) setBoldLabelsState(b === 'true');
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function setPalette(p: PaletteKey) {
|
||||||
|
setPaletteState(p);
|
||||||
|
AsyncStorage.setItem('themePalette', p);
|
||||||
|
}
|
||||||
|
function setFontSize(f: FontSizeKey) {
|
||||||
|
setFontSizeState(f);
|
||||||
|
AsyncStorage.setItem('themeFontSize', f);
|
||||||
|
}
|
||||||
|
function setBoldLabels(b: boolean) {
|
||||||
|
setBoldLabelsState(b);
|
||||||
|
AsyncStorage.setItem('themeBoldLabels', String(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{
|
||||||
|
accent: PALETTES[palette].accent,
|
||||||
|
accentDim: PALETTES[palette].accentDim,
|
||||||
|
palette, setPalette,
|
||||||
|
fontSize, setFontSize,
|
||||||
|
boldLabels, setBoldLabels,
|
||||||
|
scale: FONT_SCALE[fontSize],
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
return useContext(ThemeContext);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { SavedRecordingsScreen } from '../screens/SavedRecordingsScreen';
|
|||||||
import { SettingsScreen } from '../screens/SettingsScreen';
|
import { SettingsScreen } from '../screens/SettingsScreen';
|
||||||
import { RootStackParamList, TabParamList } from '../types';
|
import { RootStackParamList, TabParamList } from '../types';
|
||||||
import { colors } from '../theme';
|
import { colors } from '../theme';
|
||||||
|
import { useTheme } from '../ThemeContext';
|
||||||
|
|
||||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||||
const Tab = createBottomTabNavigator<TabParamList>();
|
const Tab = createBottomTabNavigator<TabParamList>();
|
||||||
@@ -22,13 +23,14 @@ const TAB_ICONS: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function Tabs() {
|
function Tabs() {
|
||||||
|
const { accent } = useTheme();
|
||||||
return (
|
return (
|
||||||
<Tab.Navigator
|
<Tab.Navigator
|
||||||
screenOptions={({ route }) => ({
|
screenOptions={({ route }) => ({
|
||||||
headerStyle: { backgroundColor: colors.bg },
|
headerStyle: { backgroundColor: colors.bg },
|
||||||
headerTintColor: colors.text,
|
headerTintColor: colors.text,
|
||||||
tabBarStyle: { backgroundColor: colors.surface, borderTopColor: colors.border },
|
tabBarStyle: { backgroundColor: colors.surface, borderTopColor: colors.border },
|
||||||
tabBarActiveTintColor: colors.accent,
|
tabBarActiveTintColor: accent,
|
||||||
tabBarInactiveTintColor: colors.textMuted,
|
tabBarInactiveTintColor: colors.textMuted,
|
||||||
tabBarIcon: ({ color }) => (
|
tabBarIcon: ({ color }) => (
|
||||||
<Text style={{ color, fontSize: 18 }}>{TAB_ICONS[route.name]}</Text>
|
<Text style={{ color, fontSize: 18 }}>{TAB_ICONS[route.name]}</Text>
|
||||||
|
|||||||
@@ -9,20 +9,15 @@ import { useRecordingStore } from '../store/recording';
|
|||||||
import { startGpsRecording, stopGpsRecording, requestLocationPermissions } from '../services/gps';
|
import { startGpsRecording, stopGpsRecording, requestLocationPermissions } from '../services/gps';
|
||||||
import { RootStackParamList } from '../types';
|
import { RootStackParamList } from '../types';
|
||||||
import { colors } from '../theme';
|
import { colors } from '../theme';
|
||||||
|
import { useTheme } from '../ThemeContext';
|
||||||
|
|
||||||
const MAP_STYLE = 'https://tiles.openfreemap.org/styles/liberty';
|
const MAP_STYLE = 'https://tiles.openfreemap.org/styles/liberty';
|
||||||
|
|
||||||
const trackLineStyle: LineLayerStyle = {
|
|
||||||
lineColor: colors.accent,
|
|
||||||
lineWidth: 3,
|
|
||||||
lineJoin: 'round',
|
|
||||||
lineCap: 'round',
|
|
||||||
};
|
|
||||||
|
|
||||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||||
|
|
||||||
export function RecordingScreen() {
|
export function RecordingScreen() {
|
||||||
const nav = useNavigation<Nav>();
|
const nav = useNavigation<Nav>();
|
||||||
|
const { accent, accentDim, scale, boldLabels } = useTheme();
|
||||||
const { status, ble, keepAwake, trackPoints, start, pause, resume, stop, setKeepAwake, getStats } = useRecordingStore();
|
const { status, ble, keepAwake, trackPoints, start, pause, resume, stop, setKeepAwake, getStats } = useRecordingStore();
|
||||||
const [stats, setStats] = useState(getStats());
|
const [stats, setStats] = useState(getStats());
|
||||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
@@ -33,44 +28,48 @@ export function RecordingScreen() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (keepAwake && status === 'recording') {
|
if (keepAwake && status === 'recording') activateKeepAwakeAsync();
|
||||||
activateKeepAwakeAsync();
|
else deactivateKeepAwake();
|
||||||
} else {
|
|
||||||
deactivateKeepAwake();
|
|
||||||
}
|
|
||||||
}, [keepAwake, status]);
|
}, [keepAwake, status]);
|
||||||
|
|
||||||
|
const trackLineStyle = useMemo<LineLayerStyle>(() => ({
|
||||||
|
lineColor: accent,
|
||||||
|
lineWidth: 3,
|
||||||
|
lineJoin: 'round',
|
||||||
|
lineCap: 'round',
|
||||||
|
}), [accent]);
|
||||||
|
|
||||||
const trackGeoJSON = useMemo<GeoJSON.Feature<GeoJSON.LineString>>(() => ({
|
const trackGeoJSON = useMemo<GeoJSON.Feature<GeoJSON.LineString>>(() => ({
|
||||||
type: 'Feature',
|
type: 'Feature',
|
||||||
geometry: { type: 'LineString', coordinates: trackPoints.map((p) => [p.lon, p.lat]) },
|
geometry: { type: 'LineString', coordinates: trackPoints.map((p) => [p.lon, p.lat]) },
|
||||||
properties: {},
|
properties: {},
|
||||||
}), [trackPoints]);
|
}), [trackPoints]);
|
||||||
|
|
||||||
|
const statLabelStyle = useMemo(() => ({
|
||||||
|
color: accent,
|
||||||
|
fontSize: Math.round(10 * scale),
|
||||||
|
fontWeight: boldLabels ? '700' as const : '500' as const,
|
||||||
|
textTransform: 'uppercase' as const,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
}), [accent, scale, boldLabels]);
|
||||||
|
|
||||||
|
const statValueStyle = useMemo(() => ({
|
||||||
|
color: colors.text,
|
||||||
|
fontSize: Math.round(17 * scale),
|
||||||
|
fontWeight: '600' as const,
|
||||||
|
marginTop: 2,
|
||||||
|
}), [scale]);
|
||||||
|
|
||||||
async function handleStart() {
|
async function handleStart() {
|
||||||
const granted = await requestLocationPermissions();
|
const granted = await requestLocationPermissions();
|
||||||
if (!granted) {
|
if (!granted) { Alert.alert('Permission required', 'Location permission is required to record.'); return; }
|
||||||
Alert.alert('Permission required', 'Location permission is required to record.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
start();
|
start();
|
||||||
await startGpsRecording();
|
await startGpsRecording();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handlePause() {
|
async function handlePause() { await stopGpsRecording(); pause(); }
|
||||||
await stopGpsRecording();
|
async function handleResume() { resume(); await startGpsRecording(); }
|
||||||
pause();
|
async function handleStop() { await stopGpsRecording(); stop(); nav.navigate('PostRecording'); }
|
||||||
}
|
|
||||||
|
|
||||||
async function handleResume() {
|
|
||||||
resume();
|
|
||||||
await startGpsRecording();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleStop() {
|
|
||||||
await stopGpsRecording();
|
|
||||||
stop();
|
|
||||||
nav.navigate('PostRecording');
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatDuration = (secs: number) => {
|
const formatDuration = (secs: number) => {
|
||||||
const h = Math.floor(secs / 3600).toString().padStart(2, '0');
|
const h = Math.floor(secs / 3600).toString().padStart(2, '0');
|
||||||
@@ -82,14 +81,14 @@ export function RecordingScreen() {
|
|||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<View style={styles.statsGrid}>
|
<View style={styles.statsGrid}>
|
||||||
<StatBox label="Time" value={formatDuration(stats.elapsedSeconds)} />
|
<StatBox label="Time" value={formatDuration(stats.elapsedSeconds)} labelStyle={statLabelStyle} valueStyle={statValueStyle} />
|
||||||
<StatBox label="Distance" value={`${(stats.distanceMeters / 1000).toFixed(2)} km`} />
|
<StatBox label="Distance" value={`${(stats.distanceMeters / 1000).toFixed(2)} km`} labelStyle={statLabelStyle} valueStyle={statValueStyle} />
|
||||||
<StatBox label="Speed" value={`${stats.currentSpeedKph.toFixed(1)} km/h`} />
|
<StatBox label="Speed" value={`${stats.currentSpeedKph.toFixed(1)} km/h`} labelStyle={statLabelStyle} valueStyle={statValueStyle} />
|
||||||
<StatBox label="Avg Speed" value={`${stats.avgSpeedKph.toFixed(1)} km/h`} />
|
<StatBox label="Avg Speed" value={`${stats.avgSpeedKph.toFixed(1)} km/h`} labelStyle={statLabelStyle} valueStyle={statValueStyle} />
|
||||||
<StatBox label="Elevation" value={`+${stats.elevationGainMeters.toFixed(0)} m`} />
|
<StatBox label="Elevation" value={`+${stats.elevationGainMeters.toFixed(0)} m`} labelStyle={statLabelStyle} valueStyle={statValueStyle} />
|
||||||
<StatBox label="HR" value={ble.hr ? `${ble.hr} bpm` : '—'} />
|
<StatBox label="HR" value={ble.hr ? `${ble.hr} bpm` : '—'} labelStyle={statLabelStyle} valueStyle={statValueStyle} />
|
||||||
<StatBox label="Power" value={ble.power ? `${ble.power} W` : '—'} />
|
<StatBox label="Power" value={ble.power ? `${ble.power} W` : '—'} labelStyle={statLabelStyle} valueStyle={statValueStyle} />
|
||||||
<StatBox label="Cadence" value={ble.cadence ? `${ble.cadence} rpm` : '—'} />
|
<StatBox label="Cadence" value={ble.cadence ? `${ble.cadence} rpm` : '—'} labelStyle={statLabelStyle} valueStyle={statValueStyle} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.mapArea}>
|
<View style={styles.mapArea}>
|
||||||
@@ -105,15 +104,14 @@ export function RecordingScreen() {
|
|||||||
</GeoJSONSource>
|
</GeoJSONSource>
|
||||||
)}
|
)}
|
||||||
</Map>
|
</Map>
|
||||||
|
<TouchableOpacity style={[styles.sensorBtn, { borderColor: colors.border }]} onPress={() => nav.navigate('SensorPairing')}>
|
||||||
<TouchableOpacity style={styles.sensorBtn} onPress={() => nav.navigate('SensorPairing')}>
|
<Text style={[styles.sensorBtnText, { color: accent }]}>⚡ Sensors</Text>
|
||||||
<Text style={styles.sensorBtnText}>⚡ Sensors</Text>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.controls}>
|
<View style={styles.controls}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.awakeBtn, keepAwake && styles.awakeBtnOn]}
|
style={[styles.awakeBtn, keepAwake && { borderColor: accent }]}
|
||||||
onPress={() => setKeepAwake(!keepAwake)}
|
onPress={() => setKeepAwake(!keepAwake)}
|
||||||
>
|
>
|
||||||
<Text style={styles.awakeBtnText}>{keepAwake ? '◑ Awake' : '◌ Sleep'}</Text>
|
<Text style={styles.awakeBtnText}>{keepAwake ? '◑ Awake' : '◌ Sleep'}</Text>
|
||||||
@@ -149,11 +147,14 @@ export function RecordingScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatBox({ label, value }: { label: string; value: string }) {
|
function StatBox({ label, value, labelStyle, valueStyle }: {
|
||||||
|
label: string; value: string;
|
||||||
|
labelStyle: object; valueStyle: object;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.statBox}>
|
<View style={styles.statBox}>
|
||||||
<Text style={styles.statLabel}>{label}</Text>
|
<Text style={labelStyle}>{label}</Text>
|
||||||
<Text style={styles.statValue}>{value}</Text>
|
<Text style={valueStyle}>{value}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -162,14 +163,11 @@ const styles = StyleSheet.create({
|
|||||||
container: { flex: 1, backgroundColor: colors.bg },
|
container: { flex: 1, backgroundColor: colors.bg },
|
||||||
statsGrid: { flexDirection: 'row', flexWrap: 'wrap', padding: 8, borderBottomWidth: 1, borderBottomColor: colors.border },
|
statsGrid: { flexDirection: 'row', flexWrap: 'wrap', padding: 8, borderBottomWidth: 1, borderBottomColor: colors.border },
|
||||||
statBox: { width: '25%', padding: 8, alignItems: 'center' },
|
statBox: { width: '25%', padding: 8, alignItems: 'center' },
|
||||||
statLabel: { color: colors.textMuted, fontSize: 10, textTransform: 'uppercase', letterSpacing: 0.5 },
|
|
||||||
statValue: { color: colors.text, fontSize: 17, fontWeight: '600', marginTop: 2 },
|
|
||||||
mapArea: { flex: 1, overflow: 'hidden' },
|
mapArea: { flex: 1, overflow: 'hidden' },
|
||||||
sensorBtn: { position: 'absolute', top: 10, right: 10, backgroundColor: 'rgba(9,9,11,0.8)', borderRadius: 8, borderWidth: 1, borderColor: colors.border, paddingVertical: 6, paddingHorizontal: 12 },
|
sensorBtn: { position: 'absolute', top: 10, right: 10, backgroundColor: 'rgba(9,9,11,0.8)', borderRadius: 8, borderWidth: 1, paddingVertical: 6, paddingHorizontal: 12 },
|
||||||
sensorBtnText: { color: colors.accent, fontSize: 13, fontWeight: '600' },
|
sensorBtnText: { fontSize: 13, fontWeight: '600' },
|
||||||
controls: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 12, padding: 20, borderTopWidth: 1, borderTopColor: colors.border },
|
controls: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 12, padding: 20, borderTopWidth: 1, borderTopColor: colors.border },
|
||||||
awakeBtn: { backgroundColor: colors.surface, borderRadius: 20, borderWidth: 1, borderColor: colors.border, paddingVertical: 8, paddingHorizontal: 14 },
|
awakeBtn: { backgroundColor: colors.surface, borderRadius: 20, borderWidth: 1, borderColor: colors.border, paddingVertical: 8, paddingHorizontal: 14 },
|
||||||
awakeBtnOn: { borderColor: colors.accent },
|
|
||||||
awakeBtnText: { color: colors.textSub, fontSize: 13 },
|
awakeBtnText: { color: colors.textSub, fontSize: 13 },
|
||||||
btn: { paddingVertical: 15, paddingHorizontal: 30, borderRadius: 50 },
|
btn: { paddingVertical: 15, paddingHorizontal: 30, borderRadius: 50 },
|
||||||
btnStart: { backgroundColor: colors.btnStart },
|
btnStart: { backgroundColor: colors.btnStart },
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import { uploadGpx } from '../services/upload';
|
|||||||
import { SavedRecording } from '../types';
|
import { SavedRecording } from '../types';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { colors } from '../theme';
|
import { colors } from '../theme';
|
||||||
|
import { useTheme } from '../ThemeContext';
|
||||||
|
|
||||||
export function SavedRecordingsScreen() {
|
export function SavedRecordingsScreen() {
|
||||||
|
const { accent } = useTheme();
|
||||||
const [recordings, setRecordings] = useState<SavedRecording[]>([]);
|
const [recordings, setRecordings] = useState<SavedRecording[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [uploading, setUploading] = useState<string | null>(null);
|
const [uploading, setUploading] = useState<string | null>(null);
|
||||||
@@ -57,7 +59,7 @@ export function SavedRecordingsScreen() {
|
|||||||
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) return <View style={styles.center}><ActivityIndicator color={colors.accent} /></View>;
|
if (loading) return <View style={styles.center}><ActivityIndicator color={accent} /></View>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
@@ -75,10 +77,10 @@ export function SavedRecordingsScreen() {
|
|||||||
</View>
|
</View>
|
||||||
<View style={styles.cardActions}>
|
<View style={styles.cardActions}>
|
||||||
<TouchableOpacity onPress={() => handleShare(item)}>
|
<TouchableOpacity onPress={() => handleShare(item)}>
|
||||||
<Text style={styles.action}>Export</Text>
|
<Text style={[styles.action, { color: accent }]}>Export</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity onPress={() => handleUpload(item)} disabled={uploading === item.id}>
|
<TouchableOpacity onPress={() => handleUpload(item)} disabled={uploading === item.id}>
|
||||||
<Text style={styles.action}>{uploading === item.id ? '…' : 'Upload'}</Text>
|
<Text style={[styles.action, { color: accent }]}>{uploading === item.id ? '…' : 'Upload'}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity onPress={() => handleDelete(item)}>
|
<TouchableOpacity onPress={() => handleDelete(item)}>
|
||||||
<Text style={[styles.action, { color: colors.error }]}>Delete</Text>
|
<Text style={[styles.action, { color: colors.error }]}>Delete</Text>
|
||||||
@@ -101,6 +103,6 @@ const styles = StyleSheet.create({
|
|||||||
cardTitle: { color: colors.text, fontSize: 15, fontWeight: '600' },
|
cardTitle: { color: colors.text, fontSize: 15, fontWeight: '600' },
|
||||||
cardSub: { color: colors.textMuted, fontSize: 12, marginTop: 4 },
|
cardSub: { color: colors.textMuted, fontSize: 12, marginTop: 4 },
|
||||||
cardActions:{ flexDirection: 'row', gap: 20 },
|
cardActions:{ flexDirection: 'row', gap: 20 },
|
||||||
action: { color: colors.accent, fontWeight: '600', fontSize: 14 },
|
action: { fontWeight: '600', fontSize: 14 },
|
||||||
empty: { color: colors.textMuted, textAlign: 'center', marginTop: 60 },
|
empty: { color: colors.textMuted, textAlign: 'center', marginTop: 60 },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from '../services/ble';
|
} from '../services/ble';
|
||||||
import { BleDevice } from '../types';
|
import { BleDevice } from '../types';
|
||||||
import { colors } from '../theme';
|
import { colors } from '../theme';
|
||||||
|
import { useTheme } from '../ThemeContext';
|
||||||
|
|
||||||
const TYPE_LABEL: Record<BleDevice['type'], string> = {
|
const TYPE_LABEL: Record<BleDevice['type'], string> = {
|
||||||
hr: 'Heart Rate',
|
hr: 'Heart Rate',
|
||||||
@@ -24,6 +25,7 @@ interface SavedEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SensorPairingScreen() {
|
export function SensorPairingScreen() {
|
||||||
|
const { accent, accentDim } = useTheme();
|
||||||
const [scanning, setScanning] = useState(false);
|
const [scanning, setScanning] = useState(false);
|
||||||
const [found, setFound] = useState<BleDevice[]>([]);
|
const [found, setFound] = useState<BleDevice[]>([]);
|
||||||
const [saved, setSaved] = useState<SavedEntry[]>([]);
|
const [saved, setSaved] = useState<SavedEntry[]>([]);
|
||||||
@@ -127,11 +129,11 @@ export function SensorPairingScreen() {
|
|||||||
<Text style={styles.deviceType}>{TYPE_LABEL[entry.device.type]}</Text>
|
<Text style={styles.deviceType}>{TYPE_LABEL[entry.device.type]}</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.deviceActions}>
|
<View style={styles.deviceActions}>
|
||||||
{entry.status === 'connecting' && <ActivityIndicator color={colors.accent} />}
|
{entry.status === 'connecting' && <ActivityIndicator color={accent} />}
|
||||||
{entry.status === 'connected' && <Text style={styles.connectedLabel}>Connected</Text>}
|
{entry.status === 'connected' && <Text style={styles.connectedLabel}>Connected</Text>}
|
||||||
{(entry.status === 'saved' || entry.status === 'error') && (
|
{(entry.status === 'saved' || entry.status === 'error') && (
|
||||||
<TouchableOpacity style={styles.reconnectBtn} onPress={() => reconnect(entry.device, true)}>
|
<TouchableOpacity style={[styles.reconnectBtn, { backgroundColor: accentDim }]} onPress={() => reconnect(entry.device, true)}>
|
||||||
<Text style={styles.reconnectBtnText}>{entry.status === 'error' ? 'Retry' : 'Reconnect'}</Text>
|
<Text style={[styles.reconnectBtnText, { color: accent }]}>{entry.status === 'error' ? 'Retry' : 'Reconnect'}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
<TouchableOpacity onPress={() => handleForget(entry.device)}>
|
<TouchableOpacity onPress={() => handleForget(entry.device)}>
|
||||||
@@ -150,13 +152,13 @@ export function SensorPairingScreen() {
|
|||||||
<Text style={styles.deviceType}>{TYPE_LABEL[device.type]}</Text>
|
<Text style={styles.deviceType}>{TYPE_LABEL[device.type]}</Text>
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.connectBtn}
|
style={[styles.connectBtn, { backgroundColor: accentDim }]}
|
||||||
onPress={() => handleConnect(device)}
|
onPress={() => handleConnect(device)}
|
||||||
disabled={isConnecting}
|
disabled={isConnecting}
|
||||||
>
|
>
|
||||||
{isConnecting
|
{isConnecting
|
||||||
? <ActivityIndicator color={colors.text} />
|
? <ActivityIndicator color={accent} />
|
||||||
: <Text style={styles.connectBtnText}>Connect</Text>}
|
: <Text style={[styles.connectBtnText, { color: accent }]}>Connect</Text>}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -182,10 +184,10 @@ const styles = StyleSheet.create({
|
|||||||
deviceType: { color: colors.textMuted, fontSize: 12, marginTop: 2 },
|
deviceType: { color: colors.textMuted, fontSize: 12, marginTop: 2 },
|
||||||
deviceActions: { flexDirection: 'row', alignItems: 'center', gap: 12 },
|
deviceActions: { flexDirection: 'row', alignItems: 'center', gap: 12 },
|
||||||
connectedLabel: { color: colors.success, fontWeight: '600', fontSize: 13 },
|
connectedLabel: { color: colors.success, fontWeight: '600', fontSize: 13 },
|
||||||
reconnectBtn: { backgroundColor: colors.accentDim, borderRadius: 8, paddingVertical: 6, paddingHorizontal: 12 },
|
reconnectBtn: { borderRadius: 8, paddingVertical: 6, paddingHorizontal: 12 },
|
||||||
reconnectBtnText:{ color: colors.accent, fontWeight: '600', fontSize: 13 },
|
reconnectBtnText:{ fontWeight: '600', fontSize: 13 },
|
||||||
forgetLabel: { color: colors.error, fontSize: 13 },
|
forgetLabel: { color: colors.error, fontSize: 13 },
|
||||||
connectBtn: { backgroundColor: colors.accentDim, borderRadius: 8, paddingVertical: 7, paddingHorizontal: 14, minWidth: 80, alignItems: 'center' },
|
connectBtn: { borderRadius: 8, paddingVertical: 7, paddingHorizontal: 14, minWidth: 80, alignItems: 'center' },
|
||||||
connectBtnText: { color: colors.accent, fontWeight: '600' },
|
connectBtnText: { fontWeight: '600' },
|
||||||
empty: { color: colors.textMuted, textAlign: 'center', marginTop: 40 },
|
empty: { color: colors.textMuted, textAlign: 'center', marginTop: 40 },
|
||||||
});
|
});
|
||||||
|
|||||||
+171
-95
@@ -5,71 +5,166 @@ import {
|
|||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { login, logout, loadAuthState } from '../services/auth';
|
import { login, logout, loadAuthState } from '../services/auth';
|
||||||
import { colors } from '../theme';
|
import { colors, PALETTES, type PaletteKey, type FontSizeKey } from '../theme';
|
||||||
|
import { useTheme } from '../ThemeContext';
|
||||||
|
|
||||||
|
type Tab = 'ui' | 'app' | 'sync';
|
||||||
|
|
||||||
export function SettingsScreen() {
|
export function SettingsScreen() {
|
||||||
const [instanceUrl, setInstanceUrl] = useState('');
|
const [tab, setTab] = useState<Tab>('ui');
|
||||||
const [handle, setHandle] = useState('');
|
const { accent } = useTheme();
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [connectedAs, setConnectedAs] = useState<string | null>(null);
|
return (
|
||||||
const [connecting, setConnecting] = useState(false);
|
<View style={styles.container}>
|
||||||
|
{/* Tab bar */}
|
||||||
|
<View style={styles.tabBar}>
|
||||||
|
{(['ui', 'app', 'sync'] as Tab[]).map((t) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={t}
|
||||||
|
style={[styles.tabBtn, tab === t && { borderBottomColor: accent, borderBottomWidth: 2 }]}
|
||||||
|
onPress={() => setTab(t)}
|
||||||
|
>
|
||||||
|
<Text style={[styles.tabLabel, tab === t && { color: accent }]}>
|
||||||
|
{t === 'ui' ? 'Interface' : t === 'app' ? 'App' : 'Sync'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{tab === 'ui' && <UITab />}
|
||||||
|
{tab === 'app' && <AppTab />}
|
||||||
|
{tab === 'sync' && <SyncTab />}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── UI tab ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function UITab() {
|
||||||
|
const { accent, palette, setPalette, fontSize, setFontSize, boldLabels, setBoldLabels } = useTheme();
|
||||||
|
|
||||||
|
const fontSizes: FontSizeKey[] = ['small', 'medium', 'large'];
|
||||||
|
const palettes = Object.entries(PALETTES) as [PaletteKey, typeof PALETTES[PaletteKey]][];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView contentContainerStyle={styles.content}>
|
||||||
|
<Text style={styles.sectionTitle}>Colour palette</Text>
|
||||||
|
<View style={styles.pillRow}>
|
||||||
|
{palettes.map(([key, val]) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={key}
|
||||||
|
style={[styles.pill, palette === key && { borderColor: val.accent, backgroundColor: val.accentDim }]}
|
||||||
|
onPress={() => setPalette(key)}
|
||||||
|
>
|
||||||
|
<View style={[styles.paletteDot, { backgroundColor: val.accent }]} />
|
||||||
|
<Text style={[styles.pillText, palette === key && { color: val.accent }]}>{val.label}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.sectionTitle}>Font size</Text>
|
||||||
|
<View style={styles.pillRow}>
|
||||||
|
{fontSizes.map((s) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={s}
|
||||||
|
style={[styles.pill, fontSize === s && { borderColor: accent, backgroundColor: PALETTES[palette].accentDim }]}
|
||||||
|
onPress={() => setFontSize(s)}
|
||||||
|
>
|
||||||
|
<Text style={[styles.pillText, fontSize === s && { color: accent }]}>
|
||||||
|
{s.charAt(0).toUpperCase() + s.slice(1)}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.sectionTitle}>Stat labels</Text>
|
||||||
|
<View style={styles.row}>
|
||||||
|
<Text style={styles.rowLabel}>Bold labels</Text>
|
||||||
|
<Switch
|
||||||
|
value={boldLabels}
|
||||||
|
onValueChange={setBoldLabels}
|
||||||
|
trackColor={{ true: accent }}
|
||||||
|
thumbColor={colors.text}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── App tab ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function AppTab() {
|
||||||
|
const { accent } = useTheme();
|
||||||
const [kmNotifications, setKmNotifications] = useState(true);
|
const [kmNotifications, setKmNotifications] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
AsyncStorage.getItem('kmNotifications').then((v) => {
|
||||||
const [auth, km] = await Promise.all([
|
if (v !== null) setKmNotifications(v === 'true');
|
||||||
loadAuthState(),
|
});
|
||||||
AsyncStorage.getItem('kmNotifications'),
|
|
||||||
]);
|
|
||||||
if (auth) {
|
|
||||||
setInstanceUrl(auth.instanceUrl);
|
|
||||||
setHandle(auth.handle);
|
|
||||||
setConnectedAs(auth.handle);
|
|
||||||
}
|
|
||||||
if (km !== null) setKmNotifications(km === 'true');
|
|
||||||
})();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function handleConnect() {
|
|
||||||
if (!instanceUrl.trim()) { Alert.alert('Required', 'Enter the instance URL.'); return; }
|
|
||||||
if (!handle.trim()) { Alert.alert('Required', 'Enter your handle.'); return; }
|
|
||||||
if (!password) { Alert.alert('Required', 'Enter your password.'); return; }
|
|
||||||
|
|
||||||
setConnecting(true);
|
|
||||||
const result = await login(instanceUrl.trim(), handle.trim(), password);
|
|
||||||
setConnecting(false);
|
|
||||||
|
|
||||||
if (result.ok) {
|
|
||||||
setConnectedAs(result.displayName || handle.trim());
|
|
||||||
setPassword('');
|
|
||||||
} else {
|
|
||||||
Alert.alert('Login failed', result.error ?? 'Unknown error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDisconnect() {
|
|
||||||
Alert.alert('Disconnect', 'Remove saved credentials?', [
|
|
||||||
{ text: 'Cancel', style: 'cancel' },
|
|
||||||
{
|
|
||||||
text: 'Disconnect',
|
|
||||||
style: 'destructive',
|
|
||||||
onPress: async () => {
|
|
||||||
await logout();
|
|
||||||
setConnectedAs(null);
|
|
||||||
setHandle('');
|
|
||||||
setPassword('');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleKmToggle(value: boolean) {
|
async function handleKmToggle(value: boolean) {
|
||||||
setKmNotifications(value);
|
setKmNotifications(value);
|
||||||
await AsyncStorage.setItem('kmNotifications', String(value));
|
await AsyncStorage.setItem('kmNotifications', String(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
<ScrollView contentContainerStyle={styles.content}>
|
||||||
|
<Text style={styles.sectionTitle}>Notifications</Text>
|
||||||
|
<View style={styles.row}>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.rowLabel}>Kilometre alerts</Text>
|
||||||
|
<Text style={styles.rowSub}>Notify at every km during recording</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={kmNotifications}
|
||||||
|
onValueChange={handleKmToggle}
|
||||||
|
trackColor={{ true: accent }}
|
||||||
|
thumbColor={colors.text}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Sync tab ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SyncTab() {
|
||||||
|
const { accent } = useTheme();
|
||||||
|
const [instanceUrl, setInstanceUrl] = useState('');
|
||||||
|
const [handle, setHandle] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [connectedAs, setConnectedAs] = useState<string | null>(null);
|
||||||
|
const [connecting, setConnecting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadAuthState().then((auth) => {
|
||||||
|
if (auth) { setInstanceUrl(auth.instanceUrl); setHandle(auth.handle); setConnectedAs(auth.handle); }
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleConnect() {
|
||||||
|
if (!instanceUrl.trim()) { Alert.alert('Required', 'Enter the instance URL.'); return; }
|
||||||
|
if (!handle.trim()) { Alert.alert('Required', 'Enter your handle.'); return; }
|
||||||
|
if (!password) { Alert.alert('Required', 'Enter your password.'); return; }
|
||||||
|
setConnecting(true);
|
||||||
|
const result = await login(instanceUrl.trim(), handle.trim(), password);
|
||||||
|
setConnecting(false);
|
||||||
|
if (result.ok) { setConnectedAs(result.displayName || handle.trim()); setPassword(''); }
|
||||||
|
else Alert.alert('Login failed', result.error ?? 'Unknown error');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDisconnect() {
|
||||||
|
Alert.alert('Disconnect', 'Remove saved credentials?', [
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{ text: 'Disconnect', style: 'destructive', onPress: async () => {
|
||||||
|
await logout(); setConnectedAs(null); setHandle(''); setPassword('');
|
||||||
|
}},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView contentContainerStyle={styles.content}>
|
||||||
<Text style={styles.sectionTitle}>bincio instance</Text>
|
<Text style={styles.sectionTitle}>bincio instance</Text>
|
||||||
|
|
||||||
<Text style={styles.label}>Instance URL</Text>
|
<Text style={styles.label}>Instance URL</Text>
|
||||||
@@ -87,7 +182,7 @@ export function SettingsScreen() {
|
|||||||
{connectedAs ? (
|
{connectedAs ? (
|
||||||
<View style={styles.connectedBox}>
|
<View style={styles.connectedBox}>
|
||||||
<View>
|
<View>
|
||||||
<Text style={styles.connectedLabel}>Connected</Text>
|
<Text style={[styles.connectedLabel, { color: colors.success }]}>Connected</Text>
|
||||||
<Text style={styles.connectedName}>{connectedAs}</Text>
|
<Text style={styles.connectedName}>{connectedAs}</Text>
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity style={styles.disconnectBtn} onPress={handleDisconnect}>
|
<TouchableOpacity style={styles.disconnectBtn} onPress={handleDisconnect}>
|
||||||
@@ -97,72 +192,53 @@ export function SettingsScreen() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Text style={styles.label}>Handle</Text>
|
<Text style={styles.label}>Handle</Text>
|
||||||
<TextInput
|
<TextInput style={styles.input} value={handle} onChangeText={setHandle} placeholder="your-handle" placeholderTextColor={colors.placeholder} autoCapitalize="none" autoCorrect={false} />
|
||||||
style={styles.input}
|
|
||||||
value={handle}
|
|
||||||
onChangeText={setHandle}
|
|
||||||
placeholder="your-handle"
|
|
||||||
placeholderTextColor={colors.placeholder}
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect={false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Text style={styles.label}>Password</Text>
|
<Text style={styles.label}>Password</Text>
|
||||||
<TextInput
|
<TextInput style={styles.input} value={password} onChangeText={setPassword} placeholder="••••••••" placeholderTextColor={colors.placeholder} secureTextEntry />
|
||||||
style={styles.input}
|
|
||||||
value={password}
|
|
||||||
onChangeText={setPassword}
|
|
||||||
placeholder="••••••••"
|
|
||||||
placeholderTextColor={colors.placeholder}
|
|
||||||
secureTextEntry
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Text style={styles.hint}>
|
<Text style={styles.hint}>Your password is used once to obtain a session token, then forgotten.</Text>
|
||||||
Your password is used once to obtain a session token, then forgotten.
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity style={[styles.connectBtn, connecting && styles.connectBtnDisabled]} onPress={handleConnect} disabled={connecting}>
|
||||||
style={[styles.connectBtn, connecting && styles.connectBtnDisabled]}
|
{connecting ? <ActivityIndicator color={colors.text} /> : <Text style={styles.connectBtnText}>Connect</Text>}
|
||||||
onPress={handleConnect}
|
|
||||||
disabled={connecting}
|
|
||||||
>
|
|
||||||
{connecting
|
|
||||||
? <ActivityIndicator color={colors.text} />
|
|
||||||
: <Text style={styles.connectBtnText}>Connect</Text>}
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Text style={styles.sectionTitle}>Notifications</Text>
|
<Text style={styles.sectionTitle}>bincio-autarchive</Text>
|
||||||
|
<View style={[styles.row, { opacity: 0.5 }]}>
|
||||||
<View style={styles.row}>
|
<Text style={styles.rowLabel}>Local sync</Text>
|
||||||
<Text style={styles.rowLabel}>Kilometre alerts</Text>
|
<Text style={styles.rowSub}>Coming soon</Text>
|
||||||
<Switch
|
|
||||||
value={kmNotifications}
|
|
||||||
onValueChange={handleKmToggle}
|
|
||||||
trackColor={{ true: colors.accent }}
|
|
||||||
thumbColor={colors.text}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Shared styles ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: { flex: 1, backgroundColor: colors.bg },
|
container: { flex: 1, backgroundColor: colors.bg },
|
||||||
|
tabBar: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: colors.border },
|
||||||
|
tabBtn: { flex: 1, alignItems: 'center', paddingVertical: 14, borderBottomWidth: 2, borderBottomColor: 'transparent' },
|
||||||
|
tabLabel: { color: colors.textMuted, fontSize: 13, fontWeight: '600' },
|
||||||
content: { padding: 16, gap: 10 },
|
content: { padding: 16, gap: 10 },
|
||||||
sectionTitle: { color: colors.textMuted, fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.8, marginTop: 16 },
|
sectionTitle: { color: colors.textMuted, fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.8, marginTop: 16 },
|
||||||
|
pillRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
|
||||||
|
pill: { flexDirection: 'row', alignItems: 'center', gap: 6, paddingVertical: 8, paddingHorizontal: 14, borderRadius: 20, borderWidth: 1, borderColor: colors.borderStrong },
|
||||||
|
paletteDot: { width: 10, height: 10, borderRadius: 5 },
|
||||||
|
pillText: { color: colors.textSub, fontSize: 13, fontWeight: '500' },
|
||||||
label: { color: colors.textSub, fontSize: 13, marginBottom: 2 },
|
label: { color: colors.textSub, fontSize: 13, marginBottom: 2 },
|
||||||
input: { backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, color: colors.text, borderRadius: 8, padding: 12, fontSize: 15 },
|
input: { backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, color: colors.text, borderRadius: 8, padding: 12, fontSize: 15 },
|
||||||
hint: { color: colors.textMuted, fontSize: 12, lineHeight: 18 },
|
hint: { color: colors.textMuted, fontSize: 12, lineHeight: 18 },
|
||||||
|
row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, borderRadius: 10, padding: 14 },
|
||||||
|
rowLabel: { color: colors.text, fontSize: 15 },
|
||||||
|
rowSub: { color: colors.textMuted, fontSize: 12, marginTop: 2 },
|
||||||
connectedBox: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, borderRadius: 10, padding: 14 },
|
connectedBox: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, borderRadius: 10, padding: 14 },
|
||||||
connectedLabel: { color: colors.success, fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.6 },
|
connectedLabel: { fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.6 },
|
||||||
connectedName: { color: colors.text, fontSize: 15, fontWeight: '600', marginTop: 2 },
|
connectedName: { color: colors.text, fontSize: 15, fontWeight: '600', marginTop: 2 },
|
||||||
disconnectBtn: { paddingVertical: 6, paddingHorizontal: 12, borderRadius: 8, borderWidth: 1, borderColor: colors.errorBg },
|
disconnectBtn: { paddingVertical: 6, paddingHorizontal: 12, borderRadius: 8, borderWidth: 1, borderColor: colors.errorBg },
|
||||||
disconnectBtnText: { color: colors.error, fontWeight: '600', fontSize: 13 },
|
disconnectBtnText: { color: colors.error, fontWeight: '600', fontSize: 13 },
|
||||||
connectBtn: { backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, borderRadius: 10, padding: 14, alignItems: 'center', marginTop: 4 },
|
connectBtn: { backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, borderRadius: 10, padding: 14, alignItems: 'center', marginTop: 4 },
|
||||||
connectBtnDisabled: { opacity: 0.5 },
|
connectBtnDisabled:{ opacity: 0.5 },
|
||||||
connectBtnText: { color: colors.text, fontSize: 15, fontWeight: '600' },
|
connectBtnText: { color: colors.text, fontSize: 15, fontWeight: '600' },
|
||||||
row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, borderRadius: 10, padding: 14 },
|
|
||||||
rowLabel: { color: colors.text, fontSize: 15 },
|
|
||||||
});
|
});
|
||||||
|
|||||||
+16
-4
@@ -9,16 +9,28 @@ export const colors = {
|
|||||||
textMuted: '#71717a',
|
textMuted: '#71717a',
|
||||||
placeholder: '#52525b',
|
placeholder: '#52525b',
|
||||||
|
|
||||||
accent: '#60a5fa',
|
|
||||||
accentDim: 'rgba(96,165,250,0.15)',
|
|
||||||
|
|
||||||
success: '#86efac',
|
success: '#86efac',
|
||||||
successBg: '#14532d',
|
successBg: '#14532d',
|
||||||
error: '#fca5a5',
|
error: '#fca5a5',
|
||||||
errorBg: '#7f1d1d',
|
errorBg: '#7f1d1d',
|
||||||
|
|
||||||
// recording action buttons
|
|
||||||
btnStart: '#16a34a',
|
btnStart: '#16a34a',
|
||||||
btnPause: '#d97706',
|
btnPause: '#d97706',
|
||||||
btnStop: '#dc2626',
|
btnStop: '#dc2626',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export type PaletteKey = 'default' | 'giro' | 'tour' | 'vuelta';
|
||||||
|
export type FontSizeKey = 'small' | 'medium' | 'large';
|
||||||
|
|
||||||
|
export const PALETTES: Record<PaletteKey, { accent: string; accentDim: string; label: string }> = {
|
||||||
|
default: { accent: '#60a5fa', accentDim: 'rgba(96,165,250,0.15)', label: 'Default' },
|
||||||
|
giro: { accent: '#f472b6', accentDim: 'rgba(244,114,182,0.15)', label: "Giro d'Italia" },
|
||||||
|
tour: { accent: '#facc15', accentDim: 'rgba(250,204,21,0.15)', label: 'Tour de France' },
|
||||||
|
vuelta: { accent: '#ef4444', accentDim: 'rgba(239,68,68,0.15)', label: 'Vuelta a España' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FONT_SCALE: Record<FontSizeKey, number> = {
|
||||||
|
small: 0.88,
|
||||||
|
medium: 1.00,
|
||||||
|
large: 1.15,
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user