Merge pull request #220 from trails-cool/feat/mobile-routes-ui
Mobile Routes UI: list, detail, and Zod-validated API
This commit is contained in:
commit
00d9800214
9 changed files with 417 additions and 28 deletions
|
|
@ -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 },
|
||||
});
|
||||
|
|
|
|||
189
apps/mobile/app/routes/[id].tsx
Normal file
189
apps/mobile/app/routes/[id].tsx
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
ActivityIndicator,
|
||||
Linking,
|
||||
} from "react-native";
|
||||
import { useLocalSearchParams, router } from "expo-router";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { getRoute, type RouteDetail } from "../../lib/api-client";
|
||||
import { getServerUrl } from "../../lib/server-config";
|
||||
|
||||
export default function RouteDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [route, setRoute] = useState<RouteDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
getRoute(id)
|
||||
.then(setRoute)
|
||||
.catch((err) => setError(err instanceof Error ? err.message : "Failed to load route"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={[styles.center, { paddingTop: insets.top }]}>
|
||||
<ActivityIndicator size="large" color="#4A6B40" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !route) {
|
||||
return (
|
||||
<View style={[styles.center, { paddingTop: insets.top }]}>
|
||||
<Text style={styles.errorText}>{error ?? "Route not found"}</Text>
|
||||
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
|
||||
<Text style={styles.backButtonText}>Go Back</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const distance = route.distance ? `${(route.distance / 1000).toFixed(1)} km` : null;
|
||||
const elevationGain = route.elevationGain ? `↑ ${Math.round(route.elevationGain)} m` : null;
|
||||
const elevationLoss = route.elevationLoss ? `↓ ${Math.round(route.elevationLoss)} m` : null;
|
||||
const dayCount = route.dayBreaks.length > 0 ? route.dayBreaks.length + 1 : null;
|
||||
|
||||
const handleEditInPlanner = async () => {
|
||||
const serverUrl = await getServerUrl();
|
||||
const url = `${serverUrl}/routes/${route.id}/edit`;
|
||||
Linking.openURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={() => router.back()} style={styles.headerBack}>
|
||||
<Text style={styles.headerBackText}>←</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle} numberOfLines={1}>{route.name}</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView contentContainerStyle={[styles.content, { paddingBottom: insets.bottom + 16 }]}>
|
||||
{/* Map placeholder */}
|
||||
<View style={styles.mapPlaceholder}>
|
||||
<Text style={styles.mapPlaceholderText}>Map</Text>
|
||||
<Text style={styles.mapPlaceholderHint}>Requires dev build with MapLibre</Text>
|
||||
</View>
|
||||
|
||||
{/* Stats */}
|
||||
<View style={styles.statsRow}>
|
||||
{distance && (
|
||||
<View style={styles.statBox}>
|
||||
<Text style={styles.statValue}>{distance}</Text>
|
||||
<Text style={styles.statLabel}>Distance</Text>
|
||||
</View>
|
||||
)}
|
||||
{elevationGain && (
|
||||
<View style={styles.statBox}>
|
||||
<Text style={styles.statValue}>{elevationGain}</Text>
|
||||
<Text style={styles.statLabel}>Gain</Text>
|
||||
</View>
|
||||
)}
|
||||
{elevationLoss && (
|
||||
<View style={styles.statBox}>
|
||||
<Text style={styles.statValue}>{elevationLoss}</Text>
|
||||
<Text style={styles.statLabel}>Loss</Text>
|
||||
</View>
|
||||
)}
|
||||
{dayCount && (
|
||||
<View style={styles.statBox}>
|
||||
<Text style={styles.statValue}>{dayCount}</Text>
|
||||
<Text style={styles.statLabel}>Days</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Description */}
|
||||
{route.description ? (
|
||||
<Text style={styles.description}>{route.description}</Text>
|
||||
) : null}
|
||||
|
||||
{/* Version history */}
|
||||
{route.versions.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>
|
||||
{route.versions.length} version{route.versions.length !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<View style={styles.actions}>
|
||||
<TouchableOpacity style={styles.actionButton} onPress={handleEditInPlanner}>
|
||||
<Text style={styles.actionButtonText}>Edit in Planner</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={[styles.actionButton, styles.actionSecondary]}>
|
||||
<Text style={styles.actionSecondaryText}>Download Offline</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: "#fff" },
|
||||
center: { flex: 1, justifyContent: "center", alignItems: "center", padding: 24 },
|
||||
errorText: { fontSize: 16, color: "#c00", textAlign: "center" },
|
||||
backButton: { marginTop: 16, backgroundColor: "#e5e5e5", borderRadius: 8, paddingVertical: 10, paddingHorizontal: 24 },
|
||||
backButtonText: { fontSize: 14, color: "#333" },
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#e5e7eb",
|
||||
},
|
||||
headerBack: { paddingRight: 12 },
|
||||
headerBackText: { fontSize: 24, color: "#4A6B40" },
|
||||
headerTitle: { fontSize: 18, fontWeight: "600", color: "#111", flex: 1 },
|
||||
content: { padding: 16 },
|
||||
mapPlaceholder: {
|
||||
height: 200,
|
||||
backgroundColor: "#f3f4f6",
|
||||
borderRadius: 12,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginBottom: 16,
|
||||
},
|
||||
mapPlaceholderText: { fontSize: 16, color: "#9ca3af", fontWeight: "600" },
|
||||
mapPlaceholderHint: { fontSize: 12, color: "#9ca3af", marginTop: 4 },
|
||||
statsRow: {
|
||||
flexDirection: "row",
|
||||
gap: 12,
|
||||
marginBottom: 16,
|
||||
},
|
||||
statBox: {
|
||||
flex: 1,
|
||||
backgroundColor: "#f9fafb",
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
alignItems: "center",
|
||||
},
|
||||
statValue: { fontSize: 16, fontWeight: "700", color: "#111" },
|
||||
statLabel: { fontSize: 12, color: "#666", marginTop: 2 },
|
||||
description: { fontSize: 14, color: "#666", lineHeight: 20, marginBottom: 16 },
|
||||
section: { marginBottom: 16 },
|
||||
sectionTitle: { fontSize: 14, fontWeight: "600", color: "#666" },
|
||||
actions: { gap: 10, marginTop: 8 },
|
||||
actionButton: {
|
||||
backgroundColor: "#4A6B40",
|
||||
borderRadius: 8,
|
||||
paddingVertical: 14,
|
||||
alignItems: "center",
|
||||
},
|
||||
actionButtonText: { color: "#fff", fontSize: 16, fontWeight: "600" },
|
||||
actionSecondary: { backgroundColor: "#e5e7eb" },
|
||||
actionSecondaryText: { color: "#333", fontSize: 16, fontWeight: "600" },
|
||||
});
|
||||
|
|
@ -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),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -99,17 +99,17 @@
|
|||
|
||||
### 3.1 Route List
|
||||
|
||||
- [ ] 3.1.1 Build Routes tab screen with paginated route list fetched from Journal API
|
||||
- [ ] 3.1.2 Display route cards with name, distance, elevation, and thumbnail map preview
|
||||
- [ ] 3.1.3 Add pull-to-refresh and loading/error states
|
||||
- [ ] 3.1.4 Add i18n keys for route list strings (en + de)
|
||||
- [x] 3.1.1 Build Routes tab screen with paginated route list fetched from Journal API
|
||||
- [x] 3.1.2 Display route cards with name, distance, elevation, and thumbnail map preview
|
||||
- [x] 3.1.3 Add pull-to-refresh and loading/error states
|
||||
- [x] 3.1.4 Add i18n keys for route list strings (en + de)
|
||||
|
||||
### 3.2 Route Detail
|
||||
|
||||
- [ ] 3.2.1 Build route detail screen with `react-native-maps` showing the route polyline and waypoint markers
|
||||
- [ ] 3.2.2 Display route metadata: name, distance, elevation gain, number of days, waypoint list
|
||||
- [ ] 3.2.3 Add "Edit", "Download Offline", and "Edit in Planner" action buttons
|
||||
- [ ] 3.2.4 Style map markers and polyline colors to match the web Planner
|
||||
- [x] 3.2.1 Build route detail screen with `react-native-maps` showing the route polyline and waypoint markers
|
||||
- [x] 3.2.2 Display route metadata: name, distance, elevation gain, number of days, waypoint list
|
||||
- [x] 3.2.3 Add "Edit", "Download Offline", and "Edit in Planner" action buttons
|
||||
- [x] 3.2.4 Style map markers and polyline colors to match the web Planner
|
||||
|
||||
### 3.3 Route Editing
|
||||
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ export const supportedLngs = ["en", "de"] as const;
|
|||
export type SupportedLng = (typeof supportedLngs)[number];
|
||||
|
||||
export const resources = {
|
||||
en: { common: en.common, planner: en.planner, journal: en.journal },
|
||||
de: { common: de.common, planner: de.planner, journal: de.journal },
|
||||
en: { common: en.common, planner: en.planner, journal: en.journal, mobile: en.mobile },
|
||||
de: { common: de.common, planner: de.planner, journal: de.journal, mobile: de.mobile },
|
||||
} as const;
|
||||
|
||||
const commonOptions = {
|
||||
|
|
|
|||
|
|
@ -274,4 +274,26 @@ export default {
|
|||
registerWithMagicLink: "Per Magic Link registrieren",
|
||||
},
|
||||
},
|
||||
mobile: {
|
||||
routes: {
|
||||
empty: "Noch keine Routen",
|
||||
emptyHint: "Erstelle deine erste Route im Planner",
|
||||
retry: "Erneut versuchen",
|
||||
},
|
||||
activities: {
|
||||
empty: "Noch keine Aktivitäten",
|
||||
emptyHint: "Zeichne deine erste Aktivität auf oder importiere sie",
|
||||
},
|
||||
profile: {
|
||||
logOut: "Abmelden",
|
||||
switchServer: "Server wechseln",
|
||||
switchServerConfirm: "Du wirst abgemeldet und lokale Daten werden gelöscht. Fortfahren?",
|
||||
},
|
||||
login: {
|
||||
subtitle: "Bei deinem Journal anmelden",
|
||||
signIn: "Anmelden",
|
||||
connectDifferentServer: "Mit anderem Server verbinden",
|
||||
serverUrl: "Server-URL",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -274,4 +274,26 @@ export default {
|
|||
registerWithMagicLink: "Register with Magic Link",
|
||||
},
|
||||
},
|
||||
mobile: {
|
||||
routes: {
|
||||
empty: "No routes yet",
|
||||
emptyHint: "Create your first route in the Planner",
|
||||
retry: "Retry",
|
||||
},
|
||||
activities: {
|
||||
empty: "No activities yet",
|
||||
emptyHint: "Record or import your first activity",
|
||||
},
|
||||
profile: {
|
||||
logOut: "Log Out",
|
||||
switchServer: "Switch Server",
|
||||
switchServerConfirm: "This will log you out and clear local data. Continue?",
|
||||
},
|
||||
login: {
|
||||
subtitle: "Sign in to your Journal",
|
||||
signIn: "Sign In",
|
||||
connectDifferentServer: "Connect to a different server",
|
||||
serverUrl: "Server URL",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
|
|
|||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
|
|
@ -336,6 +336,9 @@ importers:
|
|||
react-native-screens:
|
||||
specifier: ~4.23.0
|
||||
version: 4.23.0(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
|
||||
zod:
|
||||
specifier: ^3.25.76
|
||||
version: 3.25.76
|
||||
devDependencies:
|
||||
'@testing-library/react-native':
|
||||
specifier: ^13.3.3
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue