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:
+49
-1
@@ -22,7 +22,7 @@ from typing import Any, Optional
|
|||||||
|
|
||||||
log = logging.getLogger("bincio.serve")
|
log = logging.getLogger("bincio.serve")
|
||||||
|
|
||||||
from fastapi import Cookie, FastAPI, File, Form, HTTPException, Request, Response, UploadFile
|
from fastapi import Cookie, Depends, FastAPI, File, Form, HTTPException, Request, Response, UploadFile
|
||||||
from fastapi.responses import FileResponse, RedirectResponse, StreamingResponse
|
from fastapi.responses import FileResponse, RedirectResponse, StreamingResponse
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.middleware.gzip import GZipMiddleware
|
from fastapi.middleware.gzip import GZipMiddleware
|
||||||
@@ -281,6 +281,24 @@ def _require_admin(bincio_session: Optional[str] = Cookie(default=None)) -> User
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _require_auth(
|
||||||
|
request: Request,
|
||||||
|
bincio_session: Optional[str] = Cookie(default=None),
|
||||||
|
) -> User:
|
||||||
|
"""Accept session cookie (web) OR Authorization: Bearer token (mobile)."""
|
||||||
|
token = bincio_session
|
||||||
|
if not token:
|
||||||
|
auth = request.headers.get("Authorization", "")
|
||||||
|
if auth.startswith("Bearer "):
|
||||||
|
token = auth[7:]
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(401, "Not authenticated")
|
||||||
|
user = get_session(_get_db(), token)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(401, "Invalid or expired session")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
def _set_session_cookie(response: Response, token: str) -> None:
|
def _set_session_cookie(response: Response, token: str) -> None:
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
key=_SESSION_COOKIE,
|
key=_SESSION_COOKIE,
|
||||||
@@ -499,6 +517,36 @@ async def logout(bincio_session: Optional[str] = Cookie(default=None)) -> JSONRe
|
|||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/auth/token")
|
||||||
|
async def get_token(login_req: LoginRequest, request: Request) -> JSONResponse:
|
||||||
|
"""Mobile auth: same as /api/auth/login but returns the token in the body instead of a cookie."""
|
||||||
|
ip = request.client.host if request.client else "unknown"
|
||||||
|
_check_rate_limit(ip, _login_attempts, _LOGIN_RATE_LIMIT, "Too many login attempts. Try again later.")
|
||||||
|
handle = login_req.handle.strip().lower()
|
||||||
|
user = authenticate(_get_db(), handle, login_req.password)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(401, "Invalid credentials")
|
||||||
|
token = create_session(_get_db(), handle)
|
||||||
|
return JSONResponse({
|
||||||
|
"ok": True,
|
||||||
|
"token": token,
|
||||||
|
"handle": user.handle,
|
||||||
|
"display_name": user.display_name,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/feed")
|
||||||
|
async def get_feed(user: User = Depends(_require_auth)) -> JSONResponse:
|
||||||
|
"""Return the authenticated user's activity summaries (mobile feed sync)."""
|
||||||
|
dd = _get_data_dir()
|
||||||
|
user_dir = dd / user.handle
|
||||||
|
for index_path in (user_dir / "_merged" / "index.json", user_dir / "index.json"):
|
||||||
|
if index_path.exists():
|
||||||
|
index = json.loads(index_path.read_text())
|
||||||
|
return JSONResponse({"activities": index.get("activities", [])})
|
||||||
|
return JSONResponse({"activities": []})
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/auth/reset-password", response_model=GenericResponse)
|
@app.post("/api/auth/reset-password", response_model=GenericResponse)
|
||||||
async def reset_password(reset_req: ResetPasswordRequest) -> JSONResponse:
|
async def reset_password(reset_req: ResetPasswordRequest) -> JSONResponse:
|
||||||
"""Validate a reset code and set a new password. Public endpoint."""
|
"""Validate a reset code and set a new password. Public endpoint."""
|
||||||
|
|||||||
+73
-23
@@ -1,43 +1,78 @@
|
|||||||
|
import { useSQLiteContext } from 'expo-sqlite';
|
||||||
import { useRouter } from 'expo-router';
|
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 { useActivities, type ActivitySummary } from '@/db/queries';
|
||||||
|
import { syncFeed } from '@/db/sync';
|
||||||
|
|
||||||
export default function FeedScreen() {
|
export default function FeedScreen() {
|
||||||
|
const db = useSQLiteContext();
|
||||||
const activities = useActivities();
|
const activities = useActivities();
|
||||||
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
const [syncMsg, setSyncMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
if (activities.length === 0) {
|
|
||||||
return (
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<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}>
|
<View style={styles.empty}>
|
||||||
<Text style={styles.emptyIcon}>🚴</Text>
|
<Text style={styles.emptyIcon}>🚴</Text>
|
||||||
<Text style={styles.emptyTitle}>No activities yet</Text>
|
<Text style={styles.emptyTitle}>No activities yet</Text>
|
||||||
<Text style={styles.emptyBody}>
|
<Text style={styles.emptyBody}>
|
||||||
Go to Import to add a FIT, GPX, or TCX file.
|
Import a file or tap Sync to pull from your instance.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
) : (
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<Text style={styles.header}>Feed</Text>
|
|
||||||
<FlatList
|
<FlatList
|
||||||
data={activities}
|
data={activities}
|
||||||
keyExtractor={(a) => a.id}
|
keyExtractor={(a) => a.id}
|
||||||
renderItem={({ item }) => <ActivityCard activity={item} />}
|
renderItem={({ item }) => <ActivityCard activity={item} />}
|
||||||
contentContainerStyle={styles.list}
|
contentContainerStyle={styles.list}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={syncing}
|
||||||
|
onRefresh={doSync}
|
||||||
|
tintColor="#60a5fa"
|
||||||
/>
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ActivityCard({ activity }: { activity: ActivitySummary }) {
|
function ActivityCard({ activity }: { activity: ActivitySummary }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const km = activity.distance_m != null
|
const km = activity.distance_m != null ? (activity.distance_m / 1000).toFixed(1) : null;
|
||||||
? (activity.distance_m / 1000).toFixed(1)
|
const elev = activity.elevation_gain_m != null ? Math.round(activity.elevation_gain_m) : null;
|
||||||
: null;
|
|
||||||
const elev = activity.elevation_gain_m != null
|
|
||||||
? Math.round(activity.elevation_gain_m)
|
|
||||||
: null;
|
|
||||||
const date = new Date(activity.started_at).toLocaleDateString(undefined, {
|
const date = new Date(activity.started_at).toLocaleDateString(undefined, {
|
||||||
day: 'numeric', month: 'short', year: 'numeric',
|
day: 'numeric', month: 'short', year: 'numeric',
|
||||||
});
|
});
|
||||||
@@ -51,9 +86,10 @@ function ActivityCard({ activity }: { activity: ActivitySummary }) {
|
|||||||
<Text style={styles.sportIcon}>{sportIcon(activity.sport)}</Text>
|
<Text style={styles.sportIcon}>{sportIcon(activity.sport)}</Text>
|
||||||
<View style={styles.cardMeta}>
|
<View style={styles.cardMeta}>
|
||||||
<Text style={styles.cardDate}>{date}</Text>
|
<Text style={styles.cardDate}>{date}</Text>
|
||||||
{!activity.synced_at && activity.origin === 'local' && (
|
{activity.origin === 'remote'
|
||||||
<Text style={styles.unsyncedBadge}>local</Text>
|
? <Text style={styles.remoteBadge}>cloud</Text>
|
||||||
)}
|
: !activity.synced_at && <Text style={styles.localBadge}>local</Text>
|
||||||
|
}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.cardTitle} numberOfLines={1}>{activity.title}</Text>
|
<Text style={styles.cardTitle} numberOfLines={1}>{activity.title}</Text>
|
||||||
@@ -83,10 +119,21 @@ function sportIcon(sport: string): string {
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: { flex: 1, backgroundColor: '#09090b' },
|
container: { flex: 1, backgroundColor: '#09090b' },
|
||||||
header: {
|
headerRow: {
|
||||||
color: '#fff', fontSize: 22, fontWeight: '700',
|
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
|
||||||
paddingHorizontal: 16, paddingTop: 60, paddingBottom: 12,
|
paddingHorizontal: 16, paddingTop: 60, paddingBottom: 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 },
|
list: { padding: 16, gap: 12 },
|
||||||
card: {
|
card: {
|
||||||
backgroundColor: '#18181b', borderRadius: 12,
|
backgroundColor: '#18181b', borderRadius: 12,
|
||||||
@@ -96,7 +143,11 @@ const styles = StyleSheet.create({
|
|||||||
sportIcon: { fontSize: 20 },
|
sportIcon: { fontSize: 20 },
|
||||||
cardMeta: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
cardMeta: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||||
cardDate: { color: '#71717a', fontSize: 12 },
|
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,
|
color: '#a1a1aa', fontSize: 10, borderWidth: 1,
|
||||||
borderColor: '#3f3f46', borderRadius: 4, paddingHorizontal: 4,
|
borderColor: '#3f3f46', borderRadius: 4, paddingHorizontal: 4,
|
||||||
},
|
},
|
||||||
@@ -106,8 +157,7 @@ const styles = StyleSheet.create({
|
|||||||
statValue: { color: '#f4f4f5', fontSize: 16, fontWeight: '600' },
|
statValue: { color: '#f4f4f5', fontSize: 16, fontWeight: '600' },
|
||||||
statLabel: { color: '#71717a', fontSize: 12 },
|
statLabel: { color: '#71717a', fontSize: 12 },
|
||||||
empty: {
|
empty: {
|
||||||
flex: 1, backgroundColor: '#09090b',
|
flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32,
|
||||||
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 },
|
emptyTitle: { color: '#f4f4f5', fontSize: 18, fontWeight: '600', marginBottom: 8 },
|
||||||
|
|||||||
+108
-12
@@ -1,7 +1,7 @@
|
|||||||
import { useSQLiteContext } from 'expo-sqlite';
|
import { useSQLiteContext } from 'expo-sqlite';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Platform, Pressable, ScrollView, StyleSheet,
|
ActivityIndicator, Platform, Pressable, ScrollView, StyleSheet,
|
||||||
Text, TextInput, View,
|
Text, TextInput, View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { getSetting, setSetting, useSetting } from '@/db/queries';
|
import { getSetting, setSetting, useSetting } from '@/db/queries';
|
||||||
@@ -12,12 +12,17 @@ export default function SettingsScreen() {
|
|||||||
const storedUrl = useSetting('instance_url') ?? '';
|
const storedUrl = useSetting('instance_url') ?? '';
|
||||||
const storedHandle = useSetting('handle') ?? '';
|
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 [instanceUrl, setInstanceUrl] = useState(storedUrl);
|
||||||
const [handle, setHandle] = useState(storedHandle);
|
const [handle, setHandle] = useState(storedHandle);
|
||||||
const [autoPath, setAutoPath] = useState(storedPath);
|
const [autoPath, setAutoPath] = useState(storedPath);
|
||||||
const [saved, setSaved] = useState(false);
|
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() {
|
async function save() {
|
||||||
await setSetting(db, 'instance_url', instanceUrl.trim());
|
await setSetting(db, 'instance_url', instanceUrl.trim());
|
||||||
await setSetting(db, 'handle', handle.trim());
|
await setSetting(db, 'handle', handle.trim());
|
||||||
@@ -28,11 +33,51 @@ export default function SettingsScreen() {
|
|||||||
setTimeout(() => setSaved(false), 2000);
|
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 (
|
return (
|
||||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||||
<Text style={styles.header}>Settings</Text>
|
<Text style={styles.header}>Settings</Text>
|
||||||
|
|
||||||
<Section title="Instance (optional)">
|
<Section title="Instance">
|
||||||
<Field
|
<Field
|
||||||
label="Instance URL"
|
label="Instance URL"
|
||||||
placeholder="https://bincio.org"
|
placeholder="https://bincio.org"
|
||||||
@@ -49,8 +94,53 @@ export default function SettingsScreen() {
|
|||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
/>
|
/>
|
||||||
<Text style={styles.hint}>
|
<Text style={styles.hint}>
|
||||||
Leave blank to use the app without a remote instance. When set, you can
|
Connect to a Bincio instance to sync your activities. Leave blank to use
|
||||||
push activities to the instance and pull the web feed.
|
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>
|
</Text>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
@@ -70,16 +160,9 @@ export default function SettingsScreen() {
|
|||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Pressable style={styles.saveButton} onPress={save}>
|
|
||||||
<Text style={styles.saveButtonText}>
|
|
||||||
{saved ? '✓ Saved' : 'Save'}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
<Section title="About">
|
<Section title="About">
|
||||||
<Row label="Version" value="0.1.0 (Phase 0)" />
|
<Row label="Version" value="0.1.0 (Phase 0.5)" />
|
||||||
<Row label="Schema" value="BAS 1.0" />
|
<Row label="Schema" value="BAS 1.0" />
|
||||||
<Row label="Extraction" value="Pyodide (Phase 1)" />
|
|
||||||
</Section>
|
</Section>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
@@ -156,4 +239,17 @@ const styles = StyleSheet.create({
|
|||||||
paddingVertical: 14, alignItems: 'center', marginBottom: 28,
|
paddingVertical: 14, alignItems: 'center', marginBottom: 28,
|
||||||
},
|
},
|
||||||
saveButtonText: { color: '#fff', fontWeight: '600', fontSize: 16 },
|
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
@@ -52,7 +52,7 @@ export function useActivities(): ActivitySummary[] {
|
|||||||
json_extract(detail_json, '$.duration_s') AS duration_s,
|
json_extract(detail_json, '$.duration_s') AS duration_s,
|
||||||
json_extract(detail_json, '$.elevation_gain_m') AS elevation_gain_m
|
json_extract(detail_json, '$.elevation_gain_m') AS elevation_gain_m
|
||||||
FROM activities
|
FROM activities
|
||||||
ORDER BY created_at DESC
|
ORDER BY json_extract(detail_json, '$.started_at') DESC
|
||||||
`);
|
`);
|
||||||
return rows;
|
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 ───────────────────────────────────────────────────────────────
|
// ── Settings ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function getSetting(
|
export async function getSetting(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
Generated
+9641
File diff suppressed because it is too large
Load Diff
@@ -3,13 +3,14 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./*"]
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"**/*.ts",
|
"**/*.ts",
|
||||||
"**/*.tsx",
|
"**/*.tsx",
|
||||||
".expo/types/**/*.d.ts",
|
".expo/types/**/*.d.ts"
|
||||||
"expo-env.d.ts"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user