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(