Compare commits

..

10 Commits

Author SHA1 Message Date
Davide Scaini 358f3f12c1 feat: themed user location dot on map
Replace default MapLibre puck with a custom CircleLayer child:
accent-coloured fill, 2.5px white stroke, map-pitch-aligned.
Dot colour updates live when the palette changes in Settings.
2026-06-03 10:24:58 +02:00
Davide Scaini ec6a6facd1 fix: centre map on user location at startup
Request foreground location permission on RecordingScreen mount so the
map can find the user immediately. Camera now always has trackUserLocation
set — 'default' when idle/paused (follows position, no rotation) and
'course' when recording (follows direction of travel).
2026-06-03 10:06:24 +02:00
Davide Scaini efc7af4a4a feat: ThemeContext + Settings tabs (Interface / App / Sync)
- 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()
2026-06-03 10:00:27 +02:00
Davide Scaini ea938e5644 design: align visual style with bincio_autarchive
- Add src/theme.ts with centralised color palette
- Backgrounds: #111 → #09090b, surfaces #1e1e1e → #18181b
- All cards get 1px #27272a borders (matches autarchive cards)
- Text: #fff/#888/#555 → #f4f4f5/#a1a1aa/#71717a
- Accent: #3b82f6 → #60a5fa (autarchive default palette)
- Tab icons: colored emoji → monochromatic Unicode (◉ ☰ ⚙)
- Action buttons use muted palette (#16a34a / #d97706 / #dc2626)
- Keep-awake toggle uses ◑/◌ symbols, border highlights accent on active
- Connect/Scan buttons match autarchive surface+border style
2026-06-03 09:50:05 +02:00
Davide Scaini 6e47ced264 feat: replace API token field with login flow
New auth.ts service: login() POSTs to /api/auth/token with handle +
password, stores instanceUrl/handle/apiToken in AsyncStorage, password
never persisted. logout() clears all credentials. loadAuthState()
returns stored credentials or null.

Settings screen now shows a login form (URL + handle + password) when
not connected, and a connected state card with Disconnect button when
logged in. km notifications toggle auto-saves without a separate Save
button.
2026-06-03 09:43:52 +02:00
Davide Scaini 9d82084fa1 feat: section 5 — MapLibre map with live track and camera follow
Replace SVG TrackView with a real MapLibre map:
- OpenFreeMap liberty tiles (no API key)
- Camera follows user in course mode while recording
- GeoJSONSource + LineLayer renders track polyline updated live
- UserLocation dot shows current GPS position
- Sensors button overlaid with semi-transparent background
2026-06-03 09:32:49 +02:00
Davide Scaini 4e1c2ebef9 fix: battery optimization prompt — add missing manifest permission and fix fallback
REQUEST_IGNORE_BATTERY_OPTIMIZATIONS intent is silently dropped without
the matching manifest permission. Added it to app.json permissions.
Also replaced .catch() chain (which only triggers on thrown errors) with
try/catch blocks so the fallback to IGNORE_BATTERY_OPTIMIZATION_SETTINGS
actually fires. Added resetBatteryOptPrompt() helper to re-trigger the
prompt during testing.
2026-06-03 09:18:03 +02:00
Davide Scaini 765efe288e feat: section 4 — Android battery optimization prompt
One-time prompt on first launch (Android only) directing the user to
exclude bincio-rec from battery optimization. Uses the direct
REQUEST_IGNORE_BATTERY_OPTIMIZATIONS system dialog with a fallback to
the general settings page for OEMs that block the direct intent.
Dismissal persisted in AsyncStorage so prompt never repeats.
2026-06-03 09:09:51 +02:00
Davide Scaini 5f12b2857d feat: section 3 — km milestone notifications
- Foreground notification handler in App.tsx (iOS shows banners while active)
- requestNotificationPermissions() called on app mount
- GPS task tracks running distance per recording session (module-level state)
- Fires immediate notification at each km crossed, gated on kmNotifications setting
2026-06-03 09:03:55 +02:00
Davide Scaini 2378d31f0b feat: section 2 — BLE permissions, cadence parsing, sensor persistence
- requestBlePermissions() handles BLUETOOTH_SCAN + BLUETOOTH_CONNECT on Android 12+
- parseCscMeasurement() implements stateful CSC crank RPM with uint16 rollover
- savePairedDevice / loadPairedDevices / removePairedDevice via AsyncStorage
- SensorPairingScreen: saved sensors section, auto-reconnect on mount, Forget button
2026-06-03 09:00:21 +02:00
17 changed files with 970 additions and 312 deletions
-3
View File
@@ -1,3 +0,0 @@
# Expo HAS CHANGED
Read the exact versioned docs at https://docs.expo.dev/versions/v56.0.0/ before writing any code.
+23 -2
View File
@@ -1,12 +1,33 @@
import { useEffect } from 'react';
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';
import * as Notifications from 'expo-notifications';
import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { ThemeProvider } from './src/ThemeContext';
import { AppNavigator } from './src/navigation/AppNavigator'; import { AppNavigator } from './src/navigation/AppNavigator';
import { requestNotificationPermissions } from './src/services/gps';
import { promptBatteryOptimizationIfNeeded } from './src/services/batteryOptimization';
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: true,
shouldSetBadge: false,
}),
});
export default function App() { export default function App() {
useEffect(() => {
requestNotificationPermissions();
promptBatteryOptimizationIfNeeded();
}, []);
return ( return (
<GestureHandlerRootView style={{ flex: 1 }}> <GestureHandlerRootView style={{ flex: 1 }}>
<StatusBar style="light" /> <ThemeProvider>
<AppNavigator /> <StatusBar style="light" />
<AppNavigator />
</ThemeProvider>
</GestureHandlerRootView> </GestureHandlerRootView>
); );
} }
+14 -16
View File
@@ -106,26 +106,24 @@ Items below are what remains before v1 is shippable.
- [x] **GPS pause/resume**`handlePause` calls `stopGpsRecording()`, `handleResume` calls `startGpsRecording()` - [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 - [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
- [ ] **Android runtime permissions**`BLUETOOTH_SCAN` + `BLUETOOTH_CONNECT` require explicit permission requests on Android 12+; add a `requestBlePermissions()` call in `SensorPairingScreen` before scanning - [x] **Android runtime permissions**`requestBlePermissions()` requests `BLUETOOTH_SCAN` + `BLUETOOTH_CONNECT` on Android 12+ (API 31+) before scanning
- [ ] **Cadence CSC parsing**`subscribeCadence()` in `ble.ts` is stubbed; implement stateful CSC Measurement parsing (track previous crank revolution count + timestamp, compute RPM from delta) - [x] **Cadence CSC parsing**stateful parsing in `parseCscMeasurement()`: tracks previous crank revolution count + event time (uint16, 1/1024 s), computes RPM from delta with uint16 rollover handling
- [ ] **BLE persistence** — save paired device IDs + types to AsyncStorage on connect; on app start, attempt to reconnect to previously paired devices automatically - [x] **BLE persistence**`savePairedDevice` / `loadPairedDevices` / `removePairedDevice` in `ble.ts` via AsyncStorage; SensorPairingScreen shows saved sensors at top, auto-attempts reconnect on mount, Forget button removes a device
### 3 — Km notifications (half day) ### 3 — Km notifications
- [ ] **Permission request**call `Notifications.requestPermissionsAsync()` on first launch - [x] **Permission request**`requestNotificationPermissions()` called in `App.tsx` on mount; foreground notification handler set at module level so iOS shows banners while app is active
- [ ] **Milestone tracker**track last notified km in the GPS background task (or store); fire a notification each time `distanceMeters` crosses a new km boundary, gated on the `kmNotifications` setting from AsyncStorage - [x] **Milestone tracker**module-level `runningDistanceMeters` / `lastNotifiedKm` / `prevPoint` in `gps.ts`, reset on each `startGpsRecording()`; each incoming location adds haversine distance and fires a notification when a new km is crossed, gated on the `kmNotifications` AsyncStorage setting
### 4 — Android battery optimization prompt (12 hours) ### 4 — Android battery optimization prompt
- [ ] 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 - [x] `src/services/batteryOptimization.ts` — Android-only, one-time prompt (dismissed flag in AsyncStorage); uses `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` intent to open the system dialog for bincio-rec directly; falls back to `IGNORE_BATTERY_OPTIMIZATION_SETTINGS` (general page) on OEMs that block the direct intent; called from `App.tsx` on mount alongside notification permission request
### 5 — Map (optional upgrade) ### 5 — Map
The track view from section 1 already shows the GPX polyline scaled to fit the screen. - [x] **MapLibre basemap**`Map` with OpenFreeMap liberty style (`tiles.openfreemap.org`); no API key required; logo and attribution hidden
MapLibre can be added later for a real basemap (streets/terrain), but is not required for v1. - [x] **Camera follow**`Camera` with `trackUserLocation="course"` while recording; switches off when idle/paused
- [x] **Live track line**`GeoJSONSource` fed a memoized `LineString` from `trackPoints`; `Layer` with `type="line"` and blue stroke rendered on top of the basemap
- [ ] **MapLibre basemap** — replace `TrackView` SVG with `<MapLibreGL.MapView>` + a tile source (e.g. OpenFreeMap); call `MapLibreGL.setAccessToken(null)` for raster-free usage - [x] **User location dot**`UserLocation` component shows current position on map
- [ ] **Camera follow** — add `<MapLibreGL.Camera>` that follows `trackPoints[last]` during recording
- [ ] **Line layer** — replace SVG polyline with `<MapLibreGL.ShapeSource>` + `<MapLibreGL.LineLayer>`
+2 -1
View File
@@ -32,7 +32,8 @@
"BLUETOOTH", "BLUETOOTH",
"BLUETOOTH_ADMIN", "BLUETOOTH_ADMIN",
"BLUETOOTH_SCAN", "BLUETOOTH_SCAN",
"BLUETOOTH_CONNECT" "BLUETOOTH_CONNECT",
"REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"
] ]
}, },
"web": { "web": {
View File
+75
View File
@@ -0,0 +1,75 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { PALETTES, FONT_SCALE, type PaletteKey, type FontSizeKey } from './theme';
interface ThemeValue {
accent: string;
accentDim: string;
palette: PaletteKey;
setPalette: (p: PaletteKey) => void;
fontSize: FontSizeKey;
setFontSize: (s: FontSizeKey) => void;
boldLabels: boolean;
setBoldLabels: (b: boolean) => void;
scale: number;
}
const ThemeContext = createContext<ThemeValue>({
...PALETTES.default,
palette: 'default',
setPalette: () => {},
fontSize: 'medium',
setFontSize: () => {},
boldLabels: false,
setBoldLabels: () => {},
scale: 1,
});
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [palette, setPaletteState] = useState<PaletteKey>('default');
const [fontSize, setFontSizeState] = useState<FontSizeKey>('medium');
const [boldLabels, setBoldLabelsState] = useState(false);
useEffect(() => {
(async () => {
const [p, f, b] = await Promise.all([
AsyncStorage.getItem('themePalette'),
AsyncStorage.getItem('themeFontSize'),
AsyncStorage.getItem('themeBoldLabels'),
]);
if (p && p in PALETTES) setPaletteState(p as PaletteKey);
if (f && f in FONT_SCALE) setFontSizeState(f as FontSizeKey);
if (b !== null) setBoldLabelsState(b === 'true');
})();
}, []);
function setPalette(p: PaletteKey) {
setPaletteState(p);
AsyncStorage.setItem('themePalette', p);
}
function setFontSize(f: FontSizeKey) {
setFontSizeState(f);
AsyncStorage.setItem('themeFontSize', f);
}
function setBoldLabels(b: boolean) {
setBoldLabelsState(b);
AsyncStorage.setItem('themeBoldLabels', String(b));
}
return (
<ThemeContext.Provider value={{
accent: PALETTES[palette].accent,
accentDim: PALETTES[palette].accentDim,
palette, setPalette,
fontSize, setFontSize,
boldLabels, setBoldLabels,
scale: FONT_SCALE[fontSize],
}}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
return useContext(ThemeContext);
}
+32 -32
View File
@@ -4,42 +4,42 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { Text } from 'react-native'; import { Text } from 'react-native';
import { RecordingScreen } from '../screens/RecordingScreen'; import { RecordingScreen } from '../screens/RecordingScreen';
import { PostRecordingScreen } from '../screens/PostRecordingScreen'; import { PostRecordingScreen } from '../screens/PostRecordingScreen';
import { SensorPairingScreen } from '../screens/SensorPairingScreen'; import { SensorPairingScreen } from '../screens/SensorPairingScreen';
import { SavedRecordingsScreen } from '../screens/SavedRecordingsScreen'; import { SavedRecordingsScreen } from '../screens/SavedRecordingsScreen';
import { SettingsScreen } from '../screens/SettingsScreen'; import { SettingsScreen } from '../screens/SettingsScreen';
import { RootStackParamList, TabParamList } from '../types'; import { RootStackParamList, TabParamList } from '../types';
import { colors } from '../theme';
import { useTheme } from '../ThemeContext';
const Stack = createNativeStackNavigator<RootStackParamList>(); const Stack = createNativeStackNavigator<RootStackParamList>();
const Tab = createBottomTabNavigator<TabParamList>(); const Tab = createBottomTabNavigator<TabParamList>();
const TAB_ICONS: Record<string, string> = {
Recording: '◉',
Saved: '☰',
Settings: '⚙',
};
function Tabs() { function Tabs() {
const { accent } = useTheme();
return ( return (
<Tab.Navigator <Tab.Navigator
screenOptions={{ screenOptions={({ route }) => ({
headerStyle: { backgroundColor: '#111' }, headerStyle: { backgroundColor: colors.bg },
headerTintColor: '#fff', headerTintColor: colors.text,
tabBarStyle: { backgroundColor: '#111', borderTopColor: '#222' }, tabBarStyle: { backgroundColor: colors.surface, borderTopColor: colors.border },
tabBarActiveTintColor: '#3b82f6', tabBarActiveTintColor: accent,
tabBarInactiveTintColor: '#555', tabBarInactiveTintColor: colors.textMuted,
}} tabBarIcon: ({ color }) => (
<Text style={{ color, fontSize: 18 }}>{TAB_ICONS[route.name]}</Text>
),
})}
> >
<Tab.Screen <Tab.Screen name="Recording" component={RecordingScreen} />
name="Recording" <Tab.Screen name="Saved" component={SavedRecordingsScreen} />
component={RecordingScreen} <Tab.Screen name="Settings" component={SettingsScreen} />
options={{ tabBarIcon: ({ color }) => <Text style={{ color, fontSize: 20 }}></Text> }}
/>
<Tab.Screen
name="Saved"
component={SavedRecordingsScreen}
options={{ tabBarIcon: ({ color }) => <Text style={{ color, fontSize: 20 }}>📋</Text> }}
/>
<Tab.Screen
name="Settings"
component={SettingsScreen}
options={{ tabBarIcon: ({ color }) => <Text style={{ color, fontSize: 20 }}></Text> }}
/>
</Tab.Navigator> </Tab.Navigator>
); );
} }
@@ -49,14 +49,14 @@ export function AppNavigator() {
<NavigationContainer> <NavigationContainer>
<Stack.Navigator <Stack.Navigator
screenOptions={{ screenOptions={{
headerStyle: { backgroundColor: '#111' }, headerStyle: { backgroundColor: colors.bg },
headerTintColor: '#fff', headerTintColor: colors.text,
contentStyle: { backgroundColor: '#111' }, contentStyle: { backgroundColor: colors.bg },
}} }}
> >
<Stack.Screen name="Tabs" component={Tabs} options={{ headerShown: false }} /> <Stack.Screen name="Tabs" component={Tabs} options={{ headerShown: false }} />
<Stack.Screen name="PostRecording" component={PostRecordingScreen} options={{ title: 'Save Recording', presentation: 'modal' }} /> <Stack.Screen name="PostRecording" component={PostRecordingScreen} options={{ title: 'Save Recording', presentation: 'modal' }} />
<Stack.Screen name="SensorPairing" component={SensorPairingScreen} options={{ title: 'Sensors', presentation: 'modal' }} /> <Stack.Screen name="SensorPairing" component={SensorPairingScreen} options={{ title: 'Sensors', presentation: 'modal' }} />
</Stack.Navigator> </Stack.Navigator>
</NavigationContainer> </NavigationContainer>
); );
+15 -14
View File
@@ -6,6 +6,7 @@ import { saveGpx } from '../services/gpx';
import { insertRecording } from '../services/db'; import { insertRecording } from '../services/db';
import { SavedRecording } from '../types'; import { SavedRecording } from '../types';
import { randomUUID } from 'expo-crypto'; import { randomUUID } from 'expo-crypto';
import { colors } from '../theme';
export function PostRecordingScreen() { export function PostRecordingScreen() {
const nav = useNavigation(); const nav = useNavigation();
@@ -65,7 +66,7 @@ export function PostRecordingScreen() {
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholder="Activity title" placeholder="Activity title"
placeholderTextColor="#555" placeholderTextColor={colors.placeholder}
value={title} value={title}
onChangeText={setTitle} onChangeText={setTitle}
autoFocus autoFocus
@@ -75,7 +76,7 @@ export function PostRecordingScreen() {
<Text style={styles.btnText}>{saving ? 'Saving…' : 'Save'}</Text> <Text style={styles.btnText}>{saving ? 'Saving…' : 'Save'}</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity style={[styles.btn, styles.btnDiscard]} onPress={handleDiscard}> <TouchableOpacity style={[styles.btn, styles.btnDiscard]} onPress={handleDiscard}>
<Text style={[styles.btnText, { color: '#ef4444' }]}>Discard</Text> <Text style={[styles.btnText, { color: colors.error }]}>Discard</Text>
</TouchableOpacity> </TouchableOpacity>
</ScrollView> </ScrollView>
); );
@@ -91,16 +92,16 @@ function Stat({ label, value }: { label: string; value: string }) {
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#111' }, container: { flex: 1, backgroundColor: colors.bg },
content: { padding: 24, gap: 16 }, content: { padding: 24, gap: 12 },
heading: { color: '#fff', fontSize: 24, fontWeight: '700', marginBottom: 8 }, heading: { color: colors.text, fontSize: 22, fontWeight: '700', marginBottom: 4 },
statsRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 12, marginBottom: 8 }, statsRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginBottom: 4 },
stat: { flex: 1, minWidth: '40%', backgroundColor: '#1e1e1e', borderRadius: 12, padding: 16 }, stat: { flex: 1, minWidth: '40%', backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, borderRadius: 10, padding: 14 },
statLabel: { color: '#888', fontSize: 12, textTransform: 'uppercase' }, statLabel: { color: colors.textMuted, fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.5 },
statValue: { color: '#fff', fontSize: 22, fontWeight: '600', marginTop: 4 }, statValue: { color: colors.text, fontSize: 22, fontWeight: '700', marginTop: 4 },
input: { backgroundColor: '#1e1e1e', color: '#fff', borderRadius: 12, padding: 16, fontSize: 18 }, input: { backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, color: colors.text, borderRadius: 10, padding: 14, fontSize: 16 },
btn: { borderRadius: 12, padding: 16, alignItems: 'center' }, btn: { borderRadius: 10, padding: 16, alignItems: 'center' },
btnSave: { backgroundColor: '#22c55e' }, btnSave: { backgroundColor: colors.btnStart },
btnDiscard: { backgroundColor: '#1e1e1e' }, btnDiscard:{ backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border },
btnText: { fontSize: 16, fontWeight: '700', color: '#fff' }, btnText: { fontSize: 16, fontWeight: '600', color: colors.text },
}); });
+104 -112
View File
@@ -1,20 +1,25 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState, useMemo } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Alert, LayoutChangeEvent } from 'react-native'; import { View, Text, StyleSheet, TouchableOpacity, Alert } 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 { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake'; import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake';
import Svg, { Polyline } from 'react-native-svg'; import { Map, Camera, GeoJSONSource, Layer, UserLocation } from '@maplibre/maplibre-react-native';
import type { LineLayerStyle, CircleLayerStyle } from '@maplibre/maplibre-react-native';
import { useRecordingStore } from '../store/recording'; import { useRecordingStore } from '../store/recording';
import { startGpsRecording, stopGpsRecording, requestLocationPermissions } from '../services/gps'; import { startGpsRecording, stopGpsRecording, requestLocationPermissions, requestForegroundLocation } from '../services/gps';
import { RootStackParamList, TrackPoint } from '../types'; 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>; type Nav = NativeStackNavigationProp<RootStackParamList>;
export function RecordingScreen() { export function RecordingScreen() {
const nav = useNavigation<Nav>(); const nav = useNavigation<Nav>();
const { accent, accentDim, scale, boldLabels } = useTheme();
const { status, ble, keepAwake, trackPoints, start, pause, resume, stop, setKeepAwake, 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);
useEffect(() => { useEffect(() => {
@@ -23,38 +28,60 @@ export function RecordingScreen() {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (keepAwake && status === 'recording') { requestForegroundLocation();
activateKeepAwakeAsync(); }, []);
} else {
deactivateKeepAwake(); useEffect(() => {
} if (keepAwake && status === 'recording') activateKeepAwakeAsync();
else deactivateKeepAwake();
}, [keepAwake, status]); }, [keepAwake, status]);
const trackLineStyle = useMemo<LineLayerStyle>(() => ({
lineColor: accent,
lineWidth: 3,
lineJoin: 'round',
lineCap: 'round',
}), [accent]);
const userDotStyle = useMemo<CircleLayerStyle>(() => ({
circleRadius: 8,
circleColor: accent,
circleStrokeWidth: 2.5,
circleStrokeColor: '#ffffff',
circlePitchAlignment: 'map',
}), [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() { async function handleStart() {
const granted = await requestLocationPermissions(); const granted = await requestLocationPermissions();
if (!granted) { if (!granted) { Alert.alert('Permission required', 'Location permission is required to record.'); return; }
Alert.alert('Permission required', 'Location permission is required to record.');
return;
}
start(); start();
await startGpsRecording(); await startGpsRecording();
} }
async function handlePause() { async function handlePause() { await stopGpsRecording(); pause(); }
await stopGpsRecording(); async function handleResume() { resume(); await startGpsRecording(); }
pause(); async function handleStop() { await stopGpsRecording(); stop(); nav.navigate('PostRecording'); }
}
async function handleResume() {
resume();
await startGpsRecording();
}
async function handleStop() {
await stopGpsRecording();
stop();
nav.navigate('PostRecording');
}
const formatDuration = (secs: number) => { const formatDuration = (secs: number) => {
const h = Math.floor(secs / 3600).toString().padStart(2, '0'); const h = Math.floor(secs / 3600).toString().padStart(2, '0');
@@ -66,37 +93,42 @@ export function RecordingScreen() {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<View style={styles.statsGrid}> <View style={styles.statsGrid}>
<StatBox label="Time" value={formatDuration(stats.elapsedSeconds)} /> <StatBox label="Time" value={formatDuration(stats.elapsedSeconds)} labelStyle={statLabelStyle} valueStyle={statValueStyle} />
<StatBox label="Distance" value={`${(stats.distanceMeters / 1000).toFixed(2)} km`} /> <StatBox label="Distance" value={`${(stats.distanceMeters / 1000).toFixed(2)} km`} labelStyle={statLabelStyle} valueStyle={statValueStyle} />
<StatBox label="Speed" value={`${stats.currentSpeedKph.toFixed(1)} km/h`} /> <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`} /> <StatBox label="Avg Speed" value={`${stats.avgSpeedKph.toFixed(1)} km/h`} labelStyle={statLabelStyle} valueStyle={statValueStyle} />
<StatBox label="Elevation" value={`+${stats.elevationGainMeters.toFixed(0)} m`} /> <StatBox label="Elevation" value={`+${stats.elevationGainMeters.toFixed(0)} m`} labelStyle={statLabelStyle} valueStyle={statValueStyle} />
<StatBox label="HR" value={ble.hr ? `${ble.hr} bpm` : '—'} /> <StatBox label="HR" value={ble.hr ? `${ble.hr} bpm` : '—'} labelStyle={statLabelStyle} valueStyle={statValueStyle} />
<StatBox label="Power" value={ble.power ? `${ble.power} W` : '—'} /> <StatBox label="Power" value={ble.power ? `${ble.power} W` : '—'} labelStyle={statLabelStyle} valueStyle={statValueStyle} />
<StatBox label="Cadence" value={ble.cadence ? `${ble.cadence} rpm` : '—'} /> <StatBox label="Cadence" value={ble.cadence ? `${ble.cadence} rpm` : '—'} labelStyle={statLabelStyle} valueStyle={statValueStyle} />
</View> </View>
<View <View style={styles.mapArea}>
style={styles.mapArea} <Map mapStyle={MAP_STYLE} style={StyleSheet.absoluteFill} logo={false} attribution={false}>
onLayout={(e: LayoutChangeEvent) => { <Camera
const { width, height } = e.nativeEvent.layout; trackUserLocation={status === 'recording' ? 'course' : 'default'}
setMapSize({ width, height }); initialViewState={{ zoom: 14 }}
}} />
> <UserLocation>
{mapSize.width > 0 && ( <Layer id="user-dot" type="circle" style={userDotStyle} />
<TrackView trackPoints={trackPoints} width={mapSize.width} height={mapSize.height} /> </UserLocation>
)} {trackPoints.length >= 2 && (
<TouchableOpacity style={styles.sensorBtn} onPress={() => nav.navigate('SensorPairing')}> <GeoJSONSource id="track" data={trackGeoJSON}>
<Text style={styles.sensorBtnText}> Sensors</Text> <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> </TouchableOpacity>
</View> </View>
<View style={styles.controls}> <View style={styles.controls}>
<TouchableOpacity <TouchableOpacity
style={[styles.awakeBtn, keepAwake && styles.awakeBtnOn]} style={[styles.awakeBtn, keepAwake && { borderColor: accent }]}
onPress={() => setKeepAwake(!keepAwake)} onPress={() => setKeepAwake(!keepAwake)}
> >
<Text style={styles.awakeBtnText}>{keepAwake ? '☀️ Awake' : '💤 Sleep'}</Text> <Text style={styles.awakeBtnText}>{keepAwake ? ' Awake' : ' Sleep'}</Text>
</TouchableOpacity> </TouchableOpacity>
{status === 'idle' && ( {status === 'idle' && (
@@ -129,71 +161,31 @@ export function RecordingScreen() {
); );
} }
const PADDING = 16; function StatBox({ label, value, labelStyle, valueStyle }: {
label: string; value: string;
function TrackView({ trackPoints, width, height }: { trackPoints: TrackPoint[]; width: number; height: number }) { labelStyle: object; valueStyle: object;
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 }) {
return ( return (
<View style={styles.statBox}> <View style={styles.statBox}>
<Text style={styles.statLabel}>{label}</Text> <Text style={labelStyle}>{label}</Text>
<Text style={styles.statValue}>{value}</Text> <Text style={valueStyle}>{value}</Text>
</View> </View>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#111' }, container: { flex: 1, backgroundColor: colors.bg },
statsGrid: { flexDirection: 'row', flexWrap: 'wrap', padding: 8 }, statsGrid: { flexDirection: 'row', flexWrap: 'wrap', padding: 8, borderBottomWidth: 1, borderBottomColor: colors.border },
statBox: { width: '25%', padding: 8, alignItems: 'center' }, statBox: { width: '25%', padding: 8, alignItems: 'center' },
statLabel: { color: '#888', fontSize: 11, textTransform: 'uppercase' }, mapArea: { flex: 1, overflow: 'hidden' },
statValue: { color: '#fff', fontSize: 18, fontWeight: '600', marginTop: 2 }, sensorBtn: { position: 'absolute', top: 10, right: 10, backgroundColor: 'rgba(9,9,11,0.8)', borderRadius: 8, borderWidth: 1, paddingVertical: 6, paddingHorizontal: 12 },
mapArea: { flex: 1, backgroundColor: '#0d0d1a', overflow: 'hidden' }, sensorBtnText: { fontSize: 13, fontWeight: '600' },
mapHint: { color: '#333', fontSize: 13, textAlign: 'center', marginTop: 40 }, controls: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 12, padding: 20, borderTopWidth: 1, borderTopColor: colors.border },
sensorBtn: { position: 'absolute', top: 10, right: 10, backgroundColor: '#1e1e2e', borderRadius: 8, paddingVertical: 6, paddingHorizontal: 12 }, awakeBtn: { backgroundColor: colors.surface, borderRadius: 20, borderWidth: 1, borderColor: colors.border, paddingVertical: 8, paddingHorizontal: 14 },
sensorBtnText: { color: '#3b82f6', fontSize: 13, fontWeight: '600' }, awakeBtnText: { color: colors.textSub, fontSize: 13 },
controls: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 12, padding: 20 }, btn: { paddingVertical: 15, paddingHorizontal: 30, borderRadius: 50 },
awakeBtn: { backgroundColor: '#1e1e1e', borderRadius: 20, paddingVertical: 8, paddingHorizontal: 14 }, btnStart: { backgroundColor: colors.btnStart },
awakeBtnOn: { backgroundColor: '#2a2a1a' }, btnPause: { backgroundColor: colors.btnPause },
awakeBtnText: { color: '#aaa', fontSize: 13 }, btnStop: { backgroundColor: colors.btnStop },
btn: { paddingVertical: 16, paddingHorizontal: 32, borderRadius: 50 }, btnText: { color: '#fff', fontSize: 17, fontWeight: '700' },
btnStart: { backgroundColor: '#22c55e' },
btnPause: { backgroundColor: '#f59e0b' },
btnStop: { backgroundColor: '#ef4444' },
btnText: { color: '#fff', fontSize: 18, fontWeight: '700' },
}); });
+19 -16
View File
@@ -6,8 +6,11 @@ import { listRecordings, deleteRecording } from '../services/db';
import { uploadGpx } from '../services/upload'; import { uploadGpx } from '../services/upload';
import { SavedRecording } from '../types'; import { SavedRecording } from '../types';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import { colors } from '../theme';
import { useTheme } from '../ThemeContext';
export function SavedRecordingsScreen() { export function SavedRecordingsScreen() {
const { accent } = useTheme();
const [recordings, setRecordings] = useState<SavedRecording[]>([]); const [recordings, setRecordings] = useState<SavedRecording[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState<string | null>(null); const [uploading, setUploading] = useState<string | null>(null);
@@ -24,9 +27,9 @@ export function SavedRecordingsScreen() {
async function handleUpload(rec: SavedRecording) { async function handleUpload(rec: SavedRecording) {
const instanceUrl = await AsyncStorage.getItem('instanceUrl'); const instanceUrl = await AsyncStorage.getItem('instanceUrl');
const apiToken = await AsyncStorage.getItem('apiToken'); const apiToken = await AsyncStorage.getItem('apiToken');
if (!instanceUrl || !apiToken) { if (!instanceUrl || !apiToken) {
Alert.alert('Not configured', 'Set your bincio instance URL and API token in Settings.'); Alert.alert('Not connected', 'Log in to your bincio instance in Settings first.');
return; return;
} }
setUploading(rec.id); setUploading(rec.id);
@@ -56,7 +59,7 @@ export function SavedRecordingsScreen() {
return h > 0 ? `${h}h ${m}m` : `${m}m`; return h > 0 ? `${h}h ${m}m` : `${m}m`;
}; };
if (loading) return <View style={styles.center}><ActivityIndicator color="#fff" /></View>; if (loading) return <View style={styles.center}><ActivityIndicator color={accent} /></View>;
return ( return (
<View style={styles.container}> <View style={styles.container}>
@@ -74,13 +77,13 @@ export function SavedRecordingsScreen() {
</View> </View>
<View style={styles.cardActions}> <View style={styles.cardActions}>
<TouchableOpacity onPress={() => handleShare(item)}> <TouchableOpacity onPress={() => handleShare(item)}>
<Text style={styles.action}>Export</Text> <Text style={[styles.action, { color: accent }]}>Export</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity onPress={() => handleUpload(item)} disabled={uploading === item.id}> <TouchableOpacity onPress={() => handleUpload(item)} disabled={uploading === item.id}>
<Text style={styles.action}>{uploading === item.id ? '…' : 'Upload'}</Text> <Text style={[styles.action, { color: accent }]}>{uploading === item.id ? '…' : 'Upload'}</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity onPress={() => handleDelete(item)}> <TouchableOpacity onPress={() => handleDelete(item)}>
<Text style={[styles.action, { color: '#ef4444' }]}>Delete</Text> <Text style={[styles.action, { color: colors.error }]}>Delete</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
@@ -92,14 +95,14 @@ export function SavedRecordingsScreen() {
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#111' }, container: { flex: 1, backgroundColor: colors.bg },
center: { flex: 1, backgroundColor: '#111', alignItems: 'center', justifyContent: 'center' }, center: { flex: 1, backgroundColor: colors.bg, alignItems: 'center', justifyContent: 'center' },
list: { padding: 16, gap: 12 }, list: { padding: 16, gap: 10 },
card: { backgroundColor: '#1e1e1e', borderRadius: 12, padding: 16 }, card: { backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, borderRadius: 12, padding: 16 },
cardMeta: { marginBottom: 12 }, cardMeta: { marginBottom: 12 },
cardTitle: { color: '#fff', fontSize: 17, fontWeight: '600' }, cardTitle: { color: colors.text, fontSize: 15, fontWeight: '600' },
cardSub: { color: '#888', fontSize: 13, marginTop: 4 }, cardSub: { color: colors.textMuted, fontSize: 12, marginTop: 4 },
cardActions: { flexDirection: 'row', gap: 20 }, cardActions:{ flexDirection: 'row', gap: 20 },
action: { color: '#3b82f6', fontWeight: '600', fontSize: 15 }, action: { fontWeight: '600', fontSize: 14 },
empty: { color: '#555', textAlign: 'center', marginTop: 60 }, empty: { color: colors.textMuted, textAlign: 'center', marginTop: 60 },
}); });
+152 -50
View File
@@ -1,73 +1,170 @@
import React, { useEffect, useState, useRef } from 'react'; import React, { useEffect, useState, useRef } from 'react';
import { View, Text, StyleSheet, FlatList, TouchableOpacity, ActivityIndicator } from 'react-native'; import {
import { scanForDevices, connectDevice, subscribeHr, subscribePower } from '../services/ble'; View, Text, StyleSheet, FlatList, TouchableOpacity,
ActivityIndicator, Alert,
} from 'react-native';
import {
requestBlePermissions, scanForDevices, connectDevice,
subscribeForDevice, savePairedDevice, loadPairedDevices, removePairedDevice,
} from '../services/ble';
import { BleDevice } from '../types'; import { BleDevice } from '../types';
import { colors } from '../theme';
import { useTheme } from '../ThemeContext';
const TYPE_LABEL: Record<BleDevice['type'], string> = { const TYPE_LABEL: Record<BleDevice['type'], string> = {
hr: 'Heart Rate', hr: 'Heart Rate',
power: 'Power Meter', power: 'Power Meter',
cadence: 'Cadence', cadence: 'Cadence',
}; };
type ConnectionStatus = 'saved' | 'connecting' | 'connected' | 'error';
interface SavedEntry {
device: BleDevice;
status: ConnectionStatus;
}
export function SensorPairingScreen() { export function SensorPairingScreen() {
const { accent, accentDim } = useTheme();
const [scanning, setScanning] = useState(false); const [scanning, setScanning] = useState(false);
const [devices, setDevices] = useState<BleDevice[]>([]); const [found, setFound] = useState<BleDevice[]>([]);
const [paired, setPaired] = useState<Record<string, boolean>>({}); const [saved, setSaved] = useState<SavedEntry[]>([]);
const [connecting, setConnecting] = useState<Record<string, boolean>>({});
const stopScanRef = useRef<(() => void) | null>(null); const stopScanRef = useRef<(() => void) | null>(null);
function startScan() { useEffect(() => {
setDevices([]); (async () => {
const devices = await loadPairedDevices();
setSaved(devices.map((d) => ({ device: d, status: 'saved' })));
for (const d of devices) reconnect(d, false);
})();
return () => { stopScanRef.current?.(); };
}, []);
async function handleScan() {
const granted = await requestBlePermissions();
if (!granted) {
Alert.alert('Permission required', 'Bluetooth permission is required to scan for sensors.');
return;
}
setFound([]);
setScanning(true); setScanning(true);
stopScanRef.current = scanForDevices( stopScanRef.current = scanForDevices(
(device) => setDevices((prev) => prev.find((d) => d.id === device.id) ? prev : [...prev, device]), (device) => setFound((prev) => prev.find((d) => d.id === device.id) ? prev : [...prev, device]),
() => setScanning(false), () => setScanning(false),
); );
setTimeout(() => { stopScanRef.current?.(); setScanning(false); }, 15000); setTimeout(() => { stopScanRef.current?.(); setScanning(false); }, 15000);
} }
useEffect(() => () => { stopScanRef.current?.(); }, []); async function reconnect(device: BleDevice, showError = true) {
setSaved((prev) => prev.map((e) => e.device.id === device.id ? { ...e, status: 'connecting' } : e));
async function handlePair(device: BleDevice) {
try { try {
const connected = await connectDevice(device.id); const connected = await connectDevice(device.id);
if (device.type === 'hr') subscribeHr(connected); subscribeForDevice(connected, device.type);
if (device.type === 'power') subscribePower(connected); setSaved((prev) => prev.map((e) => e.device.id === device.id ? { ...e, status: 'connected' } : e));
setPaired((prev) => ({ ...prev, [device.id]: true }));
} catch { } catch {
// error silenced — user can retry setSaved((prev) => prev.map((e) => e.device.id === device.id ? { ...e, status: 'error' } : e));
if (showError) Alert.alert('Connection failed', `Could not connect to ${device.name}.`);
} }
} }
async function handleConnect(device: BleDevice) {
setConnecting((prev) => ({ ...prev, [device.id]: true }));
try {
const connected = await connectDevice(device.id);
subscribeForDevice(connected, device.type);
await savePairedDevice(device);
setSaved((prev) => {
const exists = prev.find((e) => e.device.id === device.id);
if (exists) return prev.map((e) => e.device.id === device.id ? { ...e, status: 'connected' } : e);
return [...prev, { device, status: 'connected' }];
});
} catch {
Alert.alert('Connection failed', `Could not connect to ${device.name}.`);
} finally {
setConnecting((prev) => ({ ...prev, [device.id]: false }));
}
}
async function handleForget(device: BleDevice) {
await removePairedDevice(device.id);
setSaved((prev) => prev.filter((e) => e.device.id !== device.id));
}
const savedIds = new Set(saved.map((e) => e.device.id));
const newFound = found.filter((d) => !savedIds.has(d.id));
type ListItem =
| { kind: 'header'; label: string }
| { kind: 'saved'; entry: SavedEntry }
| { kind: 'found'; device: BleDevice };
const listData: ListItem[] = [
...(saved.length > 0 ? [{ kind: 'header' as const, label: 'Saved sensors' }, ...saved.map((e) => ({ kind: 'saved' as const, entry: e }))] : []),
...(newFound.length > 0 ? [{ kind: 'header' as const, label: 'Found nearby' }, ...newFound.map((d) => ({ kind: 'found' as const, device: d }))] : []),
];
return ( return (
<View style={styles.container}> <View style={styles.container}>
<Text style={styles.heading}>Sensor Pairing</Text> <Text style={styles.sub}>Pair your HR monitor, power meter, or cadence sensor.</Text>
<Text style={styles.sub}>Scan for nearby BLE sensors (HR, power, cadence).</Text>
<TouchableOpacity style={styles.scanBtn} onPress={startScan} disabled={scanning}> <TouchableOpacity style={styles.scanBtn} onPress={handleScan} disabled={scanning}>
{scanning ? <ActivityIndicator color="#fff" /> : <Text style={styles.scanBtnText}>Scan</Text>} {scanning ? <ActivityIndicator color={colors.text} /> : <Text style={styles.scanBtnText}>Scan for sensors</Text>}
</TouchableOpacity> </TouchableOpacity>
<FlatList <FlatList
data={devices} data={listData}
keyExtractor={(d) => d.id} keyExtractor={(item, i) => item.kind === 'header' ? `h-${i}` : item.kind === 'saved' ? item.entry.device.id : item.device.id}
style={styles.list} style={styles.list}
renderItem={({ item }) => ( renderItem={({ item }) => {
<View style={styles.deviceRow}> if (item.kind === 'header') {
<View> return <Text style={styles.sectionHeader}>{item.label}</Text>;
<Text style={styles.deviceName}>{item.name}</Text> }
<Text style={styles.deviceType}>{TYPE_LABEL[item.type]}</Text> if (item.kind === 'saved') {
const { entry } = item;
return (
<View style={styles.deviceRow}>
<View style={styles.deviceInfo}>
<Text style={styles.deviceName}>{entry.device.name}</Text>
<Text style={styles.deviceType}>{TYPE_LABEL[entry.device.type]}</Text>
</View>
<View style={styles.deviceActions}>
{entry.status === 'connecting' && <ActivityIndicator color={accent} />}
{entry.status === 'connected' && <Text style={styles.connectedLabel}>Connected</Text>}
{(entry.status === 'saved' || entry.status === 'error') && (
<TouchableOpacity style={[styles.reconnectBtn, { backgroundColor: accentDim }]} onPress={() => reconnect(entry.device, true)}>
<Text style={[styles.reconnectBtnText, { color: accent }]}>{entry.status === 'error' ? 'Retry' : 'Reconnect'}</Text>
</TouchableOpacity>
)}
<TouchableOpacity onPress={() => handleForget(entry.device)}>
<Text style={styles.forgetLabel}>Forget</Text>
</TouchableOpacity>
</View>
</View>
);
}
const { device } = item;
const isConnecting = !!connecting[device.id];
return (
<View style={styles.deviceRow}>
<View style={styles.deviceInfo}>
<Text style={styles.deviceName}>{device.name}</Text>
<Text style={styles.deviceType}>{TYPE_LABEL[device.type]}</Text>
</View>
<TouchableOpacity
style={[styles.connectBtn, { backgroundColor: accentDim }]}
onPress={() => handleConnect(device)}
disabled={isConnecting}
>
{isConnecting
? <ActivityIndicator color={accent} />
: <Text style={[styles.connectBtnText, { color: accent }]}>Connect</Text>}
</TouchableOpacity>
</View> </View>
<TouchableOpacity );
style={[styles.pairBtn, paired[item.id] && styles.pairBtnPaired]} }}
onPress={() => handlePair(item)}
disabled={!!paired[item.id]}
>
<Text style={styles.pairBtnText}>{paired[item.id] ? 'Connected' : 'Connect'}</Text>
</TouchableOpacity>
</View>
)}
ListEmptyComponent={ ListEmptyComponent={
<Text style={styles.empty}>{scanning ? 'Scanning…' : 'No devices found. Tap Scan.'}</Text> !scanning ? <Text style={styles.empty}>No sensors found. Tap Scan to search.</Text> : null
} }
/> />
</View> </View>
@@ -75,17 +172,22 @@ export function SensorPairingScreen() {
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#111', padding: 24 }, container: { flex: 1, backgroundColor: colors.bg, padding: 16 },
heading: { color: '#fff', fontSize: 24, fontWeight: '700' }, sub: { color: colors.textMuted, fontSize: 13, marginBottom: 16 },
sub: { color: '#888', marginTop: 4, marginBottom: 20 }, scanBtn: { backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, borderRadius: 10, padding: 14, alignItems: 'center', marginBottom: 4 },
scanBtn: { backgroundColor: '#3b82f6', borderRadius: 12, padding: 14, alignItems: 'center', marginBottom: 16 }, scanBtnText: { color: colors.text, fontSize: 15, fontWeight: '600' },
scanBtnText: { color: '#fff', fontSize: 16, fontWeight: '700' }, list: { flex: 1, marginTop: 4 },
list: { flex: 1 }, sectionHeader: { color: colors.textMuted, fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.8, marginTop: 20, marginBottom: 8 },
deviceRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', backgroundColor: '#1e1e1e', borderRadius: 12, padding: 16, marginBottom: 10 }, deviceRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, borderRadius: 10, padding: 14, marginBottom: 8 },
deviceName: { color: '#fff', fontSize: 16, fontWeight: '600' }, deviceInfo: { flex: 1, marginRight: 12 },
deviceType: { color: '#888', fontSize: 13, marginTop: 2 }, deviceName: { color: colors.text, fontSize: 15, fontWeight: '600' },
pairBtn: { backgroundColor: '#3b82f6', borderRadius: 8, paddingVertical: 8, paddingHorizontal: 16 }, deviceType: { color: colors.textMuted, fontSize: 12, marginTop: 2 },
pairBtnPaired: { backgroundColor: '#22c55e' }, deviceActions: { flexDirection: 'row', alignItems: 'center', gap: 12 },
pairBtnText: { color: '#fff', fontWeight: '600' }, connectedLabel: { color: colors.success, fontWeight: '600', fontSize: 13 },
empty: { color: '#555', textAlign: 'center', marginTop: 40 }, reconnectBtn: { borderRadius: 8, paddingVertical: 6, paddingHorizontal: 12 },
reconnectBtnText:{ fontWeight: '600', fontSize: 13 },
forgetLabel: { color: colors.error, fontSize: 13 },
connectBtn: { borderRadius: 8, paddingVertical: 7, paddingHorizontal: 14, minWidth: 80, alignItems: 'center' },
connectBtnText: { fontWeight: '600' },
empty: { color: colors.textMuted, textAlign: 'center', marginTop: 40 },
}); });
+211 -51
View File
@@ -1,38 +1,170 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { View, Text, TextInput, StyleSheet, Switch, TouchableOpacity, Alert, ScrollView } from 'react-native'; import {
View, Text, TextInput, StyleSheet, Switch,
TouchableOpacity, Alert, ScrollView, ActivityIndicator,
} from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import { login, logout, loadAuthState } from '../services/auth';
import { colors, PALETTES, type PaletteKey, type FontSizeKey } from '../theme';
import { useTheme } from '../ThemeContext';
type Tab = 'ui' | 'app' | 'sync';
export function SettingsScreen() { export function SettingsScreen() {
const [instanceUrl, setInstanceUrl] = useState(''); const [tab, setTab] = useState<Tab>('ui');
const [apiToken, setApiToken] = useState(''); const { accent } = useTheme();
return (
<View style={styles.container}>
{/* Tab bar */}
<View style={styles.tabBar}>
{(['ui', 'app', 'sync'] as Tab[]).map((t) => (
<TouchableOpacity
key={t}
style={[styles.tabBtn, tab === t && { borderBottomColor: accent, borderBottomWidth: 2 }]}
onPress={() => setTab(t)}
>
<Text style={[styles.tabLabel, tab === t && { color: accent }]}>
{t === 'ui' ? 'Interface' : t === 'app' ? 'App' : 'Sync'}
</Text>
</TouchableOpacity>
))}
</View>
{tab === 'ui' && <UITab />}
{tab === 'app' && <AppTab />}
{tab === 'sync' && <SyncTab />}
</View>
);
}
// ─── UI tab ─────────────────────────────────────────────────────────────────
function UITab() {
const { accent, palette, setPalette, fontSize, setFontSize, boldLabels, setBoldLabels } = useTheme();
const fontSizes: FontSizeKey[] = ['small', 'medium', 'large'];
const palettes = Object.entries(PALETTES) as [PaletteKey, typeof PALETTES[PaletteKey]][];
return (
<ScrollView contentContainerStyle={styles.content}>
<Text style={styles.sectionTitle}>Colour palette</Text>
<View style={styles.pillRow}>
{palettes.map(([key, val]) => (
<TouchableOpacity
key={key}
style={[styles.pill, palette === key && { borderColor: val.accent, backgroundColor: val.accentDim }]}
onPress={() => setPalette(key)}
>
<View style={[styles.paletteDot, { backgroundColor: val.accent }]} />
<Text style={[styles.pillText, palette === key && { color: val.accent }]}>{val.label}</Text>
</TouchableOpacity>
))}
</View>
<Text style={styles.sectionTitle}>Font size</Text>
<View style={styles.pillRow}>
{fontSizes.map((s) => (
<TouchableOpacity
key={s}
style={[styles.pill, fontSize === s && { borderColor: accent, backgroundColor: PALETTES[palette].accentDim }]}
onPress={() => setFontSize(s)}
>
<Text style={[styles.pillText, fontSize === s && { color: accent }]}>
{s.charAt(0).toUpperCase() + s.slice(1)}
</Text>
</TouchableOpacity>
))}
</View>
<Text style={styles.sectionTitle}>Stat labels</Text>
<View style={styles.row}>
<Text style={styles.rowLabel}>Bold labels</Text>
<Switch
value={boldLabels}
onValueChange={setBoldLabels}
trackColor={{ true: accent }}
thumbColor={colors.text}
/>
</View>
</ScrollView>
);
}
// ─── App tab ─────────────────────────────────────────────────────────────────
function AppTab() {
const { accent } = useTheme();
const [kmNotifications, setKmNotifications] = useState(true); const [kmNotifications, setKmNotifications] = useState(true);
const [saved, setSaved] = useState(false);
useEffect(() => { useEffect(() => {
(async () => { AsyncStorage.getItem('kmNotifications').then((v) => {
const [url, token, km] = await Promise.all([ if (v !== null) setKmNotifications(v === 'true');
AsyncStorage.getItem('instanceUrl'), });
AsyncStorage.getItem('apiToken'),
AsyncStorage.getItem('kmNotifications'),
]);
if (url) setInstanceUrl(url);
if (token) setApiToken(token);
if (km !== null) setKmNotifications(km === 'true');
})();
}, []); }, []);
async function handleSave() { async function handleKmToggle(value: boolean) {
await Promise.all([ setKmNotifications(value);
AsyncStorage.setItem('instanceUrl', instanceUrl.trim()), await AsyncStorage.setItem('kmNotifications', String(value));
AsyncStorage.setItem('apiToken', apiToken.trim()),
AsyncStorage.setItem('kmNotifications', String(kmNotifications)),
]);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} }
return ( return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}> <ScrollView contentContainerStyle={styles.content}>
<Text style={styles.sectionTitle}>Notifications</Text>
<View style={styles.row}>
<View>
<Text style={styles.rowLabel}>Kilometre alerts</Text>
<Text style={styles.rowSub}>Notify at every km during recording</Text>
</View>
<Switch
value={kmNotifications}
onValueChange={handleKmToggle}
trackColor={{ true: accent }}
thumbColor={colors.text}
/>
</View>
</ScrollView>
);
}
// ─── Sync tab ─────────────────────────────────────────────────────────────────
function SyncTab() {
const { accent } = useTheme();
const [instanceUrl, setInstanceUrl] = useState('');
const [handle, setHandle] = useState('');
const [password, setPassword] = useState('');
const [connectedAs, setConnectedAs] = useState<string | null>(null);
const [connecting, setConnecting] = useState(false);
useEffect(() => {
loadAuthState().then((auth) => {
if (auth) { setInstanceUrl(auth.instanceUrl); setHandle(auth.handle); setConnectedAs(auth.handle); }
});
}, []);
async function handleConnect() {
if (!instanceUrl.trim()) { Alert.alert('Required', 'Enter the instance URL.'); return; }
if (!handle.trim()) { Alert.alert('Required', 'Enter your handle.'); return; }
if (!password) { Alert.alert('Required', 'Enter your password.'); return; }
setConnecting(true);
const result = await login(instanceUrl.trim(), handle.trim(), password);
setConnecting(false);
if (result.ok) { setConnectedAs(result.displayName || handle.trim()); setPassword(''); }
else Alert.alert('Login failed', result.error ?? 'Unknown error');
}
async function handleDisconnect() {
Alert.alert('Disconnect', 'Remove saved credentials?', [
{ text: 'Cancel', style: 'cancel' },
{ text: 'Disconnect', style: 'destructive', onPress: async () => {
await logout(); setConnectedAs(null); setHandle(''); setPassword('');
}},
]);
}
return (
<ScrollView contentContainerStyle={styles.content}>
<Text style={styles.sectionTitle}>bincio instance</Text> <Text style={styles.sectionTitle}>bincio instance</Text>
<Text style={styles.label}>Instance URL</Text> <Text style={styles.label}>Instance URL</Text>
@@ -41,44 +173,72 @@ export function SettingsScreen() {
value={instanceUrl} value={instanceUrl}
onChangeText={setInstanceUrl} onChangeText={setInstanceUrl}
placeholder="https://bincio.example.com" placeholder="https://bincio.example.com"
placeholderTextColor="#555" placeholderTextColor={colors.placeholder}
autoCapitalize="none" autoCapitalize="none"
keyboardType="url" keyboardType="url"
editable={!connectedAs}
/> />
<Text style={styles.label}>API Token</Text> {connectedAs ? (
<TextInput <View style={styles.connectedBox}>
style={styles.input} <View>
value={apiToken} <Text style={[styles.connectedLabel, { color: colors.success }]}>Connected</Text>
onChangeText={setApiToken} <Text style={styles.connectedName}>{connectedAs}</Text>
placeholder="your-api-token" </View>
placeholderTextColor="#555" <TouchableOpacity style={styles.disconnectBtn} onPress={handleDisconnect}>
autoCapitalize="none" <Text style={styles.disconnectBtnText}>Disconnect</Text>
secureTextEntry </TouchableOpacity>
/> </View>
) : (
<>
<Text style={styles.label}>Handle</Text>
<TextInput style={styles.input} value={handle} onChangeText={setHandle} placeholder="your-handle" placeholderTextColor={colors.placeholder} autoCapitalize="none" autoCorrect={false} />
<Text style={styles.sectionTitle}>Notifications</Text> <Text style={styles.label}>Password</Text>
<TextInput style={styles.input} value={password} onChangeText={setPassword} placeholder="••••••••" placeholderTextColor={colors.placeholder} secureTextEntry />
<View style={styles.row}> <Text style={styles.hint}>Your password is used once to obtain a session token, then forgotten.</Text>
<Text style={styles.rowLabel}>Kilometre alerts</Text>
<Switch value={kmNotifications} onValueChange={setKmNotifications} trackColor={{ true: '#3b82f6' }} /> <TouchableOpacity style={[styles.connectBtn, connecting && styles.connectBtnDisabled]} onPress={handleConnect} disabled={connecting}>
{connecting ? <ActivityIndicator color={colors.text} /> : <Text style={styles.connectBtnText}>Connect</Text>}
</TouchableOpacity>
</>
)}
<Text style={styles.sectionTitle}>bincio-autarchive</Text>
<View style={[styles.row, { opacity: 0.5 }]}>
<Text style={styles.rowLabel}>Local sync</Text>
<Text style={styles.rowSub}>Coming soon</Text>
</View> </View>
<TouchableOpacity style={styles.saveBtn} onPress={handleSave}>
<Text style={styles.saveBtnText}>{saved ? 'Saved ✓' : 'Save'}</Text>
</TouchableOpacity>
</ScrollView> </ScrollView>
); );
} }
// ─── Shared styles ────────────────────────────────────────────────────────────
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#111' }, container: { flex: 1, backgroundColor: colors.bg },
content: { padding: 24, gap: 12 }, tabBar: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: colors.border },
sectionTitle: { color: '#888', fontSize: 13, textTransform: 'uppercase', letterSpacing: 0.8, marginTop: 16 }, tabBtn: { flex: 1, alignItems: 'center', paddingVertical: 14, borderBottomWidth: 2, borderBottomColor: 'transparent' },
label: { color: '#aaa', fontSize: 14, marginBottom: 4 }, tabLabel: { color: colors.textMuted, fontSize: 13, fontWeight: '600' },
input: { backgroundColor: '#1e1e1e', color: '#fff', borderRadius: 10, padding: 14, fontSize: 16 }, content: { padding: 16, gap: 10 },
row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', backgroundColor: '#1e1e1e', borderRadius: 10, padding: 14 }, sectionTitle: { color: colors.textMuted, fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.8, marginTop: 16 },
rowLabel: { color: '#fff', fontSize: 16 }, pillRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
saveBtn: { backgroundColor: '#3b82f6', borderRadius: 12, padding: 16, alignItems: 'center', marginTop: 8 }, pill: { flexDirection: 'row', alignItems: 'center', gap: 6, paddingVertical: 8, paddingHorizontal: 14, borderRadius: 20, borderWidth: 1, borderColor: colors.borderStrong },
saveBtnText: { color: '#fff', fontSize: 16, fontWeight: '700' }, paletteDot: { width: 10, height: 10, borderRadius: 5 },
pillText: { color: colors.textSub, fontSize: 13, fontWeight: '500' },
label: { color: colors.textSub, fontSize: 13, marginBottom: 2 },
input: { backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, color: colors.text, borderRadius: 8, padding: 12, fontSize: 15 },
hint: { color: colors.textMuted, fontSize: 12, lineHeight: 18 },
row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, borderRadius: 10, padding: 14 },
rowLabel: { color: colors.text, fontSize: 15 },
rowSub: { color: colors.textMuted, fontSize: 12, marginTop: 2 },
connectedBox: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, borderRadius: 10, padding: 14 },
connectedLabel: { fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.6 },
connectedName: { color: colors.text, fontSize: 15, fontWeight: '600', marginTop: 2 },
disconnectBtn: { paddingVertical: 6, paddingHorizontal: 12, borderRadius: 8, borderWidth: 1, borderColor: colors.errorBg },
disconnectBtnText: { color: colors.error, fontWeight: '600', fontSize: 13 },
connectBtn: { backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, borderRadius: 10, padding: 14, alignItems: 'center', marginTop: 4 },
connectBtnDisabled:{ opacity: 0.5 },
connectBtnText: { color: colors.text, fontSize: 15, fontWeight: '600' },
}); });
+64
View File
@@ -0,0 +1,64 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
interface LoginResult {
ok: boolean;
displayName?: string;
error?: string;
}
export async function login(
instanceUrl: string,
handle: string,
password: string,
): Promise<LoginResult> {
const url = instanceUrl.replace(/\/$/, '') + '/api/auth/token';
try {
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ handle, password }),
});
if (!resp.ok) {
const text = await resp.text().catch(() => '');
return { ok: false, error: text || `HTTP ${resp.status}` };
}
const data = await resp.json();
if (!data.token) return { ok: false, error: 'No token in response' };
await Promise.all([
AsyncStorage.setItem('instanceUrl', instanceUrl.trim()),
AsyncStorage.setItem('handle', handle.trim()),
AsyncStorage.setItem('apiToken', data.token),
]);
return { ok: true, displayName: data.display_name };
} catch (e: any) {
return { ok: false, error: e?.message ?? 'Connection failed' };
}
}
export async function logout(): Promise<void> {
await Promise.all([
AsyncStorage.removeItem('instanceUrl'),
AsyncStorage.removeItem('handle'),
AsyncStorage.removeItem('apiToken'),
]);
}
export interface AuthState {
instanceUrl: string;
handle: string;
apiToken: string;
}
export async function loadAuthState(): Promise<AuthState | null> {
const [instanceUrl, handle, apiToken] = await Promise.all([
AsyncStorage.getItem('instanceUrl'),
AsyncStorage.getItem('handle'),
AsyncStorage.getItem('apiToken'),
]);
if (!instanceUrl || !apiToken) return null;
return { instanceUrl, handle: handle ?? '', apiToken };
}
+46
View File
@@ -0,0 +1,46 @@
import { Platform, Alert } from 'react-native';
import * as IntentLauncher from 'expo-intent-launcher';
import AsyncStorage from '@react-native-async-storage/async-storage';
const PROMPT_SHOWN_KEY = 'batteryOptPromptShown';
export async function resetBatteryOptPrompt(): Promise<void> {
await AsyncStorage.removeItem(PROMPT_SHOWN_KEY);
}
export async function promptBatteryOptimizationIfNeeded(): Promise<void> {
if (Platform.OS !== 'android') return;
const shown = await AsyncStorage.getItem(PROMPT_SHOWN_KEY);
if (shown) return;
await AsyncStorage.setItem(PROMPT_SHOWN_KEY, 'true');
Alert.alert(
'Allow background GPS',
'To record accurately while the screen is off, bincio-rec needs to be excluded from battery optimization.\n\nTap "Open settings", find bincio-rec, and select "Don\'t optimize".',
[
{
text: 'Open settings',
onPress: async () => {
try {
await IntentLauncher.startActivityAsync(
IntentLauncher.ActivityAction.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
{ data: 'package:com.bincio.rec' },
);
} catch {
// Fallback: some OEMs don't support the direct intent — open the general page
try {
await IntentLauncher.startActivityAsync(
IntentLauncher.ActivityAction.IGNORE_BATTERY_OPTIMIZATION_SETTINGS,
);
} catch {
// Nothing more we can do
}
}
},
},
{ text: 'Later', style: 'cancel' },
],
);
}
+117 -13
View File
@@ -1,4 +1,6 @@
import { BleManager, Device, Characteristic } from 'react-native-ble-plx'; import { Platform, PermissionsAndroid } from 'react-native';
import { BleManager, Device } from 'react-native-ble-plx';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useRecordingStore } from '../store/recording'; import { useRecordingStore } from '../store/recording';
import { BleDevice } from '../types'; import { BleDevice } from '../types';
@@ -10,9 +12,36 @@ const CYCLING_POWER_MEASUREMENT = '00002a63-0000-1000-8000-00805f9b34fb';
const CYCLING_SPEED_CADENCE_SERVICE = '00001816-0000-1000-8000-00805f9b34fb'; const CYCLING_SPEED_CADENCE_SERVICE = '00001816-0000-1000-8000-00805f9b34fb';
const CSC_MEASUREMENT = '00002a5b-0000-1000-8000-00805f9b34fb'; const CSC_MEASUREMENT = '00002a5b-0000-1000-8000-00805f9b34fb';
const PAIRED_DEVICES_KEY = 'pairedDevices';
export const bleManager = new BleManager(); export const bleManager = new BleManager();
export function scanForDevices(onFound: (device: BleDevice) => void, onError: (error: Error) => void): () => void { // ---------------------------------------------------------------------------
// Permissions
// ---------------------------------------------------------------------------
export async function requestBlePermissions(): Promise<boolean> {
if (Platform.OS !== 'android') return true;
if (Platform.Version < 31) return true; // pre-Android 12: only location needed, already granted
const results = await PermissionsAndroid.requestMultiple([
PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN,
PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT,
]);
return (
results[PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN] === PermissionsAndroid.RESULTS.GRANTED &&
results[PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT] === PermissionsAndroid.RESULTS.GRANTED
);
}
// ---------------------------------------------------------------------------
// Scan
// ---------------------------------------------------------------------------
export function scanForDevices(
onFound: (device: BleDevice) => void,
onError: (error: Error) => void,
): () => void {
bleManager.startDeviceScan( bleManager.startDeviceScan(
[HR_SERVICE, CYCLING_POWER_SERVICE, CYCLING_SPEED_CADENCE_SERVICE], [HR_SERVICE, CYCLING_POWER_SERVICE, CYCLING_SPEED_CADENCE_SERVICE],
{ allowDuplicates: false }, { allowDuplicates: false },
@@ -32,6 +61,10 @@ export function scanForDevices(onFound: (device: BleDevice) => void, onError: (e
return () => bleManager.stopDeviceScan(); return () => bleManager.stopDeviceScan();
} }
// ---------------------------------------------------------------------------
// Connect & subscribe
// ---------------------------------------------------------------------------
export async function connectDevice(deviceId: string): Promise<Device> { export async function connectDevice(deviceId: string): Promise<Device> {
const device = await bleManager.connectToDevice(deviceId); const device = await bleManager.connectToDevice(deviceId);
await device.discoverAllServicesAndCharacteristics(); await device.discoverAllServicesAndCharacteristics();
@@ -44,8 +77,7 @@ export function subscribeHr(device: Device): () => void {
HR_MEASUREMENT, HR_MEASUREMENT,
(error, char) => { (error, char) => {
if (error || !char?.value) return; if (error || !char?.value) return;
const hr = parseHrMeasurement(char.value); useRecordingStore.getState().updateBle({ hr: parseHrMeasurement(char.value) });
useRecordingStore.getState().updateBle({ hr });
}, },
); );
return () => sub.remove(); return () => sub.remove();
@@ -57,39 +89,111 @@ export function subscribePower(device: Device): () => void {
CYCLING_POWER_MEASUREMENT, CYCLING_POWER_MEASUREMENT,
(error, char) => { (error, char) => {
if (error || !char?.value) return; if (error || !char?.value) return;
const power = parsePowerMeasurement(char.value); useRecordingStore.getState().updateBle({ power: parsePowerMeasurement(char.value) });
useRecordingStore.getState().updateBle({ power });
}, },
); );
return () => sub.remove(); return () => sub.remove();
} }
// Stateful CSC crank tracking — module-level so it persists across notifications
let prevCrankRevs = 0;
let prevCrankTime = 0;
let cscInitialized = false;
export function subscribeCadence(device: Device): () => void { export function subscribeCadence(device: Device): () => void {
cscInitialized = false;
const sub = device.monitorCharacteristicForService( const sub = device.monitorCharacteristicForService(
CYCLING_SPEED_CADENCE_SERVICE, CYCLING_SPEED_CADENCE_SERVICE,
CSC_MEASUREMENT, CSC_MEASUREMENT,
(error, char) => { (error, char) => {
if (error || !char?.value) return; if (error || !char?.value) return;
// cadence parsing requires stateful wheel/crank event tracking — stub for now const cadence = parseCscMeasurement(char.value);
if (cadence !== null) useRecordingStore.getState().updateBle({ cadence });
}, },
); );
return () => sub.remove(); return () => sub.remove();
} }
// HR Measurement characteristic: flags byte + uint8 (or uint16) BPM // ---------------------------------------------------------------------------
// Persistence
// ---------------------------------------------------------------------------
export async function loadPairedDevices(): Promise<BleDevice[]> {
try {
const json = await AsyncStorage.getItem(PAIRED_DEVICES_KEY);
return json ? JSON.parse(json) : [];
} catch {
return [];
}
}
export async function savePairedDevice(device: BleDevice): Promise<void> {
const existing = await loadPairedDevices();
if (existing.find((d) => d.id === device.id)) return;
await AsyncStorage.setItem(PAIRED_DEVICES_KEY, JSON.stringify([...existing, device]));
}
export async function removePairedDevice(id: string): Promise<void> {
const existing = await loadPairedDevices();
await AsyncStorage.setItem(PAIRED_DEVICES_KEY, JSON.stringify(existing.filter((d) => d.id !== id)));
}
export function subscribeForDevice(device: Device, type: BleDevice['type']): () => void {
if (type === 'hr') return subscribeHr(device);
if (type === 'power') return subscribePower(device);
return subscribeCadence(device);
}
// ---------------------------------------------------------------------------
// Parsers
// ---------------------------------------------------------------------------
// HR Measurement: flags byte + uint8 (or uint16 if bit 0 set) BPM
function parseHrMeasurement(base64: string): number { function parseHrMeasurement(base64: string): number {
const bytes = fromBase64(base64); const b = fromBase64(base64);
const flags = bytes[0]; return b[0] & 0x01 ? (b[2] << 8) | b[1] : b[1];
return flags & 0x01 ? (bytes[2] << 8) | bytes[1] : bytes[1];
} }
// Cycling Power Measurement: flags (uint16) + instantaneous power (int16) at offset 2 // Cycling Power Measurement: flags (uint16) + instantaneous power (int16) at offset 2
function parsePowerMeasurement(base64: string): number { function parsePowerMeasurement(base64: string): number {
const bytes = fromBase64(base64); const b = fromBase64(base64);
const raw = (bytes[3] << 8) | bytes[2]; const raw = (b[3] << 8) | b[2];
return raw >= 0x8000 ? raw - 0x10000 : raw; return raw >= 0x8000 ? raw - 0x10000 : raw;
} }
// CSC Measurement — bit 0: wheel data present, bit 1: crank data present
// Crank data: uint16 cumulative revolutions + uint16 last event time (1/1024 s)
function parseCscMeasurement(base64: string): number | null {
const b = fromBase64(base64);
const flags = b[0];
const hasWheel = (flags & 0x01) !== 0;
const hasCrank = (flags & 0x02) !== 0;
if (!hasCrank) return null;
const offset = 1 + (hasWheel ? 6 : 0);
if (b.length < offset + 4) return null;
const cranks = (b[offset + 1] << 8) | b[offset];
const time = (b[offset + 3] << 8) | b[offset + 2];
if (!cscInitialized) {
prevCrankRevs = cranks;
prevCrankTime = time;
cscInitialized = true;
return null;
}
// uint16 rollover at 65536
const deltaCranks = (cranks - prevCrankRevs + 65536) % 65536;
const deltaTime = ((time - prevCrankTime + 65536) % 65536) / 1024; // seconds
prevCrankRevs = cranks;
prevCrankTime = time;
if (deltaTime < 0.1 || deltaCranks === 0) return 0;
return Math.round((deltaCranks / deltaTime) * 60);
}
function fromBase64(base64: string): Uint8Array { function fromBase64(base64: string): Uint8Array {
const binary = atob(base64); const binary = atob(base64);
const bytes = new Uint8Array(binary.length); const bytes = new Uint8Array(binary.length);
+60 -2
View File
@@ -1,9 +1,16 @@
import * as Location from 'expo-location'; import * as Location from 'expo-location';
import * as TaskManager from 'expo-task-manager'; import * as TaskManager from 'expo-task-manager';
import * as Notifications from 'expo-notifications';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useRecordingStore } from '../store/recording'; import { useRecordingStore } from '../store/recording';
const BACKGROUND_LOCATION_TASK = 'background-location-task'; const BACKGROUND_LOCATION_TASK = 'background-location-task';
// Module-level km milestone state — reset each recording
let runningDistanceMeters = 0;
let lastNotifiedKm = 0;
let prevPoint: { lat: number; lon: number } | null = null;
TaskManager.defineTask(BACKGROUND_LOCATION_TASK, async ({ data, error }: any) => { TaskManager.defineTask(BACKGROUND_LOCATION_TASK, async ({ data, error }: any) => {
if (error) return; if (error) return;
const locations: Location.LocationObject[] = data?.locations ?? []; const locations: Location.LocationObject[] = data?.locations ?? [];
@@ -11,7 +18,7 @@ TaskManager.defineTask(BACKGROUND_LOCATION_TASK, async ({ data, error }: any) =>
if (status !== 'recording') return; if (status !== 'recording') return;
for (const loc of locations) { for (const loc of locations) {
addTrackPoint({ const point = {
lat: loc.coords.latitude, lat: loc.coords.latitude,
lon: loc.coords.longitude, lon: loc.coords.longitude,
ele: loc.coords.altitude ?? 0, ele: loc.coords.altitude ?? 0,
@@ -19,10 +26,40 @@ TaskManager.defineTask(BACKGROUND_LOCATION_TASK, async ({ data, error }: any) =>
hr: ble.hr, hr: ble.hr,
power: ble.power, power: ble.power,
cad: ble.cadence, cad: ble.cadence,
}); };
addTrackPoint(point);
if (prevPoint) {
runningDistanceMeters += haversineMeters(prevPoint, point);
await maybeNotifyKm(runningDistanceMeters);
}
prevPoint = point;
} }
}); });
async function maybeNotifyKm(distanceMeters: number): Promise<void> {
const kmSetting = await AsyncStorage.getItem('kmNotifications');
if (kmSetting === 'false') return;
const crossedKm = Math.floor(distanceMeters / 1000);
if (crossedKm <= lastNotifiedKm) return;
lastNotifiedKm = crossedKm;
await Notifications.scheduleNotificationAsync({
content: {
title: `${crossedKm} km`,
body: `You've covered ${crossedKm} km!`,
sound: true,
},
trigger: null,
});
}
export async function requestForegroundLocation(): Promise<boolean> {
const { status } = await Location.requestForegroundPermissionsAsync();
return status === 'granted';
}
export async function requestLocationPermissions(): Promise<boolean> { export async function requestLocationPermissions(): Promise<boolean> {
const { status: fg } = await Location.requestForegroundPermissionsAsync(); const { status: fg } = await Location.requestForegroundPermissionsAsync();
if (fg !== 'granted') return false; if (fg !== 'granted') return false;
@@ -31,6 +68,11 @@ export async function requestLocationPermissions(): Promise<boolean> {
} }
export async function startGpsRecording(): Promise<void> { export async function startGpsRecording(): Promise<void> {
// Reset km tracking for this recording session
runningDistanceMeters = 0;
lastNotifiedKm = 0;
prevPoint = null;
await Location.startLocationUpdatesAsync(BACKGROUND_LOCATION_TASK, { await Location.startLocationUpdatesAsync(BACKGROUND_LOCATION_TASK, {
accuracy: Location.Accuracy.BestForNavigation, accuracy: Location.Accuracy.BestForNavigation,
timeInterval: 1000, timeInterval: 1000,
@@ -47,3 +89,19 @@ export async function stopGpsRecording(): Promise<void> {
const isRunning = await Location.hasStartedLocationUpdatesAsync(BACKGROUND_LOCATION_TASK); const isRunning = await Location.hasStartedLocationUpdatesAsync(BACKGROUND_LOCATION_TASK);
if (isRunning) await Location.stopLocationUpdatesAsync(BACKGROUND_LOCATION_TASK); if (isRunning) await Location.stopLocationUpdatesAsync(BACKGROUND_LOCATION_TASK);
} }
export async function requestNotificationPermissions(): Promise<void> {
await Notifications.requestPermissionsAsync();
}
function haversineMeters(a: { lat: number; lon: number }, b: { lat: number; lon: number }): number {
const R = 6371000;
const dLat = toRad(b.lat - a.lat);
const dLon = toRad(b.lon - a.lon);
const sinLat = Math.sin(dLat / 2);
const sinLon = Math.sin(dLon / 2);
const c = sinLat * sinLat + Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * sinLon * sinLon;
return R * 2 * Math.atan2(Math.sqrt(c), Math.sqrt(1 - c));
}
const toRad = (deg: number) => (deg * Math.PI) / 180;
+36
View File
@@ -0,0 +1,36 @@
export const colors = {
bg: '#09090b',
surface: '#18181b',
border: '#27272a',
borderStrong: '#3f3f46',
text: '#f4f4f5',
textSub: '#a1a1aa',
textMuted: '#71717a',
placeholder: '#52525b',
success: '#86efac',
successBg: '#14532d',
error: '#fca5a5',
errorBg: '#7f1d1d',
btnStart: '#16a34a',
btnPause: '#d97706',
btnStop: '#dc2626',
} as const;
export type PaletteKey = 'default' | 'giro' | 'tour' | 'vuelta';
export type FontSizeKey = 'small' | 'medium' | 'large';
export const PALETTES: Record<PaletteKey, { accent: string; accentDim: string; label: string }> = {
default: { accent: '#60a5fa', accentDim: 'rgba(96,165,250,0.15)', label: 'Default' },
giro: { accent: '#f472b6', accentDim: 'rgba(244,114,182,0.15)', label: "Giro d'Italia" },
tour: { accent: '#facc15', accentDim: 'rgba(250,204,21,0.15)', label: 'Tour de France' },
vuelta: { accent: '#ef4444', accentDim: 'rgba(239,68,68,0.15)', label: 'Vuelta a España' },
};
export const FONT_SCALE: Record<FontSizeKey, number> = {
small: 0.88,
medium: 1.00,
large: 1.15,
};