feat: Phase 0.5 — remote feed sync via Bearer token auth
Server (bincio/serve/server.py): - Add _require_auth: accepts session cookie OR Authorization: Bearer token - POST /api/auth/token: same as /api/auth/login but returns token in body (password used once, not stored; mobile stores only the session token) - GET /api/feed: auth-gated; reads _merged/index.json for the user and returns the activities array as JSON Mobile: - db/sync.ts: syncFeed(db) fetches /api/feed, upserts each summary into local SQLite as origin='remote'; skips locally-imported activities - db/queries.ts: add upsertRemoteActivity (INSERT ... ON CONFLICT DO UPDATE WHERE origin='remote' — never overwrites local imports); fix feed sort order to started_at DESC instead of insertion order - settings.tsx: Connect section — password field (not persisted) + Connect button calls POST /api/auth/token and stores token; Disconnect clears it - index.tsx: ↓ Sync button + pull-to-refresh both trigger syncFeed; cloud badge on remote activities; empty state updated
This commit is contained in:
+93
-43
@@ -1,43 +1,78 @@
|
||||
import { useSQLiteContext } from 'expo-sqlite';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { FlatList, Pressable, RefreshControl, StyleSheet, Text, View } from 'react-native';
|
||||
import { useActivities, type ActivitySummary } from '@/db/queries';
|
||||
import { syncFeed } from '@/db/sync';
|
||||
|
||||
export default function FeedScreen() {
|
||||
const db = useSQLiteContext();
|
||||
const activities = useActivities();
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncMsg, setSyncMsg] = useState<string | null>(null);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
const doSync = useCallback(async () => {
|
||||
setSyncing(true);
|
||||
setSyncMsg(null);
|
||||
const result = await syncFeed(db);
|
||||
setSyncing(false);
|
||||
if (result.error) {
|
||||
setSyncMsg(result.error);
|
||||
} else if (result.synced === 0) {
|
||||
setSyncMsg('Already up to date');
|
||||
} else {
|
||||
setSyncMsg(`${result.synced} ${result.synced === 1 ? 'activity' : 'activities'} synced`);
|
||||
}
|
||||
setTimeout(() => setSyncMsg(null), 3500);
|
||||
}, [db]);
|
||||
|
||||
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 style={styles.headerRow}>
|
||||
<Text style={styles.header}>Feed</Text>
|
||||
<Pressable
|
||||
style={[styles.syncButton, syncing && styles.syncButtonDisabled]}
|
||||
onPress={syncing ? undefined : doSync}
|
||||
>
|
||||
<Text style={styles.syncText}>{syncing ? 'Syncing…' : '↓ Sync'}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{syncMsg && (
|
||||
<Text style={styles.syncMsg}>{syncMsg}</Text>
|
||||
)}
|
||||
|
||||
{activities.length === 0 && !syncing ? (
|
||||
<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 Sync to pull from your instance.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={activities}
|
||||
keyExtractor={(a) => a.id}
|
||||
renderItem={({ item }) => <ActivityCard activity={item} />}
|
||||
contentContainerStyle={styles.list}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={syncing}
|
||||
onRefresh={doSync}
|
||||
tintColor="#60a5fa"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</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 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',
|
||||
});
|
||||
@@ -51,14 +86,15 @@ function ActivityCard({ activity }: { activity: ActivitySummary }) {
|
||||
<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>
|
||||
)}
|
||||
{activity.origin === 'remote'
|
||||
? <Text style={styles.remoteBadge}>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} />}
|
||||
{km && <Stat label="km" value={km} />}
|
||||
{elev != null && <Stat label="m↑" value={String(elev)} />}
|
||||
</View>
|
||||
</Pressable>
|
||||
@@ -83,33 +119,47 @@ function sportIcon(sport: string): string {
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#09090b' },
|
||||
header: {
|
||||
color: '#fff', fontSize: 22, fontWeight: '700',
|
||||
headerRow: {
|
||||
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
|
||||
paddingHorizontal: 16, paddingTop: 60, paddingBottom: 12,
|
||||
},
|
||||
list: { padding: 16, gap: 12 },
|
||||
header: { color: '#fff', fontSize: 22, fontWeight: '700' },
|
||||
syncButton: {
|
||||
backgroundColor: '#1e3a5f', borderRadius: 8,
|
||||
paddingHorizontal: 14, paddingVertical: 7,
|
||||
},
|
||||
syncButtonDisabled: { opacity: 0.5 },
|
||||
syncText: { color: '#60a5fa', fontSize: 13, fontWeight: '600' },
|
||||
syncMsg: {
|
||||
color: '#a1a1aa', fontSize: 12, textAlign: 'center',
|
||||
paddingHorizontal: 16, paddingBottom: 8,
|
||||
},
|
||||
list: { padding: 16, gap: 12 },
|
||||
card: {
|
||||
backgroundColor: '#18181b', borderRadius: 12,
|
||||
padding: 16, borderWidth: 1, borderColor: '#27272a',
|
||||
},
|
||||
cardTop: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 6 },
|
||||
cardTop: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 6 },
|
||||
sportIcon: { fontSize: 20 },
|
||||
cardMeta: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||
cardDate: { color: '#71717a', fontSize: 12 },
|
||||
unsyncedBadge: {
|
||||
remoteBadge: {
|
||||
color: '#60a5fa', fontSize: 10, borderWidth: 1,
|
||||
borderColor: '#1e3a5f', 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 },
|
||||
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,
|
||||
flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32,
|
||||
},
|
||||
emptyIcon: { fontSize: 48, marginBottom: 16 },
|
||||
emptyIcon: { fontSize: 48, marginBottom: 16 },
|
||||
emptyTitle: { color: '#f4f4f5', fontSize: 18, fontWeight: '600', marginBottom: 8 },
|
||||
emptyBody: { color: '#71717a', fontSize: 14, textAlign: 'center', lineHeight: 20 },
|
||||
emptyBody: { color: '#71717a', fontSize: 14, textAlign: 'center', lineHeight: 20 },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user