feat(mobile): search/filter tab — sport, date, sort; hidden on Karoo
Adds a fourth tab visible only on Android API 29+ (full phone, not Karoo). Filters by sport pill, date preset (7d/30d/6mo/year), and sort order (newest/distance/elevation). Paginated FlatList with the same activity cards. ActivityCard extracted to mobile/components/ActivityCard.tsx so both the feed tab and the new search tab share the same component without duplication.
This commit is contained in:
@@ -1,6 +1,9 @@
|
|||||||
import { Tabs } from 'expo-router';
|
import { Tabs } from 'expo-router';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
import { useTheme } from '@/ThemeContext';
|
import { useTheme } from '@/ThemeContext';
|
||||||
|
|
||||||
|
const isKaroo = Platform.OS === 'android' && (Platform.Version as number) < 29;
|
||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
return (
|
return (
|
||||||
@@ -20,6 +23,14 @@ export default function TabLayout() {
|
|||||||
name="import"
|
name="import"
|
||||||
options={{ title: 'Import', tabBarIcon: ({ color }) => <TabIcon label="↑" color={color} /> }}
|
options={{ title: 'Import', tabBarIcon: ({ color }) => <TabIcon label="↑" color={color} /> }}
|
||||||
/>
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="search"
|
||||||
|
options={{
|
||||||
|
title: 'Search',
|
||||||
|
tabBarIcon: ({ color }) => <TabIcon label="⌕" color={color} />,
|
||||||
|
href: isKaroo ? null : '/search',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="settings"
|
name="settings"
|
||||||
options={{ title: 'Settings', tabBarIcon: ({ color }) => <TabIcon label="⚙" color={color} /> }}
|
options={{ title: 'Settings', tabBarIcon: ({ color }) => <TabIcon label="⚙" color={color} /> }}
|
||||||
|
|||||||
+2
-103
@@ -1,12 +1,12 @@
|
|||||||
import * as FileSystem from 'expo-file-system';
|
import * as FileSystem from 'expo-file-system';
|
||||||
import { useFocusEffect } from 'expo-router';
|
import { useFocusEffect } from 'expo-router';
|
||||||
import { useSQLiteContext } from 'expo-sqlite';
|
import { useSQLiteContext } from 'expo-sqlite';
|
||||||
import { useRouter } from 'expo-router';
|
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { Alert, FlatList, Pressable, RefreshControl, StyleSheet, Text, TextInput, View } from 'react-native';
|
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 { downloadFeed, uploadFeed } from '@/db/sync';
|
||||||
import { useTheme } from '@/ThemeContext';
|
import { useTheme } from '@/ThemeContext';
|
||||||
|
import { ActivityCard } from '@/components/ActivityCard';
|
||||||
|
|
||||||
export default function FeedScreen() {
|
export default function FeedScreen() {
|
||||||
const db = useSQLiteContext();
|
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 (
|
|
||||||
<Pressable
|
|
||||||
style={[styles.card, checked && { borderColor: theme.accent }]}
|
|
||||||
onPress={handlePress}
|
|
||||||
onLongPress={onLongPress}
|
|
||||||
>
|
|
||||||
<View style={styles.cardTop}>
|
|
||||||
<View style={styles.cardLeft}>
|
|
||||||
{selecting && (
|
|
||||||
<View style={[styles.checkbox, checked && { backgroundColor: theme.accent, borderColor: theme.accent }]}>
|
|
||||||
{checked && <Text style={styles.checkmark}>✓</Text>}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
<Text style={styles.sportIcon}>{sportIcon(activity.sport)}</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.cardMeta}>
|
|
||||||
<Text style={styles.cardDate}>{date}</Text>
|
|
||||||
{activity.origin === 'remote'
|
|
||||||
? <Text style={[styles.remoteBadge, { color: theme.accent, borderColor: theme.accent }]}>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} />}
|
|
||||||
{elev != null && <Stat label="m↑" value={String(elev)} />}
|
|
||||||
</View>
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Stat({ label, value }: { label: string; value: string }) {
|
|
||||||
return (
|
|
||||||
<View style={styles.stat}>
|
|
||||||
<Text style={styles.statValue}>{value}</Text>
|
|
||||||
<Text style={styles.statLabel}>{label}</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sportIcon(sport: string): string {
|
|
||||||
const icons: Record<string, string> = {
|
|
||||||
cycling: '🚴', running: '🏃', hiking: '🥾', swimming: '🏊', walking: '🚶',
|
|
||||||
};
|
|
||||||
return icons[sport] ?? '🏅';
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: { flex: 1, backgroundColor: '#09090b' },
|
container: { flex: 1, backgroundColor: '#09090b' },
|
||||||
headerRow: {
|
headerRow: {
|
||||||
@@ -357,33 +283,6 @@ const styles = StyleSheet.create({
|
|||||||
color: '#f4f4f5', fontSize: 14,
|
color: '#f4f4f5', fontSize: 14,
|
||||||
},
|
},
|
||||||
list: { padding: 16, gap: 12, paddingBottom: 80 },
|
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: {
|
empty: {
|
||||||
flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32,
|
flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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<DatePreset>('all');
|
||||||
|
const [sort, setSort] = useState<SortKey>('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 (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.headerRow}>
|
||||||
|
<Text style={styles.header}>Filter</Text>
|
||||||
|
{total > 0 && <Text style={styles.count}>{total} activities</Text>}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}
|
||||||
|
style={styles.pillScroll} contentContainerStyle={styles.pillRow}>
|
||||||
|
{SPORTS.map(s => (
|
||||||
|
<Pill key={s.value} label={s.label} active={sport === s.value} accent={theme.accent}
|
||||||
|
onPress={() => { setSport(s.value); setLimit(PAGE_SIZE); }} />
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}
|
||||||
|
style={styles.pillScroll} contentContainerStyle={styles.pillRow}>
|
||||||
|
{DATE_PRESETS.map(d => (
|
||||||
|
<Pill key={d.value} label={d.label} active={datePre === d.value} accent={theme.accent}
|
||||||
|
onPress={() => { setDatePre(d.value); setLimit(PAGE_SIZE); }} />
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<View style={styles.sortRow}>
|
||||||
|
{SORTS.map(s => (
|
||||||
|
<Pressable key={s.value}
|
||||||
|
style={[styles.sortBtn, sort === s.value && { borderBottomColor: theme.accent, borderBottomWidth: 2 }]}
|
||||||
|
onPress={() => { setSort(s.value); setLimit(PAGE_SIZE); }}>
|
||||||
|
<Text style={[styles.sortText, sort === s.value && { color: theme.accent }]}>{s.label}</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{activities.length === 0 ? (
|
||||||
|
<View style={styles.empty}>
|
||||||
|
<Text style={styles.emptyText}>No activities match</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={activities}
|
||||||
|
keyExtractor={a => a.id}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<ActivityCard activity={item} selecting={false} checked={false}
|
||||||
|
onToggleSelect={() => {}} onLongPress={() => {}} />
|
||||||
|
)}
|
||||||
|
contentContainerStyle={styles.list}
|
||||||
|
onEndReached={() => { if (hasMore) setLimit(l => l + PAGE_SIZE); }}
|
||||||
|
onEndReachedThreshold={0.3}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pill({ label, active, accent, onPress }: {
|
||||||
|
label: string; active: boolean; accent: string; onPress: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
style={[styles.pill, active && { backgroundColor: accent + '33', borderColor: accent }]}
|
||||||
|
onPress={onPress}
|
||||||
|
>
|
||||||
|
<Text style={[styles.pillText, active && { color: accent }]}>{label}</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
});
|
||||||
@@ -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 (
|
||||||
|
<Pressable
|
||||||
|
style={[styles.card, checked && { borderColor: theme.accent }]}
|
||||||
|
onPress={handlePress}
|
||||||
|
onLongPress={onLongPress}
|
||||||
|
>
|
||||||
|
<View style={styles.cardTop}>
|
||||||
|
<View style={styles.cardLeft}>
|
||||||
|
{selecting && (
|
||||||
|
<View style={[styles.checkbox, checked && { backgroundColor: theme.accent, borderColor: theme.accent }]}>
|
||||||
|
{checked && <Text style={styles.checkmark}>✓</Text>}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<Text style={styles.sportIcon}>{sportIcon(activity.sport)}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.cardMeta}>
|
||||||
|
<Text style={styles.cardDate}>{date}</Text>
|
||||||
|
{activity.origin === 'remote'
|
||||||
|
? <Text style={[styles.remoteBadge, { color: theme.accent, borderColor: theme.accent }]}>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} />}
|
||||||
|
{elev != null && <Stat label="m↑" value={String(elev)} />}
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Stat({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<View style={styles.stat}>
|
||||||
|
<Text style={styles.statValue}>{value}</Text>
|
||||||
|
<Text style={styles.statLabel}>{label}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sportIcon(sport: string): string {
|
||||||
|
const icons: Record<string, string> = {
|
||||||
|
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' },
|
||||||
|
});
|
||||||
@@ -74,6 +74,48 @@ export function useActivityCount(searchQuery = ''): number {
|
|||||||
|
|
||||||
export { PAGE_SIZE };
|
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<string, string> = {
|
||||||
|
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<ActivitySummary>(`
|
||||||
|
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 {
|
export function useActivity(id: string): ActivityRow | null {
|
||||||
const db = useSQLiteContext();
|
const db = useSQLiteContext();
|
||||||
return db.getFirstSync<ActivityRow>(
|
return db.getFirstSync<ActivityRow>(
|
||||||
|
|||||||
Reference in New Issue
Block a user