feat: update habit progress boolean

This commit is contained in:
Théo LUDWIG 2024-04-11 23:03:45 +02:00
parent d75a8ab2cd
commit 867667f4c7
Signed by: theoludwig
GPG Key ID: ADFE5A563D718F3B
13 changed files with 5729 additions and 248 deletions

View File

@ -1,6 +1,7 @@
import type { GoalFrequency } from "./Goal"
import type { Habit } from "./Habit"
import { HabitHistory } from "./HabitHistory"
import type { HabitProgress } from "./HabitProgress"
export interface HabitsTrackerData {
habitsHistory: {
@ -35,24 +36,6 @@ 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 editHabit(habit: Habit): void {
const habitHistory = this.getHabitHistoryById(habit.id)
if (habitHistory == null) {
@ -61,6 +44,25 @@ export class HabitsTracker implements HabitsTrackerData {
habitHistory.habit = habit
}
public updateHabitProgress(habitProgress: HabitProgress): void {
const habitHistory = this.getHabitHistoryById(habitProgress.habitId)
if (habitHistory == null) {
return
}
const habitProgressSaved = habitHistory.progressHistory.find((progress) => {
return progress.id === habitProgress.id
})
if (habitProgressSaved == null) {
habitHistory.progressHistory = [
...habitHistory.progressHistory,
habitProgress,
]
return
}
habitProgressSaved.goalProgress = habitProgress.goalProgress
habitProgressSaved.date = habitProgress.date
}
public getAllHabitsHistory(): HabitHistory[] {
return [
...this.habitsHistory.daily,

View File

@ -4,7 +4,7 @@ import type {
} from "../entities/HabitProgress"
export interface HabitProgressUpdateOptions {
habitProgressData: HabitProgressData
habitProgressData: Omit<HabitProgressData, "habitId">
}
export interface HabitProgressUpdateRepository {

View File

@ -1,28 +1,56 @@
import type { GoalProgress } from "../entities/Goal"
import type { HabitHistory } from "../entities/HabitHistory"
import type { HabitProgress } from "../entities/HabitProgress"
import type { HabitProgressCreateRepository } from "../repositories/HabitProgressCreate"
import type { HabitProgressUpdateRepository } from "../repositories/HabitProgressUpdate"
export interface HabitGoalProgressUpdateOptions {
export interface HabitGoalProgressUpdateUseCaseOptions {
date: Date
goalProgress: GoalProgress
habitHistory: HabitHistory
}
export class HabitGoalProgressUpdateUseCase
implements HabitGoalProgressUpdateOptions
{
public date: Date
public goalProgress: GoalProgress
public habitHistory: HabitHistory
export interface HabitGoalProgressUpdateUseCaseDependencyOptions {
habitProgressCreateRepository: HabitProgressCreateRepository
habitProgressUpdateRepository: HabitProgressUpdateRepository
}
public constructor(option: HabitGoalProgressUpdateOptions) {
this.date = option.date
this.goalProgress = option.goalProgress
this.habitHistory = option.habitHistory
export class HabitGoalProgressUpdateUseCase
implements HabitGoalProgressUpdateUseCaseDependencyOptions
{
public habitProgressCreateRepository: HabitProgressCreateRepository
public habitProgressUpdateRepository: HabitProgressUpdateRepository
public constructor(options: HabitGoalProgressUpdateUseCaseDependencyOptions) {
this.habitProgressCreateRepository = options.habitProgressCreateRepository
this.habitProgressUpdateRepository = options.habitProgressUpdateRepository
}
public async execute(data: unknown): Promise<HabitHistory> {
//
public async execute(
options: HabitGoalProgressUpdateUseCaseOptions,
): Promise<HabitProgress> {
const { date, goalProgress, habitHistory } = options
if (goalProgress.isBoolean()) {
const currentHabitProgress = habitHistory.getProgressesByDate(date)[0]
if (currentHabitProgress == null) {
return await this.habitProgressCreateRepository.execute({
habitProgressData: {
date,
goalProgress,
habitId: habitHistory.habit.id,
},
})
}
return await this.habitProgressUpdateRepository.execute({
habitProgressData: {
date,
goalProgress,
id: currentHabitProgress.id,
},
})
}
throw new Error("Not implemented")
}
}

View File

@ -10,6 +10,9 @@ import { HabitCreateSupabaseRepository } from "./supabase/repositories/HabitCrea
import { HabitCreateUseCase } from "@/domain/use-cases/HabitCreate"
import { HabitEditSupabaseRepository } from "./supabase/repositories/HabitEdit"
import { HabitEditUseCase } from "@/domain/use-cases/HabitEdit"
import { HabitProgressCreateSupabaseRepository } from "./supabase/repositories/HabitProgressCreate"
import { HabitProgressUpdateSupabaseRepository } from "./supabase/repositories/HabitProgressUpdate"
import { HabitGoalProgressUpdateUseCase } from "@/domain/use-cases/HabitGoalProgressUpdate"
/**
* Repositories
@ -30,6 +33,16 @@ const habitCreateRepository = new HabitCreateSupabaseRepository({
const habitEditRepository = new HabitEditSupabaseRepository({
supabaseClient,
})
const habitProgressCreateRepository = new HabitProgressCreateSupabaseRepository(
{
supabaseClient,
},
)
const habitProgressUpdateRepository = new HabitProgressUpdateSupabaseRepository(
{
supabaseClient,
},
)
/**
* Use Cases
@ -47,6 +60,10 @@ const retrieveHabitsTrackerUseCase = new RetrieveHabitsTrackerUseCase({
const habitEditUseCase = new HabitEditUseCase({
habitEditRepository,
})
const habitGoalProgressUpdateUseCase = new HabitGoalProgressUpdateUseCase({
habitProgressCreateRepository,
habitProgressUpdateRepository,
})
/**
* Presenters
@ -58,4 +75,5 @@ export const habitsTrackerPresenter = new HabitsTrackerPresenter({
retrieveHabitsTrackerUseCase,
habitCreateUseCase,
habitEditUseCase,
habitGoalProgressUpdateUseCase,
})

View File

@ -0,0 +1,38 @@
import type { HabitProgressCreateRepository } from "@/domain/repositories/HabitProgressCreate"
import { SupabaseRepository } from "./_SupabaseRepository"
import { HabitProgress } from "@/domain/entities/HabitProgress"
export class HabitProgressCreateSupabaseRepository
extends SupabaseRepository
implements HabitProgressCreateRepository
{
public execute: HabitProgressCreateRepository["execute"] = async (
options,
) => {
const { habitProgressData } = options
const { goalProgress, date, habitId } = habitProgressData
let goalProgressValue = goalProgress.isCompleted() ? 1 : 0
if (goalProgress.isNumeric()) {
goalProgressValue = goalProgress.progress
}
const { data, error } = await this.supabaseClient
.from("habits_progresses")
.insert({
habit_id: Number(habitId),
date: date.toISOString(),
goal_progress: goalProgressValue,
})
.select("*")
const insertedProgress = data?.[0]
if (error != null || insertedProgress == null) {
throw new Error(error?.message ?? "Failed to create habit progress.")
}
const habitProgress = new HabitProgress({
id: insertedProgress.id.toString(),
habitId: insertedProgress.habit_id.toString(),
date: new Date(insertedProgress.date),
goalProgress,
})
return habitProgress
}
}

View File

@ -0,0 +1,38 @@
import type { HabitProgressUpdateRepository } from "@/domain/repositories/HabitProgressUpdate"
import { SupabaseRepository } from "./_SupabaseRepository"
import { HabitProgress } from "@/domain/entities/HabitProgress"
export class HabitProgressUpdateSupabaseRepository
extends SupabaseRepository
implements HabitProgressUpdateRepository
{
public execute: HabitProgressUpdateRepository["execute"] = async (
options,
) => {
const { habitProgressData } = options
const { id, goalProgress, date } = habitProgressData
let goalProgressValue = goalProgress.isCompleted() ? 1 : 0
if (goalProgress.isNumeric()) {
goalProgressValue = goalProgress.progress
}
const { data, error } = await this.supabaseClient
.from("habits_progresses")
.update({
date: date.toISOString(),
goal_progress: goalProgressValue,
})
.eq("id", id)
.select("*")
const insertedProgress = data?.[0]
if (error != null || insertedProgress == null) {
throw new Error(error?.message ?? "Failed to update habit progress.")
}
const habitProgress = new HabitProgress({
id: insertedProgress.id.toString(),
habitId: insertedProgress.habit_id.toString(),
date: new Date(insertedProgress.date),
goalProgress,
})
return habitProgress
}
}

24
package-lock.json generated
View File

@ -23,6 +23,7 @@
"expo-system-ui": "2.9.3",
"expo-web-browser": "12.8.2",
"immer": "10.0.4",
"lottie-react-native": "6.7.2",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hook-form": "7.51.2",
@ -17868,6 +17869,29 @@
"loose-envify": "cli.js"
}
},
"node_modules/lottie-react-native": {
"version": "6.7.2",
"resolved": "https://registry.npmjs.org/lottie-react-native/-/lottie-react-native-6.7.2.tgz",
"integrity": "sha512-MZVx6N1EeO/EaSx8T44mJ0aHc5Mqee+xIfWwszni0oz8U2wlHdaWGjES44dHxaxgAp/0dRaFt3PkpZ6egTzcBg==",
"peerDependencies": {
"@dotlottie/react-player": "^1.6.1",
"@lottiefiles/react-lottie-player": "^3.5.3",
"react": "*",
"react-native": ">=0.46",
"react-native-windows": ">=0.63.x"
},
"peerDependenciesMeta": {
"@dotlottie/react-player": {
"optional": true
},
"@lottiefiles/react-lottie-player": {
"optional": true
},
"react-native-windows": {
"optional": true
}
}
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",

View File

@ -33,6 +33,7 @@
"expo-system-ui": "2.9.3",
"expo-web-browser": "12.8.2",
"immer": "10.0.4",
"lottie-react-native": "6.7.2",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hook-form": "7.51.2",

File diff suppressed because it is too large Load Diff

View File

@ -11,6 +11,10 @@ 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"
import type {
HabitGoalProgressUpdateUseCase,
HabitGoalProgressUpdateUseCaseOptions,
} from "@/domain/use-cases/HabitGoalProgressUpdate"
export interface HabitsTrackerPresenterState {
habitsTracker: HabitsTracker
@ -34,12 +38,17 @@ export interface HabitsTrackerPresenterState {
global: ErrorGlobal
}
}
habitGoalProgressUpdate: {
state: FetchState
}
}
export interface HabitsTrackerPresenterOptions {
retrieveHabitsTrackerUseCase: RetrieveHabitsTrackerUseCase
habitCreateUseCase: HabitCreateUseCase
habitEditUseCase: HabitEditUseCase
habitGoalProgressUpdateUseCase: HabitGoalProgressUpdateUseCase
}
export class HabitsTrackerPresenter
@ -49,12 +58,14 @@ export class HabitsTrackerPresenter
public retrieveHabitsTrackerUseCase: RetrieveHabitsTrackerUseCase
public habitCreateUseCase: HabitCreateUseCase
public habitEditUseCase: HabitEditUseCase
public habitGoalProgressUpdateUseCase: HabitGoalProgressUpdateUseCase
public constructor(options: HabitsTrackerPresenterOptions) {
const {
retrieveHabitsTrackerUseCase,
habitCreateUseCase,
habitEditUseCase,
habitGoalProgressUpdateUseCase,
} = options
const habitsTracker = HabitsTracker.default()
super({
@ -74,10 +85,14 @@ export class HabitsTrackerPresenter
global: null,
},
},
habitGoalProgressUpdate: {
state: "idle",
},
})
this.retrieveHabitsTrackerUseCase = retrieveHabitsTrackerUseCase
this.habitCreateUseCase = habitCreateUseCase
this.habitEditUseCase = habitEditUseCase
this.habitGoalProgressUpdateUseCase = habitGoalProgressUpdateUseCase
}
public async habitCreate(data: unknown): Promise<FetchState> {
@ -158,4 +173,26 @@ export class HabitsTrackerPresenter
})
}
}
public async habitUpdateProgress(
options: HabitGoalProgressUpdateUseCaseOptions,
): Promise<FetchState> {
try {
this.setState((state) => {
state.habitGoalProgressUpdate.state = "loading"
})
const habitProgress =
await this.habitGoalProgressUpdateUseCase.execute(options)
this.setState((state) => {
state.habitsTracker.updateHabitProgress(habitProgress)
state.habitGoalProgressUpdate.state = "success"
})
return "success"
} catch (error) {
this.setState((state) => {
state.habitGoalProgressUpdate.state = "error"
})
return "error"
}
}
}

View File

@ -1,143 +0,0 @@
import { useState } from "react"
import { Calendar } from "react-native-calendars"
import { Text } from "react-native-paper"
import { SafeAreaView } from "react-native-safe-area-context"
export const HabitHistory: React.FC = () => {
const [selected, setSelected] = useState("")
return (
<SafeAreaView
style={[
{
flex: 1,
alignItems: "center",
justifyContent: "center",
},
]}
>
<Calendar
onDayPress={(day) => {
setSelected(day.dateString)
}}
markedDates={{
"2023-03-01": { selected: true, marked: true, selectedColor: "blue" },
"2023-03-02": { marked: true },
"2023-03-03": { selected: true, marked: true, selectedColor: "blue" },
[selected]: {
selected: true,
disableTouchEvent: true,
selectedColor: "orange",
},
}}
theme={{
backgroundColor: "#000000",
calendarBackground: "#000000",
textSectionTitleColor: "#b6c1cd",
selectedDayBackgroundColor: "#00adf5",
selectedDayTextColor: "#ffffff",
todayTextColor: "#00adf5",
dayTextColor: "#2d4150",
textDisabledColor: "#d9efff",
}}
/>
<Text>{selected}</Text>
</SafeAreaView>
)
}
/*
<Agenda
// The list of items that have to be displayed in agenda. If you want to render item as empty date
// the value of date key has to be an empty array []. If there exists no value for date key it is
// considered that the date in question is not yet loaded
items={{
'2012-05-22': [{name: 'item 1 - any js object'}],
'2012-05-23': [{name: 'item 2 - any js object', height: 80}],
'2012-05-24': [],
'2012-05-25': [{name: 'item 3 - any js object'}, {name: 'any js object'}]
}}
// Callback that gets called when items for a certain month should be loaded (month became visible)
loadItemsForMonth={month => {
console.log('trigger items loading');
}}
// Callback that fires when the calendar is opened or closed
onCalendarToggled={calendarOpened => {
console.log(calendarOpened);
}}
// Callback that gets called on day press
onDayPress={day => {
console.log('day pressed');
}}
// Callback that gets called when day changes while scrolling agenda list
onDayChange={day => {
console.log('day changed');
}}
// Initially selected day
selected={'2012-05-16'}
// Minimum date that can be selected, dates before minDate will be grayed out. Default = undefined
minDate={'2012-05-10'}
// Maximum date that can be selected, dates after maxDate will be grayed out. Default = undefined
maxDate={'2012-05-30'}
// Max amount of months allowed to scroll to the past. Default = 50
pastScrollRange={50}
// Max amount of months allowed to scroll to the future. Default = 50
futureScrollRange={50}
// Specify how each item should be rendered in agenda
renderItem={(item, firstItemInDay) => {
return <View />;
}}
// Specify how each date should be rendered. day can be undefined if the item is not first in that day
renderDay={(day, item) => {
return <View />;
}}
// Specify how empty date content with no items should be rendered
renderEmptyDate={() => {
return <View />;
}}
// Specify how agenda knob should look like
renderKnob={() => {
return <View />;
}}
// Override inner list with a custom implemented component
renderList={listProps => {
return <MyCustomList {...listProps} />;
}}
// Specify what should be rendered instead of ActivityIndicator
renderEmptyData={() => {
return <View />;
}}
// Specify your item comparison function for increased performance
rowHasChanged={(r1, r2) => {
return r1.text !== r2.text;
}}
// Hide knob button. Default = false
hideKnob={true}
// When `true` and `hideKnob` prop is `false`, the knob will always be visible and the user will be able to drag the knob up and close the calendar. Default = false
showClosingKnob={false}
// By default, agenda dates are marked if they have at least one item, but you can override this if needed
markedDates={{
'2012-05-16': {selected: true, marked: true},
'2012-05-17': {marked: true},
'2012-05-18': {disabled: true}
}}
// If disabledByDefault={true} dates flagged as not disabled will be enabled. Default = false
disabledByDefault={true}
// If provided, a standard RefreshControl will be added for "Pull to Refresh" functionality. Make sure to also set the refreshing prop correctly
onRefresh={() => console.log('refreshing...')}
// Set this true while waiting for new data from a refresh
refreshing={false}
// Add a custom RefreshControl component, used to provide pull-to-refresh functionality for the ScrollView
refreshControl={null}
// Agenda theme
theme={{
...calendarTheme,
agendaDayTextColor: 'yellow',
agendaDayNumColor: 'green',
agendaTodayColor: 'red',
agendaKnobColor: 'blue'
}}
// Agenda container style
style={{}}
/>
*/

View File

@ -1,30 +1,37 @@
import FontAwesome6 from "@expo/vector-icons/FontAwesome6"
import { useRouter } from "expo-router"
import { View } from "react-native"
import { List, Text, Checkbox } from "react-native-paper"
import { useState } from "react"
import { View } from "react-native"
import { Checkbox, List, Text } from "react-native-paper"
import type LottieView from "lottie-react-native"
import type { GoalProgress } from "@/domain/entities/Goal"
import type { Habit } from "@/domain/entities/Habit"
import type { GoalBoolean } from "@/domain/entities/Goal"
import { GoalBooleanProgress } from "@/domain/entities/Goal"
import type { HabitHistory } from "@/domain/entities/HabitHistory"
import { getColorRGBAFromHex } from "@/utils/colors"
import { useHabitsTracker } from "../../contexts/HabitsTracker"
export interface HabitCardProps {
habit: Habit
goalProgress: GoalProgress
habitHistory: HabitHistory
selectedDate: Date
confettiRef: React.MutableRefObject<LottieView | null>
}
export const HabitCard: React.FC<HabitCardProps> = (props) => {
const { habit, goalProgress } = props
const { habitHistory, selectedDate, confettiRef } = props
const { habit } = habitHistory
const router = useRouter()
const { habitsTrackerPresenter } = useHabitsTracker()
const goalProgress = habitHistory.getGoalProgressByDate(selectedDate)
const [checked, setChecked] = useState(goalProgress.isCompleted())
const habitColor = getColorRGBAFromHex({
hexColor: habit.color,
opacity: 0.4,
})
const [checked, setChecked] = useState(goalProgress.isCompleted())
return (
<List.Item
onPress={() => {
@ -57,15 +64,17 @@ export const HabitCard: React.FC<HabitCardProps> = (props) => {
]}
left={() => {
return (
<FontAwesome6
size={24}
name={habit.icon}
style={[
{
width: 30,
},
]}
/>
<View style={{ justifyContent: "center", alignItems: "center" }}>
<FontAwesome6
size={24}
name={habit.icon}
style={[
{
width: 30,
},
]}
/>
</View>
)
}}
right={() => {
@ -82,14 +91,25 @@ export const HabitCard: React.FC<HabitCardProps> = (props) => {
}
return (
<View>
<Checkbox
status={checked ? "checked" : "unchecked"}
onPress={() => {
setChecked(!checked)
}}
/>
</View>
<Checkbox
color="black"
status={checked ? "checked" : "unchecked"}
onPress={async () => {
const isCheckedNew = !checked
setChecked(isCheckedNew)
if (isCheckedNew) {
confettiRef.current?.play()
}
await habitsTrackerPresenter.habitUpdateProgress({
date: selectedDate,
habitHistory,
goalProgress: new GoalBooleanProgress({
goal: habit.goal as GoalBoolean,
progress: isCheckedNew,
}),
})
}}
/>
)
}}
/>

View File

@ -1,9 +1,11 @@
import { useState } from "react"
import { Dimensions, ScrollView } from "react-native"
import LottieView from "lottie-react-native"
import { useRef, useState } from "react"
import { Dimensions, ScrollView, View } from "react-native"
import { Divider, List } from "react-native-paper"
import type { GoalFrequency } from "@/domain/entities/Goal"
import type { HabitsTracker } from "@/domain/entities/HabitsTracker"
import confettiJSON from "../../../assets/confetti.json"
import { capitalize } from "@/utils/strings"
import { HabitCard } from "./HabitCard"
@ -24,51 +26,85 @@ export const HabitsList: React.FC<HabitsListProps> = (props) => {
monthly: true,
})
const confettiRef = useRef<LottieView | null>(null)
return (
<ScrollView
showsVerticalScrollIndicator={false}
style={{
paddingHorizontal: 20,
width: Dimensions.get("window").width,
backgroundColor: "white",
}}
>
<Divider />
<List.Section>
{frequenciesFiltered.map((frequency) => {
return (
<List.Accordion
expanded={accordionExpanded[frequency]}
onPress={() => {
setAccordionExpanded((old) => {
return {
...old,
[frequency]: !old[frequency],
}
})
}}
key={frequency}
title={capitalize(frequency)}
titleStyle={[
{
fontSize: 26,
},
]}
>
{habitsTracker.habitsHistory[frequency].map((item) => {
const goalProgress = item.getGoalProgressByDate(selectedDate)
return (
<HabitCard
habit={item.habit}
goalProgress={goalProgress}
key={item.habit.id}
/>
)
})}
</List.Accordion>
)
})}
</List.Section>
</ScrollView>
<>
<View
pointerEvents="none"
style={{
width: "100%",
height: "100%",
position: "absolute",
zIndex: 100,
justifyContent: "center",
alignItems: "center",
}}
>
<LottieView
ref={confettiRef}
source={confettiJSON}
autoPlay={false}
loop={false}
style={[
{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
},
]}
resizeMode="cover"
/>
</View>
<ScrollView
showsVerticalScrollIndicator={false}
style={{
paddingHorizontal: 20,
width: Dimensions.get("window").width,
backgroundColor: "white",
}}
>
<Divider />
<List.Section>
{frequenciesFiltered.map((frequency) => {
return (
<List.Accordion
expanded={accordionExpanded[frequency]}
onPress={() => {
setAccordionExpanded((old) => {
return {
...old,
[frequency]: !old[frequency],
}
})
}}
key={frequency}
title={capitalize(frequency)}
titleStyle={[
{
fontSize: 26,
},
]}
>
{habitsTracker.habitsHistory[frequency].map((item) => {
return (
<HabitCard
habitHistory={item}
selectedDate={selectedDate}
key={item.habit.id + selectedDate.toISOString()}
confettiRef={confettiRef}
/>
)
})}
</List.Accordion>
)
})}
</List.Section>
</ScrollView>
</>
)
}