feat: Phase 0 mobile app scaffold — Expo 55, SQLite, Feed/Import/Settings screens
This commit is contained in:
@@ -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