mirror of
https://github.com/FranP-code/create-better-t-stack.git
synced 2025-10-12 23:52:15 +00:00
add ai and todo example templates for native frontends (#293)
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
import { useRef, useEffect } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { fetch as expoFetch } from "expo/fetch";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Container } from "@/components/container";
|
||||
|
||||
// Utility function to generate API URLs
|
||||
const generateAPIUrl = (relativePath: string) => {
|
||||
const serverUrl = process.env.EXPO_PUBLIC_SERVER_URL;
|
||||
if (!serverUrl) {
|
||||
throw new Error("EXPO_PUBLIC_SERVER_URL environment variable is not defined");
|
||||
}
|
||||
|
||||
const path = relativePath.startsWith('/') ? relativePath : `/${relativePath}`;
|
||||
return serverUrl.concat(path);
|
||||
};
|
||||
|
||||
export default function AIScreen() {
|
||||
const { messages, input, handleInputChange, handleSubmit, error } = useChat({
|
||||
fetch: expoFetch as unknown as typeof globalThis.fetch,
|
||||
api: generateAPIUrl('/ai'),
|
||||
onError: error => console.error(error, 'AI Chat Error'),
|
||||
maxSteps: 5,
|
||||
});
|
||||
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
|
||||
useEffect(() => {
|
||||
scrollViewRef.current?.scrollToEnd({ animated: true });
|
||||
}, [messages]);
|
||||
|
||||
const onSubmit = () => {
|
||||
if (input.trim()) {
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Container>
|
||||
<View className="flex-1 justify-center items-center px-4">
|
||||
<Text className="text-destructive text-center text-lg mb-4">
|
||||
Error: {error.message}
|
||||
</Text>
|
||||
<Text className="text-muted-foreground text-center">
|
||||
Please check your connection and try again.
|
||||
</Text>
|
||||
</View>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<KeyboardAvoidingView
|
||||
className="flex-1"
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
>
|
||||
<View className="flex-1 px-4 py-6">
|
||||
<View className="mb-6">
|
||||
<Text className="text-foreground text-2xl font-bold mb-2">
|
||||
AI Chat
|
||||
</Text>
|
||||
<Text className="text-muted-foreground">
|
||||
Chat with our AI assistant
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
className="flex-1 mb-4"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{messages.length === 0 ? (
|
||||
<View className="flex-1 justify-center items-center">
|
||||
<Text className="text-center text-muted-foreground text-lg">
|
||||
Ask me anything to get started!
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View className="space-y-4">
|
||||
{messages.map((message) => (
|
||||
<View
|
||||
key={message.id}
|
||||
className={`p-3 rounded-lg ${
|
||||
message.role === "user"
|
||||
? "bg-primary/10 ml-8"
|
||||
: "bg-card mr-8 border border-border"
|
||||
}`}
|
||||
>
|
||||
<Text className="text-sm font-semibold mb-1 text-foreground">
|
||||
{message.role === "user" ? "You" : "AI Assistant"}
|
||||
</Text>
|
||||
<Text className="text-foreground leading-relaxed">
|
||||
{message.content}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
<View className="border-t border-border pt-4">
|
||||
<View className="flex-row items-end space-x-2">
|
||||
<TextInput
|
||||
value={input}
|
||||
onChange={(e) =>
|
||||
handleInputChange({
|
||||
...e,
|
||||
target: {
|
||||
...e.target,
|
||||
value: e.nativeEvent.text,
|
||||
},
|
||||
} as unknown as React.ChangeEvent<HTMLInputElement>)
|
||||
}
|
||||
placeholder="Type your message..."
|
||||
placeholderTextColor="#6b7280"
|
||||
className="flex-1 border border-border rounded-md px-3 py-2 text-foreground bg-background min-h-[40px] max-h-[120px]"
|
||||
onSubmitEditing={(e) => {
|
||||
handleSubmit(e);
|
||||
e.preventDefault();
|
||||
}}
|
||||
autoFocus={true}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
onPress={onSubmit}
|
||||
disabled={!input.trim()}
|
||||
className={`p-2 rounded-md ${
|
||||
input.trim()
|
||||
? "bg-primary"
|
||||
: "bg-muted"
|
||||
}`}
|
||||
>
|
||||
<Ionicons
|
||||
name="send"
|
||||
size={20}
|
||||
color={input.trim() ? "#ffffff" : "#6b7280"}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import structuredClone from "@ungap/structured-clone";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
if (Platform.OS !== "web") {
|
||||
const setupPolyfills = async () => {
|
||||
const { polyfillGlobal } = await import(
|
||||
"react-native/Libraries/Utilities/PolyfillFunctions"
|
||||
);
|
||||
|
||||
const { TextEncoderStream, TextDecoderStream } = await import(
|
||||
"@stardazed/streams-text-encoding"
|
||||
);
|
||||
|
||||
if (!("structuredClone" in global)) {
|
||||
polyfillGlobal("structuredClone", () => structuredClone);
|
||||
}
|
||||
|
||||
polyfillGlobal("TextEncoderStream", () => TextEncoderStream);
|
||||
polyfillGlobal("TextDecoderStream", () => TextDecoderStream);
|
||||
};
|
||||
|
||||
setupPolyfills();
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,279 @@
|
||||
import { useRef, useEffect } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { fetch as expoFetch } from "expo/fetch";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Container } from "@/components/container";
|
||||
|
||||
const generateAPIUrl = (relativePath: string) => {
|
||||
const serverUrl = process.env.EXPO_PUBLIC_SERVER_URL;
|
||||
if (!serverUrl) {
|
||||
throw new Error(
|
||||
"EXPO_PUBLIC_SERVER_URL environment variable is not defined",
|
||||
);
|
||||
}
|
||||
|
||||
const path = relativePath.startsWith("/") ? relativePath : `/${relativePath}`;
|
||||
return serverUrl.concat(path);
|
||||
};
|
||||
|
||||
export default function AIScreen() {
|
||||
const { theme } = useUnistyles();
|
||||
const { messages, input, handleInputChange, handleSubmit, error } = useChat({
|
||||
fetch: expoFetch as unknown as typeof globalThis.fetch,
|
||||
api: generateAPIUrl("/ai"),
|
||||
onError: (error) => console.error(error, "AI Chat Error"),
|
||||
maxSteps: 5,
|
||||
});
|
||||
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
|
||||
useEffect(() => {
|
||||
scrollViewRef.current?.scrollToEnd({ animated: true });
|
||||
}, [messages]);
|
||||
|
||||
const onSubmit = () => {
|
||||
if (input.trim()) {
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Container>
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>Error: {error.message}</Text>
|
||||
<Text style={styles.errorSubtext}>
|
||||
Please check your connection and try again.
|
||||
</Text>
|
||||
</View>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<KeyboardAvoidingView
|
||||
style={styles.container}
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
>
|
||||
<View style={styles.content}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>AI Chat</Text>
|
||||
<Text style={styles.headerSubtitle}>
|
||||
Chat with our AI assistant
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
style={styles.messagesContainer}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{messages.length === 0 ? (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyText}>
|
||||
Ask me anything to get started!
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.messagesWrapper}>
|
||||
{messages.map((message) => (
|
||||
<View
|
||||
key={message.id}
|
||||
style={[
|
||||
styles.messageContainer,
|
||||
message.role === "user"
|
||||
? styles.userMessage
|
||||
: styles.assistantMessage,
|
||||
]}
|
||||
>
|
||||
<Text style={styles.messageRole}>
|
||||
{message.role === "user" ? "You" : "AI Assistant"}
|
||||
</Text>
|
||||
<Text style={styles.messageContent}>{message.content}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
<View style={styles.inputSection}>
|
||||
<View style={styles.inputContainer}>
|
||||
<TextInput
|
||||
value={input}
|
||||
onChange={(e) =>
|
||||
handleInputChange({
|
||||
...e,
|
||||
target: {
|
||||
...e.target,
|
||||
value: e.nativeEvent.text,
|
||||
},
|
||||
} as unknown as React.ChangeEvent<HTMLInputElement>)
|
||||
}
|
||||
placeholder="Type your message..."
|
||||
placeholderTextColor={theme.colors.border}
|
||||
style={styles.textInput}
|
||||
onSubmitEditing={(e) => {
|
||||
handleSubmit(e);
|
||||
e.preventDefault();
|
||||
}}
|
||||
autoFocus={true}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
onPress={onSubmit}
|
||||
disabled={!input.trim()}
|
||||
style={[
|
||||
styles.sendButton,
|
||||
!input.trim() && styles.sendButtonDisabled,
|
||||
]}
|
||||
>
|
||||
<Ionicons
|
||||
name="send"
|
||||
size={20}
|
||||
color={
|
||||
input.trim() ? theme.colors.background : theme.colors.border
|
||||
}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
paddingVertical: theme.spacing.lg,
|
||||
},
|
||||
errorContainer: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
},
|
||||
errorText: {
|
||||
color: theme.colors.destructive,
|
||||
textAlign: "center",
|
||||
fontSize: 18,
|
||||
marginBottom: theme.spacing.md,
|
||||
},
|
||||
errorSubtext: {
|
||||
color: theme.colors.typography,
|
||||
textAlign: "center",
|
||||
fontSize: 16,
|
||||
},
|
||||
header: {
|
||||
marginBottom: theme.spacing.lg,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 28,
|
||||
fontWeight: "bold",
|
||||
color: theme.colors.typography,
|
||||
marginBottom: theme.spacing.sm,
|
||||
},
|
||||
headerSubtitle: {
|
||||
fontSize: 16,
|
||||
color: theme.colors.typography,
|
||||
},
|
||||
messagesContainer: {
|
||||
flex: 1,
|
||||
marginBottom: theme.spacing.md,
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
emptyText: {
|
||||
textAlign: "center",
|
||||
color: theme.colors.typography,
|
||||
fontSize: 18,
|
||||
},
|
||||
messagesWrapper: {
|
||||
gap: theme.spacing.md,
|
||||
},
|
||||
messageContainer: {
|
||||
padding: theme.spacing.md,
|
||||
borderRadius: 8,
|
||||
},
|
||||
userMessage: {
|
||||
backgroundColor: theme.colors.primary + "20",
|
||||
marginLeft: theme.spacing.xl,
|
||||
alignSelf: "flex-end",
|
||||
},
|
||||
assistantMessage: {
|
||||
backgroundColor: theme.colors.background,
|
||||
marginRight: theme.spacing.xl,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
messageRole: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
marginBottom: theme.spacing.sm,
|
||||
color: theme.colors.typography,
|
||||
},
|
||||
messageContent: {
|
||||
color: theme.colors.typography,
|
||||
lineHeight: 20,
|
||||
},
|
||||
toolInvocations: {
|
||||
fontSize: 12,
|
||||
color: theme.colors.typography,
|
||||
fontFamily: "monospace",
|
||||
backgroundColor: theme.colors.border + "40",
|
||||
padding: theme.spacing.sm,
|
||||
borderRadius: 4,
|
||||
marginTop: theme.spacing.sm,
|
||||
},
|
||||
inputSection: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.colors.border,
|
||||
paddingTop: theme.spacing.md,
|
||||
},
|
||||
inputContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-end",
|
||||
gap: theme.spacing.sm,
|
||||
},
|
||||
textInput: {
|
||||
flex: 1,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
paddingVertical: theme.spacing.sm,
|
||||
color: theme.colors.typography,
|
||||
backgroundColor: theme.colors.background,
|
||||
fontSize: 16,
|
||||
minHeight: 40,
|
||||
maxHeight: 120,
|
||||
},
|
||||
sendButton: {
|
||||
backgroundColor: theme.colors.primary,
|
||||
padding: theme.spacing.sm,
|
||||
borderRadius: 8,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
sendButtonDisabled: {
|
||||
backgroundColor: theme.colors.border,
|
||||
},
|
||||
}));
|
||||
25
apps/cli/templates/examples/ai/native/unistyles/polyfills.js
Normal file
25
apps/cli/templates/examples/ai/native/unistyles/polyfills.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import structuredClone from "@ungap/structured-clone";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
if (Platform.OS !== "web") {
|
||||
const setupPolyfills = async () => {
|
||||
const { polyfillGlobal } = await import(
|
||||
"react-native/Libraries/Utilities/PolyfillFunctions"
|
||||
);
|
||||
|
||||
const { TextEncoderStream, TextDecoderStream } = await import(
|
||||
"@stardazed/streams-text-encoding"
|
||||
);
|
||||
|
||||
if (!("structuredClone" in global)) {
|
||||
polyfillGlobal("structuredClone", () => structuredClone);
|
||||
}
|
||||
|
||||
polyfillGlobal("TextEncoderStream", () => TextEncoderStream);
|
||||
polyfillGlobal("TextDecoderStream", () => TextDecoderStream);
|
||||
};
|
||||
|
||||
setupPolyfills();
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,340 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
|
||||
{{#if (eq backend "convex")}}
|
||||
import { useMutation, useQuery } from "convex/react";
|
||||
import { api } from "@{{projectName}}/backend/convex/_generated/api";
|
||||
import type { Id } from "@{{projectName}}/backend/convex/_generated/dataModel";
|
||||
{{else}}
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
{{/if}}
|
||||
|
||||
import { Container } from "@/components/container";
|
||||
{{#unless (eq backend "convex")}}
|
||||
{{#if (eq api "orpc")}}
|
||||
import { orpc } from "@/utils/orpc";
|
||||
{{/if}}
|
||||
{{#if (eq api "trpc")}}
|
||||
import { trpc } from "@/utils/trpc";
|
||||
{{/if}}
|
||||
{{/unless}}
|
||||
|
||||
export default function TodosScreen() {
|
||||
const [newTodoText, setNewTodoText] = useState("");
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
{{#if (eq backend "convex")}}
|
||||
const todos = useQuery(api.todos.getAll);
|
||||
const createTodoMutation = useMutation(api.todos.create);
|
||||
const toggleTodoMutation = useMutation(api.todos.toggle);
|
||||
const deleteTodoMutation = useMutation(api.todos.deleteTodo);
|
||||
|
||||
const handleAddTodo = async () => {
|
||||
const text = newTodoText.trim();
|
||||
if (!text) return;
|
||||
await createTodoMutation({ text });
|
||||
setNewTodoText("");
|
||||
};
|
||||
|
||||
const handleToggleTodo = (id: Id<"todos">, currentCompleted: boolean) => {
|
||||
toggleTodoMutation({ id, completed: !currentCompleted });
|
||||
};
|
||||
|
||||
const handleDeleteTodo = (id: Id<"todos">) => {
|
||||
Alert.alert("Delete Todo", "Are you sure you want to delete this todo?", [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: () => deleteTodoMutation({ id }),
|
||||
},
|
||||
]);
|
||||
};
|
||||
{{else}}
|
||||
{{#if (eq api "orpc")}}
|
||||
const todos = useQuery(orpc.todo.getAll.queryOptions());
|
||||
const createMutation = useMutation(
|
||||
orpc.todo.create.mutationOptions({
|
||||
onSuccess: () => {
|
||||
todos.refetch();
|
||||
setNewTodoText("");
|
||||
},
|
||||
})
|
||||
);
|
||||
const toggleMutation = useMutation(
|
||||
orpc.todo.toggle.mutationOptions({
|
||||
onSuccess: () => todos.refetch(),
|
||||
})
|
||||
);
|
||||
const deleteMutation = useMutation(
|
||||
orpc.todo.delete.mutationOptions({
|
||||
onSuccess: () => todos.refetch(),
|
||||
})
|
||||
);
|
||||
{{/if}}
|
||||
{{#if (eq api "trpc")}}
|
||||
const todos = useQuery(trpc.todo.getAll.queryOptions());
|
||||
const createMutation = useMutation(
|
||||
trpc.todo.create.mutationOptions({
|
||||
onSuccess: () => {
|
||||
todos.refetch();
|
||||
setNewTodoText("");
|
||||
},
|
||||
})
|
||||
);
|
||||
const toggleMutation = useMutation(
|
||||
trpc.todo.toggle.mutationOptions({
|
||||
onSuccess: () => todos.refetch(),
|
||||
})
|
||||
);
|
||||
const deleteMutation = useMutation(
|
||||
trpc.todo.delete.mutationOptions({
|
||||
onSuccess: () => todos.refetch(),
|
||||
})
|
||||
);
|
||||
{{/if}}
|
||||
|
||||
const handleAddTodo = () => {
|
||||
if (newTodoText.trim()) {
|
||||
createMutation.mutate({ text: newTodoText });
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleTodo = (id: number, completed: boolean) => {
|
||||
toggleMutation.mutate({ id, completed: !completed });
|
||||
};
|
||||
|
||||
const handleDeleteTodo = (id: number) => {
|
||||
Alert.alert("Delete Todo", "Are you sure you want to delete this todo?", [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: () => deleteMutation.mutate({ id }),
|
||||
},
|
||||
]);
|
||||
};
|
||||
{{/if}}
|
||||
|
||||
const isLoading = {{#if (eq backend "convex")}}!todos{{else}}todos.isLoading{{/if}};
|
||||
const isCreating = {{#if (eq backend "convex")}}false{{else}}createMutation.isPending{{/if}};
|
||||
const primaryButtonTextColor = theme.colors.background;
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<ScrollView style={styles.scrollView}>
|
||||
<View style={styles.headerContainer}>
|
||||
<Text style={styles.headerTitle}>Todo List</Text>
|
||||
<Text style={styles.headerSubtitle}>
|
||||
Manage your tasks efficiently
|
||||
</Text>
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<TextInput
|
||||
value={newTodoText}
|
||||
onChangeText={setNewTodoText}
|
||||
placeholder="Add a new task..."
|
||||
placeholderTextColor={theme.colors.border}
|
||||
editable={!isCreating}
|
||||
style={styles.textInput}
|
||||
onSubmitEditing={handleAddTodo}
|
||||
returnKeyType="done"
|
||||
/>
|
||||
<TouchableOpacity
|
||||
onPress={handleAddTodo}
|
||||
disabled={isCreating || !newTodoText.trim()}
|
||||
style={[
|
||||
styles.addButton,
|
||||
(isCreating || !newTodoText.trim()) && styles.addButtonDisabled,
|
||||
]}
|
||||
>
|
||||
{isCreating ? (
|
||||
<ActivityIndicator size="small" color={primaryButtonTextColor} />
|
||||
) : (
|
||||
<Ionicons
|
||||
name="add"
|
||||
size={24}
|
||||
color={primaryButtonTextColor}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{isLoading && (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={theme.colors.primary} />
|
||||
<Text style={styles.loadingText}>Loading todos...</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{{#if (eq backend "convex")}}
|
||||
{todos && todos.length === 0 && !isLoading && (
|
||||
<Text style={styles.emptyText}>No todos yet. Add one!</Text>
|
||||
)}
|
||||
{todos?.map((todo) => (
|
||||
<View key={todo._id} style={styles.todoItem}>
|
||||
<TouchableOpacity
|
||||
onPress={() => handleToggleTodo(todo._id, todo.completed)}
|
||||
style={styles.todoContent}
|
||||
>
|
||||
<Ionicons
|
||||
name={todo.completed ? "checkbox" : "square-outline"}
|
||||
size={24}
|
||||
color={todo.completed ? theme.colors.primary : theme.colors.typography}
|
||||
style={styles.checkbox}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.todoText,
|
||||
todo.completed && styles.todoTextCompleted,
|
||||
]}
|
||||
>
|
||||
{todo.text}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => handleDeleteTodo(todo._id)}>
|
||||
<Ionicons name="trash-outline" size={24} color={theme.colors.destructive} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
))}
|
||||
{{else}}
|
||||
{todos.data && todos.data.length === 0 && !isLoading && (
|
||||
<Text style={styles.emptyText}>No todos yet. Add one!</Text>
|
||||
)}
|
||||
{todos.data?.map((todo: { id: number; text: string; completed: boolean }) => (
|
||||
<View key={todo.id} style={styles.todoItem}>
|
||||
<TouchableOpacity
|
||||
onPress={() => handleToggleTodo(todo.id, todo.completed)}
|
||||
style={styles.todoContent}
|
||||
>
|
||||
<Ionicons
|
||||
name={todo.completed ? "checkbox" : "square-outline"}
|
||||
size={24}
|
||||
color={todo.completed ? theme.colors.primary : theme.colors.typography}
|
||||
style={styles.checkbox}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.todoText,
|
||||
todo.completed && styles.todoTextCompleted,
|
||||
]}
|
||||
>
|
||||
{todo.text}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => handleDeleteTodo(todo.id)}>
|
||||
<Ionicons name="trash-outline" size={24} color={theme.colors.destructive} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
))}
|
||||
{{/if}}
|
||||
</ScrollView>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
headerContainer: {
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
paddingVertical: theme.spacing.lg,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 28,
|
||||
fontWeight: "bold",
|
||||
color: theme.colors.typography,
|
||||
marginBottom: theme.spacing.sm,
|
||||
},
|
||||
headerSubtitle: {
|
||||
fontSize: 16,
|
||||
color: theme.colors.typography,
|
||||
marginBottom: theme.spacing.md,
|
||||
},
|
||||
inputContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
marginBottom: theme.spacing.md,
|
||||
},
|
||||
textInput: {
|
||||
flex: 1,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
paddingVertical: theme.spacing.sm,
|
||||
color: theme.colors.typography,
|
||||
backgroundColor: theme.colors.background,
|
||||
marginRight: theme.spacing.sm,
|
||||
fontSize: 16,
|
||||
},
|
||||
addButton: {
|
||||
backgroundColor: theme.colors.primary,
|
||||
padding: theme.spacing.sm,
|
||||
borderRadius: 8,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
addButtonDisabled: {
|
||||
backgroundColor: theme.colors.border,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: theme.spacing.lg,
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: theme.spacing.sm,
|
||||
fontSize: 16,
|
||||
color: theme.colors.typography,
|
||||
},
|
||||
emptyText: {
|
||||
textAlign: "center",
|
||||
marginTop: theme.spacing.xl,
|
||||
fontSize: 16,
|
||||
color: theme.colors.typography,
|
||||
},
|
||||
todoItem: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingVertical: theme.spacing.md,
|
||||
paddingHorizontal: theme.spacing.md,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
todoContent: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
flex: 1,
|
||||
},
|
||||
checkbox: {
|
||||
marginRight: theme.spacing.md,
|
||||
},
|
||||
todoText: {
|
||||
fontSize: 16,
|
||||
color: theme.colors.typography,
|
||||
flex: 1,
|
||||
},
|
||||
todoTextCompleted: {
|
||||
textDecorationLine: "line-through",
|
||||
color: theme.colors.border,
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user