diff --git a/README.md b/README.md index 1562311..465b79c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Trailhead -A reusable Expo app for testing local `stim-cli` iOS and Android workflows. +A reusable Expo app for testing local `stim` iOS and Android workflows. Browse a catalogue of 200 trails with route maps and elevation profiles, record a hike with live GPS tracking, and review your history with per-month stats and @@ -35,11 +35,11 @@ The native projects are committed; `expo prebuild` is not part of the build. ```bash npm install bundle install -npx --package=stim-cli stim doctor -npx --package=stim-cli stim start -npx --package=stim-cli stim ios # or: npx --package=stim-cli stim android -npx --package=stim-cli stim logs --errors -npx --package=stim-cli stim stop +npx stim doctor +npx stim start +npx stim ios # or: npx stim android +npx stim logs --errors +npx stim stop ``` Android builds do not require secrets. To show Android map tiles, copy diff --git a/app/_layout.tsx b/app/_layout.tsx index c9d63e6..fca009b 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,10 +1,13 @@ -import { useEffect } from 'react'; +import { Fragment, useEffect, type ComponentProps, type ComponentType } from 'react'; +import { Platform } from 'react-native'; import { Stack } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import * as SplashScreen from 'expo-splash-screen'; +import Transition, { withScreenTransitions, type NativeStackAdapterOptions, type ScreenTransitionConfig } from 'react-native-screen-transitions'; +import { useReducedMotion } from 'react-native-reanimated'; import { initializeDatabase } from '@/src/db/client'; import { seedHikesIfEmpty } from '@/src/db/seed'; @@ -16,6 +19,35 @@ const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 60_000, retry: 1 } }, }); +// Adapt Expo Router's own stack so its routing and navigation context stay intact. +const TransitionStack = withScreenTransitions({ + Navigator: Stack, + Screen: Stack.Screen, + Group: Fragment, +}).Navigator; + +type RouterScreenProps = ComponentProps; +type RouterScreenOptionsCallback = Extract unknown>; +type TransitionScreenOptions = NativeStackAdapterOptions>; + +// The adapter accepts gesture directions beyond Expo Router's native-stack types. +const TransitionScreen = Stack.Screen as ComponentType & { + options?: TransitionScreenOptions | ((props: Parameters[0]) => TransitionScreenOptions); +}>; + +function trailTransition(id: string) { + return { + navigationMaskEnabled: Platform.OS === 'ios', + gestureEnabled: true, + gestureDirection: ['horizontal', 'horizontal-inverted', 'vertical'], + transitionSpec: Transition.Specs.Zoom, + screenStyleInterpolator: ({ bounds }) => { + 'worklet'; + return bounds(`trail-${id}`).navigation.zoom({ target: 'bound', borderRadius: 48 }); + }, + } satisfies ScreenTransitionConfig; +} + // Both are synchronous and idempotent, and the schema has to exist before any // screen queries it. Doing this at module scope rather than in an effect means // the first render already has a usable database, with no loading state and no @@ -25,6 +57,7 @@ seedHikesIfEmpty(); export default function RootLayout() { const { name, colors } = useTheme(); + const reducedMotion = useReducedMotion(); useEffect(() => { let active = true; @@ -43,7 +76,7 @@ export default function RootLayout() { - - + { + const id = (route.params as { id?: string } | undefined)?.id; + return { + title: 'Trail', + headerShown: false, + // Keep the list visible behind the library's animated content and backdrop. + contentStyle: { backgroundColor: 'transparent' }, + ...(id && !reducedMotion + ? { ...trailTransition(id), enableTransitions: true } + : { animation: 'none' as const }), + }; + }} + /> - + diff --git a/app/trail/[id].tsx b/app/trail/[id].tsx index 94cf443..1fa0de8 100644 --- a/app/trail/[id].tsx +++ b/app/trail/[id].tsx @@ -1,7 +1,9 @@ import { Ionicons } from '@expo/vector-icons'; import { Image } from 'expo-image'; -import { Stack, useLocalSearchParams } from 'expo-router'; -import { ScrollView, StyleSheet, Text, View } from 'react-native'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import Transition from 'react-native-screen-transitions'; import { RouteMap } from '@/src/components/RouteMap'; import { difficultyColor, difficultyLabel } from '@/src/components/TrailCard'; @@ -10,21 +12,53 @@ import { formatDistance, formatDuration, formatElevation } from '@/src/lib/forma import { useSettingsStore } from '@/src/store/settingsStore'; import { radius, spacing, useTheme } from '@/src/theme'; +function TrailBackButton() { + const { colors } = useTheme(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + + return ( + router.canGoBack() ? router.back() : router.replace('/')} + style={({ pressed }) => ({ + position: 'absolute', + top: insets.top + spacing.sm, + left: spacing.lg, + zIndex: 2, + width: 48, + height: 48, + borderRadius: 24, + backgroundColor: colors.surface, + alignItems: 'center', + justifyContent: 'center', + opacity: pressed ? 0.6 : 1, + })} + > + + + ); +} + export default function TrailDetailScreen() { const { id } = useLocalSearchParams<{ id: string }>(); const { colors } = useTheme(); + const insets = useSafeAreaInsets(); const units = useSettingsStore((s) => s.units); const trail = getTrail(id); if (!trail) { return ( - - - - Trail unavailable - - This trail is no longer part of the guide. - + + + + + Trail unavailable + + This trail is no longer part of the guide. + + ); } @@ -36,118 +70,137 @@ export default function TrailDetailScreen() { const accent = difficultyColor(trail.difficulty, colors); return ( - - - - + + + + + - - {trail.name} - - - {trail.region} - - - {difficultyLabel(trail.difficulty)} - + + + {trail.name} + + + {trail.region} + + + {difficultyLabel(trail.difficulty)} + + + + + + {trail.rating.toFixed(1)} + + {trail.reviewCount} reviews + + - - - - {trail.rating.toFixed(1)} - - {trail.reviewCount} reviews - - - - - - - - - - - {formatDistance(trail.distanceKm * 1000, units)} - - Distance - - - - - {formatElevation(trail.elevationGainM, units)} - - Ascent - - - - - {formatDuration(trail.estimatedMinutes * 60)} - - Est. time - - + + + - - About this trail - {trail.description} - + + + + {formatDistance(trail.distanceKm * 1000, units)} + + Distance + + + + + {formatElevation(trail.elevationGainM, units)} + + Ascent + + + + + {formatDuration(trail.estimatedMinutes * 60)} + + Est. time + + - - {trail.tags.map((tag) => ( - {tag} + About this trail + {trail.description} - ))} - - - - - Elevation gain - - - {formatElevation(trail.elevationGainM, units)} - - - {averageGrade.toFixed(1)}% average grade over {formatDistance(trail.distanceKm * 1000, units)} - - - - High point - - {formatElevation(highPoint, units)} - + + {trail.tags.map((tag) => ( + + {tag} + + ))} - - Low point - - {formatElevation(lowPoint, units)} + + + + + Elevation gain + + + {formatElevation(trail.elevationGainM, units)} + + {averageGrade.toFixed(1)}% average grade over {formatDistance(trail.distanceKm * 1000, units)} + + + + High point + + {formatElevation(highPoint, units)} + + + + Low point + + {formatElevation(lowPoint, units)} + + + - - + + + ); } const styles = StyleSheet.create({ - content: { paddingBottom: spacing.xxl, gap: spacing.lg }, - hero: { width: '100%', height: 220 }, section: { paddingHorizontal: spacing.lg, gap: spacing.sm }, name: { fontSize: 24, fontWeight: '700' }, metaRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs }, diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 36fe129..b1d9984 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -3074,7 +3074,7 @@ SPEC CHECKSUMS: ExpoLinking: b616f5d0c2e11db3a5cccd8ae6bff45294929fd2 ExpoLocation: cc81013b678d75014ae395cd4707c0136e88cfb1 ExpoLogBox: 80957303325cd4439137765c90a87d7f22aaf2ff - ExpoModulesCore: 6fe78554a4ceae88cf0852cb70844d0ae59aee76 + ExpoModulesCore: 3040e06901eaa0dd1a66225d81ea9cffa00bf92a ExpoModulesJSI: 1529d4289b9a3647d8901a27b41f1af173598b45 ExpoModulesWorklets: 57a11e6ae984c2680e7967b785ded96199caed8c ExpoModulesWorkletsAdapter: be52ecb2cbbed64febbb739971bd77d9c8e84569 @@ -3107,7 +3107,7 @@ SPEC CHECKSUMS: React: 52dce66f71097ed7a4968673d95eeb8095ded74e React-callinvoker: afa0525ab27f4ffbb4c377f855fe9368edb099bc React-Core: fdb67992e049f2526ade3e258c21b8404e7943a3 - React-Core-prebuilt: 9abc518a8aa7aa4fa3bbbe52bc825bcd9fb3c3ce + React-Core-prebuilt: d9cc54d89e7749f16065f8a8533f7359b4dbfb79 React-CoreModules: 456d91152aa283ddc6ae8abfcec3292af88912f6 React-cxxreact: 73a20c0dde5ec221f98d5e4da0e71349ab48353d React-debug: d06757ac6c78b84af0c31e513a83662d6b8d0ac7 @@ -3174,7 +3174,7 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: bb350d1333fd459c1aabe782088419a9b8894d3a ReactCodegen: 828f54f35f2bc4ee3f39345144ff0ec22c9535c2 ReactCommon: 02f6a489d5eb4ab79d7deba5cd4434e0ba351a07 - ReactNativeDependencies: 6738e602b46fd24aa453cd58267ec3bb8bf29d27 + ReactNativeDependencies: 9dfca41884ce7a7f361566fcd151852e0952d0ed RNCMaskedView: eb2b2e538afa907f05a5848a1a1ac26092e6fec9 RNGestureHandler: 92172e79df6e88e0e93b6341f9dcd8b5a02746ba RNReanimated: fcf6a8cdd698fb09a811a8d6c5bcfe36575d965d diff --git a/package-lock.json b/package-lock.json index 54ef30b..b440484 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@expo/vector-icons": "^15.0.2", + "@react-native-masked-view/masked-view": "0.3.2", "@react-navigation/bottom-tabs": "^7.3.10", "@react-navigation/elements": "^2.3.8", "@react-navigation/native": "^7.1.6", @@ -55,6 +56,7 @@ "react-native-nitro-modules": "^0.36.5", "react-native-reanimated": "4.5.1", "react-native-safe-area-context": "~5.7.0", + "react-native-screen-transitions": "4.0.0", "react-native-screens": "~4.26.0", "react-native-svg": "15.15.4", "react-native-web": "^0.21.2", @@ -68,6 +70,7 @@ "drizzle-kit": "^0.31.10", "eslint": "^9.25.0", "eslint-config-expo": "~57.0.2", + "stim": "1.0.0", "typescript": "~6.0.3" } }, @@ -3928,6 +3931,43 @@ "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", "license": "MIT" }, + "node_modules/@stim-cli/cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@stim-cli/cache/-/cache-1.0.0.tgz", + "integrity": "sha512-m7lgNcjrQxtQujsfpb/n+j+J9Dpy9U/3wE2bbeqevr8txkon2B7mhc6T2m74tNM+GqEZS5xBsXZSt9zNgQlm5w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.4 || >=22.12.0" + } + }, + "node_modules/@stim-cli/core": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@stim-cli/core/-/core-1.0.0.tgz", + "integrity": "sha512-KY/hBLFY40dZF1chExNJLr6VRnFoDRWmE9vkboImNbwwLwIi+u8rOMyaYNJp7eCEpATS2TNQVaYV3NU4qW+oaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.4 || >=22.12.0" + } + }, + "node_modules/@stim-cli/metro": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@stim-cli/metro/-/metro-1.0.0.tgz", + "integrity": "sha512-7yJFQ5cDM2hgRAL2XZUcQswl6vGLCEEDFQkWJzG/Okc9vJkVLMYOY1JsGW1HscUfninsGl7Z3x0HhwDAWLUiNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@stim-cli/cache": "^1.0.0", + "@stim-cli/core": "^1.0.0" + }, + "engines": { + "node": "^20.19.4 || >=22.12.0" + }, + "peerDependencies": { + "metro-cache": "*" + } + }, "node_modules/@tanstack/query-core": { "version": "5.101.4", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", @@ -10461,6 +10501,18 @@ "node": ">= 6.13.0" } }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "dev": true, + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -11440,6 +11492,47 @@ "react-native": "*" } }, + "node_modules/react-native-screen-transitions": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/react-native-screen-transitions/-/react-native-screen-transitions-4.0.0.tgz", + "integrity": "sha512-hfQzFIsqY2uxtCVhe+FeBTz0JCibyGO8qR1txQ1Acy9knzC6nnN9bjnJS7ewoYhCjYvY2UTc5GMuGtGBuuG0Iw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/eds2002" + }, + { + "type": "individual", + "url": "https://buymeacoffee.com/trpfsu" + } + ], + "license": "MIT", + "dependencies": { + "standard-navigation": "^0.0.7" + }, + "peerDependencies": { + "@react-navigation/native": ">=7.3.0", + "@types/react": "*", + "react": ">=19.2.0", + "react-native": "*", + "react-native-gesture-handler": ">=2.16.1", + "react-native-reanimated": ">=4.0.0", + "react-native-safe-area-context": "*", + "react-native-teleport": ">=1.2.0", + "react-native-worklets": ">=0.8.0" + }, + "peerDependenciesMeta": { + "react-native-teleport": { + "optional": true + } + } + }, + "node_modules/react-native-screen-transitions/node_modules/standard-navigation": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/standard-navigation/-/standard-navigation-0.0.7.tgz", + "integrity": "sha512-NCGLCNyuXrFOkGHxdNZFnpsehGtiq1oXbPhKl7ZuxFO5J//H2evqqOchmD4YwEUJnkjO4kH9Xp4hQX6hdAYCKQ==", + "license": "MIT" + }, "node_modules/react-native-screens": { "version": "4.26.2", "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.26.2.tgz", @@ -12447,6 +12540,74 @@ "node": ">= 0.6" } }, + "node_modules/stim": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stim/-/stim-1.0.0.tgz", + "integrity": "sha512-QbPZBmLmpqjNeg7IHKKBbU0g0/Oa/+keCBFLBgf/ug3NaX2fFMqX2wPME/dojhmjnqEPORncwZjmJ7VIGwNBtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@expo/fingerprint": "^0.20.10", + "@stim-cli/cache": "^1.0.0", + "@stim-cli/core": "^1.0.0", + "@stim-cli/metro": "^1.0.0", + "chalk": "^5.4.1", + "commander": "^13.1.0", + "unique-pid": "0.1.1", + "ws": "^8.21.3" + }, + "bin": { + "stim": "dist/cli.mjs" + }, + "engines": { + "node": "^20.19.4 || >=22.12.0" + } + }, + "node_modules/stim/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/stim/node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/stim/node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -13510,6 +13671,24 @@ "integrity": "sha512-wey5ChoJkCTq0j0JWdIMu2QB81vVrdhmrNAP14ZZ6WDslnZ7ff7Ezv8rMdEnVHaCKz3xK4mIVXbVU51xHgdyCA==", "license": "MIT" }, + "node_modules/unique-pid": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/unique-pid/-/unique-pid-0.1.1.tgz", + "integrity": "sha512-IG/DcbOKtMcwPiE9HyzZwBc/h/YFKB12iOP3ESu1OxecvEA8+3718MQdJvWFbzOTmhm3hoX2HHEHdVcR02rMwg==", + "dev": true, + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32" + ], + "dependencies": { + "node-gyp-build": "4.8.4" + }, + "engines": { + "node": "^20.19.4 || >=22.12.0" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/package.json b/package.json index ed18982..bf952b1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "trailhead", - "description": "Reference Expo app for local stim-cli iOS and Android testing", + "description": "Reference Expo app for local stim iOS and Android testing", "license": "MIT", "repository": "https://github.com/appandflow/trailhead", "main": "index.js", @@ -16,6 +16,7 @@ }, "dependencies": { "@expo/vector-icons": "^15.0.2", + "@react-native-masked-view/masked-view": "0.3.2", "@react-navigation/bottom-tabs": "^7.3.10", "@react-navigation/elements": "^2.3.8", "@react-navigation/native": "^7.1.6", @@ -61,6 +62,7 @@ "react-native-nitro-modules": "^0.36.5", "react-native-reanimated": "4.5.1", "react-native-safe-area-context": "~5.7.0", + "react-native-screen-transitions": "4.0.0", "react-native-screens": "~4.26.0", "react-native-svg": "15.15.4", "react-native-web": "^0.21.2", @@ -74,6 +76,7 @@ "drizzle-kit": "^0.31.10", "eslint": "^9.25.0", "eslint-config-expo": "~57.0.2", + "stim": "1.0.0", "typescript": "~6.0.3" }, "expo": { diff --git a/src/components/TrailCard.tsx b/src/components/TrailCard.tsx index 2ce7d11..e7a0b5a 100644 --- a/src/components/TrailCard.tsx +++ b/src/components/TrailCard.tsx @@ -1,7 +1,9 @@ import { Ionicons } from '@expo/vector-icons'; +import { useState } from 'react'; import { Image } from 'expo-image'; import { useRouter } from 'expo-router'; import { Pressable, StyleSheet, Text, View } from 'react-native'; +import Transition from 'react-native-screen-transitions'; import { trailPhotoUrl, type Difficulty, type Trail } from '@/src/data/trails'; import { formatDistance, formatDuration, formatElevation } from '@/src/lib/format'; @@ -10,6 +12,9 @@ import { radius, spacing, useTheme, type Palette } from '@/src/theme'; type IoniconName = React.ComponentProps['name']; +// Keep the whole row accessible while measuring its thumbnail for the transition. +const TrailCardBoundary = Transition.createBoundaryComponent(Pressable); + export function difficultyColor(difficulty: Difficulty, colors: Palette): string { switch (difficulty) { case 'easy': @@ -37,24 +42,33 @@ export function TrailCard({ trail, distanceMeters }: TrailCardProps) { const router = useRouter(); const units = useSettingsStore((s) => s.units); const accent = difficultyColor(trail.difficulty, colors); + const [pressed, setPressed] = useState(false); return ( - router.push({ pathname: '/trail/[id]', params: { id: trail.id } })} + onPressIn={() => setPressed(true)} + onPressOut={() => setPressed(false)} accessibilityRole="button" accessibilityLabel={`${trail.name}, ${trail.region}, ${difficultyLabel(trail.difficulty)}, rated ${trail.rating.toFixed(1)} out of 5`} - style={({ pressed }) => [ + style={[ styles.card, { backgroundColor: colors.surface, borderColor: colors.border, opacity: pressed ? 0.9 : 1 }, ]} > - + + + {trail.name} @@ -88,7 +102,7 @@ export function TrailCard({ trail, distanceMeters }: TrailCardProps) { - + ); } @@ -113,7 +127,6 @@ const styles = StyleSheet.create({ shadowRadius: 10, shadowOffset: { width: 0, height: 4 }, }, - photo: { width: 92, height: 92, borderRadius: radius.md }, body: { flex: 1, justifyContent: 'space-between' }, name: { fontSize: 16, fontWeight: '600' }, region: { fontSize: 13, marginTop: 2 },