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:
@@ -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 (1–2 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>`
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
Reference in New Issue
Block a user