diff --git a/mobile/app/(tabs)/_layout.tsx b/mobile/app/(tabs)/_layout.tsx index fe2925c..02b5080 100644 --- a/mobile/app/(tabs)/_layout.tsx +++ b/mobile/app/(tabs)/_layout.tsx @@ -1,6 +1,9 @@ import { Tabs } from 'expo-router'; +import { Platform } from 'react-native'; import { useTheme } from '@/ThemeContext'; +const isKaroo = Platform.OS === 'android' && (Platform.Version as number) < 29; + export default function TabLayout() { const theme = useTheme(); return ( @@ -20,6 +23,14 @@ export default function TabLayout() { name="import" options={{ title: 'Import', tabBarIcon: ({ color }) => }} /> + , + href: isKaroo ? null : '/search', + }} + /> }} diff --git a/mobile/app/(tabs)/index.tsx b/mobile/app/(tabs)/index.tsx index c8122f9..002c17b 100644 --- a/mobile/app/(tabs)/index.tsx +++ b/mobile/app/(tabs)/index.tsx @@ -1,12 +1,12 @@ 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, TextInput, View } from 'react-native'; -import { deleteActivities, useActivities, useActivityCount, PAGE_SIZE, type ActivitySummary } from '@/db/queries'; +import { deleteActivities, useActivities, useActivityCount, PAGE_SIZE } from '@/db/queries'; import { downloadFeed, uploadFeed } from '@/db/sync'; import { useTheme } from '@/ThemeContext'; +import { ActivityCard } from '@/components/ActivityCard'; export default function FeedScreen() { const db = useSQLiteContext(); @@ -255,80 +255,6 @@ function ActionButton({ ); } -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 ( - - - - {selecting && ( - - {checked && } - - )} - {sportIcon(activity.sport)} - - - {date} - {activity.origin === 'remote' - ? cloud - : !activity.synced_at && local - } - - - {activity.title} - - {km && } - {elev != null && } - - - ); -} - -function Stat({ label, value }: { label: string; value: string }) { - return ( - - {value} - {label} - - ); -} - -function sportIcon(sport: string): string { - const icons: Record = { - cycling: '🚴', running: '🏃', hiking: '🥾', swimming: '🏊', walking: '🚶', - }; - return icons[sport] ?? '🏅'; -} - const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#09090b' }, headerRow: { @@ -357,33 +283,6 @@ const styles = StyleSheet.create({ color: '#f4f4f5', fontSize: 14, }, 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, }, diff --git a/mobile/app/(tabs)/search.tsx b/mobile/app/(tabs)/search.tsx new file mode 100644 index 0000000..0a61849 --- /dev/null +++ b/mobile/app/(tabs)/search.tsx @@ -0,0 +1,145 @@ +import { useState } from 'react'; +import { FlatList, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { PAGE_SIZE, useFilteredActivities, useFilteredCount, type ActivityFilter } from '@/db/queries'; +import { ActivityCard } from '@/components/ActivityCard'; +import { useTheme } from '@/ThemeContext'; + +type DatePreset = 'all' | '7d' | '30d' | '6mo' | 'year'; +type SortKey = 'date' | 'distance' | 'elevation'; + +const SPORTS = [ + { value: '', label: 'All' }, + { value: 'cycling', label: '🚴 Cycling' }, + { value: 'running', label: '🏃 Running' }, + { value: 'hiking', label: '🥾 Hiking' }, + { value: 'swimming', label: '🏊 Swimming' }, + { value: 'walking', label: '🚶 Walking' }, +]; + +const DATE_PRESETS: { value: DatePreset; label: string }[] = [ + { value: 'all', label: 'All time' }, + { value: '7d', label: '7 days' }, + { value: '30d', label: '30 days' }, + { value: '6mo', label: '6 months' }, + { value: 'year', label: 'This year' }, +]; + +const SORTS: { value: SortKey; label: string }[] = [ + { value: 'date', label: 'Newest' }, + { value: 'distance', label: 'Distance' }, + { value: 'elevation', label: 'Elevation' }, +]; + +function computeDateFrom(preset: DatePreset): string { + if (preset === 'all') return ''; + const pad = (n: number) => String(n).padStart(2, '0'); + const now = new Date(); + let d: Date; + if (preset === '7d') d = new Date(now.getTime() - 7 * 86_400_000); + else if (preset === '30d') d = new Date(now.getTime() - 30 * 86_400_000); + else if (preset === '6mo') { d = new Date(now); d.setMonth(d.getMonth() - 6); } + else d = new Date(now.getFullYear(), 0, 1); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T000000Z`; +} + +export default function SearchScreen() { + const theme = useTheme(); + const [sport, setSport] = useState(''); + const [datePre, setDatePre] = useState('all'); + const [sort, setSort] = useState('date'); + const [limit, setLimit] = useState(PAGE_SIZE); + + const filter: ActivityFilter = { sport, dateFrom: computeDateFrom(datePre), sort }; + const activities = useFilteredActivities(filter, limit); + const total = useFilteredCount(filter); + const hasMore = activities.length < total; + + return ( + + + Filter + {total > 0 && {total} activities} + + + + {SPORTS.map(s => ( + { setSport(s.value); setLimit(PAGE_SIZE); }} /> + ))} + + + + {DATE_PRESETS.map(d => ( + { setDatePre(d.value); setLimit(PAGE_SIZE); }} /> + ))} + + + + {SORTS.map(s => ( + { setSort(s.value); setLimit(PAGE_SIZE); }}> + {s.label} + + ))} + + + {activities.length === 0 ? ( + + No activities match + + ) : ( + a.id} + renderItem={({ item }) => ( + {}} onLongPress={() => {}} /> + )} + contentContainerStyle={styles.list} + onEndReached={() => { if (hasMore) setLimit(l => l + PAGE_SIZE); }} + onEndReachedThreshold={0.3} + /> + )} + + ); +} + +function Pill({ label, active, accent, onPress }: { + label: string; active: boolean; accent: string; onPress: () => void; +}) { + return ( + + {label} + + ); +} + +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' }, + count: { color: '#71717a', fontSize: 13 }, + pillScroll: { flexGrow: 0 }, + pillRow: { flexDirection: 'row', gap: 8, paddingHorizontal: 16, paddingBottom: 10 }, + pill: { + borderRadius: 20, borderWidth: 1, borderColor: '#3f3f46', + paddingHorizontal: 14, paddingVertical: 7, + }, + pillText: { color: '#a1a1aa', fontSize: 13, fontWeight: '500' }, + sortRow: { flexDirection: 'row', paddingHorizontal: 16, marginBottom: 4 }, + sortBtn: { marginRight: 24, paddingBottom: 8, borderBottomWidth: 2, borderBottomColor: 'transparent' }, + sortText: { color: '#71717a', fontSize: 13, fontWeight: '600' }, + list: { padding: 16, gap: 12, paddingBottom: 80 }, + empty: { flex: 1, alignItems: 'center', justifyContent: 'center' }, + emptyText: { color: '#52525b', fontSize: 15 }, +}); diff --git a/mobile/components/ActivityCard.tsx b/mobile/components/ActivityCard.tsx new file mode 100644 index 0000000..703b8b5 --- /dev/null +++ b/mobile/components/ActivityCard.tsx @@ -0,0 +1,108 @@ +import { useRouter } from 'expo-router'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import type { ActivitySummary } from '@/db/queries'; +import { useTheme } from '@/ThemeContext'; + +export 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 ( + + + + {selecting && ( + + {checked && } + + )} + {sportIcon(activity.sport)} + + + {date} + {activity.origin === 'remote' + ? cloud + : !activity.synced_at && local + } + + + {activity.title} + + {km && } + {elev != null && } + + + ); +} + +export function Stat({ label, value }: { label: string; value: string }) { + return ( + + {value} + {label} + + ); +} + +export function sportIcon(sport: string): string { + const icons: Record = { + cycling: '🚴', running: '🏃', hiking: '🥾', swimming: '🏊', walking: '🚶', + }; + return icons[sport] ?? '🏅'; +} + +const styles = StyleSheet.create({ + 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' }, +}); diff --git a/mobile/db/queries.ts b/mobile/db/queries.ts index 1d0291b..33ae589 100644 --- a/mobile/db/queries.ts +++ b/mobile/db/queries.ts @@ -74,6 +74,48 @@ export function useActivityCount(searchQuery = ''): number { export { PAGE_SIZE }; +export type ActivityFilter = { + sport: string; // '' = all sports + dateFrom: string; // '' = no lower bound; ISO-like 'YYYY-MM-DDTHHMMSSZ' for comparison + sort: 'date' | 'distance' | 'elevation'; +}; + +const SORT_SQL: Record = { + date: "json_extract(detail_json, '$.started_at') DESC", + distance: "json_extract(detail_json, '$.distance_m') DESC", + elevation: "json_extract(detail_json, '$.elevation_gain_m') DESC", +}; + +export function useFilteredActivities(filter: ActivityFilter, limit = PAGE_SIZE): ActivitySummary[] { + const db = useSQLiteContext(); + const order = SORT_SQL[filter.sort] ?? SORT_SQL.date; + return db.getAllSync(` + SELECT + id, origin, synced_at, + json_extract(detail_json, '$.title') AS title, + json_extract(detail_json, '$.sport') AS sport, + json_extract(detail_json, '$.started_at') AS started_at, + json_extract(detail_json, '$.distance_m') AS distance_m, + json_extract(detail_json, '$.duration_s') AS duration_s, + json_extract(detail_json, '$.elevation_gain_m') AS elevation_gain_m + FROM activities + WHERE (? = '' OR json_extract(detail_json, '$.sport') = ?) + AND (? = '' OR json_extract(detail_json, '$.started_at') >= ?) + ORDER BY ${order} + LIMIT ? + `, [filter.sport, filter.sport, filter.dateFrom, filter.dateFrom, limit]); +} + +export function useFilteredCount(filter: ActivityFilter): number { + const db = useSQLiteContext(); + const row = db.getFirstSync<{ n: number }>(` + SELECT COUNT(*) as n FROM activities + WHERE (? = '' OR json_extract(detail_json, '$.sport') = ?) + AND (? = '' OR json_extract(detail_json, '$.started_at') >= ?) + `, [filter.sport, filter.sport, filter.dateFrom, filter.dateFrom]); + return row?.n ?? 0; +} + export function useActivity(id: string): ActivityRow | null { const db = useSQLiteContext(); return db.getFirstSync(