Build Routes tab with paginated list and Zod-validated API responses

Routes tab (Phase 3.1):
- Paginated FlatList fetching from Journal REST API
- Route cards with name, distance (km), elevation gain, day count
- Pull-to-refresh, loading spinner, error state with retry, empty state
- Safe area insets for notch and tab bar

API client:
- Import types from @trails-cool/api instead of hand-written interfaces
- Parse list/detail responses through Zod schemas at runtime
- Add zod as mobile app dependency

i18n:
- Add mobile namespace (en + de) for routes, activities, profile, login

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-13 01:10:09 +02:00
parent 05659bcfc1
commit 77e3c1b1ee
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
8 changed files with 224 additions and 24 deletions

View file

@ -1,14 +1,151 @@
import { View, Text, StyleSheet } from "react-native";
import { useState, useEffect, useCallback } from "react";
import {
View,
Text,
FlatList,
TouchableOpacity,
StyleSheet,
ActivityIndicator,
RefreshControl,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { router } from "expo-router";
import { listRoutes, type RouteSummary } from "../../lib/api-client";
export default function RoutesScreen() {
const insets = useSafeAreaInsets();
const [routes, setRoutes] = useState<RouteSummary[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchRoutes = useCallback(async (cursor?: string) => {
try {
const data = await listRoutes(cursor);
if (cursor) {
setRoutes((prev) => [...prev, ...data.routes]);
} else {
setRoutes(data.routes);
}
setNextCursor(data.nextCursor);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load routes");
}
}, []);
useEffect(() => {
fetchRoutes().finally(() => setLoading(false));
}, [fetchRoutes]);
const handleRefresh = async () => {
setRefreshing(true);
await fetchRoutes();
setRefreshing(false);
};
const handleLoadMore = async () => {
if (!nextCursor || loadingMore) return;
setLoadingMore(true);
await fetchRoutes(nextCursor);
setLoadingMore(false);
};
if (loading) {
return (
<View style={[styles.center, { paddingTop: insets.top }]}>
<ActivityIndicator size="large" color="#4A6B40" />
</View>
);
}
if (error && routes.length === 0) {
return (
<View style={[styles.center, { paddingTop: insets.top }]}>
<Text style={styles.errorText}>{error}</Text>
<TouchableOpacity style={styles.retryButton} onPress={() => { setLoading(true); fetchRoutes().finally(() => setLoading(false)); }}>
<Text style={styles.retryText}>Retry</Text>
</TouchableOpacity>
</View>
);
}
return (
<View style={styles.container}>
<Text style={styles.text}>Routes</Text>
</View>
<FlatList
data={routes}
keyExtractor={(item) => item.id}
contentContainerStyle={[
routes.length === 0 ? styles.emptyContainer : styles.list,
{ paddingTop: (routes.length === 0 ? 0 : 16) + insets.top, paddingBottom: insets.bottom },
]}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} tintColor="#4A6B40" />
}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.5}
ListEmptyComponent={
<View style={styles.center}>
<Text style={styles.emptyText}>No routes yet</Text>
<Text style={styles.emptySubtext}>Create your first route in the Planner</Text>
</View>
}
ListFooterComponent={loadingMore ? <ActivityIndicator style={styles.footer} color="#4A6B40" /> : null}
renderItem={({ item }) => <RouteCard route={item} />}
/>
);
}
function RouteCard({ route }: { route: RouteSummary }) {
const distance = route.distance ? `${(route.distance / 1000).toFixed(1)} km` : null;
const elevation = route.elevationGain ? `${Math.round(route.elevationGain)} m` : null;
const days = route.dayBreaks.length > 0 ? `${route.dayBreaks.length + 1} days` : null;
return (
<TouchableOpacity
style={styles.card}
onPress={() => router.push(`/routes/${route.id}` as never)}
activeOpacity={0.7}
>
<Text style={styles.cardName} numberOfLines={1}>{route.name}</Text>
<View style={styles.cardStats}>
{distance && <Text style={styles.cardStat}>{distance}</Text>}
{elevation && <Text style={styles.cardStat}>{elevation}</Text>}
{days && <Text style={styles.cardStat}>{days}</Text>}
</View>
{route.description ? (
<Text style={styles.cardDescription} numberOfLines={2}>{route.description}</Text>
) : null}
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: "center", alignItems: "center" },
text: { fontSize: 18, color: "#666" },
center: { flex: 1, justifyContent: "center", alignItems: "center", padding: 24 },
list: { padding: 16, gap: 12 },
emptyContainer: { flexGrow: 1, justifyContent: "center", alignItems: "center", padding: 24 },
emptyText: { fontSize: 18, color: "#666", fontWeight: "600" },
emptySubtext: { fontSize: 14, color: "#999", marginTop: 4 },
errorText: { fontSize: 16, color: "#c00", textAlign: "center" },
retryButton: {
marginTop: 16,
backgroundColor: "#4A6B40",
borderRadius: 8,
paddingVertical: 10,
paddingHorizontal: 24,
},
retryText: { color: "#fff", fontSize: 14, fontWeight: "600" },
footer: { paddingVertical: 16 },
card: {
backgroundColor: "#fff",
borderRadius: 12,
padding: 16,
borderWidth: 1,
borderColor: "#e5e7eb",
},
cardName: { fontSize: 16, fontWeight: "600", color: "#111" },
cardStats: { flexDirection: "row", gap: 12, marginTop: 6 },
cardStat: { fontSize: 13, color: "#666" },
cardDescription: { fontSize: 13, color: "#999", marginTop: 6 },
});

View file

@ -1,4 +1,16 @@
import {
RouteListResponseSchema,
RouteDetailSchema,
ActivityListResponseSchema,
type RouteSummary,
type RouteDetail,
type RouteListResponse,
type ActivitySummary,
type ActivityListResponse,
} from "@trails-cool/api";
import { getServerUrl } from "./server-config";
export type { RouteSummary, RouteDetail, RouteListResponse, ActivitySummary, ActivityListResponse };
import { getAccessToken, refreshTokens, clearTokens } from "./auth";
export class ApiError extends Error {
@ -75,25 +87,27 @@ async function request<T>(
// --- Routes ---
export function listRoutes(cursor?: string, limit = 20) {
export async function listRoutes(cursor?: string, limit = 20) {
const params = new URLSearchParams({ limit: String(limit) });
if (cursor) params.set("cursor", cursor);
return request<unknown>(`/routes?${params}`);
const data = await request<unknown>(`/routes?${params}`);
return RouteListResponseSchema.parse(data);
}
export function getRoute(id: string) {
return request<unknown>(`/routes/${id}`);
export async function getRoute(id: string) {
const data = await request<unknown>(`/routes/${id}`);
return RouteDetailSchema.parse(data);
}
export function createRoute(data: { name: string; description?: string; gpx?: string }) {
return request<unknown>("/routes", {
return request<{ id: string }>("/routes", {
method: "POST",
body: JSON.stringify(data),
});
}
export function updateRoute(id: string, data: { name?: string; description?: string; gpx?: string }) {
return request<unknown>(`/routes/${id}`, {
return request<{ ok: boolean }>(`/routes/${id}`, {
method: "PUT",
body: JSON.stringify(data),
});
@ -101,14 +115,15 @@ export function updateRoute(id: string, data: { name?: string; description?: str
// --- Activities ---
export function listActivities(cursor?: string, limit = 20) {
export async function listActivities(cursor?: string, limit = 20) {
const params = new URLSearchParams({ limit: String(limit) });
if (cursor) params.set("cursor", cursor);
return request<unknown>(`/activities?${params}`);
const data = await request<unknown>(`/activities?${params}`);
return ActivityListResponseSchema.parse(data);
}
export function getActivity(id: string) {
return request<unknown>(`/activities/${id}`);
export async function getActivity(id: string) {
return request<ActivitySummary & { gpx: string | null; geojson: string | null }>(`/activities/${id}`);
}
export function createActivity(data: {
@ -120,7 +135,7 @@ export function createActivity(data: {
duration?: number;
distance?: number;
}) {
return request<unknown>("/activities", {
return request<{ id: string }>("/activities", {
method: "POST",
body: JSON.stringify(data),
});

View file

@ -33,7 +33,8 @@
"react": "19.2.0",
"react-native": "0.83.4",
"react-native-safe-area-context": "~5.6.2",
"react-native-screens": "~4.23.0"
"react-native-screens": "~4.23.0",
"zod": "^3.25.76"
},
"devDependencies": {
"@testing-library/react-native": "^13.3.3",