efc7af4a4a
- 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()
178 lines
8.1 KiB
TypeScript
178 lines
8.1 KiB
TypeScript
import React, { useEffect, useRef, useState, useMemo } from 'react';
|
|
import { View, Text, StyleSheet, TouchableOpacity, Alert } from 'react-native';
|
|
import { useNavigation } from '@react-navigation/native';
|
|
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
|
import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake';
|
|
import { Map, Camera, GeoJSONSource, Layer, UserLocation } from '@maplibre/maplibre-react-native';
|
|
import type { LineLayerStyle } from '@maplibre/maplibre-react-native';
|
|
import { useRecordingStore } from '../store/recording';
|
|
import { startGpsRecording, stopGpsRecording, requestLocationPermissions } from '../services/gps';
|
|
import { RootStackParamList } from '../types';
|
|
import { colors } from '../theme';
|
|
import { useTheme } from '../ThemeContext';
|
|
|
|
const MAP_STYLE = 'https://tiles.openfreemap.org/styles/liberty';
|
|
|
|
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
|
|
|
export function RecordingScreen() {
|
|
const nav = useNavigation<Nav>();
|
|
const { accent, accentDim, scale, boldLabels } = useTheme();
|
|
const { status, ble, keepAwake, trackPoints, start, pause, resume, stop, setKeepAwake, getStats } = useRecordingStore();
|
|
const [stats, setStats] = useState(getStats());
|
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
|
|
useEffect(() => {
|
|
intervalRef.current = setInterval(() => setStats(getStats()), 1000);
|
|
return () => { if (intervalRef.current) clearInterval(intervalRef.current); };
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (keepAwake && status === 'recording') activateKeepAwakeAsync();
|
|
else deactivateKeepAwake();
|
|
}, [keepAwake, status]);
|
|
|
|
const trackLineStyle = useMemo<LineLayerStyle>(() => ({
|
|
lineColor: accent,
|
|
lineWidth: 3,
|
|
lineJoin: 'round',
|
|
lineCap: 'round',
|
|
}), [accent]);
|
|
|
|
const trackGeoJSON = useMemo<GeoJSON.Feature<GeoJSON.LineString>>(() => ({
|
|
type: 'Feature',
|
|
geometry: { type: 'LineString', coordinates: trackPoints.map((p) => [p.lon, p.lat]) },
|
|
properties: {},
|
|
}), [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() {
|
|
const granted = await requestLocationPermissions();
|
|
if (!granted) { Alert.alert('Permission required', 'Location permission is required to record.'); return; }
|
|
start();
|
|
await startGpsRecording();
|
|
}
|
|
|
|
async function handlePause() { await stopGpsRecording(); pause(); }
|
|
async function handleResume() { resume(); await startGpsRecording(); }
|
|
async function handleStop() { await stopGpsRecording(); stop(); nav.navigate('PostRecording'); }
|
|
|
|
const formatDuration = (secs: number) => {
|
|
const h = Math.floor(secs / 3600).toString().padStart(2, '0');
|
|
const m = Math.floor((secs % 3600) / 60).toString().padStart(2, '0');
|
|
const s = (secs % 60).toString().padStart(2, '0');
|
|
return `${h}:${m}:${s}`;
|
|
};
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
<View style={styles.statsGrid}>
|
|
<StatBox label="Time" value={formatDuration(stats.elapsedSeconds)} labelStyle={statLabelStyle} valueStyle={statValueStyle} />
|
|
<StatBox label="Distance" value={`${(stats.distanceMeters / 1000).toFixed(2)} km`} labelStyle={statLabelStyle} valueStyle={statValueStyle} />
|
|
<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`} labelStyle={statLabelStyle} valueStyle={statValueStyle} />
|
|
<StatBox label="Elevation" value={`+${stats.elevationGainMeters.toFixed(0)} m`} labelStyle={statLabelStyle} valueStyle={statValueStyle} />
|
|
<StatBox label="HR" value={ble.hr ? `${ble.hr} bpm` : '—'} labelStyle={statLabelStyle} valueStyle={statValueStyle} />
|
|
<StatBox label="Power" value={ble.power ? `${ble.power} W` : '—'} labelStyle={statLabelStyle} valueStyle={statValueStyle} />
|
|
<StatBox label="Cadence" value={ble.cadence ? `${ble.cadence} rpm` : '—'} labelStyle={statLabelStyle} valueStyle={statValueStyle} />
|
|
</View>
|
|
|
|
<View style={styles.mapArea}>
|
|
<Map mapStyle={MAP_STYLE} style={StyleSheet.absoluteFill} logo={false} attribution={false}>
|
|
<Camera
|
|
trackUserLocation={status === 'recording' ? 'course' : undefined}
|
|
initialViewState={{ zoom: 14 }}
|
|
/>
|
|
<UserLocation />
|
|
{trackPoints.length >= 2 && (
|
|
<GeoJSONSource id="track" data={trackGeoJSON}>
|
|
<Layer id="track-line" type="line" style={trackLineStyle} />
|
|
</GeoJSONSource>
|
|
)}
|
|
</Map>
|
|
<TouchableOpacity style={[styles.sensorBtn, { borderColor: colors.border }]} onPress={() => nav.navigate('SensorPairing')}>
|
|
<Text style={[styles.sensorBtnText, { color: accent }]}>⚡ Sensors</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
<View style={styles.controls}>
|
|
<TouchableOpacity
|
|
style={[styles.awakeBtn, keepAwake && { borderColor: accent }]}
|
|
onPress={() => setKeepAwake(!keepAwake)}
|
|
>
|
|
<Text style={styles.awakeBtnText}>{keepAwake ? '◑ Awake' : '◌ Sleep'}</Text>
|
|
</TouchableOpacity>
|
|
|
|
{status === 'idle' && (
|
|
<TouchableOpacity style={[styles.btn, styles.btnStart]} onPress={handleStart}>
|
|
<Text style={styles.btnText}>Start</Text>
|
|
</TouchableOpacity>
|
|
)}
|
|
{status === 'recording' && (
|
|
<>
|
|
<TouchableOpacity style={[styles.btn, styles.btnPause]} onPress={handlePause}>
|
|
<Text style={styles.btnText}>Pause</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity style={[styles.btn, styles.btnStop]} onPress={handleStop}>
|
|
<Text style={styles.btnText}>Stop</Text>
|
|
</TouchableOpacity>
|
|
</>
|
|
)}
|
|
{status === 'paused' && (
|
|
<>
|
|
<TouchableOpacity style={[styles.btn, styles.btnStart]} onPress={handleResume}>
|
|
<Text style={styles.btnText}>Resume</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity style={[styles.btn, styles.btnStop]} onPress={handleStop}>
|
|
<Text style={styles.btnText}>Stop</Text>
|
|
</TouchableOpacity>
|
|
</>
|
|
)}
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function StatBox({ label, value, labelStyle, valueStyle }: {
|
|
label: string; value: string;
|
|
labelStyle: object; valueStyle: object;
|
|
}) {
|
|
return (
|
|
<View style={styles.statBox}>
|
|
<Text style={labelStyle}>{label}</Text>
|
|
<Text style={valueStyle}>{value}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: { flex: 1, backgroundColor: colors.bg },
|
|
statsGrid: { flexDirection: 'row', flexWrap: 'wrap', padding: 8, borderBottomWidth: 1, borderBottomColor: colors.border },
|
|
statBox: { width: '25%', padding: 8, alignItems: 'center' },
|
|
mapArea: { flex: 1, overflow: 'hidden' },
|
|
sensorBtn: { position: 'absolute', top: 10, right: 10, backgroundColor: 'rgba(9,9,11,0.8)', borderRadius: 8, borderWidth: 1, paddingVertical: 6, paddingHorizontal: 12 },
|
|
sensorBtnText: { fontSize: 13, fontWeight: '600' },
|
|
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 },
|
|
awakeBtnText: { color: colors.textSub, fontSize: 13 },
|
|
btn: { paddingVertical: 15, paddingHorizontal: 30, borderRadius: 50 },
|
|
btnStart: { backgroundColor: colors.btnStart },
|
|
btnPause: { backgroundColor: colors.btnPause },
|
|
btnStop: { backgroundColor: colors.btnStop },
|
|
btnText: { color: '#fff', fontSize: 17, fontWeight: '700' },
|
|
});
|