Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 358f3f12c1 | |||
| ec6a6facd1 | |||
| efc7af4a4a | |||
| ea938e5644 | |||
| 6e47ced264 | |||
| 9d82084fa1 | |||
| 4e1c2ebef9 | |||
| 765efe288e | |||
| 5f12b2857d | |||
| 2378d31f0b |
@@ -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.
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (1–2 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>`
|
|
||||||
|
|||||||
@@ -32,7 +32,8 @@
|
|||||||
"BLUETOOTH",
|
"BLUETOOTH",
|
||||||
"BLUETOOTH_ADMIN",
|
"BLUETOOTH_ADMIN",
|
||||||
"BLUETOOTH_SCAN",
|
"BLUETOOTH_SCAN",
|
||||||
"BLUETOOTH_CONNECT"
|
"BLUETOOTH_CONNECT",
|
||||||
|
"REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
@@ -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' },
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user