mirror of
https://github.com/theoludwig/p61-project.git
synced 2024-07-17 07:00:12 +02:00
Merge branch 'develop' into feat/habit-progress-update
This commit is contained in:
commit
d75a8ab2cd
@ -1,7 +1,6 @@
|
||||
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"
|
||||
|
||||
const HabitPage: React.FC = () => {
|
||||
@ -9,23 +8,13 @@ const HabitPage: React.FC = () => {
|
||||
const { habitsTracker } = useHabitsTracker()
|
||||
|
||||
const habitHistory = habitsTracker.getHabitHistoryById(habitId as string)
|
||||
|
||||
if (habitHistory == null) {
|
||||
return <Redirect href="/application/habits/" />
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView
|
||||
style={[
|
||||
{
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text>
|
||||
Habit Page {habitId} {habitHistory.habit.name}
|
||||
</Text>
|
||||
</SafeAreaView>
|
||||
<HabitEditForm habit={habitHistory.habit} key={habitHistory.habit.id} />
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -4,7 +4,7 @@ import { useAuthentication } from "@/presentation/react/contexts/Authentication"
|
||||
const NewHabitPage: React.FC = () => {
|
||||
const { user } = useAuthentication()
|
||||
|
||||
if (user === null) {
|
||||
if (user == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
@ -15,6 +15,9 @@ export const HabitCreateSchema = HabitSchema.extend({
|
||||
}).omit({ id: true })
|
||||
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>
|
||||
|
||||
export interface HabitData extends HabitBase {
|
||||
|
@ -1,7 +1,6 @@
|
||||
import type { GoalFrequency } from "./Goal"
|
||||
import type { Habit } from "./Habit"
|
||||
import { HabitHistory } from "./HabitHistory"
|
||||
import { HabitProgress } from "./HabitProgress"
|
||||
|
||||
export interface HabitsTrackerData {
|
||||
habitsHistory: {
|
||||
@ -36,22 +35,30 @@ export class HabitsTracker implements HabitsTrackerData {
|
||||
)
|
||||
}
|
||||
|
||||
public setHabitProgress(options: SetHabitProgressOptions): void {
|
||||
const { date, goalProgress, habitHistory } = options
|
||||
if (goalProgress.isBoolean()) {
|
||||
const currentHabitProgress = habitHistory.getProgressesByDate(date)[0]
|
||||
if (currentHabitProgress == null) {
|
||||
habitHistory.progressHistory = [
|
||||
...habitHistory.progressHistory,
|
||||
new HabitProgress({
|
||||
date,
|
||||
goalProgress,
|
||||
habitId,
|
||||
id,
|
||||
}),
|
||||
]
|
||||
}
|
||||
// public setHabitProgress(options: SetHabitProgressOptions): void {
|
||||
// const { date, goalProgress, habitHistory } = options
|
||||
// if (goalProgress.isBoolean()) {
|
||||
// const currentHabitProgress = habitHistory.getProgressesByDate(date)[0]
|
||||
// if (currentHabitProgress == null) {
|
||||
// habitHistory.progressHistory = [
|
||||
// ...habitHistory.progressHistory,
|
||||
// new HabitProgress({
|
||||
// date,
|
||||
// goalProgress,
|
||||
// habitId,
|
||||
// id,
|
||||
// }),
|
||||
// ]
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
public editHabit(habit: Habit): void {
|
||||
const habitHistory = this.getHabitHistoryById(habit.id)
|
||||
if (habitHistory == null) {
|
||||
return
|
||||
}
|
||||
habitHistory.habit = habit
|
||||
}
|
||||
|
||||
public getAllHabitsHistory(): HabitHistory[] {
|
||||
|
9
domain/repositories/HabitEdit.ts
Normal file
9
domain/repositories/HabitEdit.ts
Normal 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>
|
||||
}
|
23
domain/use-cases/HabitEdit.ts
Normal file
23
domain/use-cases/HabitEdit.ts
Normal 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
|
||||
}
|
||||
}
|
@ -8,6 +8,8 @@ import { supabaseClient } from "./supabase/supabase"
|
||||
import { AuthenticationPresenter } from "@/presentation/presenters/Authentication"
|
||||
import { HabitCreateSupabaseRepository } from "./supabase/repositories/HabitCreate"
|
||||
import { HabitCreateUseCase } from "@/domain/use-cases/HabitCreate"
|
||||
import { HabitEditSupabaseRepository } from "./supabase/repositories/HabitEdit"
|
||||
import { HabitEditUseCase } from "@/domain/use-cases/HabitEdit"
|
||||
|
||||
/**
|
||||
* Repositories
|
||||
@ -25,6 +27,9 @@ const getHabitsByUserIdRepository = new GetHabitsByUserIdSupabaseRepository({
|
||||
const habitCreateRepository = new HabitCreateSupabaseRepository({
|
||||
supabaseClient,
|
||||
})
|
||||
const habitEditRepository = new HabitEditSupabaseRepository({
|
||||
supabaseClient,
|
||||
})
|
||||
|
||||
/**
|
||||
* Use Cases
|
||||
@ -39,6 +44,9 @@ const retrieveHabitsTrackerUseCase = new RetrieveHabitsTrackerUseCase({
|
||||
getHabitProgressHistoryRepository: getHabitProgressesRepository,
|
||||
getHabitsByUserIdRepository,
|
||||
})
|
||||
const habitEditUseCase = new HabitEditUseCase({
|
||||
habitEditRepository,
|
||||
})
|
||||
|
||||
/**
|
||||
* Presenters
|
||||
@ -49,4 +57,5 @@ export const authenticationPresenter = new AuthenticationPresenter({
|
||||
export const habitsTrackerPresenter = new HabitsTrackerPresenter({
|
||||
retrieveHabitsTrackerUseCase,
|
||||
habitCreateUseCase,
|
||||
habitEditUseCase,
|
||||
})
|
||||
|
49
infrastructure/supabase/repositories/HabitEdit.ts
Normal file
49
infrastructure/supabase/repositories/HabitEdit.ts
Normal 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
|
||||
}
|
||||
}
|
@ -7,9 +7,10 @@ import type {
|
||||
RetrieveHabitsTrackerUseCase,
|
||||
RetrieveHabitsTrackerUseCaseOptions,
|
||||
} 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 type { HabitCreateUseCase } from "@/domain/use-cases/HabitCreate"
|
||||
import type { HabitEditUseCase } from "@/domain/use-cases/HabitEdit"
|
||||
|
||||
export interface HabitsTrackerPresenterState {
|
||||
habitsTracker: HabitsTracker
|
||||
@ -25,11 +26,20 @@ export interface HabitsTrackerPresenterState {
|
||||
global: ErrorGlobal
|
||||
}
|
||||
}
|
||||
|
||||
habitEdit: {
|
||||
state: FetchState
|
||||
errors: {
|
||||
fields: Array<keyof HabitEditData>
|
||||
global: ErrorGlobal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface HabitsTrackerPresenterOptions {
|
||||
retrieveHabitsTrackerUseCase: RetrieveHabitsTrackerUseCase
|
||||
habitCreateUseCase: HabitCreateUseCase
|
||||
habitEditUseCase: HabitEditUseCase
|
||||
}
|
||||
|
||||
export class HabitsTrackerPresenter
|
||||
@ -38,9 +48,14 @@ export class HabitsTrackerPresenter
|
||||
{
|
||||
public retrieveHabitsTrackerUseCase: RetrieveHabitsTrackerUseCase
|
||||
public habitCreateUseCase: HabitCreateUseCase
|
||||
public habitEditUseCase: HabitEditUseCase
|
||||
|
||||
public constructor(options: HabitsTrackerPresenterOptions) {
|
||||
const { retrieveHabitsTrackerUseCase, habitCreateUseCase } = options
|
||||
const {
|
||||
retrieveHabitsTrackerUseCase,
|
||||
habitCreateUseCase,
|
||||
habitEditUseCase,
|
||||
} = options
|
||||
const habitsTracker = HabitsTracker.default()
|
||||
super({
|
||||
habitsTracker,
|
||||
@ -52,9 +67,17 @@ export class HabitsTrackerPresenter
|
||||
global: null,
|
||||
},
|
||||
},
|
||||
habitEdit: {
|
||||
state: "idle",
|
||||
errors: {
|
||||
fields: [],
|
||||
global: null,
|
||||
},
|
||||
},
|
||||
})
|
||||
this.retrieveHabitsTrackerUseCase = retrieveHabitsTrackerUseCase
|
||||
this.habitCreateUseCase = habitCreateUseCase
|
||||
this.habitEditUseCase = habitEditUseCase
|
||||
}
|
||||
|
||||
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(
|
||||
options: RetrieveHabitsTrackerUseCaseOptions,
|
||||
): Promise<void> {
|
||||
|
154
presentation/react/components/HabitEditForm/HabitEditForm.tsx
Normal file
154
presentation/react/components/HabitEditForm/HabitEditForm.tsx
Normal 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,
|
||||
},
|
||||
})
|
Loading…
Reference in New Issue
Block a user