diff --git a/app.json b/app.json
index 1599988..c201436 100644
--- a/app.json
+++ b/app.json
@@ -38,7 +38,8 @@
"backgroundColor": "#000000"
}
}
- ]
+ ],
+ "expo-secure-store"
],
"experiments": {
"typedRoutes": true,
diff --git a/app/(tabs)/_layout.tsx b/app/(protected)/(tabs)/_layout.tsx
similarity index 100%
rename from app/(tabs)/_layout.tsx
rename to app/(protected)/(tabs)/_layout.tsx
diff --git a/app/(protected)/(tabs)/calendar.tsx b/app/(protected)/(tabs)/calendar.tsx
new file mode 100644
index 0000000..34aa5bb
--- /dev/null
+++ b/app/(protected)/(tabs)/calendar.tsx
@@ -0,0 +1 @@
+export { default } from "@/features/calendar/screens/CalendarScreen";
diff --git a/app/(protected)/(tabs)/index.tsx b/app/(protected)/(tabs)/index.tsx
new file mode 100644
index 0000000..964e4e7
--- /dev/null
+++ b/app/(protected)/(tabs)/index.tsx
@@ -0,0 +1,50 @@
+import Button from "@/components/ui/buttons";
+import { useAuth } from "@/features/shared/hooks/useAuth";
+import { router } from "expo-router";
+import { StyleSheet, Text, View } from "react-native";
+
+export default function Index() {
+ const { logout } = useAuth();
+
+ const handleLogout = async () => {
+ await logout();
+ router.replace("/login");
+ };
+
+ return (
+
+ To-do app
+ You are logged in.
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ justifyContent: "center",
+ alignItems: "center",
+ gap: 12,
+ paddingHorizontal: 24,
+ },
+ actions: {
+ alignItems: "center",
+ gap: 12,
+ },
+ title: {
+ fontSize: 24,
+ fontWeight: "700",
+ },
+ subtitle: {
+ fontSize: 14,
+ color: "#555",
+ textAlign: "center",
+ },
+});
diff --git a/app/(tabs)/not-found.tsx b/app/(protected)/(tabs)/not-found.tsx
similarity index 100%
rename from app/(tabs)/not-found.tsx
rename to app/(protected)/(tabs)/not-found.tsx
diff --git a/app/(protected)/_layout.tsx b/app/(protected)/_layout.tsx
new file mode 100644
index 0000000..fd53877
--- /dev/null
+++ b/app/(protected)/_layout.tsx
@@ -0,0 +1,21 @@
+import { useAuth } from "@/features/shared/hooks/useAuth";
+import { Redirect, Slot } from "expo-router";
+import { Text, View } from "react-native";
+
+export default function ProtectedLayout() {
+ const { isAuthenticated, isLoading } = useAuth();
+
+ if (isLoading) {
+ return (
+
+ Loading...
+
+ );
+ }
+
+ if (!isAuthenticated) {
+ return ;
+ }
+
+ return ;
+}
diff --git a/app/(protected)/index.tsx b/app/(protected)/index.tsx
new file mode 100644
index 0000000..f2e2a1e
--- /dev/null
+++ b/app/(protected)/index.tsx
@@ -0,0 +1,5 @@
+import { Redirect } from "expo-router";
+
+export default function ProtectedIndex() {
+ return ;
+}
diff --git a/app/(public)/_layout.tsx b/app/(public)/_layout.tsx
new file mode 100644
index 0000000..8305a2d
--- /dev/null
+++ b/app/(public)/_layout.tsx
@@ -0,0 +1,21 @@
+import { useAuth } from "@/features/shared/hooks/useAuth";
+import { Redirect, Slot } from "expo-router";
+import { Text, View } from "react-native";
+
+export default function PublicLayout() {
+ const { isAuthenticated, isLoading } = useAuth();
+
+ if (isLoading) {
+ return (
+
+ Loading...
+
+ );
+ }
+
+ if (isAuthenticated) {
+ return ;
+ }
+
+ return ;
+}
diff --git a/app/(public)/index.tsx b/app/(public)/index.tsx
new file mode 100644
index 0000000..9a4df11
--- /dev/null
+++ b/app/(public)/index.tsx
@@ -0,0 +1,5 @@
+import { Redirect } from "expo-router";
+
+export default function PublicIndex() {
+ return ;
+}
diff --git a/app/(public)/login.tsx b/app/(public)/login.tsx
new file mode 100644
index 0000000..85d78ec
--- /dev/null
+++ b/app/(public)/login.tsx
@@ -0,0 +1,46 @@
+import Button from "@/components/ui/buttons";
+import { useAuth } from "@/features/shared/hooks/useAuth";
+import { router } from "expo-router";
+import { StyleSheet, Text, View } from "react-native";
+
+export default function LoginScreen() {
+ const { login, isLoading } = useAuth();
+
+ const handleLogin = async () => {
+ await login();
+ router.replace("/calendar");
+ };
+
+ return (
+
+ To-do app
+
+ Welcome to the app. Login to access all pages.
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ justifyContent: "center",
+ alignItems: "center",
+ gap: 12,
+ paddingHorizontal: 24,
+ },
+ title: {
+ fontSize: 24,
+ fontWeight: "700",
+ },
+ subtitle: {
+ fontSize: 14,
+ color: "#555",
+ marginBottom: 8,
+ textAlign: "center",
+ },
+});
diff --git a/app/(tabs)/calendar.tsx b/app/(tabs)/calendar.tsx
deleted file mode 100644
index e4a9974..0000000
--- a/app/(tabs)/calendar.tsx
+++ /dev/null
@@ -1,339 +0,0 @@
-import AddTodo from "@/components/todo/addtodo";
-import TodoCard from "@/components/todo/todocards";
-import type { Todo } from "@/types/todo";
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { useState } from "react";
-import {
- FlatList,
- Platform,
- Pressable,
- StyleSheet,
- Text,
- View,
-} from "react-native";
-
-const USE_LAN_DEVICE = false;
-const LAN_IP = "192.168.50.200";
-const API_HOST = USE_LAN_DEVICE
- ? LAN_IP
- : (Platform.select({
- ios: "localhost",
- android: "10.0.2.2",
- default: "localhost",
- }) ?? "localhost");
-const API_URL = `http://${API_HOST}:3001/todos`;
-const TODOS_QUERY_KEY = ["todos"];
-
-type TodoValues = {
- title: string;
- description: string;
-};
-
-async function fetchTodos(): Promise {
- const res = await fetch(API_URL);
- if (!res.ok) {
- throw new Error("Failed to fetch todos");
- }
- return res.json();
-}
-
-async function createTodo(values: TodoValues): Promise {
- const res = await fetch(API_URL, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ ...values, completed: false }),
- });
-
- if (!res.ok) {
- throw new Error("Failed to create todo");
- }
- return res.json();
-}
-
-async function updateTodo({
- id,
- values,
-}: {
- id: number;
- values: TodoValues;
-}): Promise {
- const res = await fetch(`${API_URL}/${id}`, {
- method: "PATCH",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(values),
- });
-
- if (!res.ok) {
- throw new Error("Failed to update todo");
- }
- return res.json();
-}
-
-async function deleteTodo(id: number): Promise {
- const res = await fetch(`${API_URL}/${id}`, {
- method: "DELETE",
- });
-
- if (!res.ok) {
- throw new Error("Failed to delete todo");
- }
-}
-
-async function toggleTodo(todo: Todo): Promise {
- const res = await fetch(`${API_URL}/${todo.id}`, {
- method: "PATCH",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ completed: !todo.completed }),
- });
-
- if (!res.ok) {
- throw new Error("Failed to toggle todo");
- }
- return res.json();
-}
-
-export default function CalendarScreen() {
- type Filter = "all" | "active" | "completed";
- const [filter, setFilter] = useState("all");
- const [modalOpen, setModalOpen] = useState(false);
- const [editingTodo, setEditingTodo] = useState(null);
- const queryClient = useQueryClient();
-
- const {
- data: list = [],
- isLoading,
- isError,
- error,
- } = useQuery({
- queryKey: TODOS_QUERY_KEY,
- queryFn: fetchTodos,
- });
-
- const createTodoMutation = useMutation({
- mutationFn: createTodo,
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: TODOS_QUERY_KEY });
- },
- });
-
- const updateTodoMutation = useMutation({
- mutationFn: updateTodo,
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: TODOS_QUERY_KEY });
- },
- });
-
- const deleteTodoMutation = useMutation({
- mutationFn: deleteTodo,
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: TODOS_QUERY_KEY });
- },
- });
-
- const toggleTodoMutation = useMutation({
- mutationFn: toggleTodo,
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: TODOS_QUERY_KEY });
- },
- });
-
- const handleDelete = (id: number) => {
- deleteTodoMutation.mutate(id);
- };
-
- const handleEdit = (todo: Todo) => {
- setEditingTodo(todo);
- setModalOpen(true);
- };
-
- const handleAddPress = () => {
- setEditingTodo(null);
- setModalOpen(true);
- };
-
- const handleToggleComplete = (id: number) => {
- const todo = list.find((item) => item.id === id);
- if (!todo) return;
- toggleTodoMutation.mutate(todo);
- };
-
- const allCount = list.length;
- const activeCount = list.filter((todo) => !todo.completed).length;
- const completedCount = list.filter((todo) => todo.completed).length;
-
- const filteredList = list.filter((todo) => {
- if (filter === "active") return !todo.completed;
- if (filter === "completed") return todo.completed;
- return true;
- });
-
- return (
-
- Calendar
-
-
- Add Todo
-
-
-
- setFilter("all")}
- >
-
- All ({allCount})
-
-
-
- setFilter("active")}
- >
-
- Active ({activeCount})
-
-
-
- setFilter("completed")}
- >
-
- Completed ({completedCount})
-
-
-
-
- {isLoading ? (
- Loading...
- ) : isError ? (
-
- {(error as Error)?.message ?? "Failed to load todos"}
-
- ) : (
- String(item.id)}
- renderItem={({ item }) => (
-
- )}
- contentContainerStyle={styles.list}
- />
- )}
-
- {
- setModalOpen(false);
- setEditingTodo(null);
- }}
- initialValues={
- editingTodo
- ? { title: editingTodo.title, description: editingTodo.description }
- : undefined
- }
- modalTitle={editingTodo ? "Edit Todo" : "Add New Todo"}
- submitLabel={editingTodo ? "Save Changes" : "Add Todo"}
- onSubmit={(values) => {
- if (editingTodo) {
- updateTodoMutation.mutate({
- id: editingTodo.id,
- values,
- });
- } else {
- createTodoMutation.mutate(values);
- }
- setEditingTodo(null);
- setModalOpen(false);
- }}
- />
-
- );
-}
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- backgroundColor: "#F2F2F2",
- paddingTop: 24,
- },
- header: {
- fontSize: 28,
- fontWeight: "800",
- textAlign: "center",
- marginBottom: 12,
- },
- addBtn: {
- alignSelf: "center",
- backgroundColor: "#1677ff",
- paddingHorizontal: 18,
- paddingVertical: 10,
- borderRadius: 14,
- marginBottom: 12,
- },
- addBtnText: {
- color: "white",
- fontWeight: "800",
- fontSize: 16,
- },
- list: {
- paddingHorizontal: 16,
- paddingBottom: 40,
- },
- statusText: {
- textAlign: "center",
- fontSize: 16,
- color: "#2a2f36",
- marginTop: 12,
- },
- filtersRow: {
- flexDirection: "row",
- justifyContent: "center",
- gap: 8,
- marginBottom: 12,
- paddingHorizontal: 12,
- },
- filterBtn: {
- backgroundColor: "#e7e8ea",
- paddingHorizontal: 10,
- paddingVertical: 8,
- borderRadius: 10,
- },
- filterBtnActive: {
- backgroundColor: "#1677ff",
- },
- filterText: {
- color: "#2a2f36",
- fontWeight: "700",
- fontSize: 12,
- },
- filterTextActive: {
- color: "white",
- },
-});
diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx
deleted file mode 100644
index 593e5f6..0000000
--- a/app/(tabs)/index.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import Button from "@/components/ui/buttons";
-import { router } from "expo-router";
-import { StyleSheet, Text, View } from "react-native";
-
-export default function Index() {
- return (
-
- Welcome to To-Do App
-
- router.push("/calendar")}
- />
-
-
- );
-}
-
-const styles = StyleSheet.create({
- footerContainer: {
- position: "absolute",
- top: "50%",
- left: 0,
- right: 0,
- transform: [{ translateY: -26 }],
- alignItems: "center",
- gap: 12,
- },
-});
diff --git a/app/_layout.tsx b/app/_layout.tsx
index fac35f5..a413835 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -9,7 +9,8 @@ export default function RootLayout() {
-
+
+
diff --git a/app/index.tsx b/app/index.tsx
new file mode 100644
index 0000000..cbec275
--- /dev/null
+++ b/app/index.tsx
@@ -0,0 +1,21 @@
+import { useAuth } from "@/features/shared/hooks/useAuth";
+import { Redirect } from "expo-router";
+import { Text, View } from "react-native";
+
+export default function Index() {
+ const { isAuthenticated, isLoading } = useAuth();
+
+ if (isLoading) {
+ return (
+
+ Loading...
+
+ );
+ }
+
+ if (isAuthenticated) {
+ return ;
+ }
+
+ return ;
+}
diff --git a/data/todo.ts b/data/todo.ts
index bb1394f..e237d46 100644
--- a/data/todo.ts
+++ b/data/todo.ts
@@ -1,4 +1,4 @@
-import { Todo } from "../types/todo";
+import { Todo } from "../features/calendar/models/todo";
export const todos: Todo[] = [
{
diff --git a/components/todo/addtodo.tsx b/features/calendar/components/AddTodoModal.tsx
similarity index 92%
rename from components/todo/addtodo.tsx
rename to features/calendar/components/AddTodoModal.tsx
index f8b84b9..c285328 100644
--- a/components/todo/addtodo.tsx
+++ b/features/calendar/components/AddTodoModal.tsx
@@ -1,15 +1,16 @@
+import type { TodoValues } from "@/features/calendar/models/todo";
import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form";
import { useEffect } from "react";
import {
- KeyboardAvoidingView,
- Modal,
- Platform,
- Pressable,
- StyleSheet,
- Text,
- TextInput,
- View,
+ KeyboardAvoidingView,
+ Modal,
+ Platform,
+ Pressable,
+ StyleSheet,
+ Text,
+ TextInput,
+ View,
} from "react-native";
import { z } from "zod";
@@ -18,18 +19,18 @@ const schema = z.object({
description: z.string().min(1, "Description is required"),
});
-type FormValues = z.infer;
+export type TodoFormValues = z.infer;
type Props = {
visible: boolean;
onClose: () => void;
- onSubmit: (values: FormValues) => void;
- initialValues?: Partial;
+ onSubmit: (values: TodoValues) => void;
+ initialValues?: Partial;
modalTitle?: string;
submitLabel?: string;
};
-export default function AddTodo({
+export default function AddTodoModal({
visible,
onClose,
onSubmit,
@@ -42,7 +43,7 @@ export default function AddTodo({
handleSubmit,
reset,
formState: { errors, isSubmitting },
- } = useForm({
+ } = useForm({
resolver: zodResolver(schema),
defaultValues: { title: "", description: "" },
});
@@ -56,7 +57,7 @@ export default function AddTodo({
}
}, [visible, initialValues, reset]);
- const submit = (values: FormValues) => {
+ const submit = (values: TodoFormValues) => {
onSubmit(values);
reset();
onClose();
diff --git a/features/calendar/components/CalendarHeader.tsx b/features/calendar/components/CalendarHeader.tsx
new file mode 100644
index 0000000..19bb878
--- /dev/null
+++ b/features/calendar/components/CalendarHeader.tsx
@@ -0,0 +1,39 @@
+import { Pressable, StyleSheet, Text, View } from "react-native";
+
+type Props = {
+ onAddPress: () => void;
+};
+
+export default function CalendarHeader({ onAddPress }: Props) {
+ return (
+
+ Calendar
+
+
+ Add Todo
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ header: {
+ fontSize: 28,
+ fontWeight: "800",
+ textAlign: "center",
+ marginBottom: 12,
+ },
+ addBtn: {
+ alignSelf: "center",
+ backgroundColor: "#1677ff",
+ paddingHorizontal: 18,
+ paddingVertical: 10,
+ borderRadius: 14,
+ marginBottom: 12,
+ },
+ addBtnText: {
+ color: "white",
+ fontWeight: "800",
+ fontSize: 16,
+ },
+});
diff --git a/components/todo/todocards.tsx b/features/calendar/components/TodoCard.tsx
similarity index 97%
rename from components/todo/todocards.tsx
rename to features/calendar/components/TodoCard.tsx
index 3a58ee3..7b2043b 100644
--- a/components/todo/todocards.tsx
+++ b/features/calendar/components/TodoCard.tsx
@@ -1,5 +1,6 @@
-import type { Todo } from "@/types/todo";
+import type { Todo } from "@/features/calendar/models/todo";
import { Pressable, StyleSheet, Text, View } from "react-native";
+
type Props = {
todo: Todo;
onEdit: (todo: Todo) => void;
diff --git a/features/calendar/components/TodoFilters.tsx b/features/calendar/components/TodoFilters.tsx
new file mode 100644
index 0000000..4868c74
--- /dev/null
+++ b/features/calendar/components/TodoFilters.tsx
@@ -0,0 +1,92 @@
+import { Pressable, StyleSheet, Text, View } from "react-native";
+
+export type TodoFilter = "all" | "active" | "completed";
+
+type Props = {
+ value: TodoFilter;
+ allCount: number;
+ activeCount: number;
+ completedCount: number;
+ onChange: (filter: TodoFilter) => void;
+};
+
+export default function TodoFilters({
+ value,
+ allCount,
+ activeCount,
+ completedCount,
+ onChange,
+}: Props) {
+ return (
+
+ onChange("all")}
+ >
+
+ All ({allCount})
+
+
+
+ onChange("active")}
+ >
+
+ Active ({activeCount})
+
+
+
+ onChange("completed")}
+ >
+
+ Completed ({completedCount})
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ filtersRow: {
+ flexDirection: "row",
+ justifyContent: "center",
+ gap: 8,
+ marginBottom: 12,
+ paddingHorizontal: 12,
+ },
+ filterBtn: {
+ backgroundColor: "#e7e8ea",
+ paddingHorizontal: 10,
+ paddingVertical: 8,
+ borderRadius: 10,
+ },
+ filterBtnActive: {
+ backgroundColor: "#1677ff",
+ },
+ filterText: {
+ color: "#2a2f36",
+ fontWeight: "700",
+ fontSize: 12,
+ },
+ filterTextActive: {
+ color: "white",
+ },
+});
diff --git a/features/calendar/components/TodoList.tsx b/features/calendar/components/TodoList.tsx
new file mode 100644
index 0000000..40f3574
--- /dev/null
+++ b/features/calendar/components/TodoList.tsx
@@ -0,0 +1,56 @@
+import type { Todo } from "@/features/calendar/models/todo";
+import { FlatList, StyleSheet, Text } from "react-native";
+import TodoCard from "@/features/calendar/components/TodoCard";
+
+type Props = {
+ todos: Todo[];
+ onEdit: (todo: Todo) => void;
+ onDelete: (id: number) => void;
+ onToggleComplete: (id: number) => void;
+ emptyText?: string;
+};
+
+export default function TodoList({
+ todos,
+ onEdit,
+ onDelete,
+ onToggleComplete,
+ emptyText = "No todos yet",
+}: Props) {
+ return (
+ String(item.id)}
+ renderItem={({ item }) => (
+
+ )}
+ contentContainerStyle={[
+ styles.list,
+ todos.length === 0 && styles.emptyList,
+ ]}
+ ListEmptyComponent={{emptyText}}
+ />
+ );
+}
+
+const styles = StyleSheet.create({
+ list: {
+ paddingHorizontal: 16,
+ paddingBottom: 40,
+ },
+ emptyList: {
+ flexGrow: 1,
+ justifyContent: "center",
+ },
+ emptyText: {
+ textAlign: "center",
+ fontSize: 16,
+ color: "#2a2f36",
+ opacity: 0.7,
+ },
+});
diff --git a/features/calendar/hooks/useTodos.ts b/features/calendar/hooks/useTodos.ts
new file mode 100644
index 0000000..c7c0ef6
--- /dev/null
+++ b/features/calendar/hooks/useTodos.ts
@@ -0,0 +1,77 @@
+import type { Todo, TodoValues } from "@/features/calendar/models/todo";
+import {
+ createTodo,
+ deleteTodo,
+ fetchTodos,
+ TODOS_QUERY_KEY,
+ toggleTodo,
+ updateTodo,
+} from "@/features/calendar/services/todo-service";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+
+export function useTodos() {
+ const queryClient = useQueryClient();
+
+ const {
+ data: list = [],
+ isLoading,
+ isError,
+ error,
+ } = useQuery({
+ queryKey: TODOS_QUERY_KEY,
+ queryFn: fetchTodos,
+ });
+
+ const invalidateTodos = () => {
+ queryClient.invalidateQueries({ queryKey: TODOS_QUERY_KEY });
+ };
+
+ const createTodoMutation = useMutation({
+ mutationFn: createTodo,
+ onSuccess: invalidateTodos,
+ });
+
+ const updateTodoMutation = useMutation({
+ mutationFn: updateTodo,
+ onSuccess: invalidateTodos,
+ });
+
+ const deleteTodoMutation = useMutation({
+ mutationFn: deleteTodo,
+ onSuccess: invalidateTodos,
+ });
+
+ const toggleTodoMutation = useMutation({
+ mutationFn: toggleTodo,
+ onSuccess: invalidateTodos,
+ });
+
+ const handleCreateTodo = (values: TodoValues) => {
+ createTodoMutation.mutate(values);
+ };
+
+ const handleUpdateTodo = (id: number, values: TodoValues) => {
+ updateTodoMutation.mutate({ id, values });
+ };
+
+ const handleDeleteTodo = (id: number) => {
+ deleteTodoMutation.mutate(id);
+ };
+
+ const handleToggleTodo = (id: number) => {
+ const todo = list.find((item: Todo) => item.id === id);
+ if (!todo) return;
+ toggleTodoMutation.mutate(todo);
+ };
+
+ return {
+ list,
+ isLoading,
+ isError,
+ error,
+ createTodo: handleCreateTodo,
+ updateTodo: handleUpdateTodo,
+ deleteTodo: handleDeleteTodo,
+ toggleTodo: handleToggleTodo,
+ };
+}
diff --git a/types/todo.ts b/features/calendar/models/todo.ts
similarity index 61%
rename from types/todo.ts
rename to features/calendar/models/todo.ts
index 773a684..cae22e5 100644
--- a/types/todo.ts
+++ b/features/calendar/models/todo.ts
@@ -4,3 +4,5 @@ export type Todo = {
description: string;
completed: boolean;
};
+
+export type TodoValues = Pick;
diff --git a/features/calendar/screens/CalendarScreen.tsx b/features/calendar/screens/CalendarScreen.tsx
new file mode 100644
index 0000000..19ed64a
--- /dev/null
+++ b/features/calendar/screens/CalendarScreen.tsx
@@ -0,0 +1,106 @@
+import AddTodoModal from "@/features/calendar/components/AddTodoModal";
+import CalendarHeader from "@/features/calendar/components/CalendarHeader";
+import TodoList from "@/features/calendar/components/TodoList";
+import TodoFilters, {
+ type TodoFilter,
+} from "@/features/calendar/components/TodoFilters";
+import type { Todo, TodoValues } from "@/features/calendar/models/todo";
+import { useTodos } from "@/features/calendar/hooks/useTodos";
+import { useState } from "react";
+import { StyleSheet, Text, View } from "react-native";
+
+export default function CalendarScreen() {
+ const [filter, setFilter] = useState("all");
+ const [modalOpen, setModalOpen] = useState(false);
+ const [editingTodo, setEditingTodo] = useState(null);
+ const { list, isLoading, isError, error, createTodo, updateTodo, deleteTodo, toggleTodo } =
+ useTodos();
+
+ const handleEdit = (todo: Todo) => {
+ setEditingTodo(todo);
+ setModalOpen(true);
+ };
+
+ const handleAddPress = () => {
+ setEditingTodo(null);
+ setModalOpen(true);
+ };
+
+ const allCount = list.length;
+ const activeCount = list.filter((todo) => !todo.completed).length;
+ const completedCount = list.filter((todo) => todo.completed).length;
+
+ const filteredList = list.filter((todo) => {
+ if (filter === "active") return !todo.completed;
+ if (filter === "completed") return todo.completed;
+ return true;
+ });
+
+ return (
+
+
+
+
+
+ {isLoading ? (
+ Loading...
+ ) : isError ? (
+
+ {(error as Error)?.message ?? "Failed to load todos"}
+
+ ) : (
+
+ )}
+
+ {
+ setModalOpen(false);
+ setEditingTodo(null);
+ }}
+ initialValues={
+ editingTodo
+ ? { title: editingTodo.title, description: editingTodo.description }
+ : undefined
+ }
+ modalTitle={editingTodo ? "Edit Todo" : "Add New Todo"}
+ submitLabel={editingTodo ? "Save Changes" : "Add Todo"}
+ onSubmit={(values: TodoValues) => {
+ if (editingTodo) {
+ updateTodo(editingTodo.id, values);
+ } else {
+ createTodo(values);
+ }
+ setEditingTodo(null);
+ setModalOpen(false);
+ }}
+ />
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: "#F2F2F2",
+ paddingTop: 24,
+ },
+ statusText: {
+ textAlign: "center",
+ fontSize: 16,
+ color: "#2a2f36",
+ marginTop: 12,
+ },
+});
diff --git a/features/calendar/services/todo-service.ts b/features/calendar/services/todo-service.ts
new file mode 100644
index 0000000..2b02324
--- /dev/null
+++ b/features/calendar/services/todo-service.ts
@@ -0,0 +1,78 @@
+import type { Todo, TodoValues } from "@/features/calendar/models/todo";
+import { Platform } from "react-native";
+
+const USE_LAN_DEVICE = false;
+const LAN_IP = "192.168.50.200";
+const API_HOST = USE_LAN_DEVICE
+ ? LAN_IP
+ : (Platform.select({
+ ios: "localhost",
+ android: "10.0.2.2",
+ default: "localhost",
+ }) ?? "localhost");
+const API_URL = `http://${API_HOST}:3001/todos`;
+
+export const TODOS_QUERY_KEY = ["todos"];
+
+export async function fetchTodos(): Promise {
+ const res = await fetch(API_URL);
+ if (!res.ok) {
+ throw new Error("Failed to fetch todos");
+ }
+ return res.json();
+}
+
+export async function createTodo(values: TodoValues): Promise {
+ const res = await fetch(API_URL, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ ...values, completed: false }),
+ });
+
+ if (!res.ok) {
+ throw new Error("Failed to create todo");
+ }
+ return res.json();
+}
+
+export async function updateTodo({
+ id,
+ values,
+}: {
+ id: number;
+ values: TodoValues;
+}): Promise {
+ const res = await fetch(`${API_URL}/${id}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(values),
+ });
+
+ if (!res.ok) {
+ throw new Error("Failed to update todo");
+ }
+ return res.json();
+}
+
+export async function deleteTodo(id: number): Promise {
+ const res = await fetch(`${API_URL}/${id}`, {
+ method: "DELETE",
+ });
+
+ if (!res.ok) {
+ throw new Error("Failed to delete todo");
+ }
+}
+
+export async function toggleTodo(todo: Todo): Promise {
+ const res = await fetch(`${API_URL}/${todo.id}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ completed: !todo.completed }),
+ });
+
+ if (!res.ok) {
+ throw new Error("Failed to toggle todo");
+ }
+ return res.json();
+}
diff --git a/features/shared/hooks/useAuth.ts b/features/shared/hooks/useAuth.ts
new file mode 100644
index 0000000..f927e89
--- /dev/null
+++ b/features/shared/hooks/useAuth.ts
@@ -0,0 +1,33 @@
+import * as SecureStore from 'expo-secure-store';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+
+const AUTH_KEY = 'auth_token';
+
+export const useAuth = () => {
+ const queryClient = useQueryClient();
+
+ // Read auth state from SecureStore
+ const { data: isAuthenticated = false, isLoading } = useQuery({
+ queryKey: ['auth'],
+ queryFn: async () => {
+ const token = await SecureStore.getItemAsync(AUTH_KEY);
+ return token === 'true';
+ },
+ staleTime: Infinity,
+ gcTime: Infinity,
+ });
+
+ // Login function
+ const login = async () => {
+ await SecureStore.setItemAsync(AUTH_KEY, 'true');
+ queryClient.setQueryData(['auth'], true);
+ };
+
+ // Logout function
+ const logout = async () => {
+ await SecureStore.deleteItemAsync(AUTH_KEY);
+ queryClient.setQueryData(['auth'], false);
+ };
+
+ return { isAuthenticated, isLoading, login, logout };
+};
diff --git a/package-lock.json b/package-lock.json
index d0b0383..e1d2943 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -21,6 +21,7 @@
"expo-image": "~3.0.11",
"expo-linking": "~8.0.11",
"expo-router": "~6.0.23",
+ "expo-secure-store": "~15.0.8",
"expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9",
"expo-symbols": "~1.0.8",
@@ -6857,6 +6858,15 @@
"node": ">=10"
}
},
+ "node_modules/expo-secure-store": {
+ "version": "15.0.8",
+ "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-15.0.8.tgz",
+ "integrity": "sha512-lHnzvRajBu4u+P99+0GEMijQMFCOYpWRO4dWsXSuMt77+THPIGjzNvVKrGSl6mMrLsfVaKL8BpwYZLGlgA+zAw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "expo": "*"
+ }
+ },
"node_modules/expo-server": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.5.tgz",
diff --git a/package.json b/package.json
index 5a56644..7a4e3ac 100644
--- a/package.json
+++ b/package.json
@@ -24,6 +24,7 @@
"expo-image": "~3.0.11",
"expo-linking": "~8.0.11",
"expo-router": "~6.0.23",
+ "expo-secure-store": "~15.0.8",
"expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9",
"expo-symbols": "~1.0.8",