add-mobile-screen
Adds a new screen to the Expo mobile app following the file-based routing pattern.
Placeholders: Replace
{Name}with PascalCase (e.g.Budget),{name}with kebab-case (e.g.budget),{names}with plural kebab-case (e.g.budgets),{domain}with camelCase (e.g.budget),{domains}with plural camelCase (e.g.budgets).
Critical: Expo Router File Requirements
- Route files MUST use
export default function— named exports are silently ignored by Expo Router. - Route file name becomes the URL segment:
budgets.tsx→/budgets. [id].tsxcreates a dynamic segment:/budgets/abc123.
Routing Decision Table
| What you want | File to create | Notes |
|---|---|---|
| New bottom tab | apps/mobile/app/(tabs)/{name}.tsx |
Also add <Tabs.Screen> in _layout.tsx |
| Stack screen under a tab | apps/mobile/app/{name}/index.tsx |
Navigate with router.push('/{name}') |
| Detail screen with param | apps/mobile/app/{name}/[id].tsx |
Read param with useLocalSearchParams() |
| Modal screen | Create apps/mobile/app/{name}.modal.tsx or reuse modal.tsx |
1. Tab Screen – app/(tabs)/{name}.tsx
import { StyleSheet, View } from 'react-native';
import { Stack, Title, Label } from '@luna-ui/react-native';
import { use{Name}s } from '@guallet/api-react';
export default function {Name}sScreen() {
const { {domains}, isLoading } = use{Name}s();
return (
<View style={styles.container}>
<Title>{Names}</Title>
{isLoading && <Label>Loading...</Label>}
{!isLoading && {domains}.length === 0 && (
<Label>No {names} yet.</Label>
)}
{/* Render list items here */}
<Stack>
{!isLoading && {domains}.map((item) => (
<Label key={item.id}>{item.name}</Label>
))}
</Stack>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 16,
},
});
Register the new tab in app/(tabs)/_layout.tsx
<Tabs.Screen
name="{name}"
options={{
title: '{Names}',
tabBarIcon: ({ color }) => (
<IconSymbol size={28} name="LIST_ICON_NAME.fill" color={color} />
),
}}
/>
IconSymboluses SF Symbols on iOS and MaterialIcons on Android. Common names:
house.fill– homelist.bullet– listchart.pie.fill– chartgearshape.fill– settingsplus.circle.fill– addperson.fill– profiledollarsign.circle.fill– finance
2. Stack Screen – app/{name}/index.tsx
import { View, StyleSheet, FlatList } from 'react-native';
import { useRouter } from 'expo-router';
import { Stack, Title, Button } from '@luna-ui/react-native';
import { use{Name}s } from '@guallet/api-react';
export default function {Name}ListScreen() {
const router = useRouter();
const { {domains}, isLoading } = use{Name}s();
return (
<View style={styles.container}>
<Stack>
<Title>{Names}</Title>
<Button => router.push('/{name}/new')}>
New {Name}
</Button>
</Stack>
<FlatList
data={{domains}}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<Button
variant="subtle"
=> router.push(`/{name}/${item.id}`)}
>
{item.name}
</Button>
)}
/>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 16 },
});
3. Detail Screen with Param – app/{name}/[id].tsx
import { View, StyleSheet } from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { Stack, Title, Label, Button } from '@luna-ui/react-native';
import { use{Name} } from '@guallet/api-react';
export default function {Name}DetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const { {domain}, isLoading } = use{Name}(id);
if (!isLoading && !{domain}) {
return (
<View style={styles.container}>
<Label>Not found.</Label>
<Button => router.back()}>Go back</Button>
</View>
);
}
return (
<View style={styles.container}>
{isLoading ? (
<Label>Loading...</Label>
) : (
<Stack>
<Title>{domain}?.name}</Title>
{/* Add detail fields here */}
<Button => router.back()}>Back</Button>
</Stack>
)}
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 16 },
});
Luna UI Component Reference
Import from @luna-ui/react-native.
| Component | Category | Use for |
|---|---|---|
Stack |
Layout | Vertical container (VStack equivalent) |
Group |
Layout | Horizontal container (HStack equivalent) |
Divider |
Layout | Horizontal separator line |
Title |
Typography | Bold page/section headings |
Label |
Typography | Body text and descriptions |
Button |
Buttons | Tappable buttons; variant = "filled" | "light" | "outline" | "subtle" | "transparent" |
TextInput |
Inputs | Text input field |
OtpInput |
Inputs | OTP/PIN entry |
Visibility |
Utility | Conditionally show/hide children: <Visibility isVisible={bool}> |
ModalLoaderOverlay |
Overlays | Full-screen loading overlay |
Theme hooks
import { useTheme } from '@luna-ui/react-native';
const theme = useTheme(); // access theme.colors, theme.spacing, etc.
Navigation
import { useRouter } from 'expo-router';
const router = useRouter();
router.push('/{name}'); // navigate forward
router.push(`/{name}/${id}`); // navigate to detail
router.back(); // go back
router.replace('/login'); // replace current screen
For reading route params in [id].tsx:
import { useLocalSearchParams } from 'expo-router';
const { id } = useLocalSearchParams<{ id: string }>();
Using API Hooks
Both webapp and mobile use the same @guallet/api-react hooks — no platform-specific layer needed:
import { use{Name}s, use{Name}, use{Name}Mutations } from '@guallet/api-react';
// List
const { {domains}, isLoading } = use{Name}s();
// Single item
const { {domain}, isLoading } = use{Name}(id);
// Mutations
const { create{Name}Mutation, update{Name}Mutation, delete{Name}Mutation } = use{Name}Mutations();
create{Name}Mutation.mutate(
{ request: { name: 'New item' } },
{ onSuccess: () => router.back(), onError: console.error },
);
Auth
Auth is handled globally by the (tabs)/_layout.tsx:
- It checks
useAuth()from@guallet/authand redirects to/loginif not authenticated. - Individual screens do not need their own auth checks.
Checklist
- Route file uses
export default function(not named export) - Screen wrapped in
<View style={{ flex: 1 }}>to fill available space - Styles defined with
StyleSheet.create({}), not inline objects - Tab screen registered in
(tabs)/_layout.tsxif it's a new tab - Navigation uses
useRouter()fromexpo-router, notreact-navigationdirectly - Params read with
useLocalSearchParams()for[id]route files - No auth logic in the screen — layout handles it
Source: Guallet/monorepo — distributed by TomeVault.