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

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>
);
}