feat: scaffold Expo Prebuild project with all v1 screens and services
Sets up the full bincio-rec source tree: Zustand recording store with haversine stats, background GPS via expo-task-manager, BLE scan/subscribe for HR and power, GPX writer with Garmin extensions, SQLite recordings list, multipart upload to bincio-activity, React Navigation stack with bottom tabs, and build instructions in README.md. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
import React, { useEffect, useRef, useState } 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 { useKeepAwake } from 'expo-keep-awake';
|
||||
import { useRecordingStore } from '../store/recording';
|
||||
import { startGpsRecording, stopGpsRecording, requestLocationPermissions } from '../services/gps';
|
||||
import { RootStackParamList } from '../types';
|
||||
|
||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
export function RecordingScreen() {
|
||||
const nav = useNavigation<Nav>();
|
||||
const { status, ble, keepAwake, start, pause, resume, stop, getStats } = useRecordingStore();
|
||||
const [stats, setStats] = useState(getStats());
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useKeepAwake(); // TODO: make conditional on keepAwake toggle
|
||||
|
||||
useEffect(() => {
|
||||
intervalRef.current = setInterval(() => setStats(getStats()), 1000);
|
||||
return () => { if (intervalRef.current) clearInterval(intervalRef.current); };
|
||||
}, []);
|
||||
|
||||
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 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)} />
|
||||
<StatBox label="Distance" value={`${(stats.distanceMeters / 1000).toFixed(2)} km`} />
|
||||
<StatBox label="Speed" value={`${stats.currentSpeedKph.toFixed(1)} km/h`} />
|
||||
<StatBox label="Avg Speed" value={`${stats.avgSpeedKph.toFixed(1)} km/h`} />
|
||||
<StatBox label="Elevation" value={`+${stats.elevationGainMeters.toFixed(0)} m`} />
|
||||
<StatBox label="HR" value={ble.hr ? `${ble.hr} bpm` : '—'} />
|
||||
<StatBox label="Power" value={ble.power ? `${ble.power} W` : '—'} />
|
||||
<StatBox label="Cadence" value={ble.cadence ? `${ble.cadence} rpm` : '—'} />
|
||||
</View>
|
||||
|
||||
{/* Map placeholder — MapLibre component goes here */}
|
||||
<View style={styles.mapPlaceholder}>
|
||||
<Text style={styles.mapPlaceholderText}>Map</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.controls}>
|
||||
{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={pause}>
|
||||
<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={resume}>
|
||||
<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 }: { label: string; value: string }) {
|
||||
return (
|
||||
<View style={styles.statBox}>
|
||||
<Text style={styles.statLabel}>{label}</Text>
|
||||
<Text style={styles.statValue}>{value}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#111' },
|
||||
statsGrid: { flexDirection: 'row', flexWrap: 'wrap', padding: 8 },
|
||||
statBox: { width: '25%', padding: 8, alignItems: 'center' },
|
||||
statLabel: { color: '#888', fontSize: 11, textTransform: 'uppercase' },
|
||||
statValue: { color: '#fff', fontSize: 18, fontWeight: '600', marginTop: 2 },
|
||||
mapPlaceholder: { flex: 1, backgroundColor: '#1a1a2e', alignItems: 'center', justifyContent: 'center' },
|
||||
mapPlaceholderText: { color: '#444', fontSize: 16 },
|
||||
controls: { flexDirection: 'row', justifyContent: 'center', gap: 16, padding: 24 },
|
||||
btn: { paddingVertical: 16, paddingHorizontal: 32, borderRadius: 50 },
|
||||
btnStart: { backgroundColor: '#22c55e' },
|
||||
btnPause: { backgroundColor: '#f59e0b' },
|
||||
btnStop: { backgroundColor: '#ef4444' },
|
||||
btnText: { color: '#fff', fontSize: 18, fontWeight: '700' },
|
||||
});
|
||||
Reference in New Issue
Block a user