cbe3e0eeaf
- Skip MapLibre on Android <29 (Karoo): SELinux denies kgsl-3d0 access from untrusted_app context, crashing the GPU driver on any OpenGL surface. Replace with SvgRouteView — equirectangular SVG route trace using react-native-svg, no native GL surface needed. - Add +/- zoom buttons to full-screen MapLibre map on modern devices via Camera ref and onRegionDidChange. - Skip PyodideWebView on Android <29: same GPU driver conflict; set _engineUnavailable at module init via API level gate (< 29). - Add engine_unavailable fast path in PyodideWebView: post message immediately if WebAssembly.Global is absent (Chrome <69) instead of attempting 30 MB Pyodide download. - Add server-side extraction fallback (extractServer.ts): when engine unavailable, POST raw file as base64 to /api/upload/raw; server runs full Python pipeline and returns extracted data. - Add /api/upload/raw endpoint in server.py. - Add pre-flight auth check (checkServerAuth) before batch import so an expired token errors immediately rather than after N files. - Fix uploadLocalActivities in sync.ts: was reading original_path as JSON (binary FIT file, always threw), silently skipping every upload. Now reads detail_json from DB directly. - Redesign Feed header: replace single Sync button with Upload / Download / Refresh. Pull-to-refresh and Refresh button are local-only. Auto-refresh on tab focus via useFocusEffect. - Replace ActivityIndicator with plain Text everywhere (native animation also crashes Karoo GPU driver). - Raise macOS open-file limit in dev_test.py to prevent EMFILE errors from Astro file watcher. - Document all Karoo hardware constraints in docs/mobile-app.md.
364 lines
12 KiB
TypeScript
364 lines
12 KiB
TypeScript
import * as FileSystem from 'expo-file-system';
|
|
import { useFocusEffect } from 'expo-router';
|
|
import { useSQLiteContext } from 'expo-sqlite';
|
|
import { useRouter } from 'expo-router';
|
|
import { useCallback, useState } from 'react';
|
|
import { Alert, FlatList, Pressable, RefreshControl, StyleSheet, Text, View } from 'react-native';
|
|
import { deleteActivities, useActivities, type ActivitySummary } from '@/db/queries';
|
|
import { downloadFeed, uploadFeed } from '@/db/sync';
|
|
import { useTheme } from '@/ThemeContext';
|
|
|
|
export default function FeedScreen() {
|
|
const db = useSQLiteContext();
|
|
const theme = useTheme();
|
|
const [refreshKey, setRefreshKey] = useState(0);
|
|
const activities = useActivities();
|
|
const [downloading, setDownloading] = useState(false);
|
|
const [uploading, setUploading] = useState(false);
|
|
const [statusMsg, setStatusMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
const selecting = selected.size > 0;
|
|
|
|
// Auto-refresh the local list whenever the tab comes into focus.
|
|
// SQLite getAllSync is sub-millisecond — no network, no lag.
|
|
useFocusEffect(useCallback(() => {
|
|
setRefreshKey(k => k + 1);
|
|
}, []));
|
|
|
|
function showMsg(ok: boolean, text: string) {
|
|
setStatusMsg({ ok, text });
|
|
setTimeout(() => setStatusMsg(null), 3500);
|
|
}
|
|
|
|
const doDownload = useCallback(async () => {
|
|
setDownloading(true);
|
|
setStatusMsg(null);
|
|
const result = await downloadFeed(db);
|
|
setDownloading(false);
|
|
setRefreshKey(k => k + 1);
|
|
if (result.error) {
|
|
showMsg(false, result.error);
|
|
} else if (result.total === 0) {
|
|
showMsg(true, 'No activities on instance');
|
|
} else if (result.synced === 0 && !result.fetched) {
|
|
showMsg(true, `Up to date (${result.total} activities)`);
|
|
} else {
|
|
const parts = [];
|
|
if (result.synced > 0) parts.push(`${result.synced} new`);
|
|
if (result.fetched) parts.push(`${result.fetched} full dataset${result.fetched === 1 ? '' : 's'}`);
|
|
showMsg(true, `Downloaded: ${parts.join(', ')} (${result.total} total)`);
|
|
}
|
|
}, [db]);
|
|
|
|
const doUpload = useCallback(async () => {
|
|
setUploading(true);
|
|
setStatusMsg(null);
|
|
const result = await uploadFeed(db);
|
|
setUploading(false);
|
|
if (result.error) {
|
|
showMsg(false, result.error);
|
|
} else if (!result.uploaded) {
|
|
showMsg(true, 'Nothing to upload');
|
|
} else {
|
|
showMsg(true, `Uploaded ${result.uploaded} activit${result.uploaded === 1 ? 'y' : 'ies'}`);
|
|
}
|
|
}, [db]);
|
|
|
|
function doRefresh() {
|
|
setRefreshKey(k => k + 1);
|
|
}
|
|
|
|
function toggleSelect(id: string) {
|
|
setSelected(prev => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) next.delete(id); else next.add(id);
|
|
return next;
|
|
});
|
|
}
|
|
|
|
function cancelSelect() { setSelected(new Set()); }
|
|
|
|
function confirmDeleteSelected() {
|
|
const count = selected.size;
|
|
Alert.alert(
|
|
`Delete ${count} activit${count === 1 ? 'y' : 'ies'}`,
|
|
'These activities will be permanently removed from your device.',
|
|
[
|
|
{ text: 'Cancel', style: 'cancel' },
|
|
{
|
|
text: 'Delete',
|
|
style: 'destructive',
|
|
onPress: async () => {
|
|
const ids = Array.from(selected);
|
|
const paths = await deleteActivities(db, ids);
|
|
setSelected(new Set());
|
|
for (const p of paths) {
|
|
if (p) try { await FileSystem.deleteAsync(p, { idempotent: true }); } catch {}
|
|
}
|
|
},
|
|
},
|
|
],
|
|
);
|
|
}
|
|
|
|
const busy = downloading || uploading;
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
<View style={styles.headerRow}>
|
|
{selecting ? (
|
|
<>
|
|
<Text style={styles.header}>{selected.size} selected</Text>
|
|
<Pressable style={styles.cancelButton} onPress={cancelSelect}>
|
|
<Text style={styles.cancelText}>Cancel</Text>
|
|
</Pressable>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Text style={styles.header}>Feed</Text>
|
|
<View style={styles.actionButtons}>
|
|
<ActionButton
|
|
icon="↑"
|
|
label="Upload"
|
|
loading={uploading}
|
|
disabled={busy}
|
|
accent={theme.accent}
|
|
dim={theme.dim}
|
|
onPress={doUpload}
|
|
/>
|
|
<ActionButton
|
|
icon="↓"
|
|
label="Download"
|
|
loading={downloading}
|
|
disabled={busy}
|
|
accent={theme.accent}
|
|
dim={theme.dim}
|
|
onPress={doDownload}
|
|
/>
|
|
<ActionButton
|
|
icon="↺"
|
|
label="Refresh"
|
|
loading={false}
|
|
disabled={busy}
|
|
accent={theme.accent}
|
|
dim={theme.dim}
|
|
onPress={doRefresh}
|
|
/>
|
|
</View>
|
|
</>
|
|
)}
|
|
</View>
|
|
|
|
{statusMsg && (
|
|
<Text style={statusMsg.ok ? styles.msgOk : styles.msgErr}>{statusMsg.text}</Text>
|
|
)}
|
|
|
|
{activities.length === 0 && !busy ? (
|
|
<View style={styles.empty}>
|
|
<Text style={styles.emptyIcon}>🚴</Text>
|
|
<Text style={styles.emptyTitle}>No activities yet</Text>
|
|
<Text style={styles.emptyBody}>
|
|
Import a file or tap ↓ to pull from your instance.
|
|
</Text>
|
|
</View>
|
|
) : (
|
|
<FlatList
|
|
data={activities}
|
|
keyExtractor={(a) => a.id}
|
|
extraData={refreshKey}
|
|
renderItem={({ item }) => (
|
|
<ActivityCard
|
|
activity={item}
|
|
selecting={selecting}
|
|
checked={selected.has(item.id)}
|
|
onToggleSelect={() => toggleSelect(item.id)}
|
|
onLongPress={() => toggleSelect(item.id)}
|
|
/>
|
|
)}
|
|
contentContainerStyle={styles.list}
|
|
refreshControl={
|
|
<RefreshControl
|
|
refreshing={false}
|
|
onRefresh={doRefresh}
|
|
tintColor="#60a5fa"
|
|
/>
|
|
}
|
|
/>
|
|
)}
|
|
|
|
{selecting && (
|
|
<View style={styles.actionBar}>
|
|
<Pressable style={styles.deleteBarButton} onPress={confirmDeleteSelected}>
|
|
<Text style={styles.deleteBarText}>Delete {selected.size}</Text>
|
|
</Pressable>
|
|
</View>
|
|
)}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function ActionButton({
|
|
icon, label, loading, disabled, accent, dim, onPress,
|
|
}: {
|
|
icon: string;
|
|
label: string;
|
|
loading: boolean;
|
|
disabled: boolean;
|
|
accent: string;
|
|
dim: string;
|
|
onPress: () => void;
|
|
}) {
|
|
return (
|
|
<Pressable
|
|
style={[styles.actionBtn, { backgroundColor: dim }, disabled && styles.actionBtnDisabled]}
|
|
onPress={disabled ? undefined : onPress}
|
|
accessibilityLabel={label}
|
|
>
|
|
<Text style={[styles.actionBtnIcon, { color: loading ? '#52525b' : accent }]}>
|
|
{loading ? '…' : icon}
|
|
</Text>
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
function ActivityCard({
|
|
activity,
|
|
selecting,
|
|
checked,
|
|
onToggleSelect,
|
|
onLongPress,
|
|
}: {
|
|
activity: ActivitySummary;
|
|
selecting: boolean;
|
|
checked: boolean;
|
|
onToggleSelect: () => void;
|
|
onLongPress: () => void;
|
|
}) {
|
|
const router = useRouter();
|
|
const theme = useTheme();
|
|
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',
|
|
});
|
|
|
|
function handlePress() {
|
|
if (selecting) onToggleSelect();
|
|
else router.push(`/activity/${activity.id}`);
|
|
}
|
|
|
|
return (
|
|
<Pressable
|
|
style={[styles.card, checked && { borderColor: theme.accent }]}
|
|
onPress={handlePress}
|
|
onLongPress={onLongPress}
|
|
>
|
|
<View style={styles.cardTop}>
|
|
<View style={styles.cardLeft}>
|
|
{selecting && (
|
|
<View style={[styles.checkbox, checked && { backgroundColor: theme.accent, borderColor: theme.accent }]}>
|
|
{checked && <Text style={styles.checkmark}>✓</Text>}
|
|
</View>
|
|
)}
|
|
<Text style={styles.sportIcon}>{sportIcon(activity.sport)}</Text>
|
|
</View>
|
|
<View style={styles.cardMeta}>
|
|
<Text style={styles.cardDate}>{date}</Text>
|
|
{activity.origin === 'remote'
|
|
? <Text style={[styles.remoteBadge, { color: theme.accent, borderColor: theme.accent }]}>cloud</Text>
|
|
: !activity.synced_at && <Text style={styles.localBadge}>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' },
|
|
headerRow: {
|
|
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
|
|
paddingHorizontal: 16, paddingTop: 60, paddingBottom: 12,
|
|
},
|
|
header: { color: '#fff', fontSize: 22, fontWeight: '700' },
|
|
actionButtons: { flexDirection: 'row', gap: 8 },
|
|
actionBtn: {
|
|
width: 36, height: 36, borderRadius: 8,
|
|
alignItems: 'center', justifyContent: 'center',
|
|
},
|
|
actionBtnDisabled: { opacity: 0.4 },
|
|
actionBtnIcon: { fontSize: 18, fontWeight: '700', lineHeight: 22 },
|
|
cancelButton: {
|
|
backgroundColor: '#27272a', borderRadius: 8,
|
|
paddingHorizontal: 14, paddingVertical: 7,
|
|
},
|
|
cancelText: { color: '#a1a1aa', fontSize: 13, fontWeight: '600' },
|
|
msgOk: { color: '#86efac', fontSize: 12, textAlign: 'center', paddingHorizontal: 16, paddingBottom: 8 },
|
|
msgErr: { color: '#fca5a5', fontSize: 12, textAlign: 'center', paddingHorizontal: 16, paddingBottom: 8 },
|
|
list: { padding: 16, gap: 12, paddingBottom: 80 },
|
|
card: {
|
|
backgroundColor: '#18181b', borderRadius: 12,
|
|
padding: 16, borderWidth: 1, borderColor: '#27272a',
|
|
},
|
|
cardTop: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 6 },
|
|
cardLeft: { flexDirection: 'row', alignItems: 'center', gap: 10 },
|
|
sportIcon: { fontSize: 20 },
|
|
cardMeta: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
|
cardDate: { color: '#71717a', fontSize: 12 },
|
|
remoteBadge: {
|
|
fontSize: 10, borderWidth: 1,
|
|
borderRadius: 4, paddingHorizontal: 4,
|
|
},
|
|
localBadge: {
|
|
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 },
|
|
checkbox: {
|
|
width: 20, height: 20, borderRadius: 4, borderWidth: 1.5,
|
|
borderColor: '#52525b', alignItems: 'center', justifyContent: 'center',
|
|
},
|
|
checkmark: { color: '#fff', fontSize: 12, fontWeight: '700' },
|
|
empty: {
|
|
flex: 1, 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 },
|
|
actionBar: {
|
|
position: 'absolute', bottom: 0, left: 0, right: 0,
|
|
backgroundColor: '#18181b', borderTopWidth: 1, borderTopColor: '#27272a',
|
|
paddingHorizontal: 16, paddingVertical: 12, paddingBottom: 28,
|
|
},
|
|
deleteBarButton: {
|
|
backgroundColor: '#7f1d1d', borderRadius: 10,
|
|
paddingVertical: 14, alignItems: 'center',
|
|
},
|
|
deleteBarText: { color: '#fca5a5', fontSize: 15, fontWeight: '700' },
|
|
});
|