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 { 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',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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": {
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+768
-36
File diff suppressed because it is too large
Load Diff
+22
-3
@@ -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
|
||||||
|
|||||||
@@ -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