Merge branch 'develop' into feat/habit-progress-update

This commit is contained in:
Théo LUDWIG 2024-04-11 14:02:17 +02:00
commit d75a8ab2cd
Signed by: theoludwig
GPG Key ID: ADFE5A563D718F3B
10 changed files with 328 additions and 33 deletions

View File

@ -1,7 +1,6 @@
import { Redirect, useLocalSearchParams } from "expo-router" import { Redirect, useLocalSearchParams } from "expo-router"
import { Text } from "react-native-paper"
import { SafeAreaView } from "react-native-safe-area-context"
import { HabitEditForm } from "@/presentation/react/components/HabitEditForm/HabitEditForm"
import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker" import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker"
const HabitPage: React.FC = () => { const HabitPage: React.FC = () => {
@ -9,23 +8,13 @@ const HabitPage: React.FC = () => {
const { habitsTracker } = useHabitsTracker() const { habitsTracker } = useHabitsTracker()
const habitHistory = habitsTracker.getHabitHistoryById(habitId as string) const habitHistory = habitsTracker.getHabitHistoryById(habitId as string)
if (habitHistory == null) { if (habitHistory == null) {
return <Redirect href="/application/habits/" /> return <Redirect href="/application/habits/" />
} }
return ( return (
<SafeAreaView <HabitEditForm habit={habitHistory.habit} key={habitHistory.habit.id} />
style={[
{
flex: 1,
alignItems: "center",
},
]}
>
<Text>
Habit Page {habitId} {habitHistory.habit.name}
</Text>
</SafeAreaView>
) )
} }

View File

@ -4,7 +4,7 @@ import { useAuthentication } from "@/presentation/react/contexts/Authentication"
const NewHabitPage: React.FC = () => { const NewHabitPage: React.FC = () => {
const { user } = useAuthentication() const { user } = useAuthentication()
if (user === null) { if (user == null) {
return null return null
} }

View File

@ -15,6 +15,9 @@ export const HabitCreateSchema = HabitSchema.extend({
}).omit({ id: true }) }).omit({ id: true })
export type HabitCreateData = z.infer<typeof HabitCreateSchema> export type HabitCreateData = z.infer<typeof HabitCreateSchema>
export const HabitEditSchema = HabitSchema.extend({})
export type HabitEditData = z.infer<typeof HabitEditSchema>
type HabitBase = z.infer<typeof HabitSchema> type HabitBase = z.infer<typeof HabitSchema>
export interface HabitData extends HabitBase { export interface HabitData extends HabitBase {

View File

@ -1,7 +1,6 @@
import type { GoalFrequency } from "./Goal" import type { GoalFrequency } from "./Goal"
import type { Habit } from "./Habit" import type { Habit } from "./Habit"
import { HabitHistory } from "./HabitHistory" import { HabitHistory } from "./HabitHistory"
import { HabitProgress } from "./HabitProgress"
export interface HabitsTrackerData { export interface HabitsTrackerData {
habitsHistory: { habitsHistory: {
@ -36,22 +35,30 @@ export class HabitsTracker implements HabitsTrackerData {
) )
} }
public setHabitProgress(options: SetHabitProgressOptions): void { // public setHabitProgress(options: SetHabitProgressOptions): void {
const { date, goalProgress, habitHistory } = options // const { date, goalProgress, habitHistory } = options
if (goalProgress.isBoolean()) { // if (goalProgress.isBoolean()) {
const currentHabitProgress = habitHistory.getProgressesByDate(date)[0] // const currentHabitProgress = habitHistory.getProgressesByDate(date)[0]
if (currentHabitProgress == null) { // if (currentHabitProgress == null) {
habitHistory.progressHistory = [ // habitHistory.progressHistory = [
...habitHistory.progressHistory, // ...habitHistory.progressHistory,
new HabitProgress({ // new HabitProgress({
date, // date,
goalProgress, // goalProgress,
habitId, // habitId,
id, // id,
}), // }),
] // ]
} // }
// }
// }
public editHabit(habit: Habit): void {
const habitHistory = this.getHabitHistoryById(habit.id)
if (habitHistory == null) {
return
} }
habitHistory.habit = habit
} }
public getAllHabitsHistory(): HabitHistory[] { public getAllHabitsHistory(): HabitHistory[] {

View File

@ -0,0 +1,9 @@
import type { Habit, HabitEditData } from "../entities/Habit"
export interface HabitEditOptions {
habitEditData: HabitEditData
}
export interface HabitEditRepository {
execute: (options: HabitEditOptions) => Promise<Habit>
}

View File

@ -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<Habit> {
const habitEditData = await HabitEditSchema.parseAsync(data)
const habit = await this.habitEditRepository.execute({
habitEditData,
})
return habit
}
}

View File

@ -8,6 +8,8 @@ import { supabaseClient } from "./supabase/supabase"
import { AuthenticationPresenter } from "@/presentation/presenters/Authentication" import { AuthenticationPresenter } from "@/presentation/presenters/Authentication"
import { HabitCreateSupabaseRepository } from "./supabase/repositories/HabitCreate" import { HabitCreateSupabaseRepository } from "./supabase/repositories/HabitCreate"
import { HabitCreateUseCase } from "@/domain/use-cases/HabitCreate" import { HabitCreateUseCase } from "@/domain/use-cases/HabitCreate"
import { HabitEditSupabaseRepository } from "./supabase/repositories/HabitEdit"
import { HabitEditUseCase } from "@/domain/use-cases/HabitEdit"
/** /**
* Repositories * Repositories
@ -25,6 +27,9 @@ const getHabitsByUserIdRepository = new GetHabitsByUserIdSupabaseRepository({
const habitCreateRepository = new HabitCreateSupabaseRepository({ const habitCreateRepository = new HabitCreateSupabaseRepository({
supabaseClient, supabaseClient,
}) })
const habitEditRepository = new HabitEditSupabaseRepository({
supabaseClient,
})
/** /**
* Use Cases * Use Cases
@ -39,6 +44,9 @@ const retrieveHabitsTrackerUseCase = new RetrieveHabitsTrackerUseCase({
getHabitProgressHistoryRepository: getHabitProgressesRepository, getHabitProgressHistoryRepository: getHabitProgressesRepository,
getHabitsByUserIdRepository, getHabitsByUserIdRepository,
}) })
const habitEditUseCase = new HabitEditUseCase({
habitEditRepository,
})
/** /**
* Presenters * Presenters
@ -49,4 +57,5 @@ export const authenticationPresenter = new AuthenticationPresenter({
export const habitsTrackerPresenter = new HabitsTrackerPresenter({ export const habitsTrackerPresenter = new HabitsTrackerPresenter({
retrieveHabitsTrackerUseCase, retrieveHabitsTrackerUseCase,
habitCreateUseCase, habitCreateUseCase,
habitEditUseCase,
}) })

View File

@ -0,0 +1,49 @@
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({
name: habitEditData.name,
color: habitEditData.color,
icon: habitEditData.icon,
})
.eq("id", habitEditData.id)
.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({
frequency: updatedHabit.goal_frequency,
target:
updatedHabit.goal_target != null &&
updatedHabit.goal_target_unit != null
? {
type: "numeric",
value: updatedHabit.goal_target,
unit: updatedHabit.goal_target_unit,
}
: {
type: "boolean",
},
}),
color: updatedHabit.color,
startDate: new Date(updatedHabit.start_date),
})
return habit
}
}

View File

@ -7,9 +7,10 @@ import type {
RetrieveHabitsTrackerUseCase, RetrieveHabitsTrackerUseCase,
RetrieveHabitsTrackerUseCaseOptions, RetrieveHabitsTrackerUseCaseOptions,
} from "@/domain/use-cases/RetrieveHabitsTracker" } from "@/domain/use-cases/RetrieveHabitsTracker"
import type { HabitCreateData } from "@/domain/entities/Habit" import type { HabitCreateData, HabitEditData } from "@/domain/entities/Habit"
import { getErrorsFieldsFromZodError } from "../../utils/zod" import { getErrorsFieldsFromZodError } from "../../utils/zod"
import type { HabitCreateUseCase } from "@/domain/use-cases/HabitCreate" import type { HabitCreateUseCase } from "@/domain/use-cases/HabitCreate"
import type { HabitEditUseCase } from "@/domain/use-cases/HabitEdit"
export interface HabitsTrackerPresenterState { export interface HabitsTrackerPresenterState {
habitsTracker: HabitsTracker habitsTracker: HabitsTracker
@ -25,11 +26,20 @@ export interface HabitsTrackerPresenterState {
global: ErrorGlobal global: ErrorGlobal
} }
} }
habitEdit: {
state: FetchState
errors: {
fields: Array<keyof HabitEditData>
global: ErrorGlobal
}
}
} }
export interface HabitsTrackerPresenterOptions { export interface HabitsTrackerPresenterOptions {
retrieveHabitsTrackerUseCase: RetrieveHabitsTrackerUseCase retrieveHabitsTrackerUseCase: RetrieveHabitsTrackerUseCase
habitCreateUseCase: HabitCreateUseCase habitCreateUseCase: HabitCreateUseCase
habitEditUseCase: HabitEditUseCase
} }
export class HabitsTrackerPresenter export class HabitsTrackerPresenter
@ -38,9 +48,14 @@ export class HabitsTrackerPresenter
{ {
public retrieveHabitsTrackerUseCase: RetrieveHabitsTrackerUseCase public retrieveHabitsTrackerUseCase: RetrieveHabitsTrackerUseCase
public habitCreateUseCase: HabitCreateUseCase public habitCreateUseCase: HabitCreateUseCase
public habitEditUseCase: HabitEditUseCase
public constructor(options: HabitsTrackerPresenterOptions) { public constructor(options: HabitsTrackerPresenterOptions) {
const { retrieveHabitsTrackerUseCase, habitCreateUseCase } = options const {
retrieveHabitsTrackerUseCase,
habitCreateUseCase,
habitEditUseCase,
} = options
const habitsTracker = HabitsTracker.default() const habitsTracker = HabitsTracker.default()
super({ super({
habitsTracker, habitsTracker,
@ -52,9 +67,17 @@ export class HabitsTrackerPresenter
global: null, global: null,
}, },
}, },
habitEdit: {
state: "idle",
errors: {
fields: [],
global: null,
},
},
}) })
this.retrieveHabitsTrackerUseCase = retrieveHabitsTrackerUseCase this.retrieveHabitsTrackerUseCase = retrieveHabitsTrackerUseCase
this.habitCreateUseCase = habitCreateUseCase this.habitCreateUseCase = habitCreateUseCase
this.habitEditUseCase = habitEditUseCase
} }
public async habitCreate(data: unknown): Promise<FetchState> { public async habitCreate(data: unknown): Promise<FetchState> {
@ -86,6 +109,35 @@ export class HabitsTrackerPresenter
} }
} }
public async habitEdit(data: unknown): Promise<FetchState> {
try {
this.setState((state) => {
state.habitEdit.state = "loading"
state.habitEdit.errors = {
fields: [],
global: null,
}
})
const habit = await this.habitEditUseCase.execute(data)
this.setState((state) => {
state.habitEdit.state = "success"
state.habitsTracker.editHabit(habit)
})
return "success"
} catch (error) {
this.setState((state) => {
state.habitEdit.state = "error"
if (error instanceof ZodError) {
state.habitEdit.errors.fields =
getErrorsFieldsFromZodError<HabitEditData>(error)
} else {
state.habitEdit.errors.global = "unknown"
}
})
return "error"
}
}
public async retrieveHabitsTracker( public async retrieveHabitsTracker(
options: RetrieveHabitsTrackerUseCaseOptions, options: RetrieveHabitsTrackerUseCaseOptions,
): Promise<void> { ): Promise<void> {

View File

@ -0,0 +1,154 @@
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 { useHabitsTracker } from "../../contexts/HabitsTracker"
export interface HabitEditFormProps {
habit: Habit
}
export const HabitEditForm: React.FC<HabitEditFormProps> = ({ habit }) => {
const { habitEdit, habitsTrackerPresenter } = useHabitsTracker()
const {
control,
handleSubmit,
formState: { errors },
} = useForm<HabitEditData>({
mode: "onChange",
resolver: zodResolver(HabitEditSchema),
defaultValues: {
id: habit.id,
userId: habit.userId,
name: habit.name,
color: habit.color,
icon: habit.icon,
},
})
const [isVisibleSnackbar, setIsVisibleSnackbar] = useState(false)
const onDismissSnackbar = (): void => {
setIsVisibleSnackbar(false)
}
const onSubmit = async (data: HabitEditData): Promise<void> => {
await habitsTrackerPresenter.habitEdit(data)
setIsVisibleSnackbar(true)
}
return (
<SafeAreaView>
<ScrollView
contentContainerStyle={{
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 20,
}}
>
<Controller
control={control}
render={({ field: { onChange, onBlur, value } }) => {
return (
<>
<TextInput
placeholder="Name"
onBlur={onBlur}
onChangeText={onChange}
value={value}
style={[
styles.spacing,
{
width: "90%",
},
]}
mode="outlined"
/>
{errors.name != null ? (
<HelperText type="error" visible style={[{ paddingTop: 0 }]}>
{errors.name.type === "too_big"
? "Name is too long"
: "Name is required"}
</HelperText>
) : null}
</>
)
}}
name="name"
/>
<Controller
control={control}
render={({ field: { onChange, value } }) => {
return (
<ColorPicker
style={[styles.spacing, { width: "90%" }]}
value={value}
onComplete={(value) => {
onChange(value.hex)
}}
>
<Preview hideInitialColor />
<Panel1 />
<HueSlider />
</ColorPicker>
)
}}
name="color"
/>
<Controller
control={control}
render={({ field: { onChange, onBlur, value } }) => {
return (
<TextInput
placeholder="Icon"
onBlur={onBlur}
onChangeText={onChange}
value={value}
style={[styles.spacing, { width: "90%" }]}
mode="outlined"
/>
)
}}
name="icon"
/>
<Button
mode="contained"
onPress={handleSubmit(onSubmit)}
loading={habitEdit.state === "loading"}
disabled={habitEdit.state === "loading"}
style={[styles.spacing, { width: "90%" }]}
>
Save
</Button>
</ScrollView>
<Snackbar
visible={isVisibleSnackbar}
onDismiss={onDismissSnackbar}
duration={2_000}
>
Habit Saved successfully!
</Snackbar>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
spacing: {
marginVertical: 16,
},
})