feat: section 1 — keep-awake toggle, sensor button, GPS pause, track view

- Keep-awake now conditional: activates only when recording + toggle on
- Sensor button overlaid on map area navigates to SensorPairing modal
- Pause/resume now start/stop the GPS background task, not just store state
- TrackView renders lat/lon polyline via react-native-svg (no tile server)
- react-native-svg added as dependency
This commit is contained in:
Davide Scaini
2026-06-03 00:13:35 +02:00
parent 5fa8fa86f9
commit 767c2d78aa
2 changed files with 105 additions and 22 deletions
+12 -8
View File
@@ -99,11 +99,12 @@ Files saved to a user-accessible location (iOS Files app / Android shared storag
Scaffold is done (all screens, navigation, store, services, GPX, DB, upload). Scaffold is done (all screens, navigation, store, services, GPX, DB, upload).
Items below are what remains before v1 is shippable. Items below are what remains before v1 is shippable.
### 1 — Quick fixes (each < 30 min) ### 1 — Quick fixes
- [ ] **Keep-awake toggle**`useKeepAwake()` in `RecordingScreen` is unconditional; wire it to `keepAwake` from the store so the toggle actually works - [x] **Keep-awake toggle**wired to `keepAwake` store state via `activateKeepAwakeAsync` / `deactivateKeepAwake`; toggle button visible in controls bar
- [ ] **Sensor button on Recording screen**no entry point to the SensorPairing modal; add a BLE icon button in the header or stats area that calls `nav.navigate('SensorPairing')` - [x] **Sensor button on Recording screen**"⚡ Sensors" button overlaid on map area navigates to SensorPairing modal
- [ ] **GPS pause/resume**`pause()` / `resume()` only change store state; also call `stopGpsRecording()` / `startGpsRecording()` so the background task actually pauses - [x] **GPS pause/resume**`handlePause` calls `stopGpsRecording()`, `handleResume` calls `startGpsRecording()`
- [x] **Track view**`TrackView` component renders recorded lat/lon points as a scaled SVG polyline on a dark background (no tile server needed); replaces the grey placeholder
### 2 — BLE (half day) ### 2 — BLE (half day)
@@ -120,8 +121,11 @@ Items below are what remains before v1 is shippable.
- [ ] On first launch, detect if the app is affected by battery optimization (`expo-intent-launcher`) and show a one-time prompt directing the user to whitelist bincio-rec; persist dismissal in AsyncStorage so it only shows once - [ ] On first launch, detect if the app is affected by battery optimization (`expo-intent-launcher`) and show a one-time prompt directing the user to whitelist bincio-rec; persist dismissal in AsyncStorage so it only shows once
### 5 — Map (12 days) ### 5 — Map (optional upgrade)
- [ ] **Initialize MapLibre** — call `MapLibreGL.setAccessToken(null)` at app start (or configure a tile provider in Settings); add a tile source (e.g. OpenFreeMap / self-hosted) The track view from section 1 already shows the GPX polyline scaled to fit the screen.
- [ ] **Replace placeholder** — swap the grey `<View>` in `RecordingScreen` with a `<MapLibreGL.MapView>` + `<MapLibreGL.Camera>` that follows current location MapLibre can be added later for a real basemap (streets/terrain), but is not required for v1.
- [ ] **Live track polyline** — subscribe to `trackPoints` from the store and render a `<MapLibreGL.ShapeSource>` + `<MapLibreGL.LineLayer>` that updates as points arrive
- [ ] **MapLibre basemap** — replace `TrackView` SVG with `<MapLibreGL.MapView>` + a tile source (e.g. OpenFreeMap); call `MapLibreGL.setAccessToken(null)` for raster-free usage
- [ ] **Camera follow** — add `<MapLibreGL.Camera>` that follows `trackPoints[last]` during recording
- [ ] **Line layer** — replace SVG polyline with `<MapLibreGL.ShapeSource>` + `<MapLibreGL.LineLayer>`
+93 -14
View File
@@ -1,27 +1,35 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Alert } from 'react-native'; import { View, Text, StyleSheet, TouchableOpacity, Alert, LayoutChangeEvent } from 'react-native';
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useKeepAwake } from 'expo-keep-awake'; import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake';
import Svg, { Polyline } from 'react-native-svg';
import { useRecordingStore } from '../store/recording'; 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, TrackPoint } from '../types';
type Nav = NativeStackNavigationProp<RootStackParamList>; type Nav = NativeStackNavigationProp<RootStackParamList>;
export function RecordingScreen() { export function RecordingScreen() {
const nav = useNavigation<Nav>(); const nav = useNavigation<Nav>();
const { status, ble, keepAwake, start, pause, resume, stop, 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 [mapSize, setMapSize] = useState({ width: 0, height: 0 });
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null); const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useKeepAwake(); // TODO: make conditional on keepAwake toggle
useEffect(() => { useEffect(() => {
intervalRef.current = setInterval(() => setStats(getStats()), 1000); intervalRef.current = setInterval(() => setStats(getStats()), 1000);
return () => { if (intervalRef.current) clearInterval(intervalRef.current); }; return () => { if (intervalRef.current) clearInterval(intervalRef.current); };
}, []); }, []);
useEffect(() => {
if (keepAwake && status === 'recording') {
activateKeepAwakeAsync();
} else {
deactivateKeepAwake();
}
}, [keepAwake, status]);
async function handleStart() { async function handleStart() {
const granted = await requestLocationPermissions(); const granted = await requestLocationPermissions();
if (!granted) { if (!granted) {
@@ -32,6 +40,16 @@ export function RecordingScreen() {
await startGpsRecording(); await startGpsRecording();
} }
async function handlePause() {
await stopGpsRecording();
pause();
}
async function handleResume() {
resume();
await startGpsRecording();
}
async function handleStop() { async function handleStop() {
await stopGpsRecording(); await stopGpsRecording();
stop(); stop();
@@ -58,12 +76,29 @@ export function RecordingScreen() {
<StatBox label="Cadence" value={ble.cadence ? `${ble.cadence} rpm` : '—'} /> <StatBox label="Cadence" value={ble.cadence ? `${ble.cadence} rpm` : '—'} />
</View> </View>
{/* Map placeholder — MapLibre component goes here */} <View
<View style={styles.mapPlaceholder}> style={styles.mapArea}
<Text style={styles.mapPlaceholderText}>Map</Text> onLayout={(e: LayoutChangeEvent) => {
const { width, height } = e.nativeEvent.layout;
setMapSize({ width, height });
}}
>
{mapSize.width > 0 && (
<TrackView trackPoints={trackPoints} width={mapSize.width} height={mapSize.height} />
)}
<TouchableOpacity style={styles.sensorBtn} onPress={() => nav.navigate('SensorPairing')}>
<Text style={styles.sensorBtnText}> Sensors</Text>
</TouchableOpacity>
</View> </View>
<View style={styles.controls}> <View style={styles.controls}>
<TouchableOpacity
style={[styles.awakeBtn, keepAwake && styles.awakeBtnOn]}
onPress={() => setKeepAwake(!keepAwake)}
>
<Text style={styles.awakeBtnText}>{keepAwake ? '☀️ Awake' : '💤 Sleep'}</Text>
</TouchableOpacity>
{status === 'idle' && ( {status === 'idle' && (
<TouchableOpacity style={[styles.btn, styles.btnStart]} onPress={handleStart}> <TouchableOpacity style={[styles.btn, styles.btnStart]} onPress={handleStart}>
<Text style={styles.btnText}>Start</Text> <Text style={styles.btnText}>Start</Text>
@@ -71,7 +106,7 @@ export function RecordingScreen() {
)} )}
{status === 'recording' && ( {status === 'recording' && (
<> <>
<TouchableOpacity style={[styles.btn, styles.btnPause]} onPress={pause}> <TouchableOpacity style={[styles.btn, styles.btnPause]} onPress={handlePause}>
<Text style={styles.btnText}>Pause</Text> <Text style={styles.btnText}>Pause</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity style={[styles.btn, styles.btnStop]} onPress={handleStop}> <TouchableOpacity style={[styles.btn, styles.btnStop]} onPress={handleStop}>
@@ -81,7 +116,7 @@ export function RecordingScreen() {
)} )}
{status === 'paused' && ( {status === 'paused' && (
<> <>
<TouchableOpacity style={[styles.btn, styles.btnStart]} onPress={resume}> <TouchableOpacity style={[styles.btn, styles.btnStart]} onPress={handleResume}>
<Text style={styles.btnText}>Resume</Text> <Text style={styles.btnText}>Resume</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity style={[styles.btn, styles.btnStop]} onPress={handleStop}> <TouchableOpacity style={[styles.btn, styles.btnStop]} onPress={handleStop}>
@@ -94,6 +129,45 @@ export function RecordingScreen() {
); );
} }
const PADDING = 16;
function TrackView({ trackPoints, width, height }: { trackPoints: TrackPoint[]; width: number; height: number }) {
if (trackPoints.length < 2) {
return (
<View style={StyleSheet.absoluteFill}>
<Text style={styles.mapHint}>
{trackPoints.length === 0 ? 'Start recording to see your track' : '…'}
</Text>
</View>
);
}
const lats = trackPoints.map((p) => p.lat);
const lons = trackPoints.map((p) => p.lon);
const minLat = Math.min(...lats), maxLat = Math.max(...lats);
const minLon = Math.min(...lons), maxLon = Math.max(...lons);
const latRange = maxLat - minLat || 0.0001;
const lonRange = maxLon - minLon || 0.0001;
const w = width - PADDING * 2;
const h = height - PADDING * 2;
// preserve aspect ratio
const scale = Math.min(w / lonRange, h / latRange);
const offX = (w - lonRange * scale) / 2 + PADDING;
const offY = (h - latRange * scale) / 2 + PADDING;
const points = trackPoints
.map((p) => `${offX + (p.lon - minLon) * scale},${offY + (maxLat - p.lat) * scale}`)
.join(' ');
return (
<Svg width={width} height={height} style={StyleSheet.absoluteFill}>
<Polyline points={points} fill="none" stroke="#3b82f6" strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
</Svg>
);
}
function StatBox({ label, value }: { label: string; value: string }) { function StatBox({ label, value }: { label: string; value: string }) {
return ( return (
<View style={styles.statBox}> <View style={styles.statBox}>
@@ -109,9 +183,14 @@ const styles = StyleSheet.create({
statBox: { width: '25%', padding: 8, alignItems: 'center' }, statBox: { width: '25%', padding: 8, alignItems: 'center' },
statLabel: { color: '#888', fontSize: 11, textTransform: 'uppercase' }, statLabel: { color: '#888', fontSize: 11, textTransform: 'uppercase' },
statValue: { color: '#fff', fontSize: 18, fontWeight: '600', marginTop: 2 }, statValue: { color: '#fff', fontSize: 18, fontWeight: '600', marginTop: 2 },
mapPlaceholder: { flex: 1, backgroundColor: '#1a1a2e', alignItems: 'center', justifyContent: 'center' }, mapArea: { flex: 1, backgroundColor: '#0d0d1a', overflow: 'hidden' },
mapPlaceholderText: { color: '#444', fontSize: 16 }, mapHint: { color: '#333', fontSize: 13, textAlign: 'center', marginTop: 40 },
controls: { flexDirection: 'row', justifyContent: 'center', gap: 16, padding: 24 }, sensorBtn: { position: 'absolute', top: 10, right: 10, backgroundColor: '#1e1e2e', borderRadius: 8, paddingVertical: 6, paddingHorizontal: 12 },
sensorBtnText: { color: '#3b82f6', fontSize: 13, fontWeight: '600' },
controls: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 12, padding: 20 },
awakeBtn: { backgroundColor: '#1e1e1e', borderRadius: 20, paddingVertical: 8, paddingHorizontal: 14 },
awakeBtnOn: { backgroundColor: '#2a2a1a' },
awakeBtnText: { color: '#aaa', fontSize: 13 },
btn: { paddingVertical: 16, paddingHorizontal: 32, borderRadius: 50 }, btn: { paddingVertical: 16, paddingHorizontal: 32, borderRadius: 50 },
btnStart: { backgroundColor: '#22c55e' }, btnStart: { backgroundColor: '#22c55e' },
btnPause: { backgroundColor: '#f59e0b' }, btnPause: { backgroundColor: '#f59e0b' },