feat: Phase 0 mobile app scaffold — Expo 55, SQLite, Feed/Import/Settings screens
This commit is contained in:
@@ -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 },
|
||||
});
|
||||
Reference in New Issue
Block a user