initial commit

This commit is contained in:
René Schober
2026-03-13 06:23:06 +01:00
commit 4e34270786
314 changed files with 37280 additions and 0 deletions

21
apps/native/.gitignore vendored Normal file
View File

@@ -0,0 +1,21 @@
node_modules/
.expo/
dist/
npm-debug.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
*.orig.*
web-build/
# macOS
.DS_Store
# Temporary files created by Metro to check the health of the file watcher
.metro-health-check*
# UniWind generated types
uniwind-types.d.ts

17
apps/native/app.json Normal file
View File

@@ -0,0 +1,17 @@
{
"expo": {
"scheme": "haushaltsApp",
"userInterfaceStyle": "automatic",
"orientation": "default",
"web": {
"bundler": "metro"
},
"name": "haushaltsApp",
"slug": "haushaltsApp",
"plugins": ["expo-font"],
"experiments": {
"typedRoutes": true,
"reactCompiler": true
}
}
}

View File

@@ -0,0 +1,46 @@
import { Ionicons } from "@expo/vector-icons";
import { Tabs } from "expo-router";
import { useThemeColor } from "heroui-native";
export default function TabLayout() {
const themeColorForeground = useThemeColor("foreground");
const themeColorBackground = useThemeColor("background");
return (
<Tabs
screenOptions={{
headerShown: false,
headerStyle: {
backgroundColor: themeColorBackground,
},
headerTintColor: themeColorForeground,
headerTitleStyle: {
color: themeColorForeground,
fontWeight: "600",
},
tabBarStyle: {
backgroundColor: themeColorBackground,
},
}}
>
<Tabs.Screen
name="index"
options={{
title: "Home",
tabBarIcon: ({ color, size }: { color: string; size: number }) => (
<Ionicons name="home" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="two"
options={{
title: "Explore",
tabBarIcon: ({ color, size }: { color: string; size: number }) => (
<Ionicons name="compass" size={size} color={color} />
),
}}
/>
</Tabs>
);
}

View File

@@ -0,0 +1,16 @@
import { Card } from "heroui-native";
import { Text, View } from "react-native";
import { Container } from "@/components/container";
export default function Home() {
return (
<Container className="p-6">
<View className="flex-1 justify-center items-center">
<Card variant="secondary" className="p-8 items-center">
<Card.Title className="text-3xl mb-2">Tab One</Card.Title>
</Card>
</View>
</Container>
);
}

View File

@@ -0,0 +1,16 @@
import { Card } from "heroui-native";
import { Text, View } from "react-native";
import { Container } from "@/components/container";
export default function TabTwo() {
return (
<Container className="p-6">
<View className="flex-1 justify-center items-center">
<Card variant="secondary" className="p-8 items-center">
<Card.Title className="text-3xl mb-2">TabTwo</Card.Title>
</Card>
</View>
</Container>
);
}

View File

@@ -0,0 +1,72 @@
import { Ionicons, MaterialIcons } from "@expo/vector-icons";
import { Link } from "expo-router";
import { Drawer } from "expo-router/drawer";
import { useThemeColor } from "heroui-native";
import React, { useCallback } from "react";
import { Pressable, Text } from "react-native";
import { ThemeToggle } from "@/components/theme-toggle";
function DrawerLayout() {
const themeColorForeground = useThemeColor("foreground");
const themeColorBackground = useThemeColor("background");
const renderThemeToggle = useCallback(() => <ThemeToggle />, []);
return (
<Drawer
screenOptions={{
headerTintColor: themeColorForeground,
headerStyle: { backgroundColor: themeColorBackground },
headerTitleStyle: {
fontWeight: "600",
color: themeColorForeground,
},
headerRight: renderThemeToggle,
drawerStyle: { backgroundColor: themeColorBackground },
}}
>
<Drawer.Screen
name="index"
options={{
headerTitle: "Home",
drawerLabel: ({ color, focused }) => (
<Text style={{ color: focused ? color : themeColorForeground }}>Home</Text>
),
drawerIcon: ({ size, color, focused }) => (
<Ionicons
name="home-outline"
size={size}
color={focused ? color : themeColorForeground}
/>
),
}}
/>
<Drawer.Screen
name="(tabs)"
options={{
headerTitle: "Tabs",
drawerLabel: ({ color, focused }) => (
<Text style={{ color: focused ? color : themeColorForeground }}>Tabs</Text>
),
drawerIcon: ({ size, color, focused }) => (
<MaterialIcons
name="border-bottom"
size={size}
color={focused ? color : themeColorForeground}
/>
),
headerRight: () => (
<Link href="/modal" asChild>
<Pressable className="mr-4">
<Ionicons name="add-outline" size={24} color={themeColorForeground} />
</Pressable>
</Link>
),
}}
/>
</Drawer>
);
}
export default DrawerLayout;

View File

@@ -0,0 +1,49 @@
import { Ionicons } from "@expo/vector-icons";
import { Card, Chip, useThemeColor } from "heroui-native";
import { Text, View, Pressable } from "react-native";
import { Container } from "@/components/container";
import { SignIn } from "@/components/sign-in";
import { SignUp } from "@/components/sign-up";
import { authClient } from "@/lib/auth-client";
export default function Home() {
const { data: session } = authClient.useSession();
const mutedColor = useThemeColor("muted");
const successColor = useThemeColor("success");
const dangerColor = useThemeColor("danger");
const foregroundColor = useThemeColor("foreground");
return (
<Container className="p-6">
<View className="py-4 mb-6">
<Text className="text-4xl font-bold text-foreground mb-2">BETTER T STACK</Text>
</View>
{session?.user ? (
<Card variant="secondary" className="mb-6 p-4">
<Text className="text-foreground text-base mb-2">
Welcome, <Text className="font-medium">{session.user.name}</Text>
</Text>
<Text className="text-muted text-sm mb-4">{session.user.email}</Text>
<Pressable
className="bg-danger py-3 px-4 rounded-lg self-start active:opacity-70"
onPress={() => {
authClient.signOut();
}}
>
<Text className="text-foreground font-medium">Sign Out</Text>
</Pressable>
</Card>
) : null}
{!session?.user && (
<>
<SignIn />
<SignUp />
</>
)}
</Container>
);
}

View File

@@ -0,0 +1,27 @@
import { Link, Stack } from "expo-router";
import { Button, Surface } from "heroui-native";
import { Text, View } from "react-native";
import { Container } from "@/components/container";
export default function NotFoundScreen() {
return (
<>
<Stack.Screen options={{ title: "Not Found" }} />
<Container>
<View className="flex-1 justify-center items-center p-4">
<Surface variant="secondary" className="items-center p-6 max-w-sm rounded-lg">
<Text className="text-4xl mb-3">🤔</Text>
<Text className="text-foreground font-medium text-lg mb-1">Page Not Found</Text>
<Text className="text-muted text-sm text-center mb-4">
The page you're looking for doesn't exist.
</Text>
<Link href="/" asChild>
<Button size="sm">Go Home</Button>
</Link>
</Surface>
</View>
</Container>
</>
);
}

View File

@@ -0,0 +1,34 @@
import "@/global.css";
import { Stack } from "expo-router";
import { HeroUINativeProvider } from "heroui-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { AppThemeProvider } from "@/contexts/app-theme-context";
export const unstable_settings = {
initialRouteName: "(drawer)",
};
function StackLayout() {
return (
<Stack screenOptions={{}}>
<Stack.Screen name="(drawer)" options={{ headerShown: false }} />
<Stack.Screen name="modal" options={{ title: "Modal", presentation: "modal" }} />
</Stack>
);
}
export default function Layout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<KeyboardProvider>
<AppThemeProvider>
<HeroUINativeProvider>
<StackLayout />
</HeroUINativeProvider>
</AppThemeProvider>
</KeyboardProvider>
</GestureHandlerRootView>
);
}

37
apps/native/app/modal.tsx Normal file
View File

@@ -0,0 +1,37 @@
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { Button, Surface, useThemeColor } from "heroui-native";
import { Text, View } from "react-native";
import { Container } from "@/components/container";
function Modal() {
const accentForegroundColor = useThemeColor("accent-foreground");
function handleClose() {
router.back();
}
return (
<Container>
<View className="flex-1 justify-center items-center p-4">
<Surface variant="secondary" className="p-5 w-full max-w-sm rounded-lg">
<View className="items-center">
<View className="w-12 h-12 bg-accent rounded-lg items-center justify-center mb-3">
<Ionicons name="checkmark" size={24} color={accentForegroundColor} />
</View>
<Text className="text-foreground font-medium text-lg mb-1">Modal Screen</Text>
<Text className="text-muted text-sm text-center mb-4">
This is an example modal screen for dialogs and confirmations.
</Text>
</View>
<Button onPress={handleClose} className="w-full" size="sm">
<Button.Label>Close</Button.Label>
</Button>
</Surface>
</View>
</Container>
);
}
export default Modal;

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,46 @@
import { cn } from "heroui-native";
import { type PropsWithChildren } from "react";
import { ScrollView, View, type ScrollViewProps, type ViewProps } from "react-native";
import Animated, { type AnimatedProps } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const AnimatedView = Animated.createAnimatedComponent(View);
type Props = AnimatedProps<ViewProps> & {
className?: string;
isScrollable?: boolean;
scrollViewProps?: Omit<ScrollViewProps, "contentContainerStyle">;
};
export function Container({
children,
className,
isScrollable = true,
scrollViewProps,
...props
}: PropsWithChildren<Props>) {
const insets = useSafeAreaInsets();
return (
<AnimatedView
className={cn("flex-1 bg-background", className)}
style={{
paddingBottom: insets.bottom,
}}
{...props}
>
{isScrollable ? (
<ScrollView
contentContainerStyle={{ flexGrow: 1 }}
keyboardShouldPersistTaps="handled"
contentInsetAdjustmentBehavior="automatic"
{...scrollViewProps}
>
{children}
</ScrollView>
) : (
<View className="flex-1">{children}</View>
)}
</AnimatedView>
);
}

View File

@@ -0,0 +1,166 @@
import { useForm } from "@tanstack/react-form";
import {
Button,
FieldError,
Input,
Label,
Spinner,
Surface,
TextField,
useToast,
} from "heroui-native";
import { useRef } from "react";
import { Text, TextInput, View } from "react-native";
import z from "zod";
import { authClient } from "@/lib/auth-client";
const signInSchema = z.object({
email: z.string().trim().min(1, "Email is required").email("Enter a valid email address"),
password: z.string().min(1, "Password is required").min(8, "Use at least 8 characters"),
});
function getErrorMessage(error: unknown): string | null {
if (!error) return null;
if (typeof error === "string") {
return error;
}
if (Array.isArray(error)) {
for (const issue of error) {
const message = getErrorMessage(issue);
if (message) {
return message;
}
}
return null;
}
if (typeof error === "object" && error !== null) {
const maybeError = error as { message?: unknown };
if (typeof maybeError.message === "string") {
return maybeError.message;
}
}
return null;
}
function SignIn() {
const passwordInputRef = useRef<TextInput>(null);
const { toast } = useToast();
const form = useForm({
defaultValues: {
email: "",
password: "",
},
validators: {
onSubmit: signInSchema,
},
onSubmit: async ({ value, formApi }) => {
await authClient.signIn.email(
{
email: value.email.trim(),
password: value.password,
},
{
onError(error) {
toast.show({
variant: "danger",
label: error.error?.message || "Failed to sign in",
});
},
onSuccess() {
formApi.reset();
toast.show({
variant: "success",
label: "Signed in successfully",
});
},
},
);
},
});
return (
<Surface variant="secondary" className="p-4 rounded-lg">
<Text className="text-foreground font-medium mb-4">Sign In</Text>
<form.Subscribe
selector={(state) => ({
isSubmitting: state.isSubmitting,
validationError: getErrorMessage(state.errorMap.onSubmit),
})}
>
{({ isSubmitting, validationError }) => {
const formError = validationError;
return (
<>
<FieldError isInvalid={!!formError} className="mb-3">
{formError}
</FieldError>
<View className="gap-3">
<form.Field name="email">
{(field) => (
<TextField>
<Label>Email</Label>
<Input
value={field.state.value}
onBlur={field.handleBlur}
onChangeText={field.handleChange}
placeholder="email@example.com"
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
textContentType="emailAddress"
returnKeyType="next"
blurOnSubmit={false}
onSubmitEditing={() => {
passwordInputRef.current?.focus();
}}
/>
</TextField>
)}
</form.Field>
<form.Field name="password">
{(field) => (
<TextField>
<Label>Password</Label>
<Input
ref={passwordInputRef}
value={field.state.value}
onBlur={field.handleBlur}
onChangeText={field.handleChange}
placeholder="••••••••"
secureTextEntry
autoComplete="password"
textContentType="password"
returnKeyType="go"
onSubmitEditing={form.handleSubmit}
/>
</TextField>
)}
</form.Field>
<Button onPress={form.handleSubmit} isDisabled={isSubmitting} className="mt-1">
{isSubmitting ? (
<Spinner size="sm" color="default" />
) : (
<Button.Label>Sign In</Button.Label>
)}
</Button>
</View>
</>
);
}}
</form.Subscribe>
</Surface>
);
}
export { SignIn };

View File

@@ -0,0 +1,190 @@
import { useForm } from "@tanstack/react-form";
import {
Button,
FieldError,
Input,
Label,
Spinner,
Surface,
TextField,
useToast,
} from "heroui-native";
import { useRef } from "react";
import { Text, TextInput, View } from "react-native";
import z from "zod";
import { authClient } from "@/lib/auth-client";
const signUpSchema = z.object({
name: z.string().trim().min(1, "Name is required").min(2, "Name must be at least 2 characters"),
email: z.string().trim().min(1, "Email is required").email("Enter a valid email address"),
password: z.string().min(1, "Password is required").min(8, "Use at least 8 characters"),
});
function getErrorMessage(error: unknown): string | null {
if (!error) return null;
if (typeof error === "string") {
return error;
}
if (Array.isArray(error)) {
for (const issue of error) {
const message = getErrorMessage(issue);
if (message) {
return message;
}
}
return null;
}
if (typeof error === "object" && error !== null) {
const maybeError = error as { message?: unknown };
if (typeof maybeError.message === "string") {
return maybeError.message;
}
}
return null;
}
export function SignUp() {
const emailInputRef = useRef<TextInput>(null);
const passwordInputRef = useRef<TextInput>(null);
const { toast } = useToast();
const form = useForm({
defaultValues: {
name: "",
email: "",
password: "",
},
validators: {
onSubmit: signUpSchema,
},
onSubmit: async ({ value, formApi }) => {
await authClient.signUp.email(
{
name: value.name.trim(),
email: value.email.trim(),
password: value.password,
},
{
onError(error) {
toast.show({
variant: "danger",
label: error.error?.message || "Failed to sign up",
});
},
onSuccess() {
formApi.reset();
toast.show({
variant: "success",
label: "Account created successfully",
});
},
},
);
},
});
return (
<Surface variant="secondary" className="p-4 rounded-lg">
<Text className="text-foreground font-medium mb-4">Create Account</Text>
<form.Subscribe
selector={(state) => ({
isSubmitting: state.isSubmitting,
validationError: getErrorMessage(state.errorMap.onSubmit),
})}
>
{({ isSubmitting, validationError }) => {
const formError = validationError;
return (
<>
<FieldError isInvalid={!!formError} className="mb-3">
{formError}
</FieldError>
<View className="gap-3">
<form.Field name="name">
{(field) => (
<TextField>
<Label>Name</Label>
<Input
value={field.state.value}
onBlur={field.handleBlur}
onChangeText={field.handleChange}
placeholder="John Doe"
autoComplete="name"
textContentType="name"
returnKeyType="next"
blurOnSubmit={false}
onSubmitEditing={() => {
emailInputRef.current?.focus();
}}
/>
</TextField>
)}
</form.Field>
<form.Field name="email">
{(field) => (
<TextField>
<Label>Email</Label>
<Input
ref={emailInputRef}
value={field.state.value}
onBlur={field.handleBlur}
onChangeText={field.handleChange}
placeholder="email@example.com"
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
textContentType="emailAddress"
returnKeyType="next"
blurOnSubmit={false}
onSubmitEditing={() => {
passwordInputRef.current?.focus();
}}
/>
</TextField>
)}
</form.Field>
<form.Field name="password">
{(field) => (
<TextField>
<Label>Password</Label>
<Input
ref={passwordInputRef}
value={field.state.value}
onBlur={field.handleBlur}
onChangeText={field.handleChange}
placeholder="••••••••"
secureTextEntry
autoComplete="new-password"
textContentType="newPassword"
returnKeyType="go"
onSubmitEditing={form.handleSubmit}
/>
</TextField>
)}
</form.Field>
<Button onPress={form.handleSubmit} isDisabled={isSubmitting} className="mt-1">
{isSubmitting ? (
<Spinner size="sm" color="default" />
) : (
<Button.Label>Create Account</Button.Label>
)}
</Button>
</View>
</>
);
}}
</form.Subscribe>
</Surface>
);
}

View File

@@ -0,0 +1,35 @@
import { Ionicons } from "@expo/vector-icons";
import * as Haptics from "expo-haptics";
import { Platform, Pressable } from "react-native";
import Animated, { FadeOut, ZoomIn } from "react-native-reanimated";
import { withUniwind } from "uniwind";
import { useAppTheme } from "@/contexts/app-theme-context";
const StyledIonicons = withUniwind(Ionicons);
export function ThemeToggle() {
const { toggleTheme, isLight } = useAppTheme();
return (
<Pressable
onPress={() => {
if (Platform.OS === "ios") {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
toggleTheme();
}}
className="px-2.5"
>
{isLight ? (
<Animated.View key="moon" entering={ZoomIn} exiting={FadeOut}>
<StyledIonicons name="moon" size={20} className="text-foreground" />
</Animated.View>
) : (
<Animated.View key="sun" entering={ZoomIn} exiting={FadeOut}>
<StyledIonicons name="sunny" size={20} className="text-foreground" />
</Animated.View>
)}
</Pressable>
);
}

View File

@@ -0,0 +1,55 @@
import React, { createContext, useCallback, useContext, useMemo } from "react";
import { Uniwind, useUniwind } from "uniwind";
type ThemeName = "light" | "dark";
type AppThemeContextType = {
currentTheme: string;
isLight: boolean;
isDark: boolean;
setTheme: (theme: ThemeName) => void;
toggleTheme: () => void;
};
const AppThemeContext = createContext<AppThemeContextType | undefined>(undefined);
export const AppThemeProvider = ({ children }: { children: React.ReactNode }) => {
const { theme } = useUniwind();
const isLight = useMemo(() => {
return theme === "light";
}, [theme]);
const isDark = useMemo(() => {
return theme === "dark";
}, [theme]);
const setTheme = useCallback((newTheme: ThemeName) => {
Uniwind.setTheme(newTheme);
}, []);
const toggleTheme = useCallback(() => {
Uniwind.setTheme(theme === "light" ? "dark" : "light");
}, [theme]);
const value = useMemo(
() => ({
currentTheme: theme,
isLight,
isDark,
setTheme,
toggleTheme,
}),
[theme, isLight, isDark, setTheme, toggleTheme],
);
return <AppThemeContext.Provider value={value}>{children}</AppThemeContext.Provider>;
};
export function useAppTheme() {
const context = useContext(AppThemeContext);
if (!context) {
throw new Error("useAppTheme must be used within AppThemeProvider");
}
return context;
}

5
apps/native/global.css Normal file
View File

@@ -0,0 +1,5 @@
@import "tailwindcss";
@import "uniwind";
@import "heroui-native/styles";
@source './node_modules/heroui-native/lib';

View File

@@ -0,0 +1,16 @@
import { expoClient } from "@better-auth/expo/client";
import { env } from "@haushaltsApp/env/native";
import { createAuthClient } from "better-auth/react";
import Constants from "expo-constants";
import * as SecureStore from "expo-secure-store";
export const authClient = createAuthClient({
baseURL: env.EXPO_PUBLIC_SERVER_URL,
plugins: [
expoClient({
scheme: Constants.expoConfig?.scheme as string,
storagePrefix: Constants.expoConfig?.scheme as string,
storage: SecureStore,
}),
],
});

View File

@@ -0,0 +1,13 @@
const { getDefaultConfig } = require("expo/metro-config");
const { withUniwindConfig } = require("uniwind/metro");
const { wrapWithReanimatedMetroConfig } = require("react-native-reanimated/metro-config");
/** @type {import('expo/metro-config').MetroConfig} */
const config = getDefaultConfig(__dirname);
const uniwindConfig = withUniwindConfig(wrapWithReanimatedMetroConfig(config), {
cssEntryFile: "./global.css",
dtsFile: "./uniwind-types.d.ts",
});
module.exports = uniwindConfig;

59
apps/native/package.json Normal file
View File

@@ -0,0 +1,59 @@
{
"name": "native",
"version": "1.0.0",
"private": true,
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"dev": "expo start --clear",
"android": "expo run:android",
"ios": "expo run:ios",
"prebuild": "expo prebuild",
"web": "expo start --web"
},
"dependencies": {
"@better-auth/expo": "catalog:",
"@expo/metro-runtime": "~55.0.6",
"@expo/vector-icons": "^15.0.3",
"@gorhom/bottom-sheet": "^5",
"@haushaltsApp/env": "workspace:*",
"@react-navigation/drawer": "^7.3.9",
"@react-navigation/elements": "^2.8.1",
"@tanstack/react-form": "catalog:",
"better-auth": "catalog:",
"dotenv": "catalog:",
"expo": "^55.0.0",
"expo-constants": "~55.0.7",
"expo-font": "~55.0.4",
"expo-haptics": "~55.0.8",
"expo-linking": "~55.0.7",
"expo-network": "~55.0.8",
"expo-router": "~55.0.2",
"expo-secure-store": "~55.0.8",
"expo-status-bar": "~55.0.4",
"expo-web-browser": "~55.0.9",
"heroui-native": "^1.0.0-rc.3",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-native": "0.83.2",
"react-native-gesture-handler": "~2.30.0",
"react-native-keyboard-controller": "1.20.7",
"react-native-reanimated": "4.2.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.23.0",
"react-native-svg": "15.15.3",
"react-native-web": "^0.21.0",
"react-native-worklets": "0.7.2",
"tailwind-merge": "^3.4.0",
"tailwind-variants": "^3.2.2",
"tailwindcss": "catalog:",
"uniwind": "^1.4.0",
"zod": "catalog:"
},
"devDependencies": {
"@haushaltsApp/config": "workspace:*",
"@types/node": "^24.10.0",
"@types/react": "~19.2.10",
"typescript": "^5"
}
}

11
apps/native/tsconfig.json Normal file
View File

@@ -0,0 +1,11 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
},
"include": ["**/*.ts", "**/*.tsx"]
}