stage 2: skeleton with navigation
This commit is contained in:
@@ -5,5 +5,6 @@ module.exports = function (api) {
|
|||||||
["babel-preset-expo", { jsxImportSource: "nativewind" }],
|
["babel-preset-expo", { jsxImportSource: "nativewind" }],
|
||||||
"nativewind/babel",
|
"nativewind/babel",
|
||||||
],
|
],
|
||||||
|
plugins: ["react-native-worklets/plugin"],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
+14
-10
@@ -12,26 +12,30 @@
|
|||||||
},
|
},
|
||||||
"packageManager": "[email protected]",
|
"packageManager": "[email protected]",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-native-async-storage/async-storage": "2.1.2",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
|
"@react-navigation/native": "^7.0.14",
|
||||||
|
"@react-navigation/native-stack": "^7.2.0",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"expo": "~54.0.0",
|
"expo": "~54.0.0",
|
||||||
"expo-constants": "~17.0.0",
|
"expo-constants": "~18.0.13",
|
||||||
"expo-secure-store": "~14.0.0",
|
"expo-secure-store": "~15.0.8",
|
||||||
"expo-status-bar": "~2.0.0",
|
"expo-status-bar": "~3.0.9",
|
||||||
"nativewind": "^4.1.23",
|
"nativewind": "^4.1.23",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-native": "0.81.4",
|
"react-native": "0.81.5",
|
||||||
"react-native-gesture-handler": "~2.21.0",
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
"react-native-reanimated": "~4.0.0",
|
"react-native-reanimated": "~4.1.1",
|
||||||
"react-native-safe-area-context": "~4.12.0",
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
"react-native-screens": "~4.4.0",
|
"react-native-screens": "~4.16.0",
|
||||||
|
"react-native-svg": "15.12.1",
|
||||||
|
"react-native-worklets": "0.5.1",
|
||||||
"sonner-native": "^0.21.0",
|
"sonner-native": "^0.21.0",
|
||||||
"zod": "^4.3.6",
|
"zod": "^4.3.6",
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "~19.1.0",
|
"@types/react": "~19.1.0",
|
||||||
"expo-build-properties": "~0.13.0",
|
"expo-build-properties": "~1.0.10",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "~5.9.0"
|
"typescript": "~5.9.0"
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-13
@@ -1,25 +1,34 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
import { StatusBar } from "expo-status-bar";
|
import { StatusBar } from "expo-status-bar";
|
||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { Text, View } from "react-native";
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
|
import { NavigationContainer } from "@react-navigation/native";
|
||||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||||
import { Toaster } from "sonner-native";
|
import { Toaster } from "sonner-native";
|
||||||
import { createQueryClient } from "@/lib/query-client";
|
import { createQueryClient } from "@/lib/query-client";
|
||||||
|
import { RootNavigator } from "@/navigation/RootNavigator";
|
||||||
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
|
||||||
const queryClient = createQueryClient();
|
const queryClient = createQueryClient();
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const restoreSession = useAuthStore((s) => s.restoreSession);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void restoreSession();
|
||||||
|
}, [restoreSession]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<SafeAreaProvider>
|
<QueryClientProvider client={queryClient}>
|
||||||
<View className="flex-1 items-center justify-center bg-background">
|
<SafeAreaProvider>
|
||||||
<Text className="text-foreground text-2xl font-semibold">Flowy</Text>
|
<NavigationContainer>
|
||||||
<Text className="text-muted-foreground mt-2">
|
<RootNavigator />
|
||||||
Mobile scaffold — step 1
|
</NavigationContainer>
|
||||||
</Text>
|
<Toaster />
|
||||||
</View>
|
<StatusBar style="auto" />
|
||||||
<Toaster />
|
</SafeAreaProvider>
|
||||||
<StatusBar style="auto" />
|
</QueryClientProvider>
|
||||||
</SafeAreaProvider>
|
</GestureHandlerRootView>
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
|
||||||
|
type Step = "email" | "code";
|
||||||
|
|
||||||
|
export function SignInScreen() {
|
||||||
|
const [step, setStep] = useState<Step>("email");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView className="flex-1 bg-background">
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<View className="flex-1 justify-center px-6">
|
||||||
|
{step === "email" ? (
|
||||||
|
<EmailStep
|
||||||
|
onCodeSent={(submittedEmail) => {
|
||||||
|
setEmail(submittedEmail);
|
||||||
|
setStep("code");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CodeStep email={email} onBack={() => setStep("email")} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmailStep({ onCodeSent }: { onCodeSent: (email: string) => void }) {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const isRequestingCode = useAuthStore((s) => s.isRequestingCode);
|
||||||
|
const error = useAuthStore((s) => s.error);
|
||||||
|
const requestCode = useAuthStore((s) => s.requestCode);
|
||||||
|
const clearError = useAuthStore((s) => s.clearError);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
try {
|
||||||
|
await requestCode(email);
|
||||||
|
onCodeSent(email);
|
||||||
|
} catch {
|
||||||
|
// Error surfaced via the store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const disabled = isRequestingCode || email.trim().length === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="gap-5">
|
||||||
|
<View className="gap-1">
|
||||||
|
<Text className="text-foreground text-3xl font-semibold">Sign in</Text>
|
||||||
|
<Text className="text-muted-foreground text-base">
|
||||||
|
Enter your email to receive a sign-in code.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="gap-2">
|
||||||
|
<Text className="text-foreground text-sm font-medium">Email</Text>
|
||||||
|
<TextInput
|
||||||
|
value={email}
|
||||||
|
onChangeText={(text) => {
|
||||||
|
setEmail(text);
|
||||||
|
if (error) clearError();
|
||||||
|
}}
|
||||||
|
placeholder="[email protected]"
|
||||||
|
placeholderTextColor="#878787"
|
||||||
|
keyboardType="email-address"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
autoComplete="email"
|
||||||
|
textContentType="emailAddress"
|
||||||
|
autoFocus
|
||||||
|
returnKeyType="go"
|
||||||
|
onSubmitEditing={submit}
|
||||||
|
className="border-input text-foreground rounded-lg border bg-background px-4 py-3 text-base"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<Text className="text-destructive text-sm">{error}</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={submit}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`rounded-lg px-4 py-3.5 items-center ${
|
||||||
|
disabled ? "bg-muted" : "bg-primary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={`text-base font-semibold ${
|
||||||
|
disabled ? "text-muted-foreground" : "text-primary-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isRequestingCode ? "Sending..." : "Continue"}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CodeStep({ email, onBack }: { email: string; onBack: () => void }) {
|
||||||
|
const [code, setCode] = useState("");
|
||||||
|
const isSigningIn = useAuthStore((s) => s.isSigningIn);
|
||||||
|
const error = useAuthStore((s) => s.error);
|
||||||
|
const signIn = useAuthStore((s) => s.signIn);
|
||||||
|
const clearError = useAuthStore((s) => s.clearError);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
try {
|
||||||
|
await signIn(email, code);
|
||||||
|
} catch {
|
||||||
|
// Error surfaced via the store; keep the screen visible
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const disabled = isSigningIn || code.trim().length === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="gap-5">
|
||||||
|
<View className="gap-1">
|
||||||
|
<Text className="text-foreground text-3xl font-semibold">
|
||||||
|
Check your email
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground text-base">
|
||||||
|
We sent a code to{" "}
|
||||||
|
<Text className="text-foreground font-medium">{email}</Text>.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="gap-2">
|
||||||
|
<Text className="text-foreground text-sm font-medium">Code</Text>
|
||||||
|
<TextInput
|
||||||
|
value={code}
|
||||||
|
onChangeText={(text) => {
|
||||||
|
setCode(text);
|
||||||
|
if (error) clearError();
|
||||||
|
}}
|
||||||
|
placeholder="Enter code"
|
||||||
|
placeholderTextColor="#878787"
|
||||||
|
keyboardType="number-pad"
|
||||||
|
autoFocus
|
||||||
|
returnKeyType="go"
|
||||||
|
textContentType="oneTimeCode"
|
||||||
|
onSubmitEditing={submit}
|
||||||
|
className="border-input text-foreground rounded-lg border bg-background px-4 py-3 text-base tracking-widest"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<Text className="text-destructive text-sm">{error}</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View className="gap-2">
|
||||||
|
<Pressable
|
||||||
|
onPress={submit}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`rounded-lg px-4 py-3.5 items-center ${
|
||||||
|
disabled ? "bg-muted" : "bg-primary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={`text-base font-semibold ${
|
||||||
|
disabled ? "text-muted-foreground" : "text-primary-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isSigningIn ? "Signing in..." : "Sign in"}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={onBack}
|
||||||
|
className="rounded-lg px-4 py-3.5 items-center"
|
||||||
|
>
|
||||||
|
<Text className="text-muted-foreground text-base font-medium">
|
||||||
|
Back
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import {
|
||||||
|
Animated,
|
||||||
|
Dimensions,
|
||||||
|
Easing,
|
||||||
|
Modal,
|
||||||
|
Pressable,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
|
||||||
|
const SCREEN_WIDTH = Dimensions.get("window").width;
|
||||||
|
const DRAWER_WIDTH = Math.min(320, Math.round(SCREEN_WIDTH * 0.82));
|
||||||
|
const ANIM_MS = 220;
|
||||||
|
|
||||||
|
interface DrawerProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onNavigateAccount: () => void;
|
||||||
|
onNavigateSettings: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Drawer({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onNavigateAccount,
|
||||||
|
onNavigateSettings,
|
||||||
|
}: DrawerProps) {
|
||||||
|
const translateX = useRef(new Animated.Value(-DRAWER_WIDTH)).current;
|
||||||
|
const backdropOpacity = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(translateX, {
|
||||||
|
toValue: open ? 0 : -DRAWER_WIDTH,
|
||||||
|
duration: ANIM_MS,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(backdropOpacity, {
|
||||||
|
toValue: open ? 0.4 : 0,
|
||||||
|
duration: ANIM_MS,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
}, [open, translateX, backdropOpacity]);
|
||||||
|
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const signOut = useAuthStore((s) => s.signOut);
|
||||||
|
const isSigningOut = useAuthStore((s) => s.isSigningOut);
|
||||||
|
|
||||||
|
const initials = user?.email_prefix.slice(0, 2).toUpperCase() ?? "??";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={open}
|
||||||
|
transparent
|
||||||
|
animationType="none"
|
||||||
|
onRequestClose={onClose}
|
||||||
|
statusBarTranslucent
|
||||||
|
>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Animated.View
|
||||||
|
pointerEvents={open ? "auto" : "none"}
|
||||||
|
style={{ opacity: backdropOpacity }}
|
||||||
|
className="absolute inset-0 bg-black"
|
||||||
|
>
|
||||||
|
<Pressable className="flex-1" onPress={onClose} />
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
width: DRAWER_WIDTH,
|
||||||
|
transform: [{ translateX }],
|
||||||
|
}}
|
||||||
|
className="absolute left-0 top-0 bottom-0 bg-sidebar"
|
||||||
|
>
|
||||||
|
<SafeAreaView edges={["top", "bottom", "left"]} className="flex-1">
|
||||||
|
<View className="border-sidebar-border border-b px-5 py-5 flex-row items-center gap-3">
|
||||||
|
<View className="bg-sidebar-accent h-10 w-10 items-center justify-center rounded-full">
|
||||||
|
<Text className="text-sidebar-accent-foreground text-sm font-semibold">
|
||||||
|
{initials}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text
|
||||||
|
className="text-sidebar-foreground text-base font-medium"
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{user?.email_prefix ?? ""}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
className="text-muted-foreground text-xs"
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{user?.email ?? ""}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-1 py-2">
|
||||||
|
<DrawerRow
|
||||||
|
label="Account"
|
||||||
|
onPress={() => {
|
||||||
|
onClose();
|
||||||
|
onNavigateAccount();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<DrawerRow
|
||||||
|
label="Settings"
|
||||||
|
onPress={() => {
|
||||||
|
onClose();
|
||||||
|
onNavigateSettings();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="border-sidebar-border border-t px-2 py-2">
|
||||||
|
<DrawerRow
|
||||||
|
label={isSigningOut ? "Signing out..." : "Sign out"}
|
||||||
|
disabled={isSigningOut}
|
||||||
|
onPress={() => {
|
||||||
|
void signOut();
|
||||||
|
}}
|
||||||
|
tone="destructive"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerRow({
|
||||||
|
label,
|
||||||
|
onPress,
|
||||||
|
disabled,
|
||||||
|
tone = "default",
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
onPress: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
tone?: "default" | "destructive";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
disabled={disabled}
|
||||||
|
className="px-5 py-3 active:bg-sidebar-accent"
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={`text-base font-medium ${
|
||||||
|
tone === "destructive"
|
||||||
|
? "text-destructive"
|
||||||
|
: "text-sidebar-foreground"
|
||||||
|
} ${disabled ? "opacity-50" : ""}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
FlatList,
|
||||||
|
Pressable,
|
||||||
|
RefreshControl,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
import type { Network } from "@/api/types";
|
||||||
|
import { useNetworks } from "@/hooks/use-networks";
|
||||||
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
import { toUserMessage } from "@/lib/errors";
|
||||||
|
import type { RootStackScreenProps } from "@/navigation/types";
|
||||||
|
import { Drawer } from "./Drawer";
|
||||||
|
|
||||||
|
export function NetworkListScreen({
|
||||||
|
navigation,
|
||||||
|
}: RootStackScreenProps<"NetworkList">) {
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const { data, isLoading, isRefetching, refetch, error } = useNetworks();
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const initials = user?.email_prefix.slice(0, 2).toUpperCase() ?? "??";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView className="flex-1 bg-background" edges={["top"]}>
|
||||||
|
<View className="flex-row items-center justify-between px-4 py-3 border-b border-border">
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setDrawerOpen(true)}
|
||||||
|
accessibilityLabel="Open menu"
|
||||||
|
className="bg-muted h-9 w-9 items-center justify-center rounded-full"
|
||||||
|
>
|
||||||
|
<Text className="text-muted-foreground text-xs font-semibold">
|
||||||
|
{initials}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Text className="text-foreground text-base font-semibold">Flowy</Text>
|
||||||
|
<View className="w-9" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<View className="flex-1 items-center justify-center">
|
||||||
|
<ActivityIndicator />
|
||||||
|
</View>
|
||||||
|
) : error ? (
|
||||||
|
<View className="flex-1 items-center justify-center px-6">
|
||||||
|
<Text className="text-destructive text-center">
|
||||||
|
{toUserMessage(error)}
|
||||||
|
</Text>
|
||||||
|
<Pressable onPress={() => refetch()} className="mt-3 px-4 py-2">
|
||||||
|
<Text className="text-foreground font-medium">Retry</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
) : !data || data.length === 0 ? (
|
||||||
|
<EmptyState />
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={data}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
contentContainerClassName="p-4 gap-2"
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={isRefetching}
|
||||||
|
onRefresh={() => refetch()}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<NetworkCard
|
||||||
|
network={item}
|
||||||
|
onPress={() =>
|
||||||
|
navigation.navigate("StreamList", { networkId: item.id })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
open={drawerOpen}
|
||||||
|
onClose={() => setDrawerOpen(false)}
|
||||||
|
onNavigateAccount={() => navigation.navigate("Account")}
|
||||||
|
onNavigateSettings={() => navigation.navigate("Settings")}
|
||||||
|
/>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NetworkCard({
|
||||||
|
network,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
network: Network;
|
||||||
|
onPress: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
className="bg-card border-border active:bg-accent rounded-lg border px-4 py-4 flex-row items-center justify-between"
|
||||||
|
>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className="text-card-foreground text-base font-semibold">
|
||||||
|
{network.name}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground text-sm">
|
||||||
|
{network.humans.length}{" "}
|
||||||
|
{network.humans.length === 1 ? "member" : "members"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text className="text-muted-foreground text-xl">›</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState() {
|
||||||
|
return (
|
||||||
|
<View className="flex-1 items-center justify-center px-6">
|
||||||
|
<Text className="text-foreground text-lg font-medium text-center">
|
||||||
|
You aren't in any networks yet.
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mt-2 text-center">
|
||||||
|
Ask a friend for an invite, or create one on desktop.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Pressable, Text, View } from "react-native";
|
||||||
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
import type { RootStackScreenProps } from "@/navigation/types";
|
||||||
|
|
||||||
|
export function AccountScreen({ navigation }: RootStackScreenProps<"Account">) {
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView className="flex-1 bg-background" edges={["top"]}>
|
||||||
|
<View className="flex-row items-center px-3 py-3 border-b border-border">
|
||||||
|
<Pressable onPress={() => navigation.goBack()} className="px-2 py-1">
|
||||||
|
<Text className="text-foreground text-2xl">‹</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Text className="flex-1 text-center text-foreground text-base font-semibold">
|
||||||
|
Account
|
||||||
|
</Text>
|
||||||
|
<View className="w-8" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="px-6 py-6 gap-4">
|
||||||
|
<Field label="Email" value={user?.email ?? "—"} />
|
||||||
|
<Field label="Account ID" value={user?.id ?? "—"} />
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<View className="gap-1">
|
||||||
|
<Text className="text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-foreground text-base">{value}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Pressable, Text, View } from "react-native";
|
||||||
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
import type { RootStackScreenProps } from "@/navigation/types";
|
||||||
|
|
||||||
|
export function SettingsScreen({
|
||||||
|
navigation,
|
||||||
|
}: RootStackScreenProps<"Settings">) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView className="flex-1 bg-background" edges={["top"]}>
|
||||||
|
<View className="flex-row items-center px-3 py-3 border-b border-border">
|
||||||
|
<Pressable onPress={() => navigation.goBack()} className="px-2 py-1">
|
||||||
|
<Text className="text-foreground text-2xl">‹</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Text className="flex-1 text-center text-foreground text-base font-semibold">
|
||||||
|
Settings
|
||||||
|
</Text>
|
||||||
|
<View className="w-8" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-1 items-center justify-center px-6">
|
||||||
|
<Text className="text-muted-foreground text-center">
|
||||||
|
Theme, notifications, and account preferences land here later.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Pressable, Text, View } from "react-native";
|
||||||
|
import type { RootStackScreenProps } from "@/navigation/types";
|
||||||
|
|
||||||
|
export function StreamViewScreen({
|
||||||
|
navigation,
|
||||||
|
}: RootStackScreenProps<"StreamView">) {
|
||||||
|
return (
|
||||||
|
<View className="flex-1 bg-black items-center justify-center">
|
||||||
|
<Text className="text-white text-base">
|
||||||
|
Stream view — coming in step 4.
|
||||||
|
</Text>
|
||||||
|
<Pressable onPress={() => navigation.goBack()} className="mt-6 px-4 py-2">
|
||||||
|
<Text className="text-white/70">Close</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Pressable, Text, View } from "react-native";
|
||||||
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
import { useNetwork } from "@/hooks/use-networks";
|
||||||
|
import type { RootStackScreenProps } from "@/navigation/types";
|
||||||
|
|
||||||
|
export function StreamListScreen({
|
||||||
|
route,
|
||||||
|
navigation,
|
||||||
|
}: RootStackScreenProps<"StreamList">) {
|
||||||
|
const network = useNetwork(route.params.networkId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView className="flex-1 bg-background" edges={["top"]}>
|
||||||
|
<View className="flex-row items-center px-3 py-3 border-b border-border">
|
||||||
|
<Pressable onPress={() => navigation.goBack()} className="px-2 py-1">
|
||||||
|
<Text className="text-foreground text-2xl">‹</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Text
|
||||||
|
className="flex-1 text-center text-foreground text-base font-semibold"
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{network?.name ?? "Streams"}
|
||||||
|
</Text>
|
||||||
|
<View className="w-8" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="flex-1 items-center justify-center px-6">
|
||||||
|
<Text className="text-muted-foreground text-center">
|
||||||
|
Stream list — coming in step 3.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { apiClient } from "@/api/client";
|
||||||
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
|
||||||
|
export function useNetworks() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["networks"],
|
||||||
|
queryFn: () => apiClient.listNetworks(),
|
||||||
|
meta: { toastOnError: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNetwork(networkId: string) {
|
||||||
|
const { data: networks } = useNetworks();
|
||||||
|
return networks?.find((n) => n.id === networkId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useIsNetworkAdmin(networkId: string): boolean {
|
||||||
|
const network = useNetwork(networkId);
|
||||||
|
const userId = useAuthStore((s) => s.user?.id);
|
||||||
|
if (!network || !userId) return false;
|
||||||
|
return network.admin_human.id === userId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { createNativeStackNavigator } from "@react-navigation/native-stack";
|
||||||
|
import { ActivityIndicator, View } from "react-native";
|
||||||
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
import { SignInScreen } from "@/features/auth/SignInScreen";
|
||||||
|
import { NetworkListScreen } from "@/features/networks/NetworkListScreen";
|
||||||
|
import { StreamListScreen } from "@/features/streams/StreamListScreen";
|
||||||
|
import { StreamViewScreen } from "@/features/stream-view/StreamViewScreen";
|
||||||
|
import { SettingsScreen } from "@/features/settings/SettingsScreen";
|
||||||
|
import { AccountScreen } from "@/features/settings/AccountScreen";
|
||||||
|
import type { RootStackParamList } from "./types";
|
||||||
|
|
||||||
|
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||||
|
|
||||||
|
export function RootNavigator() {
|
||||||
|
const status = useAuthStore((s) => s.status);
|
||||||
|
|
||||||
|
if (status === "idle" || status === "restoring") {
|
||||||
|
return (
|
||||||
|
<View className="flex-1 items-center justify-center bg-background">
|
||||||
|
<ActivityIndicator />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "unauthenticated") {
|
||||||
|
return (
|
||||||
|
<Stack.Navigator screenOptions={{ headerShown: false }}>
|
||||||
|
<Stack.Screen name="SignIn" component={SignInScreen} />
|
||||||
|
</Stack.Navigator>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack.Navigator
|
||||||
|
initialRouteName="NetworkList"
|
||||||
|
screenOptions={{ headerShown: false }}
|
||||||
|
>
|
||||||
|
<Stack.Screen name="NetworkList" component={NetworkListScreen} />
|
||||||
|
<Stack.Screen name="StreamList" component={StreamListScreen} />
|
||||||
|
<Stack.Screen
|
||||||
|
name="StreamView"
|
||||||
|
component={StreamViewScreen}
|
||||||
|
options={{ animation: "fade", gestureEnabled: false }}
|
||||||
|
/>
|
||||||
|
<Stack.Screen name="Settings" component={SettingsScreen} />
|
||||||
|
<Stack.Screen name="Account" component={AccountScreen} />
|
||||||
|
</Stack.Navigator>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { NativeStackScreenProps } from "@react-navigation/native-stack";
|
||||||
|
|
||||||
|
// Pure stack model from PRD §5. Drawer affordance lives inside the
|
||||||
|
// NetworkList screen itself, not the navigator — see Drawer.tsx.
|
||||||
|
export type RootStackParamList = {
|
||||||
|
SignIn: undefined;
|
||||||
|
NetworkList: undefined;
|
||||||
|
StreamList: { networkId: string };
|
||||||
|
StreamView: { networkId: string; streamId: string };
|
||||||
|
Settings: undefined;
|
||||||
|
Account: undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RootStackScreenProps<T extends keyof RootStackParamList> =
|
||||||
|
NativeStackScreenProps<RootStackParamList, T>;
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace ReactNavigation {
|
||||||
|
interface RootParamList extends RootStackParamList {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { apiClient } from "@/api/client";
|
||||||
|
import type { Human } from "@/api/types";
|
||||||
|
import { logError, ApiError } from "@/lib/errors";
|
||||||
|
import { hydrateSession, useSessionStore } from "./session-store";
|
||||||
|
|
||||||
|
// TODO(step-3): once @react-native-firebase/auth lands, mirror desktop's
|
||||||
|
// signInToFirebase() — fetch /auth/firebase-token then signInWithCustomToken.
|
||||||
|
// Until then, Firestore reads will fail with permission errors; mobile only
|
||||||
|
// uses Orion REST until that wiring exists.
|
||||||
|
async function signInToFirebase(): Promise<void> {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthStatus = "idle" | "restoring" | "unauthenticated" | "authenticated";
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
status: AuthStatus;
|
||||||
|
user: Human | null;
|
||||||
|
isRequestingCode: boolean;
|
||||||
|
isSigningIn: boolean;
|
||||||
|
isSigningOut: boolean;
|
||||||
|
error: string | null;
|
||||||
|
restoreSession: () => Promise<void>;
|
||||||
|
requestCode: (email: string) => Promise<void>;
|
||||||
|
signIn: (email: string, code: string) => Promise<void>;
|
||||||
|
signOut: () => Promise<void>;
|
||||||
|
clearError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>((set) => ({
|
||||||
|
status: "idle",
|
||||||
|
user: null,
|
||||||
|
isRequestingCode: false,
|
||||||
|
isSigningIn: false,
|
||||||
|
isSigningOut: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
restoreSession: async () => {
|
||||||
|
set({ status: "restoring" });
|
||||||
|
if (!useSessionStore.getState().hydrated) {
|
||||||
|
await hydrateSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = useSessionStore.getState().token;
|
||||||
|
if (!token) {
|
||||||
|
set({ status: "unauthenticated" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await apiClient.me();
|
||||||
|
await signInToFirebase();
|
||||||
|
set({ status: "authenticated", user });
|
||||||
|
} catch (err) {
|
||||||
|
// Expected on expired/invalid tokens — fall back to the login screen.
|
||||||
|
logError(err, { scope: "auth.restore" });
|
||||||
|
await useSessionStore.getState().clearToken();
|
||||||
|
set({ status: "unauthenticated", user: null });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
requestCode: async (email: string) => {
|
||||||
|
set({ isRequestingCode: true, error: null });
|
||||||
|
try {
|
||||||
|
await apiClient.requestCode({ email });
|
||||||
|
} catch (e) {
|
||||||
|
const message =
|
||||||
|
e instanceof ApiError ? e.message : "Failed to send code";
|
||||||
|
set({ error: message });
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
set({ isRequestingCode: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
signIn: async (email: string, code: string) => {
|
||||||
|
set({ isSigningIn: true, error: null });
|
||||||
|
try {
|
||||||
|
const { human, token } = await apiClient.signIn({ email, code });
|
||||||
|
await useSessionStore.getState().setToken(token);
|
||||||
|
await signInToFirebase();
|
||||||
|
set({ status: "authenticated", user: human });
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof ApiError ? e.message : "Failed to sign in";
|
||||||
|
set({ error: message });
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
set({ isSigningIn: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
signOut: async () => {
|
||||||
|
set({ isSigningOut: true });
|
||||||
|
try {
|
||||||
|
await apiClient.signOut();
|
||||||
|
} catch (err) {
|
||||||
|
// Best-effort — sign out locally regardless.
|
||||||
|
logError(err, { scope: "auth.signOut" });
|
||||||
|
} finally {
|
||||||
|
await useSessionStore.getState().clearToken();
|
||||||
|
set({
|
||||||
|
status: "unauthenticated",
|
||||||
|
user: null,
|
||||||
|
isSigningOut: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// React to token being cleared externally (e.g. 401 from API client).
|
||||||
|
useSessionStore.subscribe((state, prevState) => {
|
||||||
|
if (prevState.token && !state.token) {
|
||||||
|
const authState = useAuthStore.getState();
|
||||||
|
if (authState.status === "authenticated") {
|
||||||
|
useAuthStore.setState({
|
||||||
|
status: "unauthenticated",
|
||||||
|
user: null,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -4,16 +4,20 @@
|
|||||||
"strict": true,
|
"strict": true,
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["src/*"]
|
"@/*": [
|
||||||
|
"src/*"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"types": ["nativewind/types"]
|
"types": [
|
||||||
|
"nativewind/types"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"**/*.ts",
|
"**/*.ts",
|
||||||
"**/*.tsx",
|
"**/*.tsx",
|
||||||
".expo/types/**/*.ts",
|
|
||||||
"expo-env.d.ts",
|
|
||||||
"nativewind-env.d.ts"
|
"nativewind-env.d.ts"
|
||||||
],
|
],
|
||||||
"exclude": ["node_modules"]
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+325
-394
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user