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:
Davide Scaini
2026-06-02 22:16:56 +02:00
parent ee28cb0c30
commit 896b528a4c
18 changed files with 2048 additions and 60 deletions
+6 -14
View File
@@ -1,20 +1,12 @@
import { StatusBar } from 'expo-status-bar'; 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() { export default function App() {
return ( return (
<View style={styles.container}> <GestureHandlerRootView style={{ flex: 1 }}>
<Text>Open up App.tsx to start working on your app!</Text> <StatusBar style="light" />
<StatusBar style="auto" /> <AppNavigator />
</View> </GestureHandlerRootView>
); );
} }
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
alignItems: 'center',
justifyContent: 'center',
},
});
+206
View File
@@ -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 ..
```
+42 -7
View File
@@ -1,25 +1,60 @@
{ {
"expo": { "expo": {
"name": "bincio_rec_scaffold", "name": "bincio-rec",
"slug": "bincio_rec_scaffold", "slug": "bincio-rec",
"version": "1.0.0", "version": "1.0.0",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/icon.png", "icon": "./assets/icon.png",
"userInterfaceStyle": "light", "userInterfaceStyle": "dark",
"ios": { "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": { "android": {
"package": "com.bincio.rec",
"adaptiveIcon": { "adaptiveIcon": {
"backgroundColor": "#E6F4FE", "backgroundColor": "#111111",
"foregroundImage": "./assets/android-icon-foreground.png", "foregroundImage": "./assets/android-icon-foreground.png",
"backgroundImage": "./assets/android-icon-background.png", "backgroundImage": "./assets/android-icon-background.png",
"monochromeImage": "./assets/android-icon-monochrome.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": { "web": {
"favicon": "./assets/favicon.png" "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"
}
]
]
} }
} }
+768 -36
View File
File diff suppressed because it is too large Load Diff
+22 -3
View File
@@ -3,10 +3,29 @@
"version": "1.0.0", "version": "1.0.0",
"main": "index.ts", "main": "index.ts",
"dependencies": { "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": "~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-status-bar": "~56.0.4",
"expo-task-manager": "~56.0.16",
"react": "19.2.3", "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": { "devDependencies": {
"@types/react": "~19.2.2", "@types/react": "~19.2.2",
@@ -14,8 +33,8 @@
}, },
"scripts": { "scripts": {
"start": "expo start", "start": "expo start",
"android": "expo start --android", "android": "expo run:android",
"ios": "expo start --ios", "ios": "expo run:ios",
"web": "expo start --web" "web": "expo start --web"
}, },
"private": true "private": true
+63
View File
@@ -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>
);
}
+106
View File
@@ -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' },
});
+120
View File
@@ -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' },
});
+105
View File
@@ -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 },
});
+91
View File
@@ -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 },
});
+84
View File
@@ -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' },
});
+98
View File
@@ -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;
}
+47
View File
@@ -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]);
}
+49
View File
@@ -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);
}
+64
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function sanitizeFilename(s: string): string {
return s.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64);
}
+27
View File
@@ -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' };
}
}
+100
View File
@@ -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;
+50
View File
@@ -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;
};