Files
create-glstack/template/apps/<@if(eq(context.project.mobile,"expo"))>mobile/src/app/index.tsx
T
2026-10-07 15:35:04 +02:00

127 lines
5.5 KiB
TypeScript

import { createRouter, Todo } from "@<@var(context.project.name)>/rpc"
import { useEffect, useState } from "react"
import { Platform, PlatformColor, ScrollView, useColorScheme } from "react-native"
import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"
import { SafeAreaView } from "react-native-safe-area-context"
import { Button, Checkbox, Host, Column, TextInput, Text, useNativeState, Row, Icon } from "@expo/ui";
import * as Crypto from "expo-crypto"
import { background, colorInvert, controlSize, padding as iosPadding, shapes } from "@expo/ui/swift-ui/modifiers";
// No numeric width() on Android: @expo/ui 57 can't convert it natively and crashes.
import { fillMaxWidth, padding, weight } from "@expo/ui/jetpack-compose/modifiers";
// The iOS simulator shares the host's network; devenv runs `adb reverse` so Android devices do too.
const router = createRouter("http://127.0.0.1:8080")
// Changes made elsewhere (another device, the web app) show up within this interval.
const syncIntervalMs = 2000
// SwiftUI clips a border to its corner radius, cutting off the corners, so on iOS the
// border is an outer rounded fill showing 1pt around an inner one.
const iosInputBorder = Platform.OS === "ios" ? [
iosPadding({ all: 10 }),
background(PlatformColor("secondarySystemGroupedBackground"), shapes.roundedRectangle({ cornerRadius: 9, roundedCornerStyle: "continuous" })),
iosPadding({ all: 1 }),
background(PlatformColor("separator"), shapes.roundedRectangle({ cornerRadius: 10, roundedCornerStyle: "continuous" })),
] : []
// Animates its position when the list re-sorts. The Host's measured height goes through state:
// on Android a layout transition ignores the size matchContents sets natively, collapsing the row.
function AnimatedRow({ children }: { children: React.ReactNode }) {
const [height, setHeight] = useState<number>()
return (
<Animated.View layout={LinearTransition.springify()} entering={FadeIn} exiting={FadeOut}>
<Host
matchContents={{ vertical: true }}
style={height ? { height } : undefined}
onLayoutContent={(e) => setHeight(e.nativeEvent.height)}
>
{children}
</Host>
</Animated.View>
)
}
export default function Index() {
const [todos, setTodos] = useState<Array<Todo>>([])
const [offline, setOffline] = useState(false)
const todoToCreateTask = useNativeState<string>("")
const colorScheme = useColorScheme()
const fetchTodos = () => {
router.todos.listTodos({}).then((r) => {
setOffline(false)
// Polling returns the same list most of the time; keep the old array to skip re-rendering.
setTodos((current) => JSON.stringify(current) === JSON.stringify(r.todos) ? current : r.todos)
}).catch(() => setOffline(true))
}
useEffect(() => {
fetchTodos()
const interval = setInterval(fetchTodos, syncIntervalMs)
return () => clearInterval(interval)
}, [])
const setTodoDone = (id: string, task: string) => {
return (done: boolean) => {
router.todos.updateTodo({ todo: { id, task, done } }).then(fetchTodos, () => setOffline(true))
}
}
const deleteTodo = (id: string) => {
return () => {
router.todos.deleteTodo({ todo: { id } }).then(fetchTodos, () => setOffline(true))
}
}
const createTodo = () => {
const task = todoToCreateTask.value.trim()
if (!task) return
router.todos.createTodo({ todo: { id: Crypto.randomUUID(), task } }).then(() => {
todoToCreateTask.value = ""
fetchTodos()
}, () => setOffline(true))
}
const updateTodoToCreateTask = (task: string) => {
todoToCreateTask.value = task
}
return (
<SafeAreaView style={{ flex: 1 }}>
<Host matchContents={{ vertical: true }}>
<Column alignment="center" modifiers={[fillMaxWidth()]}>
<Text textStyle={{fontSize:30}}> Create Todo </Text>
{offline && <Text textStyle={{ color: "#FF3B30" }}>Can't reach the API, retrying…</Text>}
<TextInput
value={todoToCreateTask}
onChangeText={updateTodoToCreateTask}
placeholder="What needs to be done?"
style={Platform.OS === "ios"
? { width: 280 }
// Android draws the border as a fill behind the background, so the background must be opaque.
: { padding: 10, borderWidth: 1, borderColor: "#8E8E93", borderRadius: 8, backgroundColor: colorScheme === "dark" ? "#1C1B1F" : "#FFFFFF" }}
modifiers={Platform.OS === "ios" ? iosInputBorder : [fillMaxWidth(0.75)]}
/>
<Button modifiers={[controlSize('regular')]} label="create Todo" onPress={createTodo} />
</Column>
</Host>
<ScrollView>
{
todos.map((todo) => (
// Keyed by id so native toggles follow their todo.
<AnimatedRow key={todo.id}>
<Row alignment="center" modifiers={[fillMaxWidth(),padding(20,0,20,0)]}>
<Column alignment="start" modifiers={[weight(1)]}>
<Text>{todo.task}</Text>
</Column>
<Column alignment="center">
<Checkbox value={todo.done || false} onValueChange={setTodoDone(todo.id || "", todo.task)} />
</Column>
<Column alignment="end">
<Button modifiers={[controlSize('regular')]} label="delete" onPress={deleteTodo(todo.id || "")}/>
</Column>
</Row>
</AnimatedRow>
))
}
</ScrollView>
</SafeAreaView>
)
}