Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
55 changes: 51 additions & 4 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<typeof Stack.Screen>;
type RouterScreenOptionsCallback = Extract<RouterScreenProps['options'], (...args: never[]) => unknown>;
type TransitionScreenOptions = NativeStackAdapterOptions<ReturnType<RouterScreenOptionsCallback>>;

// The adapter accepts gesture directions beyond Expo Router's native-stack types.
const TransitionScreen = Stack.Screen as ComponentType<Omit<RouterScreenProps, 'options'> & {
options?: TransitionScreenOptions | ((props: Parameters<RouterScreenOptionsCallback>[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
Expand All @@ -25,6 +57,7 @@ seedHikesIfEmpty();

export default function RootLayout() {
const { name, colors } = useTheme();
const reducedMotion = useReducedMotion();

useEffect(() => {
let active = true;
Expand All @@ -43,18 +76,32 @@ export default function RootLayout() {
<SafeAreaProvider>
<QueryClientProvider client={queryClient}>
<StatusBar style={name === 'dark' ? 'light' : 'dark'} />
<Stack
<TransitionStack
screenOptions={{
headerStyle: { backgroundColor: colors.surface },
headerTintColor: colors.text,
contentStyle: { backgroundColor: colors.background },
}}
>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="trail/[id]" options={{ title: 'Trail' }} />
<TransitionScreen
name="trail/[id]"
options={({ route }) => {
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 }),
};
}}
/>
<Stack.Screen name="hike/[id]" options={{ title: 'Hike' }} />
<Stack.Screen name="+not-found" options={{ title: 'Not found' }} />
</Stack>
</TransitionStack>
</QueryClientProvider>
</SafeAreaProvider>
</GestureHandlerRootView>
Expand Down
Loading