feat: Phase 0 mobile app scaffold — Expo 55, SQLite, Feed/Import/Settings screens
This commit is contained in:
@@ -437,6 +437,20 @@ async def stats() -> JSONResponse:
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/wheel/version")
|
||||||
|
async def wheel_version() -> JSONResponse:
|
||||||
|
"""Public endpoint: current bincio wheel version for mobile app update checks."""
|
||||||
|
import importlib.metadata
|
||||||
|
try:
|
||||||
|
version = importlib.metadata.version("bincio")
|
||||||
|
except importlib.metadata.PackageNotFoundError:
|
||||||
|
version = "0.1.0"
|
||||||
|
return JSONResponse({
|
||||||
|
"version": version,
|
||||||
|
"url": f"/bincio-{version}-py3-none-any.whl",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/auth/login", response_model=LoginResponse)
|
@app.post("/api/auth/login", response_model=LoginResponse)
|
||||||
async def login(
|
async def login(
|
||||||
login_req: LoginRequest,
|
login_req: LoginRequest,
|
||||||
|
|||||||
@@ -51,6 +51,51 @@ server uses. Any tool in any language can read them.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
| Tool | Minimum version | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Node.js | 18 | 20 LTS recommended — install via [nodejs.org](https://nodejs.org) or `nvm` |
|
||||||
|
| npm | ships with Node | |
|
||||||
|
| Expo Go app | latest | Install on your phone — scan the QR code to run the app instantly during development |
|
||||||
|
| Xcode | 15+ | **macOS only, iOS builds.** Install from the App Store, then `xcode-select --install` |
|
||||||
|
| Android Studio | latest | **Android builds / emulator.** Includes the SDK and `adb` |
|
||||||
|
|
||||||
|
You do **not** need Xcode or Android Studio to start. Expo Go lets you run the app
|
||||||
|
on your physical device by scanning a QR code — no native build required.
|
||||||
|
|
||||||
|
### First-time setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From the repo root:
|
||||||
|
bash mobile/setup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The script checks prerequisites, installs npm dependencies, and generates the
|
||||||
|
required Expo type declarations. It prints next steps when done.
|
||||||
|
|
||||||
|
### Running the app
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mobile
|
||||||
|
|
||||||
|
# Development server — scan QR with Expo Go on your phone
|
||||||
|
npx expo start
|
||||||
|
|
||||||
|
# Run on a connected Android device or emulator
|
||||||
|
npx expo run:android
|
||||||
|
|
||||||
|
# Run on iOS simulator (macOS only)
|
||||||
|
npx expo run:ios
|
||||||
|
|
||||||
|
# Build a standalone APK for Karoo sideloading
|
||||||
|
npx eas build -p android --profile preview
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Repository layout
|
## Repository layout
|
||||||
|
|
||||||
The mobile app lives in `mobile/` inside the main bincio repository (Option A).
|
The mobile app lives in `mobile/` inside the main bincio repository (Option A).
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
node_modules/
|
||||||
|
.expo/
|
||||||
|
dist/
|
||||||
|
npm-debug.*
|
||||||
|
*.jks
|
||||||
|
*.p8
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
*.mobileprovision
|
||||||
|
*.orig.*
|
||||||
|
web-build/
|
||||||
|
|
||||||
|
# Generated native projects (managed workflow — produced by EAS, not committed)
|
||||||
|
android/
|
||||||
|
ios/
|
||||||
|
|
||||||
|
# Local env overrides
|
||||||
|
.env.local
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "Bincio",
|
||||||
|
"slug": "bincio",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"scheme": "bincio",
|
||||||
|
"userInterfaceStyle": "dark",
|
||||||
|
"newArchEnabled": true,
|
||||||
|
"platforms": ["ios", "android"],
|
||||||
|
"android": {
|
||||||
|
"package": "org.bincio.app",
|
||||||
|
"permissions": [
|
||||||
|
"android.permission.READ_EXTERNAL_STORAGE",
|
||||||
|
"android.permission.READ_MEDIA_VIDEO",
|
||||||
|
"android.permission.RECEIVE_BOOT_COMPLETED",
|
||||||
|
"android.permission.VIBRATE",
|
||||||
|
"android.permission.POST_NOTIFICATIONS"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ios": {
|
||||||
|
"bundleIdentifier": "org.bincio.app",
|
||||||
|
"supportsTablet": true
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"expo-router",
|
||||||
|
"expo-sqlite",
|
||||||
|
[
|
||||||
|
"expo-document-picker",
|
||||||
|
{ "iCloudContainerEnvironment": "Production" }
|
||||||
|
],
|
||||||
|
"expo-background-fetch",
|
||||||
|
"expo-task-manager"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Tabs } from 'expo-router';
|
||||||
|
|
||||||
|
export default function TabLayout() {
|
||||||
|
return (
|
||||||
|
<Tabs
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
tabBarStyle: { backgroundColor: '#18181b', borderTopColor: '#27272a' },
|
||||||
|
tabBarActiveTintColor: '#60a5fa',
|
||||||
|
tabBarInactiveTintColor: '#71717a',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="index"
|
||||||
|
options={{ title: 'Feed', tabBarIcon: ({ color }) => <TabIcon label="⬡" color={color} /> }}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="import"
|
||||||
|
options={{ title: 'Import', tabBarIcon: ({ color }) => <TabIcon label="↑" color={color} /> }}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="settings"
|
||||||
|
options={{ title: 'Settings', tabBarIcon: ({ color }) => <TabIcon label="⚙" color={color} /> }}
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabIcon({ label, color }: { label: string; color: string }) {
|
||||||
|
const { Text } = require('react-native');
|
||||||
|
return <Text style={{ color, fontSize: 18 }}>{label}</Text>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import * as DocumentPicker from 'expo-document-picker';
|
||||||
|
import * as FileSystem from 'expo-file-system';
|
||||||
|
import { useSQLiteContext } from 'expo-sqlite';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { insertActivity } from '@/db/queries';
|
||||||
|
|
||||||
|
type ImportState =
|
||||||
|
| { status: 'idle' }
|
||||||
|
| { status: 'loading' }
|
||||||
|
| { status: 'done'; title: string; id: string }
|
||||||
|
| { status: 'error'; message: string };
|
||||||
|
|
||||||
|
export default function ImportScreen() {
|
||||||
|
const db = useSQLiteContext();
|
||||||
|
const [state, setState] = useState<ImportState>({ status: 'idle' });
|
||||||
|
|
||||||
|
async function pickFile() {
|
||||||
|
setState({ status: 'loading' });
|
||||||
|
try {
|
||||||
|
const result = await DocumentPicker.getDocumentAsync({
|
||||||
|
type: ['*/*'],
|
||||||
|
copyToCacheDirectory: true,
|
||||||
|
});
|
||||||
|
if (result.canceled || !result.assets?.[0]) {
|
||||||
|
setState({ status: 'idle' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const asset = result.assets[0];
|
||||||
|
const name = asset.name ?? '';
|
||||||
|
const uri = asset.uri;
|
||||||
|
const lower = name.toLowerCase();
|
||||||
|
|
||||||
|
if (lower.endsWith('.json')) {
|
||||||
|
await importBasJson(uri, db);
|
||||||
|
const detail = JSON.parse(await FileSystem.readAsStringAsync(uri));
|
||||||
|
setState({ status: 'done', title: detail.title ?? detail.id, id: detail.id });
|
||||||
|
} else if (['.fit', '.gpx', '.tcx', '.fit.gz', '.gpx.gz', '.tcx.gz'].some(ext => lower.endsWith(ext))) {
|
||||||
|
// Phase 1: Pyodide extraction. Placeholder for now.
|
||||||
|
Alert.alert(
|
||||||
|
'Extraction coming in Phase 1',
|
||||||
|
`File "${name}" received. FIT/GPX/TCX extraction via Pyodide will be added in Phase 1. For now, you can import a pre-extracted BAS .json file.`,
|
||||||
|
);
|
||||||
|
setState({ status: 'idle' });
|
||||||
|
} else {
|
||||||
|
setState({ status: 'error', message: `Unsupported file type: ${name}` });
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setState({ status: 'error', message: e instanceof Error ? e.message : String(e) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importBasJson(uri: string, dbCtx: typeof db) {
|
||||||
|
const text = await FileSystem.readAsStringAsync(uri);
|
||||||
|
const detail = JSON.parse(text);
|
||||||
|
|
||||||
|
if (!detail.id || !detail.started_at) {
|
||||||
|
throw new Error('Not a valid BAS activity JSON (missing id or started_at)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple hash: SHA-256 not available without a library, use content length + id as stand-in.
|
||||||
|
// Phase 1 will use a proper hash.
|
||||||
|
const hash = `${detail.id}-${text.length}`;
|
||||||
|
|
||||||
|
// Copy to permanent storage
|
||||||
|
const origDir = `${FileSystem.documentDirectory}originals/`;
|
||||||
|
await FileSystem.makeDirectoryAsync(origDir, { intermediates: true });
|
||||||
|
const dest = `${origDir}${detail.id}.json`;
|
||||||
|
await FileSystem.copyAsync({ from: uri, to: dest });
|
||||||
|
|
||||||
|
await insertActivity(dbCtx, {
|
||||||
|
id: detail.id,
|
||||||
|
source_hash: hash,
|
||||||
|
detail_json: text,
|
||||||
|
timeseries_json: null,
|
||||||
|
geojson: null,
|
||||||
|
original_path: dest,
|
||||||
|
origin: 'local',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||||
|
<Text style={styles.header}>Import</Text>
|
||||||
|
|
||||||
|
<Text style={styles.body}>
|
||||||
|
Import a FIT, GPX, or TCX file to extract and store it locally.
|
||||||
|
You can also import a pre-extracted BAS <Text style={styles.code}>.json</Text> file directly.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
style={[styles.button, state.status === 'loading' && styles.buttonDisabled]}
|
||||||
|
onPress={state.status !== 'loading' ? pickFile : undefined}
|
||||||
|
>
|
||||||
|
<Text style={styles.buttonText}>
|
||||||
|
{state.status === 'loading' ? 'Importing…' : '+ Pick file'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{state.status === 'done' && (
|
||||||
|
<View style={styles.success}>
|
||||||
|
<Text style={styles.successText}>✓ Imported: {state.title}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.status === 'error' && (
|
||||||
|
<View style={styles.error}>
|
||||||
|
<Text style={styles.errorText}>{state.message}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.divider} />
|
||||||
|
|
||||||
|
<Text style={styles.sectionTitle}>Supported formats</Text>
|
||||||
|
{[
|
||||||
|
['FIT', 'Garmin, Wahoo, Karoo native format'],
|
||||||
|
['GPX', 'Most GPS devices and apps'],
|
||||||
|
['TCX', 'Garmin Training Center'],
|
||||||
|
['BAS JSON', 'Pre-extracted Bincio format'],
|
||||||
|
].map(([fmt, desc]) => (
|
||||||
|
<View key={fmt} style={styles.formatRow}>
|
||||||
|
<Text style={styles.formatName}>{fmt}</Text>
|
||||||
|
<Text style={styles.formatDesc}>{desc}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<View style={styles.notice}>
|
||||||
|
<Text style={styles.noticeText}>
|
||||||
|
FIT/GPX/TCX extraction runs entirely on your device via the Bincio
|
||||||
|
extraction engine. No data is uploaded.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: '#09090b' },
|
||||||
|
content: { padding: 16, paddingTop: 60, paddingBottom: 40 },
|
||||||
|
header: { color: '#fff', fontSize: 22, fontWeight: '700', marginBottom: 12 },
|
||||||
|
body: { color: '#a1a1aa', fontSize: 14, lineHeight: 20, marginBottom: 24 },
|
||||||
|
code: { color: '#60a5fa', fontFamily: 'monospace' },
|
||||||
|
button: {
|
||||||
|
backgroundColor: '#2563eb', borderRadius: 10,
|
||||||
|
paddingVertical: 14, alignItems: 'center', marginBottom: 16,
|
||||||
|
},
|
||||||
|
buttonDisabled: { opacity: 0.5 },
|
||||||
|
buttonText: { color: '#fff', fontWeight: '600', fontSize: 16 },
|
||||||
|
success: {
|
||||||
|
backgroundColor: '#14532d', borderRadius: 8,
|
||||||
|
padding: 12, marginBottom: 16,
|
||||||
|
},
|
||||||
|
successText: { color: '#86efac', fontSize: 14 },
|
||||||
|
error: {
|
||||||
|
backgroundColor: '#450a0a', borderRadius: 8,
|
||||||
|
padding: 12, marginBottom: 16,
|
||||||
|
},
|
||||||
|
errorText: { color: '#fca5a5', fontSize: 14 },
|
||||||
|
divider: { height: 1, backgroundColor: '#27272a', marginVertical: 24 },
|
||||||
|
sectionTitle: { color: '#a1a1aa', fontSize: 12, fontWeight: '600', marginBottom: 12, letterSpacing: 0.5 },
|
||||||
|
formatRow: { flexDirection: 'row', gap: 12, marginBottom: 10 },
|
||||||
|
formatName: { color: '#f4f4f5', fontSize: 13, fontWeight: '600', width: 72 },
|
||||||
|
formatDesc: { color: '#71717a', fontSize: 13, flex: 1 },
|
||||||
|
notice: {
|
||||||
|
marginTop: 8, backgroundColor: '#18181b',
|
||||||
|
borderRadius: 8, padding: 12, borderWidth: 1, borderColor: '#27272a',
|
||||||
|
},
|
||||||
|
noticeText: { color: '#71717a', fontSize: 12, lineHeight: 18 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { useActivities, type ActivitySummary } from '@/db/queries';
|
||||||
|
|
||||||
|
export default function FeedScreen() {
|
||||||
|
const activities = useActivities();
|
||||||
|
|
||||||
|
if (activities.length === 0) {
|
||||||
|
return (
|
||||||
|
<View style={styles.empty}>
|
||||||
|
<Text style={styles.emptyIcon}>🚴</Text>
|
||||||
|
<Text style={styles.emptyTitle}>No activities yet</Text>
|
||||||
|
<Text style={styles.emptyBody}>
|
||||||
|
Go to Import to add a FIT, GPX, or TCX file.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.header}>Feed</Text>
|
||||||
|
<FlatList
|
||||||
|
data={activities}
|
||||||
|
keyExtractor={(a) => a.id}
|
||||||
|
renderItem={({ item }) => <ActivityCard activity={item} />}
|
||||||
|
contentContainerStyle={styles.list}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActivityCard({ activity }: { activity: ActivitySummary }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const km = activity.distance_m != null
|
||||||
|
? (activity.distance_m / 1000).toFixed(1)
|
||||||
|
: null;
|
||||||
|
const elev = activity.elevation_gain_m != null
|
||||||
|
? Math.round(activity.elevation_gain_m)
|
||||||
|
: null;
|
||||||
|
const date = new Date(activity.started_at).toLocaleDateString(undefined, {
|
||||||
|
day: 'numeric', month: 'short', year: 'numeric',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
style={styles.card}
|
||||||
|
onPress={() => router.push(`/activity/${activity.id}`)}
|
||||||
|
>
|
||||||
|
<View style={styles.cardTop}>
|
||||||
|
<Text style={styles.sportIcon}>{sportIcon(activity.sport)}</Text>
|
||||||
|
<View style={styles.cardMeta}>
|
||||||
|
<Text style={styles.cardDate}>{date}</Text>
|
||||||
|
{!activity.synced_at && activity.origin === 'local' && (
|
||||||
|
<Text style={styles.unsyncedBadge}>local</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.cardTitle} numberOfLines={1}>{activity.title}</Text>
|
||||||
|
<View style={styles.cardStats}>
|
||||||
|
{km && <Stat label="km" value={km} />}
|
||||||
|
{elev != null && <Stat label="m↑" value={String(elev)} />}
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<View style={styles.stat}>
|
||||||
|
<Text style={styles.statValue}>{value}</Text>
|
||||||
|
<Text style={styles.statLabel}>{label}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sportIcon(sport: string): string {
|
||||||
|
const icons: Record<string, string> = {
|
||||||
|
cycling: '🚴', running: '🏃', hiking: '🥾', swimming: '🏊', walking: '🚶',
|
||||||
|
};
|
||||||
|
return icons[sport] ?? '🏅';
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: '#09090b' },
|
||||||
|
header: {
|
||||||
|
color: '#fff', fontSize: 22, fontWeight: '700',
|
||||||
|
paddingHorizontal: 16, paddingTop: 60, paddingBottom: 12,
|
||||||
|
},
|
||||||
|
list: { padding: 16, gap: 12 },
|
||||||
|
card: {
|
||||||
|
backgroundColor: '#18181b', borderRadius: 12,
|
||||||
|
padding: 16, borderWidth: 1, borderColor: '#27272a',
|
||||||
|
},
|
||||||
|
cardTop: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 6 },
|
||||||
|
sportIcon: { fontSize: 20 },
|
||||||
|
cardMeta: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||||
|
cardDate: { color: '#71717a', fontSize: 12 },
|
||||||
|
unsyncedBadge: {
|
||||||
|
color: '#a1a1aa', fontSize: 10, borderWidth: 1,
|
||||||
|
borderColor: '#3f3f46', borderRadius: 4, paddingHorizontal: 4,
|
||||||
|
},
|
||||||
|
cardTitle: { color: '#f4f4f5', fontSize: 15, fontWeight: '600', marginBottom: 10 },
|
||||||
|
cardStats: { flexDirection: 'row', gap: 16 },
|
||||||
|
stat: { flexDirection: 'row', alignItems: 'baseline', gap: 3 },
|
||||||
|
statValue: { color: '#f4f4f5', fontSize: 16, fontWeight: '600' },
|
||||||
|
statLabel: { color: '#71717a', fontSize: 12 },
|
||||||
|
empty: {
|
||||||
|
flex: 1, backgroundColor: '#09090b',
|
||||||
|
alignItems: 'center', justifyContent: 'center', padding: 32,
|
||||||
|
},
|
||||||
|
emptyIcon: { fontSize: 48, marginBottom: 16 },
|
||||||
|
emptyTitle: { color: '#f4f4f5', fontSize: 18, fontWeight: '600', marginBottom: 8 },
|
||||||
|
emptyBody: { color: '#71717a', fontSize: 14, textAlign: 'center', lineHeight: 20 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { useSQLiteContext } from 'expo-sqlite';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Platform, Pressable, ScrollView, StyleSheet,
|
||||||
|
Text, TextInput, View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { getSetting, setSetting, useSetting } from '@/db/queries';
|
||||||
|
|
||||||
|
export default function SettingsScreen() {
|
||||||
|
const db = useSQLiteContext();
|
||||||
|
|
||||||
|
const storedUrl = useSetting('instance_url') ?? '';
|
||||||
|
const storedHandle = useSetting('handle') ?? '';
|
||||||
|
const storedPath = useSetting('auto_import_path') ?? '';
|
||||||
|
|
||||||
|
const [instanceUrl, setInstanceUrl] = useState(storedUrl);
|
||||||
|
const [handle, setHandle] = useState(storedHandle);
|
||||||
|
const [autoPath, setAutoPath] = useState(storedPath);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
await setSetting(db, 'instance_url', instanceUrl.trim());
|
||||||
|
await setSetting(db, 'handle', handle.trim());
|
||||||
|
if (Platform.OS === 'android') {
|
||||||
|
await setSetting(db, 'auto_import_path', autoPath.trim());
|
||||||
|
}
|
||||||
|
setSaved(true);
|
||||||
|
setTimeout(() => setSaved(false), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||||
|
<Text style={styles.header}>Settings</Text>
|
||||||
|
|
||||||
|
<Section title="Instance (optional)">
|
||||||
|
<Field
|
||||||
|
label="Instance URL"
|
||||||
|
placeholder="https://bincio.org"
|
||||||
|
value={instanceUrl}
|
||||||
|
onChangeText={setInstanceUrl}
|
||||||
|
autoCapitalize="none"
|
||||||
|
keyboardType="url"
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Handle"
|
||||||
|
placeholder="yourhandle"
|
||||||
|
value={handle}
|
||||||
|
onChangeText={setHandle}
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
<Text style={styles.hint}>
|
||||||
|
Leave blank to use the app without a remote instance. When set, you can
|
||||||
|
push activities to the instance and pull the web feed.
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{Platform.OS === 'android' && (
|
||||||
|
<Section title="Auto-import (Android)">
|
||||||
|
<Field
|
||||||
|
label="Watch directory"
|
||||||
|
placeholder="/sdcard/Karoo/Rides"
|
||||||
|
value={autoPath}
|
||||||
|
onChangeText={setAutoPath}
|
||||||
|
autoCapitalize="none"
|
||||||
|
/>
|
||||||
|
<Text style={styles.hint}>
|
||||||
|
New FIT files in this directory are imported automatically in the
|
||||||
|
background. Leave blank to disable. Requires storage permission.
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Pressable style={styles.saveButton} onPress={save}>
|
||||||
|
<Text style={styles.saveButtonText}>
|
||||||
|
{saved ? '✓ Saved' : 'Save'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Section title="About">
|
||||||
|
<Row label="Version" value="0.1.0 (Phase 0)" />
|
||||||
|
<Row label="Schema" value="BAS 1.0" />
|
||||||
|
<Row label="Extraction" value="Pyodide (Phase 1)" />
|
||||||
|
</Section>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>{title}</Text>
|
||||||
|
<View style={styles.sectionBody}>{children}</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label, placeholder, value, onChangeText, ...rest
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
placeholder: string;
|
||||||
|
value: string;
|
||||||
|
onChangeText: (v: string) => void;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={styles.field}>
|
||||||
|
<Text style={styles.fieldLabel}>{label}</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder={placeholder}
|
||||||
|
placeholderTextColor="#52525b"
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<View style={styles.row}>
|
||||||
|
<Text style={styles.rowLabel}>{label}</Text>
|
||||||
|
<Text style={styles.rowValue}>{value}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: '#09090b' },
|
||||||
|
content: { padding: 16, paddingTop: 60, paddingBottom: 40 },
|
||||||
|
header: { color: '#fff', fontSize: 22, fontWeight: '700', marginBottom: 24 },
|
||||||
|
section: { marginBottom: 28 },
|
||||||
|
sectionTitle: {
|
||||||
|
color: '#a1a1aa', fontSize: 11, fontWeight: '600',
|
||||||
|
letterSpacing: 0.8, marginBottom: 8,
|
||||||
|
},
|
||||||
|
sectionBody: {
|
||||||
|
backgroundColor: '#18181b', borderRadius: 10,
|
||||||
|
borderWidth: 1, borderColor: '#27272a', overflow: 'hidden',
|
||||||
|
},
|
||||||
|
field: { padding: 14, borderBottomWidth: 1, borderBottomColor: '#27272a' },
|
||||||
|
fieldLabel: { color: '#71717a', fontSize: 11, marginBottom: 4 },
|
||||||
|
input: { color: '#f4f4f5', fontSize: 15 },
|
||||||
|
hint: { color: '#52525b', fontSize: 12, lineHeight: 16, padding: 12 },
|
||||||
|
row: {
|
||||||
|
flexDirection: 'row', justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 14, paddingVertical: 12,
|
||||||
|
borderBottomWidth: 1, borderBottomColor: '#27272a',
|
||||||
|
},
|
||||||
|
rowLabel: { color: '#a1a1aa', fontSize: 14 },
|
||||||
|
rowValue: { color: '#71717a', fontSize: 14 },
|
||||||
|
saveButton: {
|
||||||
|
backgroundColor: '#2563eb', borderRadius: 10,
|
||||||
|
paddingVertical: 14, alignItems: 'center', marginBottom: 28,
|
||||||
|
},
|
||||||
|
saveButtonText: { color: '#fff', fontWeight: '600', fontSize: 16 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
import { SQLiteProvider } from 'expo-sqlite';
|
||||||
|
import { StatusBar } from 'expo-status-bar';
|
||||||
|
import { migrateDb } from '@/db';
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
return (
|
||||||
|
<SQLiteProvider databaseName="bincio.db" onInit={migrateDb}>
|
||||||
|
<StatusBar style="light" />
|
||||||
|
<Stack screenOptions={{ headerShown: false }} />
|
||||||
|
</SQLiteProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
|
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||||
|
import { useActivity } from '@/db/queries';
|
||||||
|
|
||||||
|
export default function ActivityScreen() {
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
const router = useRouter();
|
||||||
|
const row = useActivity(id);
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return (
|
||||||
|
<View style={styles.center}>
|
||||||
|
<Text style={styles.notFound}>Activity not found</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const detail = JSON.parse(row.detail_json);
|
||||||
|
const km = detail.distance_m != null
|
||||||
|
? (detail.distance_m / 1000).toFixed(2)
|
||||||
|
: null;
|
||||||
|
const elev = detail.elevation_gain_m != null
|
||||||
|
? Math.round(detail.elevation_gain_m)
|
||||||
|
: null;
|
||||||
|
const elevLoss = detail.elevation_loss_m != null
|
||||||
|
? Math.round(Math.abs(detail.elevation_loss_m))
|
||||||
|
: null;
|
||||||
|
const movingTime = detail.moving_time_s != null
|
||||||
|
? formatDuration(detail.moving_time_s)
|
||||||
|
: null;
|
||||||
|
const speed = detail.avg_speed_kmh != null
|
||||||
|
? detail.avg_speed_kmh.toFixed(1)
|
||||||
|
: null;
|
||||||
|
const hr = detail.avg_hr_bpm != null
|
||||||
|
? Math.round(detail.avg_hr_bpm)
|
||||||
|
: null;
|
||||||
|
const power = detail.avg_power_w != null
|
||||||
|
? Math.round(detail.avg_power_w)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const date = new Date(detail.started_at).toLocaleDateString(undefined, {
|
||||||
|
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||||
|
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
||||||
|
<Text style={styles.backText}>← Back</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Text style={styles.sport}>{detail.sport ?? 'Activity'}</Text>
|
||||||
|
<Text style={styles.title}>{detail.title}</Text>
|
||||||
|
<Text style={styles.date}>{date}</Text>
|
||||||
|
|
||||||
|
{/* Map placeholder — Phase 1 */}
|
||||||
|
<View style={styles.mapPlaceholder}>
|
||||||
|
<Text style={styles.mapPlaceholderText}>Map · Phase 1</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Stats grid */}
|
||||||
|
<View style={styles.grid}>
|
||||||
|
{km && <StatCell label="Distance" value={km} unit="km" />}
|
||||||
|
{movingTime && <StatCell label="Moving time" value={movingTime} unit="" />}
|
||||||
|
{elev != null && <StatCell label="Elevation gain" value={String(elev)} unit="m" />}
|
||||||
|
{elevLoss != null && <StatCell label="Elevation loss" value={String(elevLoss)} unit="m" />}
|
||||||
|
{speed && <StatCell label="Avg speed" value={speed} unit="km/h" />}
|
||||||
|
{hr && <StatCell label="Avg HR" value={String(hr)} unit="bpm" />}
|
||||||
|
{power && <StatCell label="Avg power" value={String(power)} unit="W" />}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Elevation chart placeholder — Phase 1 */}
|
||||||
|
{row.timeseries_json && (
|
||||||
|
<View style={styles.chartPlaceholder}>
|
||||||
|
<Text style={styles.mapPlaceholderText}>Elevation chart · Phase 1</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.meta}>
|
||||||
|
<MetaRow label="Source" value={detail.source ?? '—'} />
|
||||||
|
<MetaRow label="Device" value={detail.device ?? '—'} />
|
||||||
|
<MetaRow label="Origin" value={row.origin} />
|
||||||
|
<MetaRow label="Synced" value={row.synced_at ? new Date(row.synced_at * 1000).toLocaleDateString() : 'No'} />
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCell({ label, value, unit }: { label: string; value: string; unit: string }) {
|
||||||
|
return (
|
||||||
|
<View style={styles.statCell}>
|
||||||
|
<View style={styles.statValueRow}>
|
||||||
|
<Text style={styles.statValue}>{value}</Text>
|
||||||
|
{unit ? <Text style={styles.statUnit}>{unit}</Text> : null}
|
||||||
|
</View>
|
||||||
|
<Text style={styles.statLabel}>{label}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetaRow({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<View style={styles.metaRow}>
|
||||||
|
<Text style={styles.metaLabel}>{label}</Text>
|
||||||
|
<Text style={styles.metaValue}>{value}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds: number): string {
|
||||||
|
const h = Math.floor(seconds / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
const s = seconds % 60;
|
||||||
|
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||||
|
return `${m}:${String(s).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: '#09090b' },
|
||||||
|
content: { paddingBottom: 40 },
|
||||||
|
center: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: '#09090b' },
|
||||||
|
notFound: { color: '#71717a', fontSize: 16 },
|
||||||
|
backButton: { paddingHorizontal: 16, paddingTop: 60, paddingBottom: 12 },
|
||||||
|
backText: { color: '#60a5fa', fontSize: 15 },
|
||||||
|
sport: { color: '#71717a', fontSize: 12, fontWeight: '600', letterSpacing: 0.8, paddingHorizontal: 16, marginBottom: 4 },
|
||||||
|
title: { color: '#f4f4f5', fontSize: 22, fontWeight: '700', paddingHorizontal: 16, marginBottom: 4 },
|
||||||
|
date: { color: '#71717a', fontSize: 13, paddingHorizontal: 16, marginBottom: 16 },
|
||||||
|
mapPlaceholder: {
|
||||||
|
height: 200, backgroundColor: '#18181b',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
borderTopWidth: 1, borderBottomWidth: 1, borderColor: '#27272a',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
chartPlaceholder: {
|
||||||
|
height: 120, backgroundColor: '#18181b',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
borderTopWidth: 1, borderBottomWidth: 1, borderColor: '#27272a',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
mapPlaceholderText: { color: '#3f3f46', fontSize: 13 },
|
||||||
|
grid: {
|
||||||
|
flexDirection: 'row', flexWrap: 'wrap',
|
||||||
|
paddingHorizontal: 12, gap: 8, marginBottom: 16,
|
||||||
|
},
|
||||||
|
statCell: {
|
||||||
|
backgroundColor: '#18181b', borderRadius: 10,
|
||||||
|
borderWidth: 1, borderColor: '#27272a',
|
||||||
|
padding: 14, width: '47%',
|
||||||
|
},
|
||||||
|
statValueRow: { flexDirection: 'row', alignItems: 'baseline', gap: 4, marginBottom: 4 },
|
||||||
|
statValue: { color: '#f4f4f5', fontSize: 24, fontWeight: '700' },
|
||||||
|
statUnit: { color: '#71717a', fontSize: 13 },
|
||||||
|
statLabel: { color: '#71717a', fontSize: 12 },
|
||||||
|
meta: {
|
||||||
|
marginHorizontal: 16, backgroundColor: '#18181b',
|
||||||
|
borderRadius: 10, borderWidth: 1, borderColor: '#27272a',
|
||||||
|
},
|
||||||
|
metaRow: {
|
||||||
|
flexDirection: 'row', justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 14, paddingVertical: 10,
|
||||||
|
borderBottomWidth: 1, borderBottomColor: '#27272a',
|
||||||
|
},
|
||||||
|
metaLabel: { color: '#71717a', fontSize: 13 },
|
||||||
|
metaValue: { color: '#a1a1aa', fontSize: 13 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = function (api) {
|
||||||
|
api.cache(true);
|
||||||
|
return {
|
||||||
|
presets: ['babel-preset-expo'],
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { SQLiteDatabase } from 'expo-sqlite';
|
||||||
|
|
||||||
|
export async function migrateDb(db: SQLiteDatabase): Promise<void> {
|
||||||
|
await db.execAsync('PRAGMA journal_mode = WAL;');
|
||||||
|
await db.execAsync(`
|
||||||
|
CREATE TABLE IF NOT EXISTS activities (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
source_hash TEXT NOT NULL,
|
||||||
|
detail_json TEXT NOT NULL,
|
||||||
|
timeseries_json TEXT,
|
||||||
|
geojson TEXT,
|
||||||
|
original_path TEXT,
|
||||||
|
synced_at INTEGER,
|
||||||
|
origin TEXT NOT NULL CHECK(origin IN ('local', 'remote')),
|
||||||
|
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_activities_created_at
|
||||||
|
ON activities(created_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { useSQLiteContext } from 'expo-sqlite';
|
||||||
|
|
||||||
|
// ── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type ActivityRow = {
|
||||||
|
id: string;
|
||||||
|
source_hash: string;
|
||||||
|
detail_json: string;
|
||||||
|
timeseries_json: string | null;
|
||||||
|
geojson: string | null;
|
||||||
|
original_path: string | null;
|
||||||
|
synced_at: number | null;
|
||||||
|
origin: 'local' | 'remote';
|
||||||
|
created_at: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ActivitySummary = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
sport: string;
|
||||||
|
started_at: string;
|
||||||
|
distance_m: number | null;
|
||||||
|
duration_s: number | null;
|
||||||
|
elevation_gain_m: number | null;
|
||||||
|
origin: 'local' | 'remote';
|
||||||
|
synced_at: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Activities ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useActivities(): ActivitySummary[] {
|
||||||
|
const db = useSQLiteContext();
|
||||||
|
// Summaries are derived from the stored detail_json at query time.
|
||||||
|
// JSON extraction via SQLite's json_extract keeps the table schema simple.
|
||||||
|
const rows = db.getAllSync<{
|
||||||
|
id: string;
|
||||||
|
origin: 'local' | 'remote';
|
||||||
|
synced_at: number | null;
|
||||||
|
title: string;
|
||||||
|
sport: string;
|
||||||
|
started_at: string;
|
||||||
|
distance_m: number | null;
|
||||||
|
duration_s: number | null;
|
||||||
|
elevation_gain_m: number | null;
|
||||||
|
}>(`
|
||||||
|
SELECT
|
||||||
|
id, origin, synced_at,
|
||||||
|
json_extract(detail_json, '$.title') AS title,
|
||||||
|
json_extract(detail_json, '$.sport') AS sport,
|
||||||
|
json_extract(detail_json, '$.started_at') AS started_at,
|
||||||
|
json_extract(detail_json, '$.distance_m') AS distance_m,
|
||||||
|
json_extract(detail_json, '$.duration_s') AS duration_s,
|
||||||
|
json_extract(detail_json, '$.elevation_gain_m') AS elevation_gain_m
|
||||||
|
FROM activities
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useActivity(id: string): ActivityRow | null {
|
||||||
|
const db = useSQLiteContext();
|
||||||
|
return db.getFirstSync<ActivityRow>(
|
||||||
|
'SELECT * FROM activities WHERE id = ?',
|
||||||
|
[id],
|
||||||
|
) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function insertActivity(
|
||||||
|
db: ReturnType<typeof useSQLiteContext>,
|
||||||
|
row: Pick<ActivityRow, 'id' | 'source_hash' | 'detail_json' | 'timeseries_json' | 'geojson' | 'original_path' | 'origin'>,
|
||||||
|
): Promise<void> {
|
||||||
|
await db.runAsync(
|
||||||
|
`INSERT OR IGNORE INTO activities
|
||||||
|
(id, source_hash, detail_json, timeseries_json, geojson, original_path, origin)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
row.id,
|
||||||
|
row.source_hash,
|
||||||
|
row.detail_json,
|
||||||
|
row.timeseries_json ?? null,
|
||||||
|
row.geojson ?? null,
|
||||||
|
row.original_path ?? null,
|
||||||
|
row.origin,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Settings ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getSetting(
|
||||||
|
db: ReturnType<typeof useSQLiteContext>,
|
||||||
|
key: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const row = db.getFirstSync<{ value: string }>(
|
||||||
|
'SELECT value FROM settings WHERE key = ?',
|
||||||
|
[key],
|
||||||
|
);
|
||||||
|
return row?.value ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setSetting(
|
||||||
|
db: ReturnType<typeof useSQLiteContext>,
|
||||||
|
key: string,
|
||||||
|
value: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await db.runAsync(
|
||||||
|
`INSERT INTO settings (key, value) VALUES (?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
||||||
|
[key, value],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSetting(key: string): string | null {
|
||||||
|
const db = useSQLiteContext();
|
||||||
|
const row = db.getFirstSync<{ value: string }>(
|
||||||
|
'SELECT value FROM settings WHERE key = ?',
|
||||||
|
[key],
|
||||||
|
);
|
||||||
|
return row?.value ?? null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
const { getDefaultConfig } = require('expo/metro-config');
|
||||||
|
module.exports = getDefaultConfig(__dirname);
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "bincio",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"main": "expo-router/entry",
|
||||||
|
"scripts": {
|
||||||
|
"start": "expo start",
|
||||||
|
"android": "expo run:android",
|
||||||
|
"ios": "expo run:ios",
|
||||||
|
"lint": "expo lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"expo": "~55.0.0",
|
||||||
|
"expo-router": "~55.0.0",
|
||||||
|
"expo-sqlite": "~55.0.0",
|
||||||
|
"expo-document-picker": "~55.0.0",
|
||||||
|
"expo-file-system": "~55.0.0",
|
||||||
|
"expo-background-fetch": "~55.0.0",
|
||||||
|
"expo-task-manager": "~55.0.0",
|
||||||
|
"expo-notifications": "~55.0.0",
|
||||||
|
"expo-constants": "~55.0.0",
|
||||||
|
"expo-linking": "~55.0.0",
|
||||||
|
"expo-splash-screen": "~55.0.0",
|
||||||
|
"expo-status-bar": "~55.0.0",
|
||||||
|
"react": "19.0.0",
|
||||||
|
"react-native": "0.85.2",
|
||||||
|
"react-native-safe-area-context": "^5.0.0",
|
||||||
|
"react-native-screens": "^4.0.0",
|
||||||
|
"react-native-webview": "~13.16.0",
|
||||||
|
"@maplibre/maplibre-react-native": "~11.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.25.0",
|
||||||
|
"@types/react": "~19.0.0",
|
||||||
|
"typescript": "~5.8.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+104
@@ -0,0 +1,104 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Bincio mobile app — one-time setup
|
||||||
|
# Run from the mobile/ directory: ./setup.sh
|
||||||
|
# Or from the repo root: bash mobile/setup.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
# ── Colours ───────────────────────────────────────────────────────────────────
|
||||||
|
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; RESET='\033[0m'
|
||||||
|
ok() { echo -e "${GREEN}✓${RESET} $*"; }
|
||||||
|
warn() { echo -e "${YELLOW}⚠${RESET} $*"; }
|
||||||
|
die() { echo -e "${RED}✗${RESET} $*" >&2; exit 1; }
|
||||||
|
step() { echo -e "\n${YELLOW}▸${RESET} $*"; }
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo " Bincio mobile setup"
|
||||||
|
echo " ═══════════════════"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── 1. Node.js ────────────────────────────────────────────────────────────────
|
||||||
|
step "Checking Node.js..."
|
||||||
|
if ! command -v node &>/dev/null; then
|
||||||
|
die "Node.js not found. Install from https://nodejs.org (v20+ recommended)."
|
||||||
|
fi
|
||||||
|
NODE_MAJOR=$(node -v | sed 's/v//' | cut -d. -f1)
|
||||||
|
if [ "$NODE_MAJOR" -lt 18 ]; then
|
||||||
|
die "Node.js 18+ required (found $(node -v)). Update at https://nodejs.org"
|
||||||
|
fi
|
||||||
|
ok "Node.js $(node -v)"
|
||||||
|
|
||||||
|
# ── 2. npm ────────────────────────────────────────────────────────────────────
|
||||||
|
if ! command -v npm &>/dev/null; then
|
||||||
|
die "npm not found. It ships with Node.js — check your installation."
|
||||||
|
fi
|
||||||
|
ok "npm $(npm -v)"
|
||||||
|
|
||||||
|
# ── 3. Expo CLI (global, optional — we use npx) ───────────────────────────────
|
||||||
|
step "Checking Expo CLI..."
|
||||||
|
if command -v expo &>/dev/null; then
|
||||||
|
ok "Expo CLI $(expo --version) (global)"
|
||||||
|
else
|
||||||
|
warn "Expo CLI not installed globally. Using npx instead (slightly slower)."
|
||||||
|
warn "Install globally with: npm install -g expo-cli"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 4. Platform tools ─────────────────────────────────────────────────────────
|
||||||
|
step "Checking platform tools..."
|
||||||
|
PLATFORM="$(uname -s)"
|
||||||
|
|
||||||
|
if [ "$PLATFORM" = "Darwin" ]; then
|
||||||
|
if command -v xcodebuild &>/dev/null; then
|
||||||
|
ok "Xcode $(xcodebuild -version 2>/dev/null | head -1 | awk '{print $2}')"
|
||||||
|
else
|
||||||
|
warn "Xcode not found — iOS builds will not work."
|
||||||
|
warn "Install Xcode from the App Store, then: xcode-select --install"
|
||||||
|
fi
|
||||||
|
if command -v xcrun &>/dev/null && xcrun --sdk iphoneos --show-sdk-version &>/dev/null; then
|
||||||
|
ok "iOS SDK available"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v adb &>/dev/null; then
|
||||||
|
ok "Android SDK / adb found"
|
||||||
|
else
|
||||||
|
warn "adb not found — Android builds require Android Studio."
|
||||||
|
warn "Install from https://developer.android.com/studio"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 5. Install dependencies ───────────────────────────────────────────────────
|
||||||
|
step "Installing npm dependencies..."
|
||||||
|
if [ -d node_modules ] && [ -f node_modules/.package-lock.json ]; then
|
||||||
|
ok "node_modules already present — running npm install to sync..."
|
||||||
|
fi
|
||||||
|
npm install
|
||||||
|
ok "Dependencies installed"
|
||||||
|
|
||||||
|
# ── 6. expo-env.d.ts (required by expo-router) ────────────────────────────────
|
||||||
|
step "Generating Expo type declarations..."
|
||||||
|
npx expo customize expo-env.d.ts --no-install 2>/dev/null || true
|
||||||
|
if [ ! -f expo-env.d.ts ]; then
|
||||||
|
echo '/// <reference types="expo-router/types" />' > expo-env.d.ts
|
||||||
|
fi
|
||||||
|
ok "expo-env.d.ts ready"
|
||||||
|
|
||||||
|
# ── 7. Summary ────────────────────────────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
echo " ══════════════════════════════════════════"
|
||||||
|
echo " Setup complete! Next steps:"
|
||||||
|
echo ""
|
||||||
|
echo " Start with Expo Go (scan QR on your phone):"
|
||||||
|
echo " npx expo start"
|
||||||
|
echo ""
|
||||||
|
echo " Run on Android emulator:"
|
||||||
|
echo " npx expo run:android"
|
||||||
|
echo ""
|
||||||
|
echo " Run on iOS simulator (macOS only):"
|
||||||
|
echo " npx expo run:ios"
|
||||||
|
echo ""
|
||||||
|
echo " Build APK for Karoo sideload:"
|
||||||
|
echo " npx eas build -p android --profile preview"
|
||||||
|
echo " ══════════════════════════════════════════"
|
||||||
|
echo ""
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".expo/types/**/*.d.ts",
|
||||||
|
"expo-env.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user