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)
|
||||
async def login(
|
||||
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
|
||||
|
||||
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