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:
Davide Scaini
2026-06-02 22:16:56 +02:00
parent ee28cb0c30
commit 896b528a4c
18 changed files with 2048 additions and 60 deletions
+120
View File
@@ -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' },
});