diff --git a/app/application/habits/[habitId]/index.tsx b/app/application/habits/[habitId]/index.tsx index a7fcdec..5a03693 100644 --- a/app/application/habits/[habitId]/index.tsx +++ b/app/application/habits/[habitId]/index.tsx @@ -3,10 +3,17 @@ import { Text } from "react-native-paper" import { SafeAreaView } from "react-native-safe-area-context" import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker" +import { HabitEditForm } from "@/presentation/react/components/HabitEditForm/HabitEditForm" +import { useAuthentication } from "@/presentation/react/contexts/Authentication" const HabitPage: React.FC = () => { const { habitId } = useLocalSearchParams() const { habitsTracker } = useHabitsTracker() + const { user } = useAuthentication() + + if (user === null) { + return null + } const habitHistory = habitsTracker.getHabitHistoryById(habitId as string) if (habitHistory == null) { @@ -25,6 +32,7 @@ const HabitPage: React.FC = () => { Habit Page {habitId} {habitHistory.habit.name} + ) } diff --git a/domain/entities/Goal.ts b/domain/entities/Goal.ts index ded0b30..d55fcbf 100644 --- a/domain/entities/Goal.ts +++ b/domain/entities/Goal.ts @@ -33,8 +33,22 @@ export const GoalCreateSchema = z.object({ ]), }) +export const GoalEditSchema = z.object({ + frequency: goalFrequencyZod, + target: z.discriminatedUnion("type", [ + z.object({ type: z.literal("boolean") }), + z.object({ + type: z.literal("numeric"), + value: z.number().int().min(0), + unit: z.string().min(1), + }), + ]), +}) + export type GoalCreateData = z.infer +export type GoalEditData = z.infer + interface GoalBase { frequency: GoalFrequency } diff --git a/domain/entities/Habit.ts b/domain/entities/Habit.ts index f111484..e9de6c3 100644 --- a/domain/entities/Habit.ts +++ b/domain/entities/Habit.ts @@ -15,6 +15,11 @@ export const HabitCreateSchema = HabitSchema.extend({ }).omit({ id: true }) export type HabitCreateData = z.infer +export const HabitEditSchema = HabitSchema.extend({ + goal: GoalCreateSchema, +}) +export type HabitEditData = z.infer + type HabitBase = z.infer export interface HabitData extends HabitBase { diff --git a/domain/repositories/HabitEdit.ts b/domain/repositories/HabitEdit.ts new file mode 100644 index 0000000..3f5bddd --- /dev/null +++ b/domain/repositories/HabitEdit.ts @@ -0,0 +1,9 @@ +import type { Habit, HabitEditData } from "../entities/Habit" + +export interface HabitEditOptions { + habitEditData: HabitEditData +} + +export interface HabitEditRepository { + execute: (options: HabitEditOptions) => Promise +} diff --git a/domain/use-cases/HabitEdit.ts b/domain/use-cases/HabitEdit.ts new file mode 100644 index 0000000..555b4e3 --- /dev/null +++ b/domain/use-cases/HabitEdit.ts @@ -0,0 +1,23 @@ +import type { Habit } from "../entities/Habit" +import { HabitEditSchema } from "../entities/Habit" +import type { HabitEditRepository } from "../repositories/HabitEdit" + +export interface HabitEditUseCaseDependencyOptions { + habitEditRepository: HabitEditRepository +} + +export class HabitEditUseCase implements HabitEditUseCaseDependencyOptions { + public habitEditRepository: HabitEditRepository + + public constructor(options: HabitEditUseCaseDependencyOptions) { + this.habitEditRepository = options.habitEditRepository + } + + public async execute(data: unknown): Promise { + const habitEditData = await HabitEditSchema.parseAsync(data) + const habit = await this.habitEditRepository.execute({ + habitEditData, + }) + return habit + } +} diff --git a/infrastructure/supabase/repositories/HabitEdit.ts b/infrastructure/supabase/repositories/HabitEdit.ts new file mode 100644 index 0000000..9020098 --- /dev/null +++ b/infrastructure/supabase/repositories/HabitEdit.ts @@ -0,0 +1,43 @@ +import { Habit } from "@/domain/entities/Habit" +import type { HabitEditRepository } from "@/domain/repositories/HabitEdit" +import { SupabaseRepository } from "./_SupabaseRepository" +import { Goal } from "@/domain/entities/Goal" + +export class HabitEditSupabaseRepository + extends SupabaseRepository + implements HabitEditRepository +{ + public execute: HabitEditRepository["execute"] = async (options) => { + const { habitEditData } = options + const { data, error } = await this.supabaseClient + .from("habits") + .update({ + id: Number(habitEditData.id), + name: habitEditData.name, + color: habitEditData.color, + icon: habitEditData.icon, + goal_frequency: habitEditData.goal.frequency, + ...(habitEditData.goal.target.type === "numeric" + ? { + goal_target: habitEditData.goal.target.value, + goal_target_unit: habitEditData.goal.target.unit, + } + : {}), + }) + .select("*") + const updatedHabit = data?.[0] + if (error != null || updatedHabit == null) { + throw new Error(error?.message ?? "Failed to edit habit.") + } + const habit = new Habit({ + id: updatedHabit.id.toString(), + userId: updatedHabit.user_id.toString(), + name: updatedHabit.name, + icon: updatedHabit.icon, + goal: Goal.create(habitEditData.goal), + color: updatedHabit.color, + startDate: new Date(updatedHabit.start_date), + }) + return habit + } +} diff --git a/presentation/presenters/HabitsTracker.ts b/presentation/presenters/HabitsTracker.ts index b619559..7e0d923 100644 --- a/presentation/presenters/HabitsTracker.ts +++ b/presentation/presenters/HabitsTracker.ts @@ -25,6 +25,14 @@ export interface HabitsTrackerPresenterState { global: ErrorGlobal } } + + habitEdit: { + state: FetchState + errors: { + fields: Array + global: ErrorGlobal + } + } } export interface HabitsTrackerPresenterOptions { @@ -52,6 +60,13 @@ export class HabitsTrackerPresenter global: null, }, }, + habitEdit: { + state: "idle", + errors: { + fields: [], + global: null, + }, + }, }) this.retrieveHabitsTrackerUseCase = retrieveHabitsTrackerUseCase this.habitCreateUseCase = habitCreateUseCase @@ -86,6 +101,35 @@ export class HabitsTrackerPresenter } } + public async habitEdit(data: unknown): Promise { + try { + this.setState((state) => { + state.habitEdit.state = "loading" + state.habitEdit.errors = { + fields: [], + global: null, + } + }) + const habit = await this.habitCreateUseCase.execute(data) + this.setState((state) => { + state.habitEdit.state = "success" + state.habitsTracker.addHabit(habit) + }) + return "success" + } catch (error) { + this.setState((state) => { + state.habitEdit.state = "error" + if (error instanceof ZodError) { + state.habitEdit.errors.fields = + getErrorsFieldsFromZodError(error) + } else { + state.habitEdit.errors.global = "unknown" + } + }) + return "error" + } + } + public async retrieveHabitsTracker( options: RetrieveHabitsTrackerUseCaseOptions, ): Promise { diff --git a/presentation/react/components/HabitEditForm/HabitEditForm.tsx b/presentation/react/components/HabitEditForm/HabitEditForm.tsx new file mode 100644 index 0000000..b26d6e2 --- /dev/null +++ b/presentation/react/components/HabitEditForm/HabitEditForm.tsx @@ -0,0 +1,163 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { useState } from "react" +import { Controller, useForm } from "react-hook-form" +import { ScrollView, StyleSheet } from "react-native" +import { Button, HelperText, Snackbar, TextInput } from "react-native-paper" +import { SafeAreaView } from "react-native-safe-area-context" +import ColorPicker, { + HueSlider, + Panel1, + Preview, +} from "reanimated-color-picker" + +import type { Habit, HabitEditData } from "@/domain/entities/Habit" +import { HabitEditSchema } from "@/domain/entities/Habit" +import type { User } from "@/domain/entities/User" +import { useHabitsTracker } from "../../contexts/HabitsTracker" + +export interface HabitEditFormProps { + user: User + habit: Habit +} + +export const HabitEditForm: React.FC = ({ user }) => { + const { habitEdit, habitsTrackerPresenter } = useHabitsTracker() + + const { + control, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + mode: "onChange", + resolver: zodResolver(HabitEditSchema), + defaultValues: { + userId: user.id, + name: "", + color: "#006CFF", + icon: "lightbulb", + goal: { + frequency: "daily", + target: { + type: "boolean", + }, + }, + }, + }) + + const [isVisibleSnackbar, setIsVisibleSnackbar] = useState(false) + + const onDismissSnackbar = (): void => { + setIsVisibleSnackbar(false) + } + + const onSubmit = async (data: HabitEditData): Promise => { + await habitsTrackerPresenter.habitEdit(data) + setIsVisibleSnackbar(true) + reset() + } + + return ( + + + { + return ( + <> + + {errors.name != null ? ( + + {errors.name.type === "too_big" + ? "Name is too long" + : "Name is required"} + + ) : null} + + ) + }} + name="name" + /> + + { + return ( + { + onChange(value.hex) + }} + > + + + + + ) + }} + name="color" + /> + + { + return ( + + ) + }} + name="icon" + /> + + + + + + ✅ Habit created successfully! + + + ) +} + +const styles = StyleSheet.create({ + spacing: { + marginVertical: 16, + }, +})