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:
Davide Scaini
2026-04-24 12:07:49 +02:00
parent 79c572bf8b
commit 44b2878b14
7 changed files with 9991 additions and 72 deletions
+93 -43
View File
@@ -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 },
});
+120 -24
View File
@@ -1,7 +1,7 @@
import { useSQLiteContext } from 'expo-sqlite';
import { useState } from 'react';
import {
Platform, Pressable, ScrollView, StyleSheet,
ActivityIndicator, Platform, Pressable, ScrollView, StyleSheet,
Text, TextInput, View,
} from 'react-native';
import { getSetting, setSetting, useSetting } from '@/db/queries';
@@ -9,14 +9,19 @@ import { getSetting, setSetting, useSetting } from '@/db/queries';
export default function SettingsScreen() {
const db = useSQLiteContext();
const storedUrl = useSetting('instance_url') ?? '';
const storedUrl = useSetting('instance_url') ?? '';
const storedHandle = useSetting('handle') ?? '';
const storedPath = useSetting('auto_import_path') ?? '';
const storedPath = useSetting('auto_import_path') ?? '';
const storedToken = useSetting('api_token');
const [instanceUrl, setInstanceUrl] = useState(storedUrl);
const [handle, setHandle] = useState(storedHandle);
const [autoPath, setAutoPath] = useState(storedPath);
const [saved, setSaved] = useState(false);
const [handle, setHandle] = useState(storedHandle);
const [autoPath, setAutoPath] = useState(storedPath);
const [saved, setSaved] = useState(false);
const [password, setPassword] = useState('');
const [connecting, setConnecting] = useState(false);
const [connectMsg, setConnectMsg] = useState<{ ok: boolean; text: string } | null>(null);
async function save() {
await setSetting(db, 'instance_url', instanceUrl.trim());
@@ -28,11 +33,51 @@ export default function SettingsScreen() {
setTimeout(() => setSaved(false), 2000);
}
async function connect() {
const url = instanceUrl.trim().replace(/\/$/, '');
const h = handle.trim();
if (!url || !h || !password) {
setConnectMsg({ ok: false, text: 'Fill in URL, handle, and password first.' });
return;
}
setConnecting(true);
setConnectMsg(null);
try {
const resp = await fetch(`${url}/api/auth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ handle: h, password }),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
setConnectMsg({ ok: false, text: err.detail ?? `Error ${resp.status}` });
return;
}
const data = await resp.json();
await setSetting(db, 'instance_url', url);
await setSetting(db, 'handle', h);
await setSetting(db, 'api_token', data.token);
setPassword('');
setConnectMsg({ ok: true, text: `Connected as ${data.display_name || h}` });
} catch {
setConnectMsg({ ok: false, text: 'Could not reach instance — check the URL.' });
} finally {
setConnecting(false);
}
}
async function disconnect() {
await setSetting(db, 'api_token', '');
setConnectMsg(null);
}
const isConnected = !!storedToken;
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<Text style={styles.header}>Settings</Text>
<Section title="Instance (optional)">
<Section title="Instance">
<Field
label="Instance URL"
placeholder="https://bincio.org"
@@ -49,8 +94,53 @@ export default function SettingsScreen() {
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.
Connect to a Bincio instance to sync your activities. Leave blank to use
the app offline only.
</Text>
</Section>
<Pressable style={styles.saveButton} onPress={save}>
<Text style={styles.saveButtonText}>
{saved ? '✓ Saved' : 'Save'}
</Text>
</Pressable>
<Section title="Connection">
{isConnected ? (
<>
<Row label="Status" value={`Connected as ${storedHandle || '—'}`} />
<Pressable style={styles.disconnectButton} onPress={disconnect}>
<Text style={styles.disconnectText}>Disconnect</Text>
</Pressable>
</>
) : (
<>
<Field
label="Password"
placeholder="••••••••"
value={password}
onChangeText={setPassword}
autoCapitalize="none"
secureTextEntry
/>
<Pressable
style={[styles.connectButton, connecting && styles.buttonDisabled]}
onPress={connecting ? undefined : connect}
>
{connecting
? <ActivityIndicator color="#fff" size="small" />
: <Text style={styles.connectText}>Connect</Text>}
</Pressable>
</>
)}
{connectMsg && (
<Text style={connectMsg.ok ? styles.msgOk : styles.msgErr}>
{connectMsg.text}
</Text>
)}
<Text style={styles.hint}>
Your password is used once to obtain a session token, then forgotten.
The token is stored locally and sent with each sync request.
</Text>
</Section>
@@ -70,16 +160,9 @@ export default function SettingsScreen() {
</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)" />
<Row label="Version" value="0.1.0 (Phase 0.5)" />
<Row label="Schema" value="BAS 1.0" />
</Section>
</ScrollView>
);
@@ -129,9 +212,9 @@ function Row({ label, value }: { label: string; value: string }) {
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 },
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,
@@ -140,10 +223,10 @@ const styles = StyleSheet.create({
backgroundColor: '#18181b', borderRadius: 10,
borderWidth: 1, borderColor: '#27272a', overflow: 'hidden',
},
field: { padding: 14, borderBottomWidth: 1, borderBottomColor: '#27272a' },
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 },
input: { color: '#f4f4f5', fontSize: 15 },
hint: { color: '#52525b', fontSize: 12, lineHeight: 16, padding: 12 },
row: {
flexDirection: 'row', justifyContent: 'space-between',
paddingHorizontal: 14, paddingVertical: 12,
@@ -156,4 +239,17 @@ const styles = StyleSheet.create({
paddingVertical: 14, alignItems: 'center', marginBottom: 28,
},
saveButtonText: { color: '#fff', fontWeight: '600', fontSize: 16 },
connectButton: {
backgroundColor: '#059669', borderRadius: 8, margin: 12,
paddingVertical: 12, alignItems: 'center',
},
connectText: { color: '#fff', fontWeight: '600', fontSize: 15 },
buttonDisabled: { opacity: 0.5 },
disconnectButton: {
margin: 12, paddingVertical: 10, alignItems: 'center',
borderRadius: 8, borderWidth: 1, borderColor: '#3f3f46',
},
disconnectText: { color: '#71717a', fontSize: 14 },
msgOk: { color: '#86efac', fontSize: 13, paddingHorizontal: 12, paddingBottom: 10 },
msgErr: { color: '#fca5a5', fontSize: 13, paddingHorizontal: 12, paddingBottom: 10 },
});
+19 -1
View File
@@ -52,7 +52,7 @@ export function useActivities(): ActivitySummary[] {
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
ORDER BY json_extract(detail_json, '$.started_at') DESC
`);
return rows;
}
@@ -85,6 +85,24 @@ export async function insertActivity(
);
}
export async function upsertRemoteActivity(
db: ReturnType<typeof useSQLiteContext>,
id: string,
detailJson: string,
): Promise<boolean> {
const now = Math.floor(Date.now() / 1000);
const result = await db.runAsync(
`INSERT INTO activities (id, source_hash, detail_json, origin, synced_at)
VALUES (?, ?, ?, 'remote', ?)
ON CONFLICT(id) DO UPDATE SET
detail_json = excluded.detail_json,
synced_at = excluded.synced_at
WHERE origin = 'remote'`,
[id, id, detailJson, now],
);
return result.changes > 0;
}
// ── Settings ───────────────────────────────────────────────────────────────
export async function getSetting(
+65
View File
@@ -0,0 +1,65 @@
import type { SQLiteDatabase } from 'expo-sqlite';
import { getSetting, upsertRemoteActivity } from './queries';
export type SyncResult = { synced: number; error?: string };
export async function syncFeed(db: SQLiteDatabase): Promise<SyncResult> {
const instanceUrl = (await getSetting(db, 'instance_url'))?.replace(/\/$/, '');
const token = await getSetting(db, 'api_token');
if (!instanceUrl || !token) {
return { synced: 0, error: 'No instance configured — add one in Settings.' };
}
let resp: Response;
try {
resp = await fetch(`${instanceUrl}/api/feed`, {
headers: { Authorization: `Bearer ${token}` },
});
} catch {
return { synced: 0, error: 'Could not reach instance — check your connection.' };
}
if (resp.status === 401) {
return { synced: 0, error: 'Session expired — reconnect in Settings.' };
}
if (!resp.ok) {
return { synced: 0, error: `Server error (${resp.status})` };
}
const data: { activities?: RemoteSummary[] } = await resp.json();
const activities = data.activities ?? [];
let synced = 0;
for (const a of activities) {
const detailJson = JSON.stringify({
id: a.id,
title: a.title ?? a.id,
sport: a.sport ?? null,
started_at: a.started_at ?? null,
distance_m: a.distance_m ?? null,
moving_time_s: a.moving_time_s ?? null,
elevation_gain_m: a.elevation_gain_m ?? null,
avg_speed_kmh: a.avg_speed_kmh ?? null,
avg_hr_bpm: a.avg_hr_bpm ?? null,
avg_power_w: a.avg_power_w ?? null,
});
const changed = await upsertRemoteActivity(db, a.id, detailJson);
if (changed) synced++;
}
return { synced };
}
type RemoteSummary = {
id: string;
title?: string;
sport?: string;
started_at?: string;
distance_m?: number | null;
moving_time_s?: number | null;
elevation_gain_m?: number | null;
avg_speed_kmh?: number | null;
avg_hr_bpm?: number | null;
avg_power_w?: number | null;
};
+9641
View File
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -3,13 +3,14 @@
"compilerOptions": {
"strict": true,
"paths": {
"@/*": ["./*"]
"@/*": [
"./*"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.d.ts",
"expo-env.d.ts"
".expo/types/**/*.d.ts"
]
}