feat: scaffold Expo Prebuild project with all v1 screens and services
Sets up the full bincio-rec source tree: Zustand recording store with haversine stats, background GPS via expo-task-manager, BLE scan/subscribe for HR and power, GPX writer with Garmin extensions, SQLite recordings list, multipart upload to bincio-activity, React Navigation stack with bottom tabs, and build instructions in README.md. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,20 +1,12 @@
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { AppNavigator } from './src/navigation/AppNavigator';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text>Open up App.tsx to start working on your app!</Text>
|
||||
<StatusBar style="auto" />
|
||||
</View>
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<StatusBar style="light" />
|
||||
<AppNavigator />
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#fff',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
# bincio-rec
|
||||
|
||||
GPS activity recorder for cycling, running, hiking, and walking.
|
||||
Records GPS tracks + BLE sensor data (HR, power, cadence) and saves them as GPX files.
|
||||
Part of the [bincio ecosystem](#ecosystem).
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### All platforms
|
||||
|
||||
| Tool | Version | Notes |
|
||||
|---|---|---|
|
||||
| Node.js | 22+ | [nodejs.org](https://nodejs.org) |
|
||||
| npm | 10+ | Comes with Node |
|
||||
| Expo CLI | latest | `npm install -g expo-cli` (optional, `npx expo` works too) |
|
||||
|
||||
### Android
|
||||
|
||||
| Tool | Notes |
|
||||
|---|---|
|
||||
| Android Studio | [developer.android.com/studio](https://developer.android.com/studio) |
|
||||
| Android SDK | Install via Android Studio SDK Manager |
|
||||
| JDK 17 | Bundled with Android Studio, or install separately |
|
||||
| `ANDROID_HOME` env var | Set to your SDK path, e.g. `~/Library/Android/sdk` |
|
||||
|
||||
Minimum supported Android version: **API 26 (Android 8.0)**.
|
||||
|
||||
### iOS (macOS only)
|
||||
|
||||
| Tool | Notes |
|
||||
|---|---|
|
||||
| Xcode 16+ | Install from the Mac App Store |
|
||||
| Xcode Command Line Tools | `xcode-select --install` |
|
||||
| CocoaPods | `sudo gem install cocoapods` |
|
||||
| Apple Developer account | Free account works for device sideloading |
|
||||
|
||||
Minimum supported iOS version: **16.4**.
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
# 1. Install JS dependencies
|
||||
npm install
|
||||
|
||||
# 2. Prebuild was already run — android/ and ios/ directories exist.
|
||||
# Re-run only when you add/remove native modules:
|
||||
npx expo prebuild --clean
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Running in development
|
||||
|
||||
### Android
|
||||
|
||||
```bash
|
||||
# Start Metro bundler + launch on a connected device or emulator
|
||||
npm run android
|
||||
|
||||
# Or target a specific device
|
||||
npx expo run:android --device
|
||||
```
|
||||
|
||||
**Android emulator setup:** In Android Studio → Device Manager, create an AVD with API 34+, x86_64 image. Start it before running the command above.
|
||||
|
||||
**Physical device:** Enable Developer Options → USB Debugging, then connect via USB.
|
||||
|
||||
> **Battery optimization:** On first launch, the app will prompt you to whitelist it in battery settings. This is required for background GPS recording to survive on Xiaomi / Samsung / Huawei devices.
|
||||
|
||||
### iOS
|
||||
|
||||
```bash
|
||||
# Install CocoaPods dependencies (first time and after native changes)
|
||||
cd ios && pod install && cd ..
|
||||
|
||||
# Start Metro bundler + launch on a connected device or simulator
|
||||
npm run ios
|
||||
|
||||
# Or target a specific simulator
|
||||
npx expo run:ios --simulator "iPhone 16"
|
||||
|
||||
# Or target a physical device (requires Apple Developer account)
|
||||
npx expo run:ios --device
|
||||
```
|
||||
|
||||
**Location permission:** iOS will prompt for location access. Choose **Always Allow** — background GPS recording requires it.
|
||||
|
||||
---
|
||||
|
||||
## Building release APK / IPA
|
||||
|
||||
### Android — local release APK
|
||||
|
||||
```bash
|
||||
cd android
|
||||
./gradlew assembleRelease
|
||||
# Output: android/app/build/outputs/apk/release/app-release.apk
|
||||
```
|
||||
|
||||
To build a release AAB (required for Play Store):
|
||||
|
||||
```bash
|
||||
./gradlew bundleRelease
|
||||
# Output: android/app/build/outputs/bundle/release/app-release.aab
|
||||
```
|
||||
|
||||
**Signing:** Create a keystore and configure `android/app/build.gradle` with your signing config before a production release. See [React Native signing docs](https://reactnative.dev/docs/signed-apk-android).
|
||||
|
||||
### iOS — local release archive
|
||||
|
||||
Open Xcode:
|
||||
|
||||
```bash
|
||||
open ios/binciorec.xcworkspace
|
||||
```
|
||||
|
||||
1. Select your device or **Any iOS Device (arm64)** as the target.
|
||||
2. Set your Team in **Signing & Capabilities**.
|
||||
3. **Product → Archive**.
|
||||
4. In the Organizer window, click **Distribute App**.
|
||||
|
||||
---
|
||||
|
||||
## Building with EAS Build (recommended for CI / TestFlight)
|
||||
|
||||
EAS Build runs in Expo's cloud so you don't need Android Studio or Xcode locally.
|
||||
|
||||
```bash
|
||||
# Install EAS CLI
|
||||
npm install -g eas-cli
|
||||
|
||||
# Log in to your Expo account
|
||||
eas login
|
||||
|
||||
# Configure the project (first time only — creates eas.json)
|
||||
eas build:configure
|
||||
|
||||
# Build Android APK for local testing
|
||||
eas build --platform android --profile preview
|
||||
|
||||
# Build Android AAB for Play Store
|
||||
eas build --platform android --profile production
|
||||
|
||||
# Build iOS IPA for TestFlight / App Store
|
||||
eas build --platform ios --profile production
|
||||
```
|
||||
|
||||
Builds appear at [expo.dev](https://expo.dev) and a download link is printed when done.
|
||||
|
||||
---
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
bincio-rec/
|
||||
├── App.tsx # Entry point
|
||||
├── app.json # Expo config (permissions, plugins, bundle IDs)
|
||||
├── android/ # Generated Android project (do not edit manually)
|
||||
├── ios/ # Generated iOS project (do not edit manually)
|
||||
└── src/
|
||||
├── types/index.ts # Shared TypeScript types
|
||||
├── store/recording.ts # Zustand recording state + haversine stats
|
||||
├── services/
|
||||
│ ├── gps.ts # Background GPS via expo-location + expo-task-manager
|
||||
│ ├── ble.ts # BLE scan / connect / HR + power subscriptions
|
||||
│ ├── gpx.ts # GPX file builder (Garmin trackpoint extensions)
|
||||
│ ├── upload.ts # Upload GPX to bincio-activity /api/upload/raw
|
||||
│ └── db.ts # SQLite CRUD for saved recordings list
|
||||
├── screens/
|
||||
│ ├── RecordingScreen.tsx
|
||||
│ ├── PostRecordingScreen.tsx
|
||||
│ ├── SensorPairingScreen.tsx
|
||||
│ ├── SavedRecordingsScreen.tsx
|
||||
│ └── SettingsScreen.tsx
|
||||
└── navigation/
|
||||
└── AppNavigator.tsx # Root stack + bottom tabs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Ecosystem
|
||||
|
||||
```
|
||||
bincio-rec → writes GPX files to device storage
|
||||
bincio-autarchive → imports GPX, stores locally, syncs to server
|
||||
bincio-activity → server: parses GPX, stores activities, serves API
|
||||
bincio-auth → auth service: issues JWTs for all bincio apps
|
||||
```
|
||||
|
||||
Configure your bincio instance URL and API token in **Settings** to enable direct upload from the app.
|
||||
|
||||
---
|
||||
|
||||
## Adding native modules
|
||||
|
||||
Any new Expo or React Native library with native code requires a prebuild + reinstall cycle:
|
||||
|
||||
```bash
|
||||
npx expo install <package-name>
|
||||
npx expo prebuild --clean # regenerates android/ and ios/
|
||||
cd ios && pod install && cd ..
|
||||
```
|
||||
@@ -1,25 +1,60 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "bincio_rec_scaffold",
|
||||
"slug": "bincio_rec_scaffold",
|
||||
"name": "bincio-rec",
|
||||
"slug": "bincio-rec",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
"userInterfaceStyle": "dark",
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
"supportsTablet": false,
|
||||
"bundleIdentifier": "com.bincio.rec",
|
||||
"infoPlist": {
|
||||
"NSLocationWhenInUseUsageDescription": "bincio-rec uses your location to record your activity track.",
|
||||
"NSLocationAlwaysAndWhenInUseUsageDescription": "bincio-rec uses your location in the background to record your activity track.",
|
||||
"UIBackgroundModes": ["location"]
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"package": "com.bincio.rec",
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#E6F4FE",
|
||||
"backgroundColor": "#111111",
|
||||
"foregroundImage": "./assets/android-icon-foreground.png",
|
||||
"backgroundImage": "./assets/android-icon-background.png",
|
||||
"monochromeImage": "./assets/android-icon-monochrome.png"
|
||||
},
|
||||
"predictiveBackGestureEnabled": false
|
||||
"predictiveBackGestureEnabled": false,
|
||||
"permissions": [
|
||||
"ACCESS_FINE_LOCATION",
|
||||
"ACCESS_BACKGROUND_LOCATION",
|
||||
"FOREGROUND_SERVICE",
|
||||
"FOREGROUND_SERVICE_LOCATION",
|
||||
"BLUETOOTH",
|
||||
"BLUETOOTH_ADMIN",
|
||||
"BLUETOOTH_SCAN",
|
||||
"BLUETOOTH_CONNECT"
|
||||
]
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
}
|
||||
},
|
||||
"plugins": [
|
||||
"expo-sqlite",
|
||||
[
|
||||
"expo-location",
|
||||
{
|
||||
"locationAlwaysAndWhenInUsePermission": "bincio-rec records your GPS track during activities.",
|
||||
"isAndroidBackgroundLocationEnabled": true,
|
||||
"isAndroidForegroundServiceEnabled": true
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-notifications",
|
||||
{
|
||||
"icon": "./assets/icon.png",
|
||||
"color": "#3b82f6"
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+768
-36
File diff suppressed because it is too large
Load Diff
+22
-3
@@ -3,10 +3,29 @@
|
||||
"version": "1.0.0",
|
||||
"main": "index.ts",
|
||||
"dependencies": {
|
||||
"@maplibre/maplibre-react-native": "^11.3.2",
|
||||
"@react-native-async-storage/async-storage": "^3.1.1",
|
||||
"@react-navigation/bottom-tabs": "^7.16.2",
|
||||
"@react-navigation/native": "^7.2.5",
|
||||
"@react-navigation/native-stack": "^7.16.0",
|
||||
"expo": "~56.0.8",
|
||||
"expo-av": "^16.0.8",
|
||||
"expo-crypto": "^56.0.4",
|
||||
"expo-file-system": "~56.0.7",
|
||||
"expo-keep-awake": "~56.0.3",
|
||||
"expo-location": "~56.0.15",
|
||||
"expo-notifications": "~56.0.15",
|
||||
"expo-sharing": "^56.0.15",
|
||||
"expo-sqlite": "~56.0.4",
|
||||
"expo-status-bar": "~56.0.4",
|
||||
"expo-task-manager": "~56.0.16",
|
||||
"react": "19.2.3",
|
||||
"react-native": "0.85.3"
|
||||
"react-native": "0.85.3",
|
||||
"react-native-ble-plx": "^3.5.1",
|
||||
"react-native-gesture-handler": "~2.31.1",
|
||||
"react-native-safe-area-context": "~5.7.0",
|
||||
"react-native-screens": "4.25.2",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.2.2",
|
||||
@@ -14,8 +33,8 @@
|
||||
},
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"private": true
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
import { NavigationContainer } from '@react-navigation/native';
|
||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
import { Text } from 'react-native';
|
||||
|
||||
import { RecordingScreen } from '../screens/RecordingScreen';
|
||||
import { PostRecordingScreen } from '../screens/PostRecordingScreen';
|
||||
import { SensorPairingScreen } from '../screens/SensorPairingScreen';
|
||||
import { SavedRecordingsScreen } from '../screens/SavedRecordingsScreen';
|
||||
import { SettingsScreen } from '../screens/SettingsScreen';
|
||||
import { RootStackParamList, TabParamList } from '../types';
|
||||
|
||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||
const Tab = createBottomTabNavigator<TabParamList>();
|
||||
|
||||
function Tabs() {
|
||||
return (
|
||||
<Tab.Navigator
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: '#111' },
|
||||
headerTintColor: '#fff',
|
||||
tabBarStyle: { backgroundColor: '#111', borderTopColor: '#222' },
|
||||
tabBarActiveTintColor: '#3b82f6',
|
||||
tabBarInactiveTintColor: '#555',
|
||||
}}
|
||||
>
|
||||
<Tab.Screen
|
||||
name="Recording"
|
||||
component={RecordingScreen}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppNavigator() {
|
||||
return (
|
||||
<NavigationContainer>
|
||||
<Stack.Navigator
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: '#111' },
|
||||
headerTintColor: '#fff',
|
||||
contentStyle: { backgroundColor: '#111' },
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="Tabs" component={Tabs} options={{ headerShown: false }} />
|
||||
<Stack.Screen name="PostRecording" component={PostRecordingScreen} options={{ title: 'Save Recording', presentation: 'modal' }} />
|
||||
<Stack.Screen name="SensorPairing" component={SensorPairingScreen} options={{ title: 'Sensors', presentation: 'modal' }} />
|
||||
</Stack.Navigator>
|
||||
</NavigationContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, TextInput, StyleSheet, TouchableOpacity, Alert, ScrollView } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { useRecordingStore } from '../store/recording';
|
||||
import { saveGpx } from '../services/gpx';
|
||||
import { insertRecording } from '../services/db';
|
||||
import { SavedRecording } from '../types';
|
||||
import { randomUUID } from 'expo-crypto';
|
||||
|
||||
export function PostRecordingScreen() {
|
||||
const nav = useNavigation();
|
||||
const { trackPoints, reset, getStats } = useRecordingStore();
|
||||
const stats = getStats();
|
||||
const [title, setTitle] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const formatDuration = (secs: number) => {
|
||||
const h = Math.floor(secs / 3600).toString().padStart(2, '0');
|
||||
const m = Math.floor((secs % 3600) / 60).toString().padStart(2, '0');
|
||||
const s = (secs % 60).toString().padStart(2, '0');
|
||||
return `${h}:${m}:${s}`;
|
||||
};
|
||||
|
||||
async function handleSave() {
|
||||
if (!title.trim()) { Alert.alert('Title required', 'Enter a title for this recording.'); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
const filePath = saveGpx(trackPoints, title.trim());
|
||||
const rec: SavedRecording = {
|
||||
id: randomUUID(),
|
||||
title: title.trim(),
|
||||
date: new Date().toISOString(),
|
||||
durationSeconds: stats.elapsedSeconds,
|
||||
distanceMeters: stats.distanceMeters,
|
||||
filePath,
|
||||
};
|
||||
await insertRecording(rec);
|
||||
reset();
|
||||
nav.goBack();
|
||||
} catch (e: any) {
|
||||
Alert.alert('Save failed', e?.message ?? 'Unknown error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDiscard() {
|
||||
Alert.alert('Discard recording?', 'This cannot be undone.', [
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{ text: 'Discard', style: 'destructive', onPress: () => { reset(); nav.goBack(); } },
|
||||
]);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||
<Text style={styles.heading}>Recording complete</Text>
|
||||
|
||||
<View style={styles.statsRow}>
|
||||
<Stat label="Duration" value={formatDuration(stats.elapsedSeconds)} />
|
||||
<Stat label="Distance" value={`${(stats.distanceMeters / 1000).toFixed(2)} km`} />
|
||||
<Stat label="Avg Speed" value={`${stats.avgSpeedKph.toFixed(1)} km/h`} />
|
||||
<Stat label="Elevation" value={`+${stats.elevationGainMeters.toFixed(0)} m`} />
|
||||
</View>
|
||||
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Activity title"
|
||||
placeholderTextColor="#555"
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<TouchableOpacity style={[styles.btn, styles.btnSave]} onPress={handleSave} disabled={saving}>
|
||||
<Text style={styles.btnText}>{saving ? 'Saving…' : 'Save'}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={[styles.btn, styles.btnDiscard]} onPress={handleDiscard}>
|
||||
<Text style={[styles.btnText, { color: '#ef4444' }]}>Discard</Text>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<View style={styles.stat}>
|
||||
<Text style={styles.statLabel}>{label}</Text>
|
||||
<Text style={styles.statValue}>{value}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#111' },
|
||||
content: { padding: 24, gap: 16 },
|
||||
heading: { color: '#fff', fontSize: 24, fontWeight: '700', marginBottom: 8 },
|
||||
statsRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 12, marginBottom: 8 },
|
||||
stat: { flex: 1, minWidth: '40%', backgroundColor: '#1e1e1e', borderRadius: 12, padding: 16 },
|
||||
statLabel: { color: '#888', fontSize: 12, textTransform: 'uppercase' },
|
||||
statValue: { color: '#fff', fontSize: 22, fontWeight: '600', marginTop: 4 },
|
||||
input: { backgroundColor: '#1e1e1e', color: '#fff', borderRadius: 12, padding: 16, fontSize: 18 },
|
||||
btn: { borderRadius: 12, padding: 16, alignItems: 'center' },
|
||||
btnSave: { backgroundColor: '#22c55e' },
|
||||
btnDiscard: { backgroundColor: '#1e1e1e' },
|
||||
btnText: { fontSize: 16, fontWeight: '700', color: '#fff' },
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Alert } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { useKeepAwake } from 'expo-keep-awake';
|
||||
import { useRecordingStore } from '../store/recording';
|
||||
import { startGpsRecording, stopGpsRecording, requestLocationPermissions } from '../services/gps';
|
||||
import { RootStackParamList } from '../types';
|
||||
|
||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
export function RecordingScreen() {
|
||||
const nav = useNavigation<Nav>();
|
||||
const { status, ble, keepAwake, start, pause, resume, stop, getStats } = useRecordingStore();
|
||||
const [stats, setStats] = useState(getStats());
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useKeepAwake(); // TODO: make conditional on keepAwake toggle
|
||||
|
||||
useEffect(() => {
|
||||
intervalRef.current = setInterval(() => setStats(getStats()), 1000);
|
||||
return () => { if (intervalRef.current) clearInterval(intervalRef.current); };
|
||||
}, []);
|
||||
|
||||
async function handleStart() {
|
||||
const granted = await requestLocationPermissions();
|
||||
if (!granted) {
|
||||
Alert.alert('Permission required', 'Location permission is required to record.');
|
||||
return;
|
||||
}
|
||||
start();
|
||||
await startGpsRecording();
|
||||
}
|
||||
|
||||
async function handleStop() {
|
||||
await stopGpsRecording();
|
||||
stop();
|
||||
nav.navigate('PostRecording');
|
||||
}
|
||||
|
||||
const formatDuration = (secs: number) => {
|
||||
const h = Math.floor(secs / 3600).toString().padStart(2, '0');
|
||||
const m = Math.floor((secs % 3600) / 60).toString().padStart(2, '0');
|
||||
const s = (secs % 60).toString().padStart(2, '0');
|
||||
return `${h}:${m}:${s}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.statsGrid}>
|
||||
<StatBox label="Time" value={formatDuration(stats.elapsedSeconds)} />
|
||||
<StatBox label="Distance" value={`${(stats.distanceMeters / 1000).toFixed(2)} km`} />
|
||||
<StatBox label="Speed" value={`${stats.currentSpeedKph.toFixed(1)} km/h`} />
|
||||
<StatBox label="Avg Speed" value={`${stats.avgSpeedKph.toFixed(1)} km/h`} />
|
||||
<StatBox label="Elevation" value={`+${stats.elevationGainMeters.toFixed(0)} m`} />
|
||||
<StatBox label="HR" value={ble.hr ? `${ble.hr} bpm` : '—'} />
|
||||
<StatBox label="Power" value={ble.power ? `${ble.power} W` : '—'} />
|
||||
<StatBox label="Cadence" value={ble.cadence ? `${ble.cadence} rpm` : '—'} />
|
||||
</View>
|
||||
|
||||
{/* Map placeholder — MapLibre component goes here */}
|
||||
<View style={styles.mapPlaceholder}>
|
||||
<Text style={styles.mapPlaceholderText}>Map</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.controls}>
|
||||
{status === 'idle' && (
|
||||
<TouchableOpacity style={[styles.btn, styles.btnStart]} onPress={handleStart}>
|
||||
<Text style={styles.btnText}>Start</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{status === 'recording' && (
|
||||
<>
|
||||
<TouchableOpacity style={[styles.btn, styles.btnPause]} onPress={pause}>
|
||||
<Text style={styles.btnText}>Pause</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={[styles.btn, styles.btnStop]} onPress={handleStop}>
|
||||
<Text style={styles.btnText}>Stop</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
{status === 'paused' && (
|
||||
<>
|
||||
<TouchableOpacity style={[styles.btn, styles.btnStart]} onPress={resume}>
|
||||
<Text style={styles.btnText}>Resume</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={[styles.btn, styles.btnStop]} onPress={handleStop}>
|
||||
<Text style={styles.btnText}>Stop</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function StatBox({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<View style={styles.statBox}>
|
||||
<Text style={styles.statLabel}>{label}</Text>
|
||||
<Text style={styles.statValue}>{value}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#111' },
|
||||
statsGrid: { flexDirection: 'row', flexWrap: 'wrap', padding: 8 },
|
||||
statBox: { width: '25%', padding: 8, alignItems: 'center' },
|
||||
statLabel: { color: '#888', fontSize: 11, textTransform: 'uppercase' },
|
||||
statValue: { color: '#fff', fontSize: 18, fontWeight: '600', marginTop: 2 },
|
||||
mapPlaceholder: { flex: 1, backgroundColor: '#1a1a2e', alignItems: 'center', justifyContent: 'center' },
|
||||
mapPlaceholderText: { color: '#444', fontSize: 16 },
|
||||
controls: { flexDirection: 'row', justifyContent: 'center', gap: 16, padding: 24 },
|
||||
btn: { paddingVertical: 16, paddingHorizontal: 32, borderRadius: 50 },
|
||||
btnStart: { backgroundColor: '#22c55e' },
|
||||
btnPause: { backgroundColor: '#f59e0b' },
|
||||
btnStop: { backgroundColor: '#ef4444' },
|
||||
btnText: { color: '#fff', fontSize: 18, fontWeight: '700' },
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { View, Text, StyleSheet, FlatList, TouchableOpacity, Alert, ActivityIndicator } from 'react-native';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import * as Sharing from 'expo-sharing';
|
||||
import { listRecordings, deleteRecording } from '../services/db';
|
||||
import { uploadGpx } from '../services/upload';
|
||||
import { SavedRecording } from '../types';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
export function SavedRecordingsScreen() {
|
||||
const [recordings, setRecordings] = useState<SavedRecording[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploading, setUploading] = useState<string | null>(null);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
listRecordings().then((r) => { setRecordings(r); setLoading(false); });
|
||||
}, []),
|
||||
);
|
||||
|
||||
async function handleShare(rec: SavedRecording) {
|
||||
await Sharing.shareAsync(rec.filePath, { mimeType: 'application/gpx+xml', dialogTitle: rec.title });
|
||||
}
|
||||
|
||||
async function handleUpload(rec: SavedRecording) {
|
||||
const instanceUrl = await AsyncStorage.getItem('instanceUrl');
|
||||
const apiToken = await AsyncStorage.getItem('apiToken');
|
||||
if (!instanceUrl || !apiToken) {
|
||||
Alert.alert('Not configured', 'Set your bincio instance URL and API token in Settings.');
|
||||
return;
|
||||
}
|
||||
setUploading(rec.id);
|
||||
const result = await uploadGpx(rec.filePath, instanceUrl, apiToken);
|
||||
setUploading(null);
|
||||
if (result.ok) Alert.alert('Uploaded', `"${rec.title}" uploaded successfully.`);
|
||||
else Alert.alert('Upload failed', result.error);
|
||||
}
|
||||
|
||||
function handleDelete(rec: SavedRecording) {
|
||||
Alert.alert('Delete recording?', `"${rec.title}" will be removed.`, [
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Delete',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
await deleteRecording(rec.id);
|
||||
setRecordings((prev) => prev.filter((r) => r.id !== rec.id));
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
const formatDuration = (secs: number) => {
|
||||
const h = Math.floor(secs / 3600);
|
||||
const m = Math.floor((secs % 3600) / 60);
|
||||
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
||||
};
|
||||
|
||||
if (loading) return <View style={styles.center}><ActivityIndicator color="#fff" /></View>;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<FlatList
|
||||
data={recordings}
|
||||
keyExtractor={(r) => r.id}
|
||||
contentContainerStyle={styles.list}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.card}>
|
||||
<View style={styles.cardMeta}>
|
||||
<Text style={styles.cardTitle}>{item.title}</Text>
|
||||
<Text style={styles.cardSub}>
|
||||
{new Date(item.date).toLocaleDateString()} · {formatDuration(item.durationSeconds)} · {(item.distanceMeters / 1000).toFixed(2)} km
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.cardActions}>
|
||||
<TouchableOpacity onPress={() => handleShare(item)}>
|
||||
<Text style={styles.action}>Export</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => handleUpload(item)} disabled={uploading === item.id}>
|
||||
<Text style={styles.action}>{uploading === item.id ? '…' : 'Upload'}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => handleDelete(item)}>
|
||||
<Text style={[styles.action, { color: '#ef4444' }]}>Delete</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
ListEmptyComponent={<Text style={styles.empty}>No recordings yet.</Text>}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#111' },
|
||||
center: { flex: 1, backgroundColor: '#111', alignItems: 'center', justifyContent: 'center' },
|
||||
list: { padding: 16, gap: 12 },
|
||||
card: { backgroundColor: '#1e1e1e', borderRadius: 12, padding: 16 },
|
||||
cardMeta: { marginBottom: 12 },
|
||||
cardTitle: { color: '#fff', fontSize: 17, fontWeight: '600' },
|
||||
cardSub: { color: '#888', fontSize: 13, marginTop: 4 },
|
||||
cardActions: { flexDirection: 'row', gap: 20 },
|
||||
action: { color: '#3b82f6', fontWeight: '600', fontSize: 15 },
|
||||
empty: { color: '#555', textAlign: 'center', marginTop: 60 },
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { View, Text, StyleSheet, FlatList, TouchableOpacity, ActivityIndicator } from 'react-native';
|
||||
import { scanForDevices, connectDevice, subscribeHr, subscribePower } from '../services/ble';
|
||||
import { BleDevice } from '../types';
|
||||
|
||||
const TYPE_LABEL: Record<BleDevice['type'], string> = {
|
||||
hr: 'Heart Rate',
|
||||
power: 'Power Meter',
|
||||
cadence: 'Cadence',
|
||||
};
|
||||
|
||||
export function SensorPairingScreen() {
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [devices, setDevices] = useState<BleDevice[]>([]);
|
||||
const [paired, setPaired] = useState<Record<string, boolean>>({});
|
||||
const stopScanRef = useRef<(() => void) | null>(null);
|
||||
|
||||
function startScan() {
|
||||
setDevices([]);
|
||||
setScanning(true);
|
||||
stopScanRef.current = scanForDevices(
|
||||
(device) => setDevices((prev) => prev.find((d) => d.id === device.id) ? prev : [...prev, device]),
|
||||
() => setScanning(false),
|
||||
);
|
||||
setTimeout(() => { stopScanRef.current?.(); setScanning(false); }, 15000);
|
||||
}
|
||||
|
||||
useEffect(() => () => { stopScanRef.current?.(); }, []);
|
||||
|
||||
async function handlePair(device: BleDevice) {
|
||||
try {
|
||||
const connected = await connectDevice(device.id);
|
||||
if (device.type === 'hr') subscribeHr(connected);
|
||||
if (device.type === 'power') subscribePower(connected);
|
||||
setPaired((prev) => ({ ...prev, [device.id]: true }));
|
||||
} catch {
|
||||
// error silenced — user can retry
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.heading}>Sensor Pairing</Text>
|
||||
<Text style={styles.sub}>Scan for nearby BLE sensors (HR, power, cadence).</Text>
|
||||
|
||||
<TouchableOpacity style={styles.scanBtn} onPress={startScan} disabled={scanning}>
|
||||
{scanning ? <ActivityIndicator color="#fff" /> : <Text style={styles.scanBtnText}>Scan</Text>}
|
||||
</TouchableOpacity>
|
||||
|
||||
<FlatList
|
||||
data={devices}
|
||||
keyExtractor={(d) => d.id}
|
||||
style={styles.list}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.deviceRow}>
|
||||
<View>
|
||||
<Text style={styles.deviceName}>{item.name}</Text>
|
||||
<Text style={styles.deviceType}>{TYPE_LABEL[item.type]}</Text>
|
||||
</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={
|
||||
<Text style={styles.empty}>{scanning ? 'Scanning…' : 'No devices found. Tap Scan.'}</Text>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#111', padding: 24 },
|
||||
heading: { color: '#fff', fontSize: 24, fontWeight: '700' },
|
||||
sub: { color: '#888', marginTop: 4, marginBottom: 20 },
|
||||
scanBtn: { backgroundColor: '#3b82f6', borderRadius: 12, padding: 14, alignItems: 'center', marginBottom: 16 },
|
||||
scanBtnText: { color: '#fff', fontSize: 16, fontWeight: '700' },
|
||||
list: { flex: 1 },
|
||||
deviceRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', backgroundColor: '#1e1e1e', borderRadius: 12, padding: 16, marginBottom: 10 },
|
||||
deviceName: { color: '#fff', fontSize: 16, fontWeight: '600' },
|
||||
deviceType: { color: '#888', fontSize: 13, marginTop: 2 },
|
||||
pairBtn: { backgroundColor: '#3b82f6', borderRadius: 8, paddingVertical: 8, paddingHorizontal: 16 },
|
||||
pairBtnPaired: { backgroundColor: '#22c55e' },
|
||||
pairBtnText: { color: '#fff', fontWeight: '600' },
|
||||
empty: { color: '#555', textAlign: 'center', marginTop: 40 },
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, TextInput, StyleSheet, Switch, TouchableOpacity, Alert, ScrollView } from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
export function SettingsScreen() {
|
||||
const [instanceUrl, setInstanceUrl] = useState('');
|
||||
const [apiToken, setApiToken] = useState('');
|
||||
const [kmNotifications, setKmNotifications] = useState(true);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const [url, token, km] = await Promise.all([
|
||||
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() {
|
||||
await Promise.all([
|
||||
AsyncStorage.setItem('instanceUrl', instanceUrl.trim()),
|
||||
AsyncStorage.setItem('apiToken', apiToken.trim()),
|
||||
AsyncStorage.setItem('kmNotifications', String(kmNotifications)),
|
||||
]);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||
<Text style={styles.sectionTitle}>bincio instance</Text>
|
||||
|
||||
<Text style={styles.label}>Instance URL</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={instanceUrl}
|
||||
onChangeText={setInstanceUrl}
|
||||
placeholder="https://bincio.example.com"
|
||||
placeholderTextColor="#555"
|
||||
autoCapitalize="none"
|
||||
keyboardType="url"
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>API Token</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={apiToken}
|
||||
onChangeText={setApiToken}
|
||||
placeholder="your-api-token"
|
||||
placeholderTextColor="#555"
|
||||
autoCapitalize="none"
|
||||
secureTextEntry
|
||||
/>
|
||||
|
||||
<Text style={styles.sectionTitle}>Notifications</Text>
|
||||
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.rowLabel}>Kilometre alerts</Text>
|
||||
<Switch value={kmNotifications} onValueChange={setKmNotifications} trackColor={{ true: '#3b82f6' }} />
|
||||
</View>
|
||||
|
||||
<TouchableOpacity style={styles.saveBtn} onPress={handleSave}>
|
||||
<Text style={styles.saveBtnText}>{saved ? 'Saved ✓' : 'Save'}</Text>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#111' },
|
||||
content: { padding: 24, gap: 12 },
|
||||
sectionTitle: { color: '#888', fontSize: 13, textTransform: 'uppercase', letterSpacing: 0.8, marginTop: 16 },
|
||||
label: { color: '#aaa', fontSize: 14, marginBottom: 4 },
|
||||
input: { backgroundColor: '#1e1e1e', color: '#fff', borderRadius: 10, padding: 14, fontSize: 16 },
|
||||
row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', backgroundColor: '#1e1e1e', borderRadius: 10, padding: 14 },
|
||||
rowLabel: { color: '#fff', fontSize: 16 },
|
||||
saveBtn: { backgroundColor: '#3b82f6', borderRadius: 12, padding: 16, alignItems: 'center', marginTop: 8 },
|
||||
saveBtnText: { color: '#fff', fontSize: 16, fontWeight: '700' },
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { BleManager, Device, Characteristic } from 'react-native-ble-plx';
|
||||
import { useRecordingStore } from '../store/recording';
|
||||
import { BleDevice } from '../types';
|
||||
|
||||
// Standard GATT UUIDs
|
||||
const HR_SERVICE = '0000180d-0000-1000-8000-00805f9b34fb';
|
||||
const HR_MEASUREMENT = '00002a37-0000-1000-8000-00805f9b34fb';
|
||||
const CYCLING_POWER_SERVICE = '00001818-0000-1000-8000-00805f9b34fb';
|
||||
const CYCLING_POWER_MEASUREMENT = '00002a63-0000-1000-8000-00805f9b34fb';
|
||||
const CYCLING_SPEED_CADENCE_SERVICE = '00001816-0000-1000-8000-00805f9b34fb';
|
||||
const CSC_MEASUREMENT = '00002a5b-0000-1000-8000-00805f9b34fb';
|
||||
|
||||
export const bleManager = new BleManager();
|
||||
|
||||
export function scanForDevices(onFound: (device: BleDevice) => void, onError: (error: Error) => void): () => void {
|
||||
bleManager.startDeviceScan(
|
||||
[HR_SERVICE, CYCLING_POWER_SERVICE, CYCLING_SPEED_CADENCE_SERVICE],
|
||||
{ allowDuplicates: false },
|
||||
(error, device) => {
|
||||
if (error) { onError(error); return; }
|
||||
if (!device?.name) return;
|
||||
|
||||
const type = device.serviceUUIDs?.includes(HR_SERVICE)
|
||||
? 'hr'
|
||||
: device.serviceUUIDs?.includes(CYCLING_POWER_SERVICE)
|
||||
? 'power'
|
||||
: 'cadence';
|
||||
|
||||
onFound({ id: device.id, name: device.name, type });
|
||||
},
|
||||
);
|
||||
return () => bleManager.stopDeviceScan();
|
||||
}
|
||||
|
||||
export async function connectDevice(deviceId: string): Promise<Device> {
|
||||
const device = await bleManager.connectToDevice(deviceId);
|
||||
await device.discoverAllServicesAndCharacteristics();
|
||||
return device;
|
||||
}
|
||||
|
||||
export function subscribeHr(device: Device): () => void {
|
||||
const sub = device.monitorCharacteristicForService(
|
||||
HR_SERVICE,
|
||||
HR_MEASUREMENT,
|
||||
(error, char) => {
|
||||
if (error || !char?.value) return;
|
||||
const hr = parseHrMeasurement(char.value);
|
||||
useRecordingStore.getState().updateBle({ hr });
|
||||
},
|
||||
);
|
||||
return () => sub.remove();
|
||||
}
|
||||
|
||||
export function subscribePower(device: Device): () => void {
|
||||
const sub = device.monitorCharacteristicForService(
|
||||
CYCLING_POWER_SERVICE,
|
||||
CYCLING_POWER_MEASUREMENT,
|
||||
(error, char) => {
|
||||
if (error || !char?.value) return;
|
||||
const power = parsePowerMeasurement(char.value);
|
||||
useRecordingStore.getState().updateBle({ power });
|
||||
},
|
||||
);
|
||||
return () => sub.remove();
|
||||
}
|
||||
|
||||
export function subscribeCadence(device: Device): () => void {
|
||||
const sub = device.monitorCharacteristicForService(
|
||||
CYCLING_SPEED_CADENCE_SERVICE,
|
||||
CSC_MEASUREMENT,
|
||||
(error, char) => {
|
||||
if (error || !char?.value) return;
|
||||
// cadence parsing requires stateful wheel/crank event tracking — stub for now
|
||||
},
|
||||
);
|
||||
return () => sub.remove();
|
||||
}
|
||||
|
||||
// HR Measurement characteristic: flags byte + uint8 (or uint16) BPM
|
||||
function parseHrMeasurement(base64: string): number {
|
||||
const bytes = fromBase64(base64);
|
||||
const flags = bytes[0];
|
||||
return flags & 0x01 ? (bytes[2] << 8) | bytes[1] : bytes[1];
|
||||
}
|
||||
|
||||
// Cycling Power Measurement: flags (uint16) + instantaneous power (int16) at offset 2
|
||||
function parsePowerMeasurement(base64: string): number {
|
||||
const bytes = fromBase64(base64);
|
||||
const raw = (bytes[3] << 8) | bytes[2];
|
||||
return raw >= 0x8000 ? raw - 0x10000 : raw;
|
||||
}
|
||||
|
||||
function fromBase64(base64: string): Uint8Array {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as SQLite from 'expo-sqlite';
|
||||
import { SavedRecording } from '../types';
|
||||
|
||||
let db: SQLite.SQLiteDatabase | null = null;
|
||||
|
||||
async function getDb(): Promise<SQLite.SQLiteDatabase> {
|
||||
if (!db) {
|
||||
db = await SQLite.openDatabaseAsync('bincio_rec.db');
|
||||
await db.execAsync(`
|
||||
CREATE TABLE IF NOT EXISTS recordings (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
duration_seconds INTEGER NOT NULL,
|
||||
distance_meters REAL NOT NULL,
|
||||
file_path TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
export async function insertRecording(rec: SavedRecording): Promise<void> {
|
||||
const d = await getDb();
|
||||
await d.runAsync(
|
||||
'INSERT INTO recordings (id, title, date, duration_seconds, distance_meters, file_path) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[rec.id, rec.title, rec.date, rec.durationSeconds, rec.distanceMeters, rec.filePath],
|
||||
);
|
||||
}
|
||||
|
||||
export async function listRecordings(): Promise<SavedRecording[]> {
|
||||
const d = await getDb();
|
||||
const rows = await d.getAllAsync<any>('SELECT * FROM recordings ORDER BY date DESC');
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
date: r.date,
|
||||
durationSeconds: r.duration_seconds,
|
||||
distanceMeters: r.distance_meters,
|
||||
filePath: r.file_path,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function deleteRecording(id: string): Promise<void> {
|
||||
const d = await getDb();
|
||||
await d.runAsync('DELETE FROM recordings WHERE id = ?', [id]);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import * as Location from 'expo-location';
|
||||
import * as TaskManager from 'expo-task-manager';
|
||||
import { useRecordingStore } from '../store/recording';
|
||||
|
||||
const BACKGROUND_LOCATION_TASK = 'background-location-task';
|
||||
|
||||
TaskManager.defineTask(BACKGROUND_LOCATION_TASK, async ({ data, error }: any) => {
|
||||
if (error) return;
|
||||
const locations: Location.LocationObject[] = data?.locations ?? [];
|
||||
const { status, addTrackPoint, ble } = useRecordingStore.getState();
|
||||
if (status !== 'recording') return;
|
||||
|
||||
for (const loc of locations) {
|
||||
addTrackPoint({
|
||||
lat: loc.coords.latitude,
|
||||
lon: loc.coords.longitude,
|
||||
ele: loc.coords.altitude ?? 0,
|
||||
time: new Date(loc.timestamp),
|
||||
hr: ble.hr,
|
||||
power: ble.power,
|
||||
cad: ble.cadence,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export async function requestLocationPermissions(): Promise<boolean> {
|
||||
const { status: fg } = await Location.requestForegroundPermissionsAsync();
|
||||
if (fg !== 'granted') return false;
|
||||
const { status: bg } = await Location.requestBackgroundPermissionsAsync();
|
||||
return bg === 'granted';
|
||||
}
|
||||
|
||||
export async function startGpsRecording(): Promise<void> {
|
||||
await Location.startLocationUpdatesAsync(BACKGROUND_LOCATION_TASK, {
|
||||
accuracy: Location.Accuracy.BestForNavigation,
|
||||
timeInterval: 1000,
|
||||
distanceInterval: 0,
|
||||
showsBackgroundLocationIndicator: true,
|
||||
foregroundService: {
|
||||
notificationTitle: 'bincio-rec',
|
||||
notificationBody: 'Recording your activity',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function stopGpsRecording(): Promise<void> {
|
||||
const isRunning = await Location.hasStartedLocationUpdatesAsync(BACKGROUND_LOCATION_TASK);
|
||||
if (isRunning) await Location.stopLocationUpdatesAsync(BACKGROUND_LOCATION_TASK);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { File, Directory, Paths } from 'expo-file-system';
|
||||
import { TrackPoint } from '../types';
|
||||
|
||||
function recordingsDir(): Directory {
|
||||
return new Directory(Paths.document, 'recordings');
|
||||
}
|
||||
|
||||
export function ensureRecordingsDir(): void {
|
||||
const dir = recordingsDir();
|
||||
if (!dir.exists) dir.create();
|
||||
}
|
||||
|
||||
export function saveGpx(trackPoints: TrackPoint[], title: string): string {
|
||||
ensureRecordingsDir();
|
||||
const filename = `${sanitizeFilename(title)}_${Date.now()}.gpx`;
|
||||
const file = new File(recordingsDir(), filename);
|
||||
file.write(buildGpx(trackPoints, title));
|
||||
return file.uri;
|
||||
}
|
||||
|
||||
export function buildGpx(trackPoints: TrackPoint[], title: string): string {
|
||||
const trkpts = trackPoints.map(buildTrkpt).join('\n');
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="bincio-rec"
|
||||
xmlns="http://www.topografix.com/GPX/1/1"
|
||||
xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1">
|
||||
<metadata><name>${escapeXml(title)}</name></metadata>
|
||||
<trk>
|
||||
<name>${escapeXml(title)}</name>
|
||||
<trkseg>
|
||||
${trkpts}
|
||||
</trkseg>
|
||||
</trk>
|
||||
</gpx>`;
|
||||
}
|
||||
|
||||
function buildTrkpt(pt: TrackPoint): string {
|
||||
const ext = buildExtensions(pt);
|
||||
return ` <trkpt lat="${pt.lat}" lon="${pt.lon}">
|
||||
<ele>${pt.ele.toFixed(1)}</ele>
|
||||
<time>${pt.time.toISOString()}</time>${ext}
|
||||
</trkpt>`;
|
||||
}
|
||||
|
||||
function buildExtensions(pt: TrackPoint): string {
|
||||
if (pt.hr == null && pt.power == null && pt.cad == null) return '';
|
||||
const hr = pt.hr != null ? `<gpxtpx:hr>${pt.hr}</gpxtpx:hr>` : '';
|
||||
const power = pt.power != null ? `<gpxtpx:power>${pt.power}</gpxtpx:power>` : '';
|
||||
const cad = pt.cad != null ? `<gpxtpx:cad>${pt.cad}</gpxtpx:cad>` : '';
|
||||
return `
|
||||
<extensions>
|
||||
<gpxtpx:TrackPointExtension>
|
||||
${hr}${power}${cad}
|
||||
</gpxtpx:TrackPointExtension>
|
||||
</extensions>`;
|
||||
}
|
||||
|
||||
function escapeXml(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function sanitizeFilename(s: string): string {
|
||||
return s.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { File, UploadType } from 'expo-file-system';
|
||||
|
||||
interface UploadResult {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function uploadGpx(
|
||||
fileUri: string,
|
||||
instanceUrl: string,
|
||||
apiToken: string,
|
||||
): Promise<UploadResult> {
|
||||
const url = instanceUrl.replace(/\/$/, '') + '/api/upload/raw';
|
||||
try {
|
||||
const file = new File(fileUri);
|
||||
const result = await file.upload(url, {
|
||||
httpMethod: 'POST',
|
||||
uploadType: UploadType.MULTIPART,
|
||||
fieldName: 'file',
|
||||
headers: { Authorization: `Bearer ${apiToken}` },
|
||||
});
|
||||
if (result.status >= 200 && result.status < 300) return { ok: true };
|
||||
return { ok: false, error: `HTTP ${result.status}` };
|
||||
} catch (e: any) {
|
||||
return { ok: false, error: e?.message ?? 'Unknown error' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { create } from 'zustand';
|
||||
import { TrackPoint, BleData, RecordingStats } from '../types';
|
||||
|
||||
type RecordingStatus = 'idle' | 'recording' | 'paused';
|
||||
|
||||
interface RecordingStore {
|
||||
status: RecordingStatus;
|
||||
startTime: Date | null;
|
||||
pausedAt: Date | null;
|
||||
totalPausedMs: number;
|
||||
trackPoints: TrackPoint[];
|
||||
ble: BleData;
|
||||
keepAwake: boolean;
|
||||
|
||||
start: () => void;
|
||||
pause: () => void;
|
||||
resume: () => void;
|
||||
stop: () => void;
|
||||
addTrackPoint: (point: TrackPoint) => void;
|
||||
updateBle: (data: Partial<BleData>) => void;
|
||||
setKeepAwake: (value: boolean) => void;
|
||||
reset: () => void;
|
||||
getStats: () => RecordingStats;
|
||||
}
|
||||
|
||||
export const useRecordingStore = create<RecordingStore>((set, get) => ({
|
||||
status: 'idle',
|
||||
startTime: null,
|
||||
pausedAt: null,
|
||||
totalPausedMs: 0,
|
||||
trackPoints: [],
|
||||
ble: {},
|
||||
keepAwake: true,
|
||||
|
||||
start: () => set({ status: 'recording', startTime: new Date(), pausedAt: null, totalPausedMs: 0, trackPoints: [] }),
|
||||
|
||||
pause: () => set((s) => s.status === 'recording' ? { status: 'paused', pausedAt: new Date() } : s),
|
||||
|
||||
resume: () =>
|
||||
set((s) => {
|
||||
if (s.status !== 'paused' || !s.pausedAt) return s;
|
||||
return {
|
||||
status: 'recording',
|
||||
pausedAt: null,
|
||||
totalPausedMs: s.totalPausedMs + (Date.now() - s.pausedAt.getTime()),
|
||||
};
|
||||
}),
|
||||
|
||||
stop: () => set({ status: 'idle' }),
|
||||
|
||||
addTrackPoint: (point) =>
|
||||
set((s) => ({ trackPoints: [...s.trackPoints, point] })),
|
||||
|
||||
updateBle: (data) =>
|
||||
set((s) => ({ ble: { ...s.ble, ...data } })),
|
||||
|
||||
setKeepAwake: (value) => set({ keepAwake: value }),
|
||||
|
||||
reset: () =>
|
||||
set({ status: 'idle', startTime: null, pausedAt: null, totalPausedMs: 0, trackPoints: [], ble: {} }),
|
||||
|
||||
getStats: (): RecordingStats => {
|
||||
const { startTime, totalPausedMs, trackPoints } = get();
|
||||
if (!startTime) return { elapsedSeconds: 0, distanceMeters: 0, currentSpeedKph: 0, avgSpeedKph: 0, elevationGainMeters: 0 };
|
||||
|
||||
const elapsedSeconds = Math.floor((Date.now() - startTime.getTime() - totalPausedMs) / 1000);
|
||||
|
||||
let distanceMeters = 0;
|
||||
let elevationGainMeters = 0;
|
||||
for (let i = 1; i < trackPoints.length; i++) {
|
||||
distanceMeters += haversineMeters(trackPoints[i - 1], trackPoints[i]);
|
||||
const gain = trackPoints[i].ele - trackPoints[i - 1].ele;
|
||||
if (gain > 0) elevationGainMeters += gain;
|
||||
}
|
||||
|
||||
const avgSpeedKph = elapsedSeconds > 0 ? (distanceMeters / 1000) / (elapsedSeconds / 3600) : 0;
|
||||
|
||||
let currentSpeedKph = 0;
|
||||
if (trackPoints.length >= 2) {
|
||||
const last = trackPoints[trackPoints.length - 1];
|
||||
const prev = trackPoints[trackPoints.length - 2];
|
||||
const dt = (last.time.getTime() - prev.time.getTime()) / 1000;
|
||||
if (dt > 0) currentSpeedKph = (haversineMeters(prev, last) / 1000) / (dt / 3600);
|
||||
}
|
||||
|
||||
return { elapsedSeconds, distanceMeters, currentSpeedKph, avgSpeedKph, elevationGainMeters };
|
||||
},
|
||||
}));
|
||||
|
||||
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,50 @@
|
||||
export interface TrackPoint {
|
||||
lat: number;
|
||||
lon: number;
|
||||
ele: number;
|
||||
time: Date;
|
||||
hr?: number;
|
||||
power?: number;
|
||||
cad?: number;
|
||||
}
|
||||
|
||||
export interface BleDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'hr' | 'power' | 'cadence';
|
||||
}
|
||||
|
||||
export interface BleData {
|
||||
hr?: number;
|
||||
power?: number;
|
||||
cadence?: number;
|
||||
}
|
||||
|
||||
export interface RecordingStats {
|
||||
elapsedSeconds: number;
|
||||
distanceMeters: number;
|
||||
currentSpeedKph: number;
|
||||
avgSpeedKph: number;
|
||||
elevationGainMeters: number;
|
||||
}
|
||||
|
||||
export interface SavedRecording {
|
||||
id: string;
|
||||
title: string;
|
||||
date: string;
|
||||
durationSeconds: number;
|
||||
distanceMeters: number;
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
export type RootStackParamList = {
|
||||
Tabs: undefined;
|
||||
PostRecording: undefined;
|
||||
SensorPairing: undefined;
|
||||
};
|
||||
|
||||
export type TabParamList = {
|
||||
Recording: undefined;
|
||||
Saved: undefined;
|
||||
Settings: undefined;
|
||||
};
|
||||
Reference in New Issue
Block a user