Compare commits
80 Commits
v1.0.0-sta
...
develop
Author | SHA1 | Date | |
---|---|---|---|
fbbe74a082 | |||
8029204d44 | |||
15ab592513 | |||
beac8b37dc | |||
0793720f70 | |||
|
b789fad149 | ||
|
671639862c | ||
5099e472bc | |||
|
ab6af07a31 | ||
|
66501cc595 | ||
|
fdbbec3e11 | ||
89aab00e6e | |||
47bf926fd8 | |||
c455326f8e | |||
dbc19d7056 | |||
|
35b3c5b965 | ||
|
1bf5fdeaca | ||
d98f3144cb | |||
e15a3982fd | |||
ca122e9fce | |||
|
42f5623c92 | ||
2ab7413f32 | |||
2b15e9f28e | |||
cd7aa235ab | |||
5462b47112 | |||
|
e68fe6075e | ||
651e8e2633 | |||
49dbd18606 | |||
d596b37be5 | |||
e9afc81bab | |||
71987799c0 | |||
3c0c34d187 | |||
b95d466a77 | |||
06ef8515cb | |||
f3156eee61 | |||
748ac2476c | |||
172e8edf78 | |||
094b581949 | |||
c00b96a8e2 | |||
97ae14d182 | |||
26b5a18edd | |||
|
c03cd2b96d | ||
1a89fa03c5 | |||
|
dffadb47d0 | ||
|
b6395b71b9 | ||
|
5e3cee079b | ||
cf959c7088 | |||
1ab504324a | |||
4062ad268b | |||
867667f4c7 | |||
|
1fca00addb | ||
d75a8ab2cd | |||
|
49e8460e5c | ||
246cbe918a | |||
|
a411f91c8e | ||
|
3fa3681c9b | ||
c11f7c1474 | |||
20b4456245 | |||
2ab83dfc89 | |||
|
fa9431b788 | ||
|
a5de568bf4 | ||
|
a5455f3948 | ||
17c1c1041e | |||
|
e367c09b34 | ||
|
2452e3dedd | ||
e4fcb1894c | |||
|
a2d187a27a | ||
|
bc9d7ae1af | ||
|
6c95386666 | ||
|
f17c7d6e11 | ||
c9e720cb13 | |||
|
1dc3939d42 | ||
|
eea32ec256 | ||
7543a4f820 | |||
8fa50c3954 | |||
bb126e907c | |||
8774bc735a | |||
57058c97b1 | |||
1c648972d5 | |||
39ebe3a152 |
@ -1,5 +1,5 @@
|
||||
# Supabase - Local
|
||||
# EXPO_PUBLIC_SUPABASE_URL='http://127.0.0.1:54321' # Replace `127.0.0.1` with local IP (`hostname -I` on Linux)
|
||||
# EXPO_PUBLIC_SUPABASE_URL='http://127.0.0.1:54321' # Replace `127.0.0.1` with local IP (e.g: `hostname -i` on GNU/Linux)
|
||||
# EXPO_PUBLIC_SUPABASE_ANON_KEY=''
|
||||
|
||||
# Supabase - Production
|
||||
|
@ -1,12 +1,11 @@
|
||||
{
|
||||
"root": true,
|
||||
"extends": [
|
||||
"conventions",
|
||||
"plugin:react/recommended",
|
||||
"plugin:react-hooks/recommended",
|
||||
"prettier"
|
||||
"plugin:react-hooks/recommended"
|
||||
],
|
||||
"ignorePatterns": ["jest.setup.ts"],
|
||||
"plugins": ["prettier"],
|
||||
"env": {
|
||||
"browser": true,
|
||||
"node": true,
|
||||
@ -17,11 +16,7 @@
|
||||
"version": "detect"
|
||||
}
|
||||
},
|
||||
"parserOptions": {
|
||||
"project": "./tsconfig.json"
|
||||
},
|
||||
"rules": {
|
||||
"prettier/prettier": "error",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/prop-types": "off",
|
||||
"react/self-closing-comp": [
|
||||
@ -37,7 +32,11 @@
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts", "*.tsx"],
|
||||
"parser": "@typescript-eslint/parser"
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": ["@typescript-eslint"],
|
||||
"parserOptions": {
|
||||
"project": "./tsconfig.json"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
27
.github/workflows/ci.yml
vendored
Normal file
@ -0,0 +1,27 @@
|
||||
name: "ci"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [develop]
|
||||
pull_request:
|
||||
branches: [main, develop, staging]
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: "ubuntu-latest"
|
||||
steps:
|
||||
- uses: "actions/checkout@v4.1.6"
|
||||
|
||||
- name: "Setup Node.js"
|
||||
uses: "actions/setup-node@v4.0.2"
|
||||
with:
|
||||
node-version: "20.x"
|
||||
cache: "npm"
|
||||
|
||||
- run: "npm clean-install"
|
||||
- run: "npm run expo:typed-routes"
|
||||
- run: 'npm run lint:commit -- --to "${{ github.sha }}"'
|
||||
- run: "npm run lint:prettier"
|
||||
- run: "npm run lint:eslint"
|
||||
- run: "npm run lint:typescript"
|
||||
- run: "npm run test"
|
1
.gitignore
vendored
@ -3,6 +3,7 @@
|
||||
# dependencies
|
||||
node_modules/
|
||||
.npm/
|
||||
.temp/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
|
@ -1,28 +0,0 @@
|
||||
default:
|
||||
image: "node:20.11.1"
|
||||
|
||||
stages:
|
||||
- "test"
|
||||
|
||||
test:
|
||||
stage: "test"
|
||||
only:
|
||||
- "merge_requests"
|
||||
- "develop"
|
||||
script:
|
||||
- "npm clean-install"
|
||||
- "npm run expo:typed-routes"
|
||||
- 'echo "${CI_COMMIT_MESSAGE}" | npm run lint:commit'
|
||||
- "npm run lint:prettier"
|
||||
- "npm run lint:eslint"
|
||||
- "npm run lint:typescript"
|
||||
- "npm run test"
|
||||
# artifacts:
|
||||
# paths:
|
||||
# - "coverage/"
|
||||
# reports:
|
||||
# coverage_report:
|
||||
# coverage_format: "cobertura"
|
||||
# path: "coverage/cobertura-coverage.xml"
|
||||
# junit: "junit.xml"
|
||||
# coverage: '/All files[^|]*\|[^|]*\s+([\d\.]+)/'
|
24
LICENSE
Normal file
@ -0,0 +1,24 @@
|
||||
# MIT License
|
||||
|
||||
Copyright (c) Théo LUDWIG <contact@theoludwig.fr>
|
||||
Copyright (c) Haoxuan LI <haoxuan.li@etu.unistra.fr>
|
||||
Copyright (c) Maxime RUMPLER <mrumpler68@gmail.com>
|
||||
Copyright (c) Maxime RICHARD <maxime.richard2@etu.unistra.fr>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
26
README.md
@ -1,4 +1,4 @@
|
||||
# P61 - Projet
|
||||
# Habits Tracker - P61 Projet
|
||||
|
||||
## À propos
|
||||
|
||||
@ -6,6 +6,10 @@ Application mobile en [React Native](https://reactnative.dev/) pour le projet du
|
||||
|
||||
Un tracker d'habitudes pour performer au boulot et dans la vie de tous les jours.
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/screenshots/habits.png" alt="Habits Tracker Screenshot" height="400px" />
|
||||
</p>
|
||||
|
||||
### Membres du Groupe 7
|
||||
|
||||
- [Théo LUDWIG](https://git.unistra.fr/t.ludwig)
|
||||
@ -17,7 +21,6 @@ Un tracker d'habitudes pour performer au boulot et dans la vie de tous les jours
|
||||
|
||||
- [Sujet](./docs/SUJET.md) + [Cahier des charges](./docs/CAHIER-DES-CHARGES.md)
|
||||
- [Modèle Logique des Données (MLD)](./docs/MLD.md)
|
||||
- [Architecture](./docs/ARCHITECTURE.md)
|
||||
- [Conventions développement informatique](./docs/CONVENTIONS.md)
|
||||
- [Kanban Board (Trello)](https://trello.com/b/8kYlcLA8/habits-tracker)
|
||||
|
||||
@ -28,9 +31,6 @@ Un tracker d'habitudes pour performer au boulot et dans la vie de tous les jours
|
||||
- [React Native Paper](https://callstack.github.io/react-native-paper/): Bibliothèque de composants pour React Native.
|
||||
- [React Hook Form](https://react-hook-form.com/) + [Zod](https://zod.dev/): Gestion des formulaires et validation des données.
|
||||
- [Supabase](https://supabase.io/): Backend, serveur d'API pour le stockage des données.
|
||||
<!--
|
||||
- [WatermelonDB](https://nozbe.github.io/WatermelonDB/): Base de données locale, pour permettre une utilisation hors-ligne de l'application.
|
||||
-->
|
||||
|
||||
## Développement du projet en local
|
||||
|
||||
@ -38,14 +38,14 @@ Un tracker d'habitudes pour performer au boulot et dans la vie de tous les jours
|
||||
|
||||
- [Node.js](https://nodejs.org/) >= 20.0.0
|
||||
- [npm](https://www.npmjs.com/) >= 10.0.0
|
||||
- [Expo Go](https://expo.io/client)
|
||||
- [Expo Go](https://expo.io/client) ~2.31.0
|
||||
- [Docker](https://www.docker.com/) (facultatif, utilisé pour lancer [Supabase](https://supabase.io/) en local)
|
||||
|
||||
### Installation
|
||||
|
||||
```sh
|
||||
# Cloner le projet
|
||||
git clone git@git.unistra.fr:rrll/p61-project.git
|
||||
git clone git@github.com:theoludwig/p61-project.git
|
||||
|
||||
# Se déplacer dans le répertoire du projet
|
||||
cd p61-project
|
||||
@ -68,24 +68,24 @@ npm run start
|
||||
Ce n'est pas strictement nécessaire pour le développement de l'application (même si recommandé), de lancer [Supabase](https://supabase.io/) en local, car l'application est déjà déployée sur un serveur [Supabase](https://supabase.io/) en production (`.env.example` est pré-configuré avec cet environnement).
|
||||
|
||||
```sh
|
||||
npm run supabase
|
||||
npm run supabase-cli start
|
||||
```
|
||||
|
||||
#### Principales Commandes Supabase
|
||||
|
||||
```sh
|
||||
# Pour réinitialiser la base de données avec les données de test (seed.sql)
|
||||
npm run supabase db reset
|
||||
npm run supabase-cli db reset
|
||||
|
||||
# Pour synchroniser le modèle (local) avec la base de données (remote)
|
||||
npm run supabase db pull
|
||||
npm run supabase-cli db pull
|
||||
|
||||
# Pour synchroniser la base de données (remote) avec le modèle (local)
|
||||
npm run supabase db push
|
||||
npm run supabase-cli db push
|
||||
|
||||
# Pour générer les types TypeScript
|
||||
npm run supabase gen types typescript -- --local > ./infrastructure/repositories/supabase/supabase-types.ts
|
||||
npm run supabase-cli gen types typescript -- --local > ./infrastructure/supabase/supabase-types.ts
|
||||
|
||||
# Crée un nouveau script de migration à partir des modifications déjà appliquées à votre base de données locale (remplacer `<name-of-migration>` avec le nom de la migration)
|
||||
npm run supabase db diff -- -f <name-of-migration>
|
||||
npm run supabase-cli db diff -- -f <name-of-migration>
|
||||
```
|
||||
|
21
app.json
@ -1,26 +1,29 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "p61-project",
|
||||
"name": "Habits Tracker",
|
||||
"slug": "p61-project",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.1",
|
||||
"orientation": "portrait",
|
||||
"icon": "./presentation/assets/images/icon.png",
|
||||
"scheme": "p61-project",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"splash": {
|
||||
"image": "./presentation/assets/images/splashscreen.jpg",
|
||||
"image": "./presentation/assets/images/splashscreen.png",
|
||||
"resizeMode": "cover",
|
||||
"backgroundColor": "#74b6cb"
|
||||
},
|
||||
"assetBundlePatterns": ["**/*"],
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
"supportsTablet": true,
|
||||
"buildNumber": "1.1.1"
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./presentation/assets/images/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
}
|
||||
},
|
||||
"package": "com.theoludwig.p61project",
|
||||
"versionCode": 6
|
||||
},
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
@ -30,6 +33,14 @@
|
||||
"plugins": ["expo-router"],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
},
|
||||
"extra": {
|
||||
"router": {
|
||||
"origin": false
|
||||
},
|
||||
"eas": {
|
||||
"projectId": "5c0a922a-564b-4d62-8231-ce5aef7ff978"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,6 @@
|
||||
import { Stack } from "expo-router"
|
||||
import { fas } from "@fortawesome/free-solid-svg-icons"
|
||||
import { library } from "@fortawesome/fontawesome-svg-core"
|
||||
import * as SplashScreen from "expo-splash-screen"
|
||||
import {
|
||||
MD3LightTheme as DefaultTheme,
|
||||
@ -6,6 +8,7 @@ import {
|
||||
} from "react-native-paper"
|
||||
import { StatusBar } from "expo-status-bar"
|
||||
import { useEffect } from "react"
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler"
|
||||
|
||||
import { HabitsTrackerProvider } from "@/presentation/react/contexts/HabitsTracker"
|
||||
import {
|
||||
@ -19,6 +22,8 @@ export const unstableSettings = {
|
||||
initialRouteName: "index",
|
||||
}
|
||||
|
||||
library.add(fas)
|
||||
|
||||
SplashScreen.preventAutoHideAsync().catch((error) => {
|
||||
console.error(error)
|
||||
})
|
||||
@ -61,7 +66,9 @@ const RootLayout: React.FC = () => {
|
||||
},
|
||||
}}
|
||||
>
|
||||
<StackLayout />
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<StackLayout />
|
||||
</GestureHandlerRootView>
|
||||
|
||||
<StatusBar style="dark" />
|
||||
</PaperProvider>
|
||||
|
@ -1,14 +1,14 @@
|
||||
import { Redirect, Tabs } from "expo-router"
|
||||
import React from "react"
|
||||
|
||||
import { TabBarIcon } from "@/presentation/react/components/TabBarIcon"
|
||||
import { TabBarIcon } from "@/presentation/react-native/ui/TabBarIcon"
|
||||
import { useAuthentication } from "@/presentation/react/contexts/Authentication"
|
||||
|
||||
const TabLayout: React.FC = () => {
|
||||
const { user } = useAuthentication()
|
||||
|
||||
if (user == null) {
|
||||
return <Redirect href="/authentication/login" />
|
||||
return <Redirect href="/authentication/about" />
|
||||
}
|
||||
|
||||
return (
|
||||
@ -31,17 +31,25 @@ const TabLayout: React.FC = () => {
|
||||
name="habits/new"
|
||||
options={{
|
||||
title: "New Habit",
|
||||
unmountOnBlur: true,
|
||||
tabBarIcon: ({ color }) => {
|
||||
return <TabBarIcon name="plus-square" color={color} />
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="habits/history"
|
||||
name="habits/[habitId]"
|
||||
options={{
|
||||
title: "History",
|
||||
unmountOnBlur: true,
|
||||
href: null,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="habits/statistics"
|
||||
options={{
|
||||
title: "Statistics",
|
||||
tabBarIcon: ({ color }) => {
|
||||
return <TabBarIcon name="history" color={color} />
|
||||
return <TabBarIcon name="line-chart" color={color} />
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
7
app/application/habits/[habitId]/_layout.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import { Slot } from "expo-router"
|
||||
|
||||
const HabitLayout: React.FC = () => {
|
||||
return <Slot />
|
||||
}
|
||||
|
||||
export default HabitLayout
|
21
app/application/habits/[habitId]/index.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import { Redirect, useLocalSearchParams } from "expo-router"
|
||||
|
||||
import { HabitEditForm } from "@/presentation/react-native/components/HabitForm/HabitEditForm"
|
||||
import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker"
|
||||
|
||||
const HabitPage: React.FC = () => {
|
||||
const { habitId } = useLocalSearchParams()
|
||||
const { habitsTracker } = useHabitsTracker()
|
||||
|
||||
const habitHistory = habitsTracker.getHabitHistoryById(habitId as string)
|
||||
|
||||
if (habitHistory == null) {
|
||||
return <Redirect href="/application/habits/" />
|
||||
}
|
||||
|
||||
return (
|
||||
<HabitEditForm habit={habitHistory.habit} key={habitHistory.habit.id} />
|
||||
)
|
||||
}
|
||||
|
||||
export default HabitPage
|
26
app/application/habits/[habitId]/progress/[selectedDate].tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import { Redirect, useLocalSearchParams } from "expo-router"
|
||||
|
||||
import { HabitProgress } from "@/presentation/react-native/components/HabitProgress"
|
||||
import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker"
|
||||
|
||||
const HabitProgressPage: React.FC = () => {
|
||||
const { habitId, selectedDate } = useLocalSearchParams()
|
||||
const { habitsTracker } = useHabitsTracker()
|
||||
|
||||
const habitHistory = habitsTracker.getHabitHistoryById(habitId as string)
|
||||
const selectedDateParsed = new Date(selectedDate as string)
|
||||
|
||||
if (habitHistory == null) {
|
||||
return <Redirect href="/application/habits/" />
|
||||
}
|
||||
|
||||
return (
|
||||
<HabitProgress
|
||||
habitHistory={habitHistory}
|
||||
key={habitHistory.habit.id}
|
||||
selectedDate={selectedDateParsed}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default HabitProgressPage
|
@ -1,48 +0,0 @@
|
||||
import { useState } from "react"
|
||||
import { StyleSheet } from "react-native"
|
||||
import { Calendar } from "react-native-calendars"
|
||||
import { SafeAreaView } from "react-native-safe-area-context"
|
||||
|
||||
const HistoryPage: React.FC = () => {
|
||||
const [selected, setSelected] = useState("")
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<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",
|
||||
}}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
})
|
||||
|
||||
export default HistoryPage
|
@ -1,34 +1,71 @@
|
||||
import { StyleSheet, Text, View } from "react-native"
|
||||
import { SafeAreaView } from "react-native-safe-area-context"
|
||||
import { ActivityIndicator, Button, Text } from "react-native-paper"
|
||||
|
||||
import { HabitsMainPage } from "@/presentation/react-native/components/HabitsMainPage/HabitsMainPage"
|
||||
import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker"
|
||||
import { useAuthentication } from "@/presentation/react/contexts/Authentication"
|
||||
|
||||
const HabitsPage: React.FC = () => {
|
||||
const { habitsTracker } = useHabitsTracker()
|
||||
const { habitsTracker, retrieveHabitsTracker, habitsTrackerPresenter } =
|
||||
useHabitsTracker()
|
||||
|
||||
const { user } = useAuthentication()
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
{habitsTracker.habitsHistory.map((progressHistory) => {
|
||||
const { habit } = progressHistory
|
||||
|
||||
return (
|
||||
<View key={habit.id}>
|
||||
<Text>
|
||||
{habit.name} ({habit.goal.type})
|
||||
<SafeAreaView
|
||||
style={[
|
||||
{
|
||||
flex: 1,
|
||||
backgroundColor: "white",
|
||||
alignItems: "center",
|
||||
justifyContent:
|
||||
retrieveHabitsTracker.state === "loading" ||
|
||||
retrieveHabitsTracker.state === "error"
|
||||
? "center"
|
||||
: "flex-start",
|
||||
},
|
||||
]}
|
||||
>
|
||||
{retrieveHabitsTracker.state === "loading" ? (
|
||||
<ActivityIndicator animating size="large" />
|
||||
) : retrieveHabitsTracker.state === "error" ? (
|
||||
<>
|
||||
<Text variant="titleLarge">
|
||||
Error: There was an issue while retrieving habits, please try again
|
||||
later.
|
||||
</Text>
|
||||
<Button
|
||||
mode="contained"
|
||||
style={{
|
||||
marginTop: 16,
|
||||
width: 150,
|
||||
height: 40,
|
||||
}}
|
||||
onPress={async () => {
|
||||
if (user === null) {
|
||||
return
|
||||
}
|
||||
await habitsTrackerPresenter.retrieveHabitsTracker({
|
||||
userId: user.id,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: "white",
|
||||
fontWeight: "bold",
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<HabitsMainPage habitsTracker={habitsTracker} />
|
||||
)}
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
})
|
||||
|
||||
export default HabitsPage
|
||||
|
@ -1,21 +1,14 @@
|
||||
import { StyleSheet } from "react-native"
|
||||
import { Text } from "react-native-paper"
|
||||
import { SafeAreaView } from "react-native-safe-area-context"
|
||||
import { HabitCreateForm } from "@/presentation/react-native/components/HabitForm/HabitCreateForm"
|
||||
import { useAuthentication } from "@/presentation/react/contexts/Authentication"
|
||||
|
||||
const NewHabitPage: React.FC = () => {
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<Text>New Habit</Text>
|
||||
</SafeAreaView>
|
||||
)
|
||||
const { user } = useAuthentication()
|
||||
|
||||
if (user == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <HabitCreateForm user={user} />
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
})
|
||||
|
||||
export default NewHabitPage
|
||||
|
23
app/application/habits/statistics.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { SafeAreaView } from "react-native-safe-area-context"
|
||||
|
||||
import { HabitsStatistics } from "@/presentation/react-native/components/HabitsStatistics"
|
||||
import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker"
|
||||
|
||||
const StatisticsPage: React.FC = () => {
|
||||
const { habitsTracker } = useHabitsTracker()
|
||||
|
||||
return (
|
||||
<SafeAreaView
|
||||
style={[
|
||||
{
|
||||
flex: 1,
|
||||
backgroundColor: "white",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<HabitsStatistics habitsTracker={habitsTracker} />
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
export default StatisticsPage
|
@ -1,38 +1,59 @@
|
||||
import { StyleSheet, Text } from "react-native"
|
||||
import { Button } from "react-native-paper"
|
||||
import { SafeAreaView } from "react-native-safe-area-context"
|
||||
import { Button, Text } from "react-native-paper"
|
||||
import { View } from "react-native"
|
||||
|
||||
import { About } from "@/presentation/react-native/components/About"
|
||||
import { useAuthentication } from "@/presentation/react/contexts/Authentication"
|
||||
|
||||
const SettingsPage: React.FC = () => {
|
||||
const { logout, authenticationPresenter } = useAuthentication()
|
||||
const { logout, authenticationPresenter, user } = useAuthentication()
|
||||
|
||||
const handleLogout = async (): Promise<void> => {
|
||||
await authenticationPresenter.logout()
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<Text>Settings</Text>
|
||||
|
||||
<Button
|
||||
mode="contained"
|
||||
onPress={handleLogout}
|
||||
loading={logout.state === "loading"}
|
||||
disabled={logout.state === "loading"}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</SafeAreaView>
|
||||
<About
|
||||
actionButton={
|
||||
<Button
|
||||
mode="contained"
|
||||
labelStyle={{ fontSize: 18 }}
|
||||
onPress={handleLogout}
|
||||
loading={logout.state === "loading"}
|
||||
disabled={logout.state === "loading"}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
}
|
||||
footer={
|
||||
<View
|
||||
style={{
|
||||
alignItems: "center",
|
||||
marginVertical: 20,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
fontSize: 18,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
Currenty logged in as
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
marginTop: 6,
|
||||
fontWeight: "bold",
|
||||
fontSize: 16,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{user?.displayName}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
})
|
||||
|
||||
export default SettingsPage
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { Redirect, Tabs } from "expo-router"
|
||||
import React from "react"
|
||||
|
||||
import { TabBarIcon } from "@/presentation/react/components/TabBarIcon"
|
||||
import { TabBarIcon } from "@/presentation/react-native/ui/TabBarIcon"
|
||||
import { useAuthentication } from "@/presentation/react/contexts/Authentication"
|
||||
|
||||
const TabLayout: React.FC = () => {
|
||||
@ -17,6 +17,15 @@ const TabLayout: React.FC = () => {
|
||||
headerShown: false,
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="about"
|
||||
options={{
|
||||
title: "About",
|
||||
tabBarIcon: ({ color }) => {
|
||||
return <TabBarIcon name="info" color={color} />
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="login"
|
||||
options={{
|
||||
|
26
app/authentication/about.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import { Button } from "react-native-paper"
|
||||
import { useRouter } from "expo-router"
|
||||
|
||||
import { About } from "@/presentation/react-native/components/About"
|
||||
|
||||
const AboutPage: React.FC = () => {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<About
|
||||
actionButton={
|
||||
<Button
|
||||
mode="contained"
|
||||
labelStyle={{ fontSize: 18 }}
|
||||
onPress={() => {
|
||||
router.push("/authentication/login")
|
||||
}}
|
||||
>
|
||||
Get Started 🚀
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default AboutPage
|
@ -21,7 +21,7 @@ const LoginPage: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<SafeAreaView style={[styles.container]}>
|
||||
<Controller
|
||||
control={control}
|
||||
render={({ field: { onChange, onBlur, value } }) => {
|
||||
@ -31,7 +31,7 @@ const LoginPage: React.FC = () => {
|
||||
onBlur={onBlur}
|
||||
onChangeText={onChange}
|
||||
value={value}
|
||||
style={styles.input}
|
||||
style={[styles.input]}
|
||||
mode="outlined"
|
||||
/>
|
||||
)
|
||||
@ -48,7 +48,7 @@ const LoginPage: React.FC = () => {
|
||||
onBlur={onBlur}
|
||||
onChangeText={onChange}
|
||||
value={value}
|
||||
style={styles.input}
|
||||
style={[styles.input]}
|
||||
mode="outlined"
|
||||
secureTextEntry
|
||||
/>
|
||||
@ -60,13 +60,14 @@ const LoginPage: React.FC = () => {
|
||||
<HelperText
|
||||
type="error"
|
||||
visible={login.state === "error"}
|
||||
style={styles.helperText}
|
||||
style={[styles.helperText]}
|
||||
>
|
||||
Invalid credentials.
|
||||
</HelperText>
|
||||
|
||||
<Button
|
||||
mode="contained"
|
||||
labelStyle={{ fontSize: 18 }}
|
||||
onPress={handleSubmit(onSubmit)}
|
||||
loading={login.state === "loading"}
|
||||
disabled={login.state === "loading"}
|
||||
|
@ -24,13 +24,13 @@ const RegisterPage: React.FC = () => {
|
||||
|
||||
const helperMessage = useMemo(() => {
|
||||
if (register.state === "error") {
|
||||
if (register.errorsFields.includes("displayName")) {
|
||||
if (register.errors.fields.includes("displayName")) {
|
||||
return "Display Name is required."
|
||||
}
|
||||
if (register.errorsFields.includes("email")) {
|
||||
if (register.errors.fields.includes("email")) {
|
||||
return "Invalid email."
|
||||
}
|
||||
if (register.errorsFields.includes("password")) {
|
||||
if (register.errors.fields.includes("password")) {
|
||||
return "Password must be at least 6 characters."
|
||||
}
|
||||
return "Invalid credentials."
|
||||
@ -41,10 +41,10 @@ const RegisterPage: React.FC = () => {
|
||||
// }
|
||||
|
||||
return ""
|
||||
}, [register.errorsFields, register.state])
|
||||
}, [register.errors.fields, register.state])
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<SafeAreaView style={[styles.container]}>
|
||||
<Controller
|
||||
control={control}
|
||||
render={({ field: { onChange, onBlur, value } }) => {
|
||||
@ -54,7 +54,7 @@ const RegisterPage: React.FC = () => {
|
||||
onBlur={onBlur}
|
||||
onChangeText={onChange}
|
||||
value={value}
|
||||
style={styles.input}
|
||||
style={[styles.input]}
|
||||
mode="outlined"
|
||||
/>
|
||||
)
|
||||
@ -71,7 +71,7 @@ const RegisterPage: React.FC = () => {
|
||||
onBlur={onBlur}
|
||||
onChangeText={onChange}
|
||||
value={value}
|
||||
style={styles.input}
|
||||
style={[styles.input]}
|
||||
mode="outlined"
|
||||
/>
|
||||
)
|
||||
@ -88,7 +88,7 @@ const RegisterPage: React.FC = () => {
|
||||
onBlur={onBlur}
|
||||
onChangeText={onChange}
|
||||
value={value}
|
||||
style={styles.input}
|
||||
style={[styles.input]}
|
||||
mode="outlined"
|
||||
secureTextEntry
|
||||
/>
|
||||
@ -100,13 +100,14 @@ const RegisterPage: React.FC = () => {
|
||||
<HelperText
|
||||
type={register.state === "error" ? "error" : "info"}
|
||||
visible={register.state === "error" || register.state === "success"}
|
||||
style={styles.helperText}
|
||||
style={[styles.helperText]}
|
||||
>
|
||||
{helperMessage}
|
||||
</HelperText>
|
||||
|
||||
<Button
|
||||
mode="contained"
|
||||
labelStyle={{ fontSize: 18 }}
|
||||
onPress={handleSubmit(onSubmit)}
|
||||
loading={register.state === "loading"}
|
||||
disabled={register.state === "loading"}
|
||||
|
@ -6,7 +6,7 @@ const HomePage: React.FC = () => {
|
||||
const { user } = useAuthentication()
|
||||
|
||||
if (user == null) {
|
||||
return <Redirect href="/authentication/login" />
|
||||
return <Redirect href="/authentication/about" />
|
||||
}
|
||||
|
||||
return <Redirect href="/application/habits/" />
|
||||
|
@ -1 +0,0 @@
|
||||
# Clean Architecture
|
@ -4,15 +4,15 @@ Un tracker d'habitudes pour performer au boulot et dans la vie de tous les jours
|
||||
|
||||
## Contexte
|
||||
|
||||
On voit ces derniers temps une émergence de nombreuses applications de suivi d'habitudes sur le marché. Malheureusement, les solutions proposées sont souvent soit trop complexes et encombrées, soit trop basiques et limitées par des modèles de tarification agressive (après tout c’est normal, les gens sont prêts à payer alors pourquoi s’en priver).
|
||||
On voit ces derniers temps une émergence de nombreuses applications de suivi d'habitudes sur le marché. Malheureusement, les solutions proposées sont souvent soit trop complexes et encombrées, soit trop basiques et limitées par des modèles de tarification agressive (après tout c'est normal, les gens sont prêts à payer alors pourquoi s'en priver).
|
||||
|
||||
Relevons le défi de créer une application qui équilibre parfaitement fonctionnalité et simplicité !
|
||||
|
||||
## Objectif principal
|
||||
|
||||
L'objectif de ce projet de groupe est de développer une application nommée “**Habits Tracker**” qui trouve le juste équilibre entre <ins>richesse fonctionnelle</ins> et <ins>simplicité</ins> d'utilisation (ne faites pas d’usine à gaz s’il vous plaît !).
|
||||
L'objectif de ce projet de groupe est de développer une application nommée “**Habits Tracker**” qui trouve le juste équilibre entre <ins>richesse fonctionnelle</ins> et <ins>simplicité</ins> d'utilisation (ne faites pas d'usine à gaz s'il vous plaît !).
|
||||
|
||||
L'application doit permettre aux utilisateurs de suivre efficacement leurs habitudes quotidiennes et d'analyser leurs progrès dans le but d’améliorer leur performance au travail et leur bien-être général.
|
||||
L'application doit permettre aux utilisateurs de suivre efficacement leurs habitudes quotidiennes et d'analyser leurs progrès dans le but d'améliorer leur performance au travail et leur bien-être général.
|
||||
|
||||
## Conseils
|
||||
|
||||
@ -22,7 +22,7 @@ Si votre application atteint une bonne qualité, avec une interface attrayante e
|
||||
|
||||
## Fonctionnalités de base (noyau commun)
|
||||
|
||||
- **Gestion des utilisateurs :** inscription, création d’un profil utilisateur avec identifiant unique, et fonctionnalités de connexion/déconnexion.
|
||||
- **Gestion des utilisateurs :** inscription, création d'un profil utilisateur avec identifiant unique, et fonctionnalités de connexion/déconnexion.
|
||||
- **Gestion des habitudes :** création et modification d'habitudes, avec personnalisation d'objectif (+ rappels et fréquences en bonus…)
|
||||
- **Suivi quotidien :** interface intuitive pour le suivi des habitudes depuis l'écran d'accueil, incluant un calendrier pour consulter l'historique.
|
||||
- **Analyse et statistiques :** graphiques simplifiés (librairie externe) pour visualiser les progrès et les tendances des habitudes sur des périodes définies
|
||||
@ -49,6 +49,12 @@ Vous serez évalués sur les aspects suivants :
|
||||
- Design UI : (esthétique de l'application) **1 point**
|
||||
- Expérience utilisateur UX : (facilité d'utilisation) **1 point**
|
||||
|
||||
## Exemple d’interfaces
|
||||
## Exemple d'interfaces
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
@ -19,17 +19,17 @@ npm run test
|
||||
npm run test -- --u
|
||||
```
|
||||
|
||||
Une pipeline CI ([`.gitlab-ci.yml`](../.gitlab-ci.yml)) est en place pour vérifier que le code respecte ces bonnes pratiques et que les tests passent.
|
||||
Une pipeline CI est en place pour vérifier que le code respecte ces bonnes pratiques et que les tests passent.
|
||||
|
||||
## GitFlow
|
||||
|
||||
Le projet suit la convention [GitFlow](https://nvie.com/posts/a-successful-git-branching-model/) reposant sur 2 branches principales:
|
||||
Le projet suit la convention [GitFlow](https://nvie.com/posts/a-successful-git-branching-model/) reposant sur 3 branches principales:
|
||||
|
||||
- `main`: Contient le code de la dernière version stable et déployé en production.
|
||||
- `staging`: Contient le code en cours de test avant déploiement en production, Quality Assurance (QA).
|
||||
- `develop`: Contient le code en cours de développement. Les nouvelles fonctionnalités et les correctifs de bugs sont fusionnés ici.
|
||||
- `develop`: Contient le code en cours de développement. Les nouvelles fonctionnalités et les correctifs de bugs sont fusionnés ici régulièrement.
|
||||
|
||||
Chaque nouvelle fonctionnalité ou correctif de bug est développé dans une branche dédiée à partir de `develop`, nommée `feat/<nom-de-la-fonctionnalité>` ou `fix/<nom-du-bug>`. Une fois le développement terminé, une merge request est créée pour demander une revue de code, et une fois validée, la branche est fusionnée dans `develop`, puis supprimée.
|
||||
Idéalement, chaque nouvelle fonctionnalité ou correctif de bug est développé dans une branche dédiée à partir de `develop`, nommée `feat/<nom-de-la-fonctionnalité>` ou `fix/<nom-du-bug>`. Une fois le développement terminé, une merge request est créée pour demander une revue de code, et une fois validée, la branche est fusionnée dans `develop`, puis supprimée.
|
||||
|
||||
## Convention des commits
|
||||
|
||||
@ -39,4 +39,4 @@ Les commits doivent être **atomiques** c'est à dire qu'il respecte 3 règles:
|
||||
|
||||
- Ne concerne qu'un seul sujet (une fonctionnalité, une correction de bug, etc.).
|
||||
- Doit avoir un message clair et concis.
|
||||
- Ne doit pas rendre de dépôt "incohérent" (bloque la CI du projet).
|
||||
- Ne doit pas rendre de dépôt "incohérent" (ne bloque pas la CI du projet).
|
||||
|
@ -4,14 +4,6 @@
|
||||
|
||||
Le **Modèle Logique des Données (MLD)** est une représentation de la structure de la base de données de l'application.
|
||||
|
||||
On représente ainsi les données sous la forme suivante:
|
||||
|
||||
- Chaque table est représentée par un bloc.
|
||||
- Le nom de la table est écrit en **gras**.
|
||||
- Les champs sont listés en dessous du nom de la table.
|
||||
- Les clés primaires sont <u>soulignées</u> et placées au début de la liste des champs.
|
||||
- Les clés étrangères sont préfixées par un dièse (#), et placées après les clés primaires. Les clés étrangères sont suivies entre parenthèses du nom de la table suivi d'une flèche (->) et du nom du champ de la table référencée.
|
||||
|
||||
## Modèle
|
||||
|
||||
- **users**
|
||||
|
@ -77,7 +77,7 @@ Quelques liens utiles pour comprendre... :
|
||||
- [Authentication in React Native, Easy, Secure, and Reusable solution.](https://www.obytes.com/blog/authentication-in-react-native-easy-secure-and-reusable-solution)
|
||||
- [How To Add Authentication to Your React Native App](https://betterprogramming.pub/how-to-add-authentication-to-your-react-native-app-with-react-hooks-and-react-context-api-46f57aedbbd)
|
||||
- [Github | mesan-react-native-authentication-app](https://github.com/MosesEsan/mesan-react-native-authentication-app/tree/auth)
|
||||
- [React Native: Implementing Browser-Based Authentication using Expo’s AuthSession Component](https://levelup.gitconnected.com/react-native-implementing-browser-based-authentication-using-expos-authsession-component-ffee25b50ae8)
|
||||
- [React Native: Implementing Browser-Based Authentication using Expo's AuthSession Component](https://levelup.gitconnected.com/react-native-implementing-browser-based-authentication-using-expos-authsession-component-ffee25b50ae8)
|
||||
- [Build a great login experience with React Native, Axios and JSONWebToken](https://www.willandskill.se/en/build-a-great-login-experience-with-react-native/)
|
||||
- [Adding Authentication to Your React Native App Using JSON Web Tokens](https://dzone.com/articles/adding-authentication-to-your-react-native-app-usi-1)
|
||||
|
||||
|
BIN
docs/screenshots/habits.png
Normal file
After Width: | Height: | Size: 158 KiB |
@ -1,9 +1,46 @@
|
||||
export const GOAL_FREQUENCIES = ["daily", "weekly", "monthly"] as const
|
||||
import { z } from "zod"
|
||||
|
||||
export const GOAL_FREQUENCIES_ZOD = [
|
||||
z.literal("daily"),
|
||||
z.literal("weekly"),
|
||||
z.literal("monthly"),
|
||||
] as const
|
||||
export const goalFrequencyZod = z.union(GOAL_FREQUENCIES_ZOD)
|
||||
export const GOAL_FREQUENCIES = GOAL_FREQUENCIES_ZOD.map((frequency) => {
|
||||
return frequency.value
|
||||
})
|
||||
export type GoalFrequency = (typeof GOAL_FREQUENCIES)[number]
|
||||
|
||||
export const GOAL_TYPES = ["numeric", "boolean"] as const
|
||||
export const GOAL_FREQUENCIES_TYPES = {
|
||||
daily: "day",
|
||||
weekly: "week",
|
||||
monthly: "month",
|
||||
} as const
|
||||
|
||||
export const GOAL_TYPES_ZOD = [
|
||||
z.literal("boolean"),
|
||||
z.literal("numeric"),
|
||||
] as const
|
||||
export const goalTypeZod = z.union(GOAL_TYPES_ZOD)
|
||||
export const GOAL_TYPES = GOAL_TYPES_ZOD.map((type) => {
|
||||
return type.value
|
||||
})
|
||||
export type GoalType = (typeof GOAL_TYPES)[number]
|
||||
|
||||
export const GoalCreateSchema = 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(1),
|
||||
unit: z.string().min(1),
|
||||
}),
|
||||
]),
|
||||
})
|
||||
|
||||
export type GoalCreateData = z.infer<typeof GoalCreateSchema>
|
||||
|
||||
interface GoalBase {
|
||||
frequency: GoalFrequency
|
||||
}
|
||||
@ -20,6 +57,16 @@ export abstract class Goal implements GoalBase {
|
||||
this.frequency = frequency
|
||||
}
|
||||
|
||||
public static create(options: GoalCreateData): Goal {
|
||||
if (options.target.type === "boolean") {
|
||||
return new GoalBoolean(options)
|
||||
}
|
||||
return new GoalNumeric({
|
||||
frequency: options.frequency,
|
||||
target: options.target,
|
||||
})
|
||||
}
|
||||
|
||||
public static isNumeric(goal: Goal): goal is GoalNumeric {
|
||||
return goal.type === "numeric"
|
||||
}
|
||||
@ -45,6 +92,24 @@ export abstract class GoalProgress implements GoalProgressBase {
|
||||
public abstract isCompleted(): boolean
|
||||
|
||||
public abstract toJSON(): GoalProgressBase
|
||||
|
||||
public static isNumeric(
|
||||
goalProgress: GoalProgress,
|
||||
): goalProgress is GoalNumericProgress {
|
||||
return goalProgress.goal.isNumeric()
|
||||
}
|
||||
public isNumeric(): this is GoalNumericProgress {
|
||||
return GoalProgress.isNumeric(this)
|
||||
}
|
||||
|
||||
public static isBoolean(
|
||||
goalProgress: GoalProgress,
|
||||
): goalProgress is GoalBooleanProgress {
|
||||
return goalProgress.goal.isBoolean()
|
||||
}
|
||||
public isBoolean(): this is GoalBooleanProgress {
|
||||
return GoalProgress.isBoolean(this)
|
||||
}
|
||||
}
|
||||
|
||||
interface GoalNumericOptions extends GoalBase {
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import type { Goal, GoalBaseJSON } from "./Goal"
|
||||
import { GoalCreateSchema, type Goal, type GoalBaseJSON } from "./Goal"
|
||||
import { Entity, EntitySchema } from "./_Entity"
|
||||
|
||||
export const HabitSchema = EntitySchema.extend({
|
||||
@ -8,23 +8,28 @@ export const HabitSchema = EntitySchema.extend({
|
||||
name: z.string().min(1).max(50),
|
||||
color: z.string().min(4).max(9).regex(/^#/),
|
||||
icon: z.string().min(1),
|
||||
endDate: z.date().optional(),
|
||||
})
|
||||
|
||||
export const HabitCreateSchema = HabitSchema.extend({}).omit({ id: true })
|
||||
export const HabitCreateSchema = HabitSchema.extend({
|
||||
goal: GoalCreateSchema,
|
||||
}).omit({ id: true })
|
||||
export type HabitCreateData = z.infer<typeof HabitCreateSchema>
|
||||
|
||||
type HabitDataBase = z.infer<typeof HabitSchema>
|
||||
export const HabitEditSchema = HabitSchema.extend({})
|
||||
export type HabitEditData = z.infer<typeof HabitEditSchema>
|
||||
|
||||
export interface HabitData extends HabitDataBase {
|
||||
type HabitBase = z.infer<typeof HabitSchema>
|
||||
|
||||
export interface HabitData extends HabitBase {
|
||||
goal: Goal
|
||||
startDate: Date
|
||||
endDate?: Date
|
||||
}
|
||||
|
||||
export interface HabitJSON extends HabitDataBase {
|
||||
export interface HabitJSON extends HabitBase {
|
||||
goal: GoalBaseJSON
|
||||
startDate: string
|
||||
endDate?: string
|
||||
}
|
||||
|
||||
export class Habit extends Entity implements HabitData {
|
||||
@ -57,7 +62,7 @@ export class Habit extends Entity implements HabitData {
|
||||
icon: this.icon,
|
||||
goal: this.goal,
|
||||
startDate: this.startDate.toISOString(),
|
||||
endDate: this.endDate?.toISOString(),
|
||||
endDate: this?.endDate,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,6 @@
|
||||
import { getISODate, getWeekNumber } from "@/utils/dates"
|
||||
import type { GoalProgress } from "./Goal"
|
||||
import { GoalBooleanProgress, GoalNumericProgress } from "./Goal"
|
||||
import type { Habit } from "./Habit"
|
||||
import type { HabitProgress } from "./HabitProgress"
|
||||
|
||||
@ -8,11 +11,70 @@ export interface HabitHistoryJSON {
|
||||
|
||||
export class HabitHistory implements HabitHistoryJSON {
|
||||
public habit: Habit
|
||||
public progressHistory: HabitProgress[]
|
||||
|
||||
private _progressHistory: HabitProgress[] = []
|
||||
|
||||
public constructor(options: HabitHistoryJSON) {
|
||||
const { habit, progressHistory } = options
|
||||
this.habit = habit
|
||||
this.progressHistory = progressHistory
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress History sorted chronologically (from old to most recent progress at the end).
|
||||
*/
|
||||
public get progressHistory(): HabitProgress[] {
|
||||
return this._progressHistory
|
||||
}
|
||||
|
||||
public set progressHistory(progressHistory: HabitProgress[]) {
|
||||
this._progressHistory = [...progressHistory]
|
||||
this._progressHistory.sort((a, b) => {
|
||||
return a.date.getTime() - b.date.getTime()
|
||||
})
|
||||
}
|
||||
|
||||
public getProgressesByDate(date: Date): HabitProgress[] {
|
||||
return this._progressHistory.filter((progress) => {
|
||||
if (this.habit.goal.frequency === "monthly") {
|
||||
return (
|
||||
date.getFullYear() === progress.date.getFullYear() &&
|
||||
date.getMonth() === progress.date.getMonth()
|
||||
)
|
||||
}
|
||||
if (this.habit.goal.frequency === "weekly") {
|
||||
return (
|
||||
getWeekNumber(date) === getWeekNumber(progress.date) &&
|
||||
date.getFullYear() === progress.date.getFullYear()
|
||||
)
|
||||
}
|
||||
if (this.habit.goal.frequency === "daily") {
|
||||
return getISODate(date) === getISODate(progress.date)
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
public getGoalProgressByDate(date: Date): GoalProgress {
|
||||
const progresses = this.getProgressesByDate(date)
|
||||
if (this.habit.goal.isBoolean()) {
|
||||
const lastSavedProgress = progresses[progresses.length - 1]
|
||||
return new GoalBooleanProgress({
|
||||
goal: this.habit.goal,
|
||||
progress: lastSavedProgress?.goalProgress?.isCompleted() ?? false,
|
||||
})
|
||||
}
|
||||
|
||||
if (this.habit.goal.isNumeric()) {
|
||||
return new GoalNumericProgress({
|
||||
goal: this.habit.goal,
|
||||
progress: progresses.reduce((sum, current) => {
|
||||
const goalProgress = current.goalProgress as GoalNumericProgress
|
||||
return sum + goalProgress.progress
|
||||
}, 0),
|
||||
})
|
||||
}
|
||||
|
||||
throw new Error("Invalid")
|
||||
}
|
||||
}
|
||||
|
@ -3,16 +3,16 @@ import type { Habit } from "./Habit"
|
||||
import type { EntityData } from "./_Entity"
|
||||
import { Entity } from "./_Entity"
|
||||
|
||||
interface HabitProgressDataBase extends EntityData {
|
||||
interface HabitProgressBase extends EntityData {
|
||||
habitId: Habit["id"]
|
||||
}
|
||||
|
||||
export interface HabitProgressData extends HabitProgressDataBase {
|
||||
export interface HabitProgressData extends HabitProgressBase {
|
||||
goalProgress: GoalProgress
|
||||
date: Date
|
||||
}
|
||||
|
||||
export interface HabitProgressJSON extends HabitProgressDataBase {
|
||||
export interface HabitProgressJSON extends HabitProgressBase {
|
||||
goalProgress: GoalProgressBase
|
||||
date: string
|
||||
}
|
||||
|
@ -1,11 +1,16 @@
|
||||
import type { HabitHistory } from "./HabitHistory"
|
||||
import type { GoalFrequency } from "./Goal"
|
||||
import type { Habit } from "./Habit"
|
||||
import { HabitHistory } from "./HabitHistory"
|
||||
import type { HabitProgress } from "./HabitProgress"
|
||||
|
||||
export interface HabitsTrackerData {
|
||||
habitsHistory: HabitHistory[]
|
||||
habitsHistory: {
|
||||
[key in GoalFrequency]: HabitHistory[]
|
||||
}
|
||||
}
|
||||
|
||||
export class HabitsTracker implements HabitsTrackerData {
|
||||
public habitsHistory: HabitHistory[]
|
||||
public habitsHistory: HabitsTrackerData["habitsHistory"]
|
||||
|
||||
public constructor(options: HabitsTrackerData) {
|
||||
const { habitsHistory } = options
|
||||
@ -13,6 +18,107 @@ export class HabitsTracker implements HabitsTrackerData {
|
||||
}
|
||||
|
||||
public static default(): HabitsTracker {
|
||||
return new HabitsTracker({ habitsHistory: [] })
|
||||
return new HabitsTracker({
|
||||
habitsHistory: {
|
||||
daily: [],
|
||||
weekly: [],
|
||||
monthly: [],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
public addHabit(habit: Habit): void {
|
||||
this.habitsHistory[habit.goal.frequency].push(
|
||||
new HabitHistory({
|
||||
habit,
|
||||
progressHistory: [],
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
public editHabit(habit: Habit): void {
|
||||
const habitHistory = this.getHabitHistoryById(habit.id)
|
||||
if (habitHistory == null) {
|
||||
return
|
||||
}
|
||||
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,
|
||||
...this.habitsHistory.weekly,
|
||||
...this.habitsHistory.monthly,
|
||||
]
|
||||
}
|
||||
|
||||
public getHabitHistoryById(id: Habit["id"]): HabitHistory | undefined {
|
||||
return this.getAllHabitsHistory().find((habitHistory) => {
|
||||
return habitHistory.habit.id === id
|
||||
})
|
||||
}
|
||||
|
||||
public getHabitsHistoriesByDate({
|
||||
selectedDate,
|
||||
frequency,
|
||||
}: {
|
||||
selectedDate: Date
|
||||
frequency: GoalFrequency
|
||||
}): HabitHistory[] {
|
||||
return this.habitsHistory[frequency].filter((habitItem) => {
|
||||
const startDate = new Date(habitItem.habit.startDate)
|
||||
startDate.setHours(0, 0, 0, 0)
|
||||
|
||||
return (
|
||||
startDate <= selectedDate &&
|
||||
(habitItem.habit.endDate == null ||
|
||||
(habitItem.habit.endDate != null &&
|
||||
habitItem.habit.endDate >= selectedDate))
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
public getHabitsStatisticsByDateAndFrequency({
|
||||
selectedDate,
|
||||
frequency,
|
||||
}: {
|
||||
selectedDate: Date
|
||||
frequency: GoalFrequency
|
||||
}): {
|
||||
totalGoalsSuccess: number
|
||||
totalGoals: number
|
||||
} {
|
||||
const habitsHistory = this.getHabitsHistoriesByDate({
|
||||
selectedDate,
|
||||
frequency,
|
||||
})
|
||||
let totalGoalsSuccess = 0
|
||||
const totalGoals = habitsHistory.length
|
||||
for (const habitHistory of habitsHistory) {
|
||||
const goalProgress = habitHistory.getGoalProgressByDate(selectedDate)
|
||||
if (goalProgress.isCompleted()) {
|
||||
totalGoalsSuccess++
|
||||
}
|
||||
}
|
||||
return { totalGoalsSuccess, totalGoals }
|
||||
}
|
||||
}
|
||||
|
106
domain/entities/__tests__/HabitsTracker.test.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import { HABIT_MOCK } from "@/tests/mocks/domain/Habit"
|
||||
import { GOAL_FREQUENCIES } from "../Goal"
|
||||
import { HabitsTracker } from "../HabitsTracker"
|
||||
import { HabitHistory } from "../HabitHistory"
|
||||
import { HABIT_PROGRESS_MOCK } from "@/tests/mocks/domain/HabitProgress"
|
||||
|
||||
describe("domain/entities/HabitsTracker", () => {
|
||||
describe("HabitsTracker.default", () => {
|
||||
for (const frequency of GOAL_FREQUENCIES) {
|
||||
it(`should return empty habitsHistory for ${frequency}`, () => {
|
||||
const habitsTracker = HabitsTracker.default()
|
||||
expect(habitsTracker.habitsHistory[frequency]).toEqual([])
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe("getAllHabitsHistory", () => {
|
||||
it("should return all habits history", () => {
|
||||
const habitsTracker = HabitsTracker.default()
|
||||
const habit = HABIT_MOCK.examplesByNames.Walk
|
||||
habitsTracker.addHabit(habit)
|
||||
expect(habitsTracker.getAllHabitsHistory()).toEqual([
|
||||
new HabitHistory({
|
||||
habit,
|
||||
progressHistory: [],
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it("should return empty array when no habits are added", () => {
|
||||
const habitsTracker = HabitsTracker.default()
|
||||
expect(habitsTracker.getAllHabitsHistory()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("getHabitHistoryById", () => {
|
||||
it("should return habit history by id", () => {
|
||||
const habitsTracker = HabitsTracker.default()
|
||||
const habit = HABIT_MOCK.examplesByNames.Walk
|
||||
habitsTracker.addHabit(habit)
|
||||
expect(habitsTracker.getHabitHistoryById(habit.id)).toEqual(
|
||||
new HabitHistory({
|
||||
habit,
|
||||
progressHistory: [],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should return undefined when habit is not found", () => {
|
||||
const habitsTracker = HabitsTracker.default()
|
||||
expect(habitsTracker.getHabitHistoryById("invalid-id")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("addHabit", () => {
|
||||
it("should add habit to habitsHistory", () => {
|
||||
const habitsTracker = HabitsTracker.default()
|
||||
const habit = HABIT_MOCK.examplesByNames.Walk
|
||||
habitsTracker.addHabit(habit)
|
||||
expect(habitsTracker.habitsHistory[habit.goal.frequency]).toEqual([
|
||||
new HabitHistory({
|
||||
habit,
|
||||
progressHistory: [],
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("editHabit", () => {
|
||||
it("should edit habit in habitsHistory", () => {
|
||||
const habitsTracker = HabitsTracker.default()
|
||||
const habit = HABIT_MOCK.examplesByNames.Walk
|
||||
habitsTracker.addHabit(habit)
|
||||
habit.name = "Run"
|
||||
habitsTracker.editHabit(habit)
|
||||
expect(habitsTracker.habitsHistory[habit.goal.frequency]).toEqual([
|
||||
new HabitHistory({
|
||||
habit,
|
||||
progressHistory: [],
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it("should not edit habit in habitsHistory when habit is not found", () => {
|
||||
const habitsTracker = HabitsTracker.default()
|
||||
const habit = HABIT_MOCK.examplesByNames.Walk
|
||||
habitsTracker.editHabit(habit)
|
||||
expect(habitsTracker.habitsHistory[habit.goal.frequency]).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("updateHabitProgress", () => {
|
||||
it("should update habit progress in habitsHistory (add new habit progress if not yet added)", () => {
|
||||
const habitsTracker = HabitsTracker.default()
|
||||
const habit = HABIT_MOCK.examplesByNames["Clean the house"]
|
||||
habitsTracker.addHabit(habit)
|
||||
habitsTracker.updateHabitProgress(HABIT_PROGRESS_MOCK.exampleByIds[1])
|
||||
expect(habitsTracker.habitsHistory[habit.goal.frequency]).toEqual([
|
||||
new HabitHistory({
|
||||
habit,
|
||||
progressHistory: [HABIT_PROGRESS_MOCK.exampleByIds[1]],
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
9
domain/repositories/HabitCreate.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import type { Habit, HabitCreateData } from "../entities/Habit"
|
||||
|
||||
export interface HabitCreateOptions {
|
||||
habitCreateData: HabitCreateData
|
||||
}
|
||||
|
||||
export interface HabitCreateRepository {
|
||||
execute: (options: HabitCreateOptions) => Promise<Habit>
|
||||
}
|
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>
|
||||
}
|
12
domain/repositories/HabitProgressCreate.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import type {
|
||||
HabitProgress,
|
||||
HabitProgressData,
|
||||
} from "../entities/HabitProgress"
|
||||
|
||||
export interface HabitProgressCreateOptions {
|
||||
habitProgressData: Omit<HabitProgressData, "id">
|
||||
}
|
||||
|
||||
export interface HabitProgressCreateRepository {
|
||||
execute: (options: HabitProgressCreateOptions) => Promise<HabitProgress>
|
||||
}
|
12
domain/repositories/HabitProgressUpdate.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import type {
|
||||
HabitProgress,
|
||||
HabitProgressData,
|
||||
} from "../entities/HabitProgress"
|
||||
|
||||
export interface HabitProgressUpdateOptions {
|
||||
habitProgressData: Omit<HabitProgressData, "habitId">
|
||||
}
|
||||
|
||||
export interface HabitProgressUpdateRepository {
|
||||
execute: (options: HabitProgressUpdateOptions) => Promise<HabitProgress>
|
||||
}
|
@ -1,8 +1,5 @@
|
||||
import {
|
||||
UserRegisterSchema,
|
||||
type User,
|
||||
UserLoginSchema,
|
||||
} from "../entities/User"
|
||||
import type { User } from "../entities/User"
|
||||
import { UserLoginSchema, UserRegisterSchema } from "../entities/User"
|
||||
import type { AuthenticationRepository } from "../repositories/Authentication"
|
||||
|
||||
export interface AuthenticationUseCaseDependencyOptions {
|
||||
@ -42,16 +39,16 @@ export class AuthenticationUseCase
|
||||
return await this.authenticationRepository.login(userData)
|
||||
}
|
||||
|
||||
public async logout(): Promise<void> {
|
||||
public logout: AuthenticationRepository["logout"] = async () => {
|
||||
return await this.authenticationRepository.logout()
|
||||
}
|
||||
|
||||
public getUser: AuthenticationRepository["getUser"] = async (...args) => {
|
||||
return await this.authenticationRepository.getUser(...args)
|
||||
public getUser: AuthenticationRepository["getUser"] = async () => {
|
||||
return await this.authenticationRepository.getUser()
|
||||
}
|
||||
|
||||
public onUserStateChange: AuthenticationRepository["onUserStateChange"] =
|
||||
async (...args) => {
|
||||
return this.authenticationRepository.onUserStateChange(...args)
|
||||
async (callback) => {
|
||||
return this.authenticationRepository.onUserStateChange(callback)
|
||||
}
|
||||
}
|
||||
|
23
domain/use-cases/HabitCreate.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import type { Habit } from "../entities/Habit"
|
||||
import { HabitCreateSchema } from "../entities/Habit"
|
||||
import type { HabitCreateRepository } from "../repositories/HabitCreate"
|
||||
|
||||
export interface HabitCreateUseCaseDependencyOptions {
|
||||
habitCreateRepository: HabitCreateRepository
|
||||
}
|
||||
|
||||
export class HabitCreateUseCase implements HabitCreateUseCaseDependencyOptions {
|
||||
public habitCreateRepository: HabitCreateRepository
|
||||
|
||||
public constructor(options: HabitCreateUseCaseDependencyOptions) {
|
||||
this.habitCreateRepository = options.habitCreateRepository
|
||||
}
|
||||
|
||||
public async execute(data: unknown): Promise<Habit> {
|
||||
const habitCreateData = await HabitCreateSchema.parseAsync(data)
|
||||
const habit = await this.habitCreateRepository.execute({
|
||||
habitCreateData,
|
||||
})
|
||||
return habit
|
||||
}
|
||||
}
|
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
|
||||
}
|
||||
}
|
66
domain/use-cases/HabitGoalProgressUpdate.ts
Normal file
@ -0,0 +1,66 @@
|
||||
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 HabitGoalProgressUpdateUseCaseOptions {
|
||||
date: Date
|
||||
goalProgress: GoalProgress
|
||||
habitHistory: HabitHistory
|
||||
}
|
||||
|
||||
export interface HabitGoalProgressUpdateUseCaseDependencyOptions {
|
||||
habitProgressCreateRepository: HabitProgressCreateRepository
|
||||
habitProgressUpdateRepository: HabitProgressUpdateRepository
|
||||
}
|
||||
|
||||
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(
|
||||
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (goalProgress.isNumeric()) {
|
||||
return await this.habitProgressCreateRepository.execute({
|
||||
habitProgressData: {
|
||||
date,
|
||||
goalProgress,
|
||||
habitId: habitHistory.habit.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
throw new Error("Not implemented")
|
||||
}
|
||||
}
|
24
domain/use-cases/HabitStop.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import type { Habit } from "../entities/Habit"
|
||||
import type { HabitEditRepository } from "../repositories/HabitEdit"
|
||||
|
||||
export interface HabitStopUseCaseDependencyOptions {
|
||||
habitEditRepository: HabitEditRepository
|
||||
}
|
||||
|
||||
export class HabitStopUseCase implements HabitStopUseCaseDependencyOptions {
|
||||
public habitEditRepository: HabitEditRepository
|
||||
|
||||
public constructor(options: HabitStopUseCaseDependencyOptions) {
|
||||
this.habitEditRepository = options.habitEditRepository
|
||||
}
|
||||
|
||||
public async execute(habitToStop: Habit): Promise<Habit> {
|
||||
const habit = await this.habitEditRepository.execute({
|
||||
habitEditData: {
|
||||
...habitToStop,
|
||||
endDate: new Date(),
|
||||
},
|
||||
})
|
||||
return habit
|
||||
}
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
import { HabitHistory } from "../entities/HabitHistory"
|
||||
import type { HabitsTrackerData } from "../entities/HabitsTracker"
|
||||
import { HabitsTracker } from "../entities/HabitsTracker"
|
||||
import type { User } from "../entities/User"
|
||||
import type { GetHabitProgressHistoryRepository } from "../repositories/GetHabitProgressHistory"
|
||||
@ -38,14 +39,27 @@ export class RetrieveHabitsTrackerUseCase
|
||||
})
|
||||
return new HabitHistory({
|
||||
habit,
|
||||
progressHistory: progressHistory.sort((a, b) => {
|
||||
return a.date.getTime() - b.date.getTime()
|
||||
}),
|
||||
progressHistory,
|
||||
})
|
||||
}),
|
||||
)
|
||||
const habitsHistory = habitProgressHistories.reduce<
|
||||
HabitsTrackerData["habitsHistory"]
|
||||
>(
|
||||
(accumulator, habitHistory) => {
|
||||
const { habit } = habitHistory
|
||||
const frequency = habit.goal.frequency
|
||||
accumulator[frequency].push(habitHistory)
|
||||
return accumulator
|
||||
},
|
||||
{
|
||||
daily: [],
|
||||
weekly: [],
|
||||
monthly: [],
|
||||
},
|
||||
)
|
||||
const habitsTracker = new HabitsTracker({
|
||||
habitsHistory: habitProgressHistories,
|
||||
habitsHistory,
|
||||
})
|
||||
return habitsTracker
|
||||
}
|
||||
|
15
eas.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"staging": {
|
||||
"distribution": "internal",
|
||||
"android": {
|
||||
"buildType": "apk"
|
||||
}
|
||||
},
|
||||
"production": {}
|
||||
}
|
||||
}
|
@ -1,11 +1,19 @@
|
||||
import { AuthenticationUseCase } from "@/domain/use-cases/Authentication"
|
||||
import { HabitCreateUseCase } from "@/domain/use-cases/HabitCreate"
|
||||
import { HabitEditUseCase } from "@/domain/use-cases/HabitEdit"
|
||||
import { HabitGoalProgressUpdateUseCase } from "@/domain/use-cases/HabitGoalProgressUpdate"
|
||||
import { HabitStopUseCase } from "@/domain/use-cases/HabitStop"
|
||||
import { AuthenticationPresenter } from "@/presentation/presenters/Authentication"
|
||||
import { RetrieveHabitsTrackerUseCase } from "../domain/use-cases/RetrieveHabitsTracker"
|
||||
import { HabitsTrackerPresenter } from "../presentation/presenters/HabitsTracker"
|
||||
import { AuthenticationSupabaseRepository } from "./supabase/repositories/Authentication"
|
||||
import { GetHabitProgressHistorySupabaseRepository } from "./supabase/repositories/GetHabitProgressHistory"
|
||||
import { GetHabitsByUserIdSupabaseRepository } from "./supabase/repositories/GetHabitsByUserId"
|
||||
import { HabitCreateSupabaseRepository } from "./supabase/repositories/HabitCreate"
|
||||
import { HabitEditSupabaseRepository } from "./supabase/repositories/HabitEdit"
|
||||
import { HabitProgressCreateSupabaseRepository } from "./supabase/repositories/HabitProgressCreate"
|
||||
import { HabitProgressUpdateSupabaseRepository } from "./supabase/repositories/HabitProgressUpdate"
|
||||
import { supabaseClient } from "./supabase/supabase"
|
||||
import { AuthenticationPresenter } from "@/presentation/presenters/Authentication"
|
||||
|
||||
/**
|
||||
* Repositories
|
||||
@ -20,6 +28,22 @@ const getHabitProgressesRepository =
|
||||
const getHabitsByUserIdRepository = new GetHabitsByUserIdSupabaseRepository({
|
||||
supabaseClient,
|
||||
})
|
||||
const habitCreateRepository = new HabitCreateSupabaseRepository({
|
||||
supabaseClient,
|
||||
})
|
||||
const habitEditRepository = new HabitEditSupabaseRepository({
|
||||
supabaseClient,
|
||||
})
|
||||
const habitProgressCreateRepository = new HabitProgressCreateSupabaseRepository(
|
||||
{
|
||||
supabaseClient,
|
||||
},
|
||||
)
|
||||
const habitProgressUpdateRepository = new HabitProgressUpdateSupabaseRepository(
|
||||
{
|
||||
supabaseClient,
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* Use Cases
|
||||
@ -27,10 +51,23 @@ const getHabitsByUserIdRepository = new GetHabitsByUserIdSupabaseRepository({
|
||||
const authenticationUseCase = new AuthenticationUseCase({
|
||||
authenticationRepository,
|
||||
})
|
||||
const habitCreateUseCase = new HabitCreateUseCase({
|
||||
habitCreateRepository,
|
||||
})
|
||||
const retrieveHabitsTrackerUseCase = new RetrieveHabitsTrackerUseCase({
|
||||
getHabitProgressHistoryRepository: getHabitProgressesRepository,
|
||||
getHabitsByUserIdRepository,
|
||||
})
|
||||
const habitEditUseCase = new HabitEditUseCase({
|
||||
habitEditRepository,
|
||||
})
|
||||
const habitGoalProgressUpdateUseCase = new HabitGoalProgressUpdateUseCase({
|
||||
habitProgressCreateRepository,
|
||||
habitProgressUpdateRepository,
|
||||
})
|
||||
const habitStopUseCase = new HabitStopUseCase({
|
||||
habitEditRepository,
|
||||
})
|
||||
|
||||
/**
|
||||
* Presenters
|
||||
@ -40,4 +77,8 @@ export const authenticationPresenter = new AuthenticationPresenter({
|
||||
})
|
||||
export const habitsTrackerPresenter = new HabitsTrackerPresenter({
|
||||
retrieveHabitsTrackerUseCase,
|
||||
habitCreateUseCase,
|
||||
habitEditUseCase,
|
||||
habitStopUseCase,
|
||||
habitGoalProgressUpdateUseCase,
|
||||
})
|
||||
|
79
infrastructure/supabase/data-transfer-objects/HabitDTO.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import type { Goal } from "@/domain/entities/Goal"
|
||||
import { GoalBoolean, GoalNumeric } from "@/domain/entities/Goal"
|
||||
import type { HabitCreateData, HabitEditData } from "@/domain/entities/Habit"
|
||||
import { Habit } from "@/domain/entities/Habit"
|
||||
import type {
|
||||
SupabaseHabit,
|
||||
SupabaseHabitInsert,
|
||||
SupabaseHabitUpdate,
|
||||
} from "../supabase"
|
||||
|
||||
export const habitSupabaseDTO = {
|
||||
fromSupabaseToDomain: (supabaseHabit: SupabaseHabit): Habit => {
|
||||
let goal: Goal
|
||||
if (
|
||||
supabaseHabit.goal_target != null &&
|
||||
supabaseHabit.goal_target_unit != null
|
||||
) {
|
||||
goal = new GoalNumeric({
|
||||
frequency: supabaseHabit.goal_frequency,
|
||||
target: {
|
||||
value: supabaseHabit.goal_target,
|
||||
unit: supabaseHabit.goal_target_unit,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
goal = new GoalBoolean({
|
||||
frequency: supabaseHabit.goal_frequency,
|
||||
})
|
||||
}
|
||||
const habit = new Habit({
|
||||
id: supabaseHabit.id.toString(),
|
||||
name: supabaseHabit.name,
|
||||
color: supabaseHabit.color,
|
||||
icon: supabaseHabit.icon,
|
||||
userId: supabaseHabit.user_id.toString(),
|
||||
startDate: new Date(supabaseHabit.start_date),
|
||||
endDate:
|
||||
supabaseHabit.end_date != null
|
||||
? new Date(supabaseHabit.end_date)
|
||||
: undefined,
|
||||
goal,
|
||||
})
|
||||
return habit
|
||||
},
|
||||
fromDomainCreateDataToSupabaseInsert: (
|
||||
habitCreateData: HabitCreateData,
|
||||
): SupabaseHabitInsert => {
|
||||
return {
|
||||
name: habitCreateData.name,
|
||||
color: habitCreateData.color,
|
||||
icon: habitCreateData.icon,
|
||||
goal_frequency: habitCreateData.goal.frequency,
|
||||
...(habitCreateData.goal.target.type === "numeric"
|
||||
? {
|
||||
goal_target: habitCreateData.goal.target.value,
|
||||
goal_target_unit: habitCreateData.goal.target.unit,
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
},
|
||||
fromDomainEditDataToSupabaseUpdate: (
|
||||
habitEditData: HabitEditData,
|
||||
): SupabaseHabitUpdate => {
|
||||
return {
|
||||
name: habitEditData.name,
|
||||
color: habitEditData.color,
|
||||
icon: habitEditData.icon,
|
||||
end_date: habitEditData?.endDate?.toISOString(),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const habitsSupabaseDTO = {
|
||||
fromSupabaseToDomain: (supabaseHabits: SupabaseHabit[]): Habit[] => {
|
||||
return supabaseHabits.map((supabaseHabit) => {
|
||||
return habitSupabaseDTO.fromSupabaseToDomain(supabaseHabit)
|
||||
})
|
||||
},
|
||||
}
|
@ -0,0 +1,78 @@
|
||||
import type { Goal, GoalProgress } from "@/domain/entities/Goal"
|
||||
import {
|
||||
GoalBooleanProgress,
|
||||
GoalNumericProgress,
|
||||
} from "@/domain/entities/Goal"
|
||||
import { HabitProgress } from "@/domain/entities/HabitProgress"
|
||||
import type { HabitProgressCreateOptions } from "@/domain/repositories/HabitProgressCreate"
|
||||
import type { HabitProgressUpdateOptions } from "@/domain/repositories/HabitProgressUpdate"
|
||||
import type {
|
||||
SupabaseHabitProgress,
|
||||
SupabaseHabitProgressInsert,
|
||||
SupabaseHabitProgressUpdate,
|
||||
} from "../supabase"
|
||||
|
||||
export const habitProgressSupabaseDTO = {
|
||||
fromSupabaseToDomain: (
|
||||
supabaseHabitProgress: SupabaseHabitProgress,
|
||||
goal: Goal,
|
||||
): HabitProgress => {
|
||||
let goalProgress: GoalProgress | null = null
|
||||
if (goal.isNumeric()) {
|
||||
goalProgress = new GoalNumericProgress({
|
||||
goal,
|
||||
progress: supabaseHabitProgress.goal_progress,
|
||||
})
|
||||
} else if (goal.isBoolean()) {
|
||||
goalProgress = new GoalBooleanProgress({
|
||||
goal,
|
||||
progress: supabaseHabitProgress.goal_progress === 1,
|
||||
})
|
||||
}
|
||||
const habitProgress = new HabitProgress({
|
||||
id: supabaseHabitProgress.id.toString(),
|
||||
habitId: supabaseHabitProgress.habit_id.toString(),
|
||||
goalProgress: goalProgress as GoalProgress,
|
||||
date: new Date(supabaseHabitProgress.date),
|
||||
})
|
||||
return habitProgress
|
||||
},
|
||||
fromDomainDataToSupabaseInsert: (
|
||||
habitProgressData: HabitProgressCreateOptions["habitProgressData"],
|
||||
): SupabaseHabitProgressInsert => {
|
||||
const { goalProgress, date, habitId } = habitProgressData
|
||||
let goalProgressValue = goalProgress.isCompleted() ? 1 : 0
|
||||
if (goalProgress.isNumeric()) {
|
||||
goalProgressValue = goalProgress.progress
|
||||
}
|
||||
return {
|
||||
habit_id: Number.parseInt(habitId, 10),
|
||||
date: date.toISOString(),
|
||||
goal_progress: goalProgressValue,
|
||||
}
|
||||
},
|
||||
fromDomainDataToSupabaseUpdate: (
|
||||
habitProgressData: HabitProgressUpdateOptions["habitProgressData"],
|
||||
): SupabaseHabitProgressUpdate => {
|
||||
const { goalProgress, date } = habitProgressData
|
||||
let goalProgressValue = goalProgress.isCompleted() ? 1 : 0
|
||||
if (goalProgress.isNumeric()) {
|
||||
goalProgressValue = goalProgress.progress
|
||||
}
|
||||
return {
|
||||
date: date.toISOString(),
|
||||
goal_progress: goalProgressValue,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const habitProgressHistorySupabaseDTO = {
|
||||
fromSupabaseToDomain: (
|
||||
supabaseHabitHistory: SupabaseHabitProgress[],
|
||||
goal: Goal,
|
||||
): HabitProgress[] => {
|
||||
return supabaseHabitHistory.map((item) => {
|
||||
return habitProgressSupabaseDTO.fromSupabaseToDomain(item, goal)
|
||||
})
|
||||
},
|
||||
}
|
@ -0,0 +1,100 @@
|
||||
import type { GoalCreateData } from "@/domain/entities/Goal"
|
||||
import { HABIT_MOCK } from "@/tests/mocks/domain/Habit"
|
||||
import { SUPABASE_HABIT_MOCK } from "@/tests/mocks/supabase/Habit"
|
||||
import { habitSupabaseDTO, habitsSupabaseDTO } from "../HabitDTO"
|
||||
|
||||
describe("infrastructure/supabase/data-transfer-objects/HabitDTO", () => {
|
||||
describe("habitSupabaseDTO.fromSupabaseToDomain", () => {
|
||||
for (const example of SUPABASE_HABIT_MOCK.examples) {
|
||||
it(`should return correct Habit entity - ${example.name}`, () => {
|
||||
expect(habitSupabaseDTO.fromSupabaseToDomain(example)).toEqual(
|
||||
HABIT_MOCK.examplesByNames[
|
||||
example.name as keyof typeof HABIT_MOCK.examplesByNames
|
||||
],
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe("habitSupabaseDTO.fromDomainCreateDataToSupabaseInsert", () => {
|
||||
for (const example of HABIT_MOCK.examples) {
|
||||
it(`should return correct SupabaseHabitInsert entity - ${example.name}`, () => {
|
||||
let goalData = {} as GoalCreateData
|
||||
if (example.goal.isBoolean()) {
|
||||
goalData = {
|
||||
frequency: example.goal.frequency,
|
||||
target: { type: "boolean" },
|
||||
}
|
||||
}
|
||||
if (example.goal.isNumeric()) {
|
||||
goalData = {
|
||||
frequency: example.goal.frequency,
|
||||
target: {
|
||||
type: "numeric",
|
||||
value: example.goal.target.value,
|
||||
unit: example.goal.target.unit,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const supabaseData =
|
||||
SUPABASE_HABIT_MOCK.examplesByNames[
|
||||
example.name as keyof typeof SUPABASE_HABIT_MOCK.examplesByNames
|
||||
]
|
||||
expect(
|
||||
habitSupabaseDTO.fromDomainCreateDataToSupabaseInsert({
|
||||
userId: example.userId,
|
||||
name: example.name,
|
||||
color: example.color,
|
||||
icon: example.icon,
|
||||
goal: goalData,
|
||||
}),
|
||||
).toEqual({
|
||||
name: supabaseData.name,
|
||||
color: supabaseData.color,
|
||||
icon: supabaseData.icon,
|
||||
goal_frequency: supabaseData.goal_frequency,
|
||||
...(supabaseData.goal_target != null &&
|
||||
supabaseData.goal_target_unit != null
|
||||
? {
|
||||
goal_target: supabaseData.goal_target,
|
||||
goal_target_unit: supabaseData.goal_target_unit,
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe("habitSupabaseDTO.fromDomainEditDataToSupabaseUpdate", () => {
|
||||
for (const example of HABIT_MOCK.examples) {
|
||||
it(`should return correct SupabaseHabitUpdate entity - ${example.name}`, () => {
|
||||
const supabaseData =
|
||||
SUPABASE_HABIT_MOCK.examplesByNames[
|
||||
example.name as keyof typeof SUPABASE_HABIT_MOCK.examplesByNames
|
||||
]
|
||||
expect(
|
||||
habitSupabaseDTO.fromDomainEditDataToSupabaseUpdate({
|
||||
name: example.name,
|
||||
color: example.color,
|
||||
icon: example.icon,
|
||||
id: example.id,
|
||||
userId: example.userId,
|
||||
}),
|
||||
).toEqual({
|
||||
name: supabaseData.name,
|
||||
color: supabaseData.color,
|
||||
icon: supabaseData.icon,
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe("habitsSupabaseDTO.fromSupabaseToDomain", () => {
|
||||
it("should return correct Habits entities", () => {
|
||||
expect(
|
||||
habitsSupabaseDTO.fromSupabaseToDomain(SUPABASE_HABIT_MOCK.examples),
|
||||
).toEqual(HABIT_MOCK.examples)
|
||||
})
|
||||
})
|
||||
})
|
@ -0,0 +1,22 @@
|
||||
import type { Habit } from "@/domain/entities/Habit"
|
||||
import { HABIT_MOCK } from "@/tests/mocks/domain/Habit"
|
||||
import { HABIT_PROGRESS_MOCK } from "@/tests/mocks/domain/HabitProgress"
|
||||
import { SUPABASE_HABIT_PROGRESS_MOCK } from "@/tests/mocks/supabase/HabitProgress"
|
||||
import { habitProgressSupabaseDTO } from "../HabitProgressDTO"
|
||||
|
||||
describe("infrastructure/supabase/data-transfer-objects/HabitProgressDTO", () => {
|
||||
describe("habitProgressSupabaseDTO.fromSupabaseToDomain", () => {
|
||||
for (const example of SUPABASE_HABIT_PROGRESS_MOCK.examples) {
|
||||
it(`should return correct HabitProgress entity - ${example.id}`, () => {
|
||||
const habit = HABIT_MOCK.examplesByIds[example.habit_id] as Habit
|
||||
expect(
|
||||
habitProgressSupabaseDTO.fromSupabaseToDomain(example, habit.goal),
|
||||
).toEqual(
|
||||
HABIT_PROGRESS_MOCK.exampleByIds[
|
||||
example.id as keyof typeof HABIT_PROGRESS_MOCK.exampleByIds
|
||||
],
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
@ -1,8 +1,8 @@
|
||||
import type { Session } from "@supabase/supabase-js"
|
||||
|
||||
import type { AuthenticationRepository } from "@/domain/repositories/Authentication"
|
||||
import { SupabaseRepository } from "./_SupabaseRepository"
|
||||
import { User } from "@/domain/entities/User"
|
||||
import type { AuthenticationRepository } from "@/domain/repositories/Authentication"
|
||||
import { SupabaseRepository } from "@/infrastructure/supabase/repositories/_SupabaseRepository"
|
||||
|
||||
export class AuthenticationSupabaseRepository
|
||||
extends SupabaseRepository
|
||||
|
@ -1,11 +1,6 @@
|
||||
import type { GetHabitProgressHistoryRepository } from "@/domain/repositories/GetHabitProgressHistory"
|
||||
import { SupabaseRepository } from "./_SupabaseRepository"
|
||||
import { HabitProgress } from "@/domain/entities/HabitProgress"
|
||||
import type { GoalProgress } from "@/domain/entities/Goal"
|
||||
import {
|
||||
GoalBooleanProgress,
|
||||
GoalNumericProgress,
|
||||
} from "@/domain/entities/Goal"
|
||||
import { SupabaseRepository } from "@/infrastructure/supabase/repositories/_SupabaseRepository"
|
||||
import { habitProgressHistorySupabaseDTO } from "../data-transfer-objects/HabitProgressDTO"
|
||||
|
||||
export class GetHabitProgressHistorySupabaseRepository
|
||||
extends SupabaseRepository
|
||||
@ -15,37 +10,15 @@ export class GetHabitProgressHistorySupabaseRepository
|
||||
options,
|
||||
) => {
|
||||
const { habit } = options
|
||||
const { data, error } = await this.supabaseClient
|
||||
const { data } = await this.supabaseClient
|
||||
.from("habits_progresses")
|
||||
.select("*")
|
||||
.eq("habit_id", habit.id)
|
||||
if (error != null) {
|
||||
throw new Error(error.message)
|
||||
}
|
||||
const habitProgressHistory = data.map((item) => {
|
||||
let goalProgress: GoalProgress | null = null
|
||||
if (habit.goal.isNumeric()) {
|
||||
goalProgress = new GoalNumericProgress({
|
||||
goal: habit.goal,
|
||||
progress: item.goal_progress,
|
||||
})
|
||||
} else if (habit.goal.isBoolean()) {
|
||||
goalProgress = new GoalBooleanProgress({
|
||||
goal: habit.goal,
|
||||
progress: item.goal_progress === 1,
|
||||
})
|
||||
}
|
||||
if (goalProgress == null) {
|
||||
throw new Error("Goal progress is null.")
|
||||
}
|
||||
const habitProgress = new HabitProgress({
|
||||
id: item.id.toString(),
|
||||
habitId: item.habit_id.toString(),
|
||||
goalProgress,
|
||||
date: new Date(item.date),
|
||||
})
|
||||
return habitProgress
|
||||
})
|
||||
return habitProgressHistory
|
||||
.throwOnError()
|
||||
const habitProgressHistory = data as NonNullable<typeof data>
|
||||
return habitProgressHistorySupabaseDTO.fromSupabaseToDomain(
|
||||
habitProgressHistory,
|
||||
habit.goal,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,6 @@
|
||||
import type { GetHabitsByUserIdRepository } from "@/domain/repositories/GetHabitsByUserId"
|
||||
import { SupabaseRepository } from "./_SupabaseRepository"
|
||||
import { Habit } from "@/domain/entities/Habit"
|
||||
import type { Goal } from "@/domain/entities/Goal"
|
||||
import { GoalBoolean, GoalNumeric } from "@/domain/entities/Goal"
|
||||
import { SupabaseRepository } from "@/infrastructure/supabase/repositories/_SupabaseRepository"
|
||||
import { habitsSupabaseDTO } from "../data-transfer-objects/HabitDTO"
|
||||
|
||||
export class GetHabitsByUserIdSupabaseRepository
|
||||
extends SupabaseRepository
|
||||
@ -10,39 +8,12 @@ export class GetHabitsByUserIdSupabaseRepository
|
||||
{
|
||||
public execute: GetHabitsByUserIdRepository["execute"] = async (options) => {
|
||||
const { userId } = options
|
||||
const { data, error } = await this.supabaseClient
|
||||
const { data } = await this.supabaseClient
|
||||
.from("habits")
|
||||
.select("*")
|
||||
.eq("user_id", userId)
|
||||
if (error != null) {
|
||||
throw new Error(error.message)
|
||||
}
|
||||
return data.map((item) => {
|
||||
let goal: Goal
|
||||
if (item.goal_target != null && item.goal_target_unit != null) {
|
||||
goal = new GoalNumeric({
|
||||
frequency: item.goal_frequency,
|
||||
target: {
|
||||
value: item.goal_target,
|
||||
unit: item.goal_target_unit,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
goal = new GoalBoolean({
|
||||
frequency: item.goal_frequency,
|
||||
})
|
||||
}
|
||||
const habit = new Habit({
|
||||
id: item.id.toString(),
|
||||
name: item.name,
|
||||
color: item.color,
|
||||
icon: item.icon,
|
||||
userId: item.user_id.toString(),
|
||||
startDate: new Date(item.start_date),
|
||||
endDate: item.end_date != null ? new Date(item.end_date) : undefined,
|
||||
goal,
|
||||
})
|
||||
return habit
|
||||
})
|
||||
.throwOnError()
|
||||
const habits = data as NonNullable<typeof data>
|
||||
return habitsSupabaseDTO.fromSupabaseToDomain(habits)
|
||||
}
|
||||
}
|
||||
|
22
infrastructure/supabase/repositories/HabitCreate.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import type { HabitCreateRepository } from "@/domain/repositories/HabitCreate"
|
||||
import { SupabaseRepository } from "@/infrastructure/supabase/repositories/_SupabaseRepository"
|
||||
import { habitSupabaseDTO } from "../data-transfer-objects/HabitDTO"
|
||||
|
||||
export class HabitCreateSupabaseRepository
|
||||
extends SupabaseRepository
|
||||
implements HabitCreateRepository
|
||||
{
|
||||
public execute: HabitCreateRepository["execute"] = async (options) => {
|
||||
const { habitCreateData } = options
|
||||
const { data } = await this.supabaseClient
|
||||
.from("habits")
|
||||
.insert(
|
||||
habitSupabaseDTO.fromDomainCreateDataToSupabaseInsert(habitCreateData),
|
||||
)
|
||||
.select("*")
|
||||
.single()
|
||||
.throwOnError()
|
||||
const insertedHabit = data as NonNullable<typeof data>
|
||||
return habitSupabaseDTO.fromSupabaseToDomain(insertedHabit)
|
||||
}
|
||||
}
|
23
infrastructure/supabase/repositories/HabitEdit.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import type { HabitEditRepository } from "@/domain/repositories/HabitEdit"
|
||||
import { SupabaseRepository } from "@/infrastructure/supabase/repositories/_SupabaseRepository"
|
||||
import { habitSupabaseDTO } from "../data-transfer-objects/HabitDTO"
|
||||
|
||||
export class HabitEditSupabaseRepository
|
||||
extends SupabaseRepository
|
||||
implements HabitEditRepository
|
||||
{
|
||||
public execute: HabitEditRepository["execute"] = async (options) => {
|
||||
const { habitEditData } = options
|
||||
const { data } = await this.supabaseClient
|
||||
.from("habits")
|
||||
.update(
|
||||
habitSupabaseDTO.fromDomainEditDataToSupabaseUpdate(habitEditData),
|
||||
)
|
||||
.eq("id", habitEditData.id)
|
||||
.select("*")
|
||||
.single()
|
||||
.throwOnError()
|
||||
const updatedHabit = data as NonNullable<typeof data>
|
||||
return habitSupabaseDTO.fromSupabaseToDomain(updatedHabit)
|
||||
}
|
||||
}
|
29
infrastructure/supabase/repositories/HabitProgressCreate.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import type { HabitProgressCreateRepository } from "@/domain/repositories/HabitProgressCreate"
|
||||
import { SupabaseRepository } from "@/infrastructure/supabase/repositories/_SupabaseRepository"
|
||||
import { habitProgressSupabaseDTO } from "../data-transfer-objects/HabitProgressDTO"
|
||||
|
||||
export class HabitProgressCreateSupabaseRepository
|
||||
extends SupabaseRepository
|
||||
implements HabitProgressCreateRepository
|
||||
{
|
||||
public execute: HabitProgressCreateRepository["execute"] = async (
|
||||
options,
|
||||
) => {
|
||||
const { habitProgressData } = options
|
||||
const { data } = await this.supabaseClient
|
||||
.from("habits_progresses")
|
||||
.insert(
|
||||
habitProgressSupabaseDTO.fromDomainDataToSupabaseInsert(
|
||||
habitProgressData,
|
||||
),
|
||||
)
|
||||
.select("*")
|
||||
.single()
|
||||
.throwOnError()
|
||||
const insertedProgress = data as NonNullable<typeof data>
|
||||
return habitProgressSupabaseDTO.fromSupabaseToDomain(
|
||||
insertedProgress,
|
||||
habitProgressData.goalProgress.goal,
|
||||
)
|
||||
}
|
||||
}
|
30
infrastructure/supabase/repositories/HabitProgressUpdate.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import type { HabitProgressUpdateRepository } from "@/domain/repositories/HabitProgressUpdate"
|
||||
import { SupabaseRepository } from "@/infrastructure/supabase/repositories/_SupabaseRepository"
|
||||
import { habitProgressSupabaseDTO } from "../data-transfer-objects/HabitProgressDTO"
|
||||
|
||||
export class HabitProgressUpdateSupabaseRepository
|
||||
extends SupabaseRepository
|
||||
implements HabitProgressUpdateRepository
|
||||
{
|
||||
public execute: HabitProgressUpdateRepository["execute"] = async (
|
||||
options,
|
||||
) => {
|
||||
const { habitProgressData } = options
|
||||
const { data } = await this.supabaseClient
|
||||
.from("habits_progresses")
|
||||
.update(
|
||||
habitProgressSupabaseDTO.fromDomainDataToSupabaseUpdate(
|
||||
habitProgressData,
|
||||
),
|
||||
)
|
||||
.eq("id", habitProgressData.id)
|
||||
.select("*")
|
||||
.single()
|
||||
.throwOnError()
|
||||
const insertedProgress = data as NonNullable<typeof data>
|
||||
return habitProgressSupabaseDTO.fromSupabaseToDomain(
|
||||
insertedProgress,
|
||||
habitProgressData.goalProgress.goal,
|
||||
)
|
||||
}
|
||||
}
|
@ -117,7 +117,7 @@ VALUES
|
||||
'Wake up at 07h00',
|
||||
'#006CFF',
|
||||
'bed',
|
||||
timezone('utc' :: text, NOW()),
|
||||
timezone('utc' :: text, NOW() - INTERVAL '3 days'),
|
||||
NULL,
|
||||
'daily',
|
||||
NULL,
|
||||
@ -144,7 +144,7 @@ VALUES
|
||||
'Learn English',
|
||||
'#EB4034',
|
||||
'language',
|
||||
timezone('utc' :: text, NOW()),
|
||||
timezone('utc' :: text, NOW() - INTERVAL '3 days'),
|
||||
NULL,
|
||||
'daily',
|
||||
30,
|
||||
@ -171,7 +171,7 @@ VALUES
|
||||
'Walk',
|
||||
'#228B22',
|
||||
'person-walking',
|
||||
timezone('utc' :: text, NOW()),
|
||||
timezone('utc' :: text, NOW() - INTERVAL '3 days'),
|
||||
NULL,
|
||||
'daily',
|
||||
5000,
|
||||
@ -198,7 +198,7 @@ VALUES
|
||||
'Clean the house',
|
||||
'#808080',
|
||||
'broom',
|
||||
timezone('utc' :: text, NOW()),
|
||||
timezone('utc' :: text, NOW() - INTERVAL '3 days'),
|
||||
NULL,
|
||||
'weekly',
|
||||
NULL,
|
||||
@ -225,7 +225,7 @@ VALUES
|
||||
'Solve Programming Challenges',
|
||||
'#DE3163',
|
||||
'code',
|
||||
timezone('utc' :: text, NOW()),
|
||||
timezone('utc' :: text, NOW() - INTERVAL '3 days'),
|
||||
NULL,
|
||||
'monthly',
|
||||
5,
|
||||
@ -262,3 +262,6 @@ VALUES
|
||||
timezone('utc' :: text, NOW()),
|
||||
4733
|
||||
);
|
||||
|
||||
SELECT setval('habits_id_seq', (SELECT coalesce(MAX(id) + 1, 1) FROM habits), false);
|
||||
SELECT setval('habits_progresses_id_seq', (SELECT coalesce(MAX(id) + 1, 1) FROM habits_progresses), false);
|
||||
|
@ -266,7 +266,7 @@ export interface Database {
|
||||
Args: {
|
||||
name: string
|
||||
}
|
||||
Returns: unknown
|
||||
Returns: string[]
|
||||
}
|
||||
get_size_by_bucket: {
|
||||
Args: Record<PropertyKey, never>
|
||||
|
@ -1,12 +1,34 @@
|
||||
import { createClient } from "@supabase/supabase-js"
|
||||
import {
|
||||
createClient,
|
||||
type User as SupabaseUserType,
|
||||
} from "@supabase/supabase-js"
|
||||
import { AppState, Platform } from "react-native"
|
||||
import "react-native-url-polyfill/auto"
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage"
|
||||
|
||||
import type { Database } from "./supabase-types"
|
||||
|
||||
const SUPABASE_URL = process.env["EXPO_PUBLIC_SUPABASE_URL"] ?? ""
|
||||
const SUPABASE_ANON_KEY = process.env["EXPO_PUBLIC_SUPABASE_ANON_KEY"] ?? ""
|
||||
export type SupabaseUser = SupabaseUserType
|
||||
|
||||
export type SupabaseHabit = Database["public"]["Tables"]["habits"]["Row"]
|
||||
export type SupabaseHabitInsert =
|
||||
Database["public"]["Tables"]["habits"]["Insert"]
|
||||
export type SupabaseHabitUpdate =
|
||||
Database["public"]["Tables"]["habits"]["Update"]
|
||||
|
||||
export type SupabaseHabitProgress =
|
||||
Database["public"]["Tables"]["habits_progresses"]["Row"]
|
||||
export type SupabaseHabitProgressInsert =
|
||||
Database["public"]["Tables"]["habits_progresses"]["Insert"]
|
||||
export type SupabaseHabitProgressUpdate =
|
||||
Database["public"]["Tables"]["habits_progresses"]["Update"]
|
||||
|
||||
const SUPABASE_URL =
|
||||
process.env["EXPO_PUBLIC_SUPABASE_URL"] ??
|
||||
"https://wjtwtzxreersqfvfgxrz.supabase.co"
|
||||
const SUPABASE_ANON_KEY =
|
||||
process.env["EXPO_PUBLIC_SUPABASE_ANON_KEY"] ??
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6IndqdHd0enhyZWVyc3FmdmZneHJ6Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3MTExNDcyNzAsImV4cCI6MjAyNjcyMzI3MH0.AglONhsMvmcCRkqwrZsB4Ws9u3o1FAbLlpKJmqeUv8I"
|
||||
|
||||
export const supabaseClient = createClient<Database>(
|
||||
SUPABASE_URL,
|
||||
|
@ -1,7 +1,7 @@
|
||||
{
|
||||
"preset": "jest-expo",
|
||||
"roots": ["./"],
|
||||
"setupFilesAfterEnv": ["@testing-library/react-native/extend-expect"],
|
||||
"setupFilesAfterEnv": ["<rootDir>/tests/setup.ts"],
|
||||
"fakeTimers": {
|
||||
"enableGlobally": true
|
||||
},
|
||||
@ -10,7 +10,13 @@
|
||||
"coverageReporters": ["text", "text-summary", "cobertura"],
|
||||
"collectCoverageFrom": [
|
||||
"<rootDir>/**/*.{ts,tsx}",
|
||||
"!<rootDir>/presentation/react/components/ExternalLink.tsx",
|
||||
"!<rootDir>/tests/**/*",
|
||||
"!<rootDir>/domain/repositories/**/*",
|
||||
"!<rootDir>/infrastructure/instances.ts",
|
||||
"!<rootDir>/infrastructure/supabase/supabase-types.ts",
|
||||
"!<rootDir>/infrastructure/supabase/supabase.ts",
|
||||
"!<rootDir>/presentation/react-native/ui/ExternalLink.tsx",
|
||||
"!<rootDir>/presentation/react/contexts/**/*",
|
||||
"!<rootDir>/.expo",
|
||||
"!<rootDir>/app/+html.tsx",
|
||||
"!<rootDir>/app/**/_layout.tsx",
|
||||
|
7779
package-lock.json
generated
96
package.json
@ -2,81 +2,91 @@
|
||||
"name": "p61-project",
|
||||
"private": true,
|
||||
"main": "expo-router/entry",
|
||||
"version": "1.0.0-staging.1",
|
||||
"version": "1.1.1",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web",
|
||||
"expo:typed-routes": "expo customize tsconfig.json",
|
||||
"build-staging:android": "eas build --platform=android --profile=staging",
|
||||
"lint:commit": "commitlint",
|
||||
"lint:prettier": "prettier . --check",
|
||||
"lint:eslint": "eslint . --max-warnings 0 --report-unused-disable-directives --ignore-path .gitignore",
|
||||
"lint:typescript": "tsc --noEmit",
|
||||
"lint:staged": "lint-staged",
|
||||
"test": "jest --reporters=default --reporters=jest-junit",
|
||||
"supabase": "supabase --workdir \"./infrastructure\"",
|
||||
"supabase-cli": "supabase --workdir \"./infrastructure\"",
|
||||
"postinstall": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "14.0.0",
|
||||
"@hookform/resolvers": "3.3.4",
|
||||
"@react-native-async-storage/async-storage": "1.21.0",
|
||||
"@react-navigation/native": "6.1.16",
|
||||
"@supabase/supabase-js": "2.39.8",
|
||||
"expo": "50.0.14",
|
||||
"expo-font": "11.10.3",
|
||||
"expo-linking": "6.2.2",
|
||||
"expo-router": "3.4.8",
|
||||
"expo-splash-screen": "0.26.4",
|
||||
"expo-status-bar": "1.11.1",
|
||||
"expo-system-ui": "2.9.3",
|
||||
"expo-web-browser": "12.8.2",
|
||||
"immer": "10.0.4",
|
||||
"@expo/vector-icons": "14.0.2",
|
||||
"@fortawesome/fontawesome-svg-core": "6.5.2",
|
||||
"@fortawesome/free-solid-svg-icons": "6.5.2",
|
||||
"@fortawesome/react-native-fontawesome": "0.3.1",
|
||||
"@hookform/resolvers": "3.4.2",
|
||||
"@react-native-async-storage/async-storage": "1.23.1",
|
||||
"@react-navigation/native": "6.1.17",
|
||||
"@supabase/supabase-js": "2.43.2",
|
||||
"expo": "51.0.8",
|
||||
"expo-linking": "6.3.1",
|
||||
"expo-router": "3.5.14",
|
||||
"expo-splash-screen": "0.27.4",
|
||||
"expo-status-bar": "1.12.1",
|
||||
"expo-system-ui": "3.0.4",
|
||||
"expo-web-browser": "13.0.3",
|
||||
"fuse.js": "7.0.0",
|
||||
"immer": "10.1.1",
|
||||
"lottie-react-native": "6.7.0",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-hook-form": "7.51.1",
|
||||
"react-native": "0.73.6",
|
||||
"react-native-calendars": "1.1304.1",
|
||||
"react-hook-form": "7.51.5",
|
||||
"react-native": "0.74.1",
|
||||
"react-native-calendars": "1.1305.0",
|
||||
"react-native-circular-progress-indicator": "4.4.2",
|
||||
"react-native-elements": "3.4.3",
|
||||
"react-native-gesture-handler": "2.16.2",
|
||||
"react-native-paper": "5.12.3",
|
||||
"react-native-safe-area-context": "4.8.2",
|
||||
"react-native-screens": "3.29.0",
|
||||
"react-native-reanimated": "3.10.1",
|
||||
"react-native-safe-area-context": "4.10.1",
|
||||
"react-native-screens": "3.31.1",
|
||||
"react-native-svg": "15.2.0",
|
||||
"react-native-svg-transformer": "1.4.0",
|
||||
"react-native-url-polyfill": "2.0.0",
|
||||
"react-native-vector-icons": "10.0.3",
|
||||
"react-native-web": "0.19.10",
|
||||
"zod": "3.22.4"
|
||||
"react-native-vector-icons": "10.1.0",
|
||||
"react-native-web": "0.19.11",
|
||||
"reanimated-color-picker": "3.0.3",
|
||||
"zod": "3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.24.3",
|
||||
"@commitlint/cli": "19.1.0",
|
||||
"@commitlint/config-conventional": "19.1.0",
|
||||
"@testing-library/react-native": "12.4.4",
|
||||
"@babel/core": "7.24.5",
|
||||
"@commitlint/cli": "19.2.2",
|
||||
"@commitlint/config-conventional": "19.2.2",
|
||||
"@testing-library/react-native": "12.5.0",
|
||||
"@total-typescript/ts-reset": "0.5.1",
|
||||
"@tsconfig/strictest": "2.0.3",
|
||||
"@tsconfig/strictest": "2.0.5",
|
||||
"@types/jest": "29.5.12",
|
||||
"@types/node": "20.11.30",
|
||||
"@types/react": "18.2.69",
|
||||
"@types/react-test-renderer": "18.0.7",
|
||||
"@typescript-eslint/eslint-plugin": "7.3.1",
|
||||
"@typescript-eslint/parser": "7.3.1",
|
||||
"@types/node": "20.12.12",
|
||||
"@types/react": "18.2.79",
|
||||
"@types/react-test-renderer": "18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "7.10.0",
|
||||
"@typescript-eslint/parser": "7.10.0",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-conventions": "14.1.0",
|
||||
"eslint-config-prettier": "9.1.0",
|
||||
"eslint-config-conventions": "14.2.0",
|
||||
"eslint-plugin-import": "2.29.1",
|
||||
"eslint-plugin-prettier": "5.1.3",
|
||||
"eslint-plugin-promise": "6.1.1",
|
||||
"eslint-plugin-react": "7.34.1",
|
||||
"eslint-plugin-react-hooks": "4.6.0",
|
||||
"eslint-plugin-react-hooks": "4.6.2",
|
||||
"eslint-plugin-react-native": "4.1.0",
|
||||
"eslint-plugin-unicorn": "51.0.1",
|
||||
"eslint-plugin-unicorn": "53.0.0",
|
||||
"husky": "9.0.11",
|
||||
"jest": "29.7.0",
|
||||
"jest-expo": "50.0.4",
|
||||
"jest-expo": "51.0.2",
|
||||
"jest-junit": "16.0.0",
|
||||
"lint-staged": "15.2.2",
|
||||
"lint-staged": "15.2.4",
|
||||
"prettier": "3.2.5",
|
||||
"react-test-renderer": "18.2.0",
|
||||
"supabase": "1.150.0",
|
||||
"typescript": "5.4.3"
|
||||
"supabase": "1.167.4",
|
||||
"typescript": "5.3.3"
|
||||
}
|
||||
}
|
||||
|
5382
presentation/assets/confetti.json
Normal file
Before Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 97 KiB |
BIN
presentation/assets/images/splashscreen.png
Normal file
After Width: | Height: | Size: 52 KiB |
BIN
presentation/assets/images/ui-example-2.jpg
Normal file
After Width: | Height: | Size: 76 KiB |
BIN
presentation/assets/images/ui-example-3.jpg
Normal file
After Width: | Height: | Size: 115 KiB |
BIN
presentation/assets/images/ui-example-4.png
Normal file
After Width: | Height: | Size: 1.1 MiB |
@ -8,7 +8,7 @@ import type {
|
||||
import type { AuthenticationUseCase } from "@/domain/use-cases/Authentication"
|
||||
import type { ErrorGlobal, FetchState } from "./_Presenter"
|
||||
import { Presenter } from "./_Presenter"
|
||||
import { zodPresenter } from "./utils/ZodPresenter"
|
||||
import { getErrorsFieldsFromZodError } from "../../utils/zod"
|
||||
|
||||
export interface AuthenticationPresenterState {
|
||||
user: User | null
|
||||
@ -20,14 +20,18 @@ export interface AuthenticationPresenterState {
|
||||
|
||||
register: {
|
||||
state: FetchState
|
||||
errorsFields: Array<keyof UserRegisterData>
|
||||
errorGlobal: ErrorGlobal
|
||||
errors: {
|
||||
fields: Array<keyof UserRegisterData>
|
||||
global: ErrorGlobal
|
||||
}
|
||||
}
|
||||
|
||||
login: {
|
||||
state: FetchState
|
||||
errorsFields: Array<keyof UserLoginData>
|
||||
errorGlobal: ErrorGlobal
|
||||
errors: {
|
||||
fields: Array<keyof UserLoginData>
|
||||
global: ErrorGlobal
|
||||
}
|
||||
}
|
||||
|
||||
logout: {
|
||||
@ -52,13 +56,17 @@ export class AuthenticationPresenter
|
||||
hasLoaded: true,
|
||||
register: {
|
||||
state: "idle",
|
||||
errorsFields: [],
|
||||
errorGlobal: null,
|
||||
errors: {
|
||||
fields: [],
|
||||
global: null,
|
||||
},
|
||||
},
|
||||
login: {
|
||||
state: "idle",
|
||||
errorsFields: [],
|
||||
errorGlobal: null,
|
||||
errors: {
|
||||
fields: [],
|
||||
global: null,
|
||||
},
|
||||
},
|
||||
logout: {
|
||||
state: "idle",
|
||||
@ -71,8 +79,10 @@ export class AuthenticationPresenter
|
||||
try {
|
||||
this.setState((state) => {
|
||||
state.register.state = "loading"
|
||||
state.register.errorsFields = []
|
||||
state.register.errorGlobal = null
|
||||
state.register.errors = {
|
||||
fields: [],
|
||||
global: null,
|
||||
}
|
||||
})
|
||||
const user = await this.authenticationUseCase.register(data)
|
||||
this.setState((state) => {
|
||||
@ -83,10 +93,10 @@ export class AuthenticationPresenter
|
||||
this.setState((state) => {
|
||||
state.register.state = "error"
|
||||
if (error instanceof ZodError) {
|
||||
state.register.errorsFields =
|
||||
zodPresenter.getErrorsFieldsFromZodError<UserRegisterData>(error)
|
||||
state.register.errors.fields =
|
||||
getErrorsFieldsFromZodError<UserRegisterData>(error)
|
||||
} else {
|
||||
state.register.errorGlobal = "unknown"
|
||||
state.register.errors.global = "unknown"
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -96,8 +106,10 @@ export class AuthenticationPresenter
|
||||
try {
|
||||
this.setState((state) => {
|
||||
state.login.state = "loading"
|
||||
state.login.errorsFields = []
|
||||
state.login.errorGlobal = null
|
||||
state.login.errors = {
|
||||
fields: [],
|
||||
global: null,
|
||||
}
|
||||
})
|
||||
const user = await this.authenticationUseCase.login(data)
|
||||
this.setState((state) => {
|
||||
@ -108,10 +120,10 @@ export class AuthenticationPresenter
|
||||
this.setState((state) => {
|
||||
state.login.state = "error"
|
||||
if (error instanceof ZodError) {
|
||||
state.login.errorsFields =
|
||||
zodPresenter.getErrorsFieldsFromZodError<UserLoginData>(error)
|
||||
state.login.errors.fields =
|
||||
getErrorsFieldsFromZodError<UserLoginData>(error)
|
||||
} else {
|
||||
state.login.errorGlobal = "unknown"
|
||||
state.login.errors.global = "unknown"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -1,10 +1,25 @@
|
||||
import { ZodError } from "zod"
|
||||
|
||||
import { HabitsTracker } from "@/domain/entities/HabitsTracker"
|
||||
import type { FetchState } from "./_Presenter"
|
||||
import type { ErrorGlobal, FetchState } from "./_Presenter"
|
||||
import { Presenter } from "./_Presenter"
|
||||
import type {
|
||||
RetrieveHabitsTrackerUseCase,
|
||||
RetrieveHabitsTrackerUseCaseOptions,
|
||||
} from "@/domain/use-cases/RetrieveHabitsTracker"
|
||||
import type {
|
||||
Habit,
|
||||
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 { HabitStopUseCase } from "@/domain/use-cases/HabitStop"
|
||||
import type {
|
||||
HabitGoalProgressUpdateUseCase,
|
||||
HabitGoalProgressUpdateUseCaseOptions,
|
||||
} from "@/domain/use-cases/HabitGoalProgressUpdate"
|
||||
|
||||
export interface HabitsTrackerPresenterState {
|
||||
habitsTracker: HabitsTracker
|
||||
@ -12,10 +27,38 @@ export interface HabitsTrackerPresenterState {
|
||||
retrieveHabitsTracker: {
|
||||
state: FetchState
|
||||
}
|
||||
|
||||
habitCreate: {
|
||||
state: FetchState
|
||||
errors: {
|
||||
fields: Array<keyof HabitCreateData>
|
||||
global: ErrorGlobal
|
||||
}
|
||||
}
|
||||
|
||||
habitEdit: {
|
||||
state: FetchState
|
||||
errors: {
|
||||
fields: Array<keyof HabitEditData>
|
||||
global: ErrorGlobal
|
||||
}
|
||||
}
|
||||
|
||||
habitStop: {
|
||||
state: FetchState
|
||||
}
|
||||
|
||||
habitGoalProgressUpdate: {
|
||||
state: FetchState
|
||||
}
|
||||
}
|
||||
|
||||
export interface HabitsTrackerPresenterOptions {
|
||||
retrieveHabitsTrackerUseCase: RetrieveHabitsTrackerUseCase
|
||||
habitCreateUseCase: HabitCreateUseCase
|
||||
habitEditUseCase: HabitEditUseCase
|
||||
habitStopUseCase: HabitStopUseCase
|
||||
habitGoalProgressUpdateUseCase: HabitGoalProgressUpdateUseCase
|
||||
}
|
||||
|
||||
export class HabitsTrackerPresenter
|
||||
@ -23,12 +66,126 @@ export class HabitsTrackerPresenter
|
||||
implements HabitsTrackerPresenterOptions
|
||||
{
|
||||
public retrieveHabitsTrackerUseCase: RetrieveHabitsTrackerUseCase
|
||||
public habitCreateUseCase: HabitCreateUseCase
|
||||
public habitEditUseCase: HabitEditUseCase
|
||||
public habitStopUseCase: HabitStopUseCase
|
||||
public habitGoalProgressUpdateUseCase: HabitGoalProgressUpdateUseCase
|
||||
|
||||
public constructor(options: HabitsTrackerPresenterOptions) {
|
||||
const { retrieveHabitsTrackerUseCase } = options
|
||||
const {
|
||||
retrieveHabitsTrackerUseCase,
|
||||
habitCreateUseCase,
|
||||
habitEditUseCase,
|
||||
habitStopUseCase,
|
||||
habitGoalProgressUpdateUseCase,
|
||||
} = options
|
||||
const habitsTracker = HabitsTracker.default()
|
||||
super({ habitsTracker, retrieveHabitsTracker: { state: "idle" } })
|
||||
super({
|
||||
habitsTracker,
|
||||
retrieveHabitsTracker: { state: "idle" },
|
||||
habitCreate: {
|
||||
state: "idle",
|
||||
errors: {
|
||||
fields: [],
|
||||
global: null,
|
||||
},
|
||||
},
|
||||
habitEdit: {
|
||||
state: "idle",
|
||||
errors: {
|
||||
fields: [],
|
||||
global: null,
|
||||
},
|
||||
},
|
||||
habitStop: {
|
||||
state: "idle",
|
||||
},
|
||||
habitGoalProgressUpdate: {
|
||||
state: "idle",
|
||||
},
|
||||
})
|
||||
this.retrieveHabitsTrackerUseCase = retrieveHabitsTrackerUseCase
|
||||
this.habitCreateUseCase = habitCreateUseCase
|
||||
this.habitEditUseCase = habitEditUseCase
|
||||
this.habitStopUseCase = habitStopUseCase
|
||||
this.habitGoalProgressUpdateUseCase = habitGoalProgressUpdateUseCase
|
||||
}
|
||||
|
||||
public async habitCreate(data: unknown): Promise<FetchState> {
|
||||
try {
|
||||
this.setState((state) => {
|
||||
state.habitCreate.state = "loading"
|
||||
state.habitCreate.errors = {
|
||||
fields: [],
|
||||
global: null,
|
||||
}
|
||||
})
|
||||
const habit = await this.habitCreateUseCase.execute(data)
|
||||
this.setState((state) => {
|
||||
state.habitCreate.state = "success"
|
||||
state.habitsTracker.addHabit(habit)
|
||||
})
|
||||
return "success"
|
||||
} catch (error) {
|
||||
this.setState((state) => {
|
||||
state.habitCreate.state = "error"
|
||||
if (error instanceof ZodError) {
|
||||
state.habitCreate.errors.fields =
|
||||
getErrorsFieldsFromZodError<HabitCreateData>(error)
|
||||
} else {
|
||||
state.habitCreate.errors.global = "unknown"
|
||||
}
|
||||
})
|
||||
return "error"
|
||||
}
|
||||
}
|
||||
|
||||
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 habitStop(habitToStop: Habit): Promise<FetchState> {
|
||||
try {
|
||||
this.setState((state) => {
|
||||
state.habitStop.state = "loading"
|
||||
})
|
||||
const habit = await this.habitStopUseCase.execute(habitToStop)
|
||||
this.setState((state) => {
|
||||
state.habitStop.state = "success"
|
||||
state.habitsTracker.editHabit(habit)
|
||||
})
|
||||
return "success"
|
||||
} catch (error) {
|
||||
this.setState((state) => {
|
||||
state.habitStop.state = "error"
|
||||
})
|
||||
return "error"
|
||||
}
|
||||
}
|
||||
|
||||
public async retrieveHabitsTracker(
|
||||
@ -51,4 +208,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -41,10 +41,7 @@ export abstract class Presenter<State> {
|
||||
|
||||
public unsubscribe(listener: Listener<State>): void {
|
||||
const listenerIndex = this._listeners.indexOf(listener)
|
||||
const listenerFound = listenerIndex !== -1
|
||||
if (listenerFound) {
|
||||
this._listeners.splice(listenerIndex, 1)
|
||||
}
|
||||
this._listeners.splice(listenerIndex, 1)
|
||||
}
|
||||
|
||||
private notifyListeners(): void {
|
||||
|
@ -1,7 +0,0 @@
|
||||
import type { ZodError } from "zod"
|
||||
|
||||
export const zodPresenter = {
|
||||
getErrorsFieldsFromZodError: <T>(error: ZodError<T>): Array<keyof T> => {
|
||||
return Object.keys(error.format()) as Array<keyof T>
|
||||
},
|
||||
}
|
90
presentation/react-native/components/About.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import { View } from "react-native"
|
||||
import { Text } from "react-native-paper"
|
||||
import { SafeAreaView } from "react-native-safe-area-context"
|
||||
|
||||
import { ExternalLink } from "@/presentation/react-native/ui/ExternalLink"
|
||||
import { getVersion } from "@/utils/version"
|
||||
|
||||
export interface AboutProps {
|
||||
actionButton: React.ReactNode
|
||||
footer?: React.ReactNode
|
||||
}
|
||||
|
||||
export const About: React.FC<AboutProps> = (props) => {
|
||||
const { actionButton, footer } = props
|
||||
|
||||
const version = getVersion()
|
||||
|
||||
return (
|
||||
<SafeAreaView
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingHorizontal: 20,
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
alignItems: "center",
|
||||
marginVertical: 20,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
fontSize: 28,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
Habits Tracker
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
style={{
|
||||
marginTop: 6,
|
||||
fontWeight: "bold",
|
||||
fontSize: 18,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
To perform at work and in everyday life.
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
style={{
|
||||
marginTop: 6,
|
||||
fontWeight: "bold",
|
||||
fontSize: 16,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
v{version}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text variant="bodyLarge" style={{ textAlign: "center" }}>
|
||||
<ExternalLink href="https://unistra.fr" style={{ color: "#006CFF" }}>
|
||||
Université de Strasbourg
|
||||
</ExternalLink>
|
||||
</Text>
|
||||
<Text variant="bodyLarge" style={{ textAlign: "center" }}>
|
||||
BUT Informatique - IUT Robert Schuman
|
||||
</Text>
|
||||
<Text variant="bodyLarge" style={{ textAlign: "center" }}>
|
||||
P61 Mobile Development
|
||||
</Text>
|
||||
|
||||
{footer}
|
||||
|
||||
<View
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginVertical: 20,
|
||||
}}
|
||||
>
|
||||
{actionButton}
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
@ -0,0 +1,346 @@
|
||||
import type { IconName } from "@fortawesome/free-solid-svg-icons"
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-native-fontawesome"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useState } from "react"
|
||||
import { Controller, useForm } from "react-hook-form"
|
||||
import { ScrollView, StyleSheet, View } from "react-native"
|
||||
import {
|
||||
Button,
|
||||
HelperText,
|
||||
SegmentedButtons,
|
||||
Snackbar,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "react-native-paper"
|
||||
import { SafeAreaView } from "react-native-safe-area-context"
|
||||
import ColorPicker, {
|
||||
HueSlider,
|
||||
Panel1,
|
||||
Preview,
|
||||
} from "reanimated-color-picker"
|
||||
|
||||
import type { GoalFrequency, GoalType } from "@/domain/entities/Goal"
|
||||
import { GOAL_FREQUENCIES, GOAL_TYPES } from "@/domain/entities/Goal"
|
||||
import type { HabitCreateData } from "@/domain/entities/Habit"
|
||||
import { HabitCreateSchema } from "@/domain/entities/Habit"
|
||||
import type { User } from "@/domain/entities/User"
|
||||
import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker"
|
||||
import { useBoolean } from "@/presentation/react/hooks/useBoolean"
|
||||
import { IconSelectorModal } from "./IconSelectorModal"
|
||||
|
||||
export interface HabitCreateFormProps {
|
||||
user: User
|
||||
}
|
||||
|
||||
export const HabitCreateForm: React.FC<HabitCreateFormProps> = ({ user }) => {
|
||||
const { habitCreate, habitsTrackerPresenter } = useHabitsTracker()
|
||||
|
||||
const {
|
||||
control,
|
||||
formState: { errors, isValid },
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
} = useForm<HabitCreateData>({
|
||||
mode: "onChange",
|
||||
resolver: zodResolver(HabitCreateSchema),
|
||||
defaultValues: {
|
||||
userId: user.id,
|
||||
name: "",
|
||||
color: "#006CFF",
|
||||
icon: "circle-question",
|
||||
goal: {
|
||||
frequency: "daily",
|
||||
target: {
|
||||
type: "boolean",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const watchGoalType = watch("goal.target.type")
|
||||
|
||||
const [isVisibleSnackbar, setIsVisibleSnackbar] = useState(false)
|
||||
|
||||
const {
|
||||
value: isModalIconSelectorVisible,
|
||||
setTrue: openModalIconSelector,
|
||||
setFalse: closeModalIconSelector,
|
||||
} = useBoolean()
|
||||
|
||||
const onDismissSnackbar = (): void => {
|
||||
setIsVisibleSnackbar(false)
|
||||
}
|
||||
|
||||
const onSubmit = async (data: HabitCreateData): Promise<void> => {
|
||||
await habitsTrackerPresenter.habitCreate(data)
|
||||
setIsVisibleSnackbar(true)
|
||||
closeModalIconSelector()
|
||||
reset()
|
||||
}
|
||||
|
||||
const habitFrequenciesTranslations: {
|
||||
[key in GoalFrequency]: { label: string; icon: string }
|
||||
} = {
|
||||
daily: {
|
||||
label: "Daily",
|
||||
icon: "calendar",
|
||||
},
|
||||
weekly: {
|
||||
label: "Weekly",
|
||||
icon: "calendar-week",
|
||||
},
|
||||
monthly: {
|
||||
label: "Monthly",
|
||||
icon: "calendar-month",
|
||||
},
|
||||
}
|
||||
|
||||
const habitTypesTranslations: {
|
||||
[key in GoalType]: { label: string; icon: string }
|
||||
} = {
|
||||
boolean: {
|
||||
label: "Routine",
|
||||
icon: "clock",
|
||||
},
|
||||
numeric: {
|
||||
label: "Target",
|
||||
icon: "target",
|
||||
},
|
||||
}
|
||||
|
||||
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: "96%",
|
||||
},
|
||||
]}
|
||||
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 (
|
||||
<>
|
||||
<Text style={[styles.spacing]}>Habit Frequency</Text>
|
||||
<SegmentedButtons
|
||||
style={[{ width: "96%" }]}
|
||||
onValueChange={onChange}
|
||||
value={value}
|
||||
buttons={GOAL_FREQUENCIES.map((frequency) => {
|
||||
return {
|
||||
label: habitFrequenciesTranslations[frequency].label,
|
||||
value: frequency,
|
||||
icon: habitFrequenciesTranslations[frequency].icon,
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
name="goal.frequency"
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<>
|
||||
<Text
|
||||
style={[
|
||||
styles.spacing,
|
||||
{ justifyContent: "center", alignContent: "center" },
|
||||
]}
|
||||
>
|
||||
Habit Type
|
||||
</Text>
|
||||
<SegmentedButtons
|
||||
style={[{ width: "96%" }]}
|
||||
onValueChange={onChange}
|
||||
value={value}
|
||||
buttons={GOAL_TYPES.map((type) => {
|
||||
return {
|
||||
label: habitTypesTranslations[type].label,
|
||||
value: type,
|
||||
icon: habitTypesTranslations[type].icon,
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
name="goal.target.type"
|
||||
/>
|
||||
|
||||
{watchGoalType === "numeric" ? (
|
||||
<View
|
||||
style={{
|
||||
marginTop: 10,
|
||||
flexDirection: "row",
|
||||
gap: 10,
|
||||
width: "96%",
|
||||
}}
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
render={({ field: { onChange, onBlur, value } }) => {
|
||||
return (
|
||||
<TextInput
|
||||
placeholder="Target (e.g: 5 000)"
|
||||
onBlur={onBlur}
|
||||
onChangeText={(text) => {
|
||||
if (text.length <= 0) {
|
||||
onChange("")
|
||||
return
|
||||
}
|
||||
onChange(Number.parseInt(text, 10))
|
||||
}}
|
||||
value={value?.toString()}
|
||||
style={[
|
||||
styles.spacing,
|
||||
{
|
||||
width: "50%",
|
||||
},
|
||||
]}
|
||||
mode="outlined"
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
)
|
||||
}}
|
||||
name="goal.target.value"
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
render={({ field: { onChange, onBlur, value } }) => {
|
||||
return (
|
||||
<TextInput
|
||||
placeholder="Unit (e.g: Steps)"
|
||||
onBlur={onBlur}
|
||||
onChangeText={onChange}
|
||||
value={value}
|
||||
style={[
|
||||
styles.spacing,
|
||||
{
|
||||
width: "50%",
|
||||
},
|
||||
]}
|
||||
mode="outlined"
|
||||
/>
|
||||
)
|
||||
}}
|
||||
name="goal.target.unit"
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<ColorPicker
|
||||
style={[{ marginVertical: 15, width: "96%" }]}
|
||||
value={value}
|
||||
onComplete={(value) => {
|
||||
onChange(value.hex)
|
||||
}}
|
||||
>
|
||||
<Preview hideInitialColor />
|
||||
<Panel1 />
|
||||
<HueSlider />
|
||||
</ColorPicker>
|
||||
)
|
||||
}}
|
||||
name="color"
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
flexDirection: "row",
|
||||
gap: 20,
|
||||
marginVertical: 5,
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon size={36} icon={value as IconName} />
|
||||
<Button mode="contained" onPress={openModalIconSelector}>
|
||||
Choose an icon
|
||||
</Button>
|
||||
|
||||
<IconSelectorModal
|
||||
key={isModalIconSelectorVisible ? "visible" : "hidden"}
|
||||
isVisible={isModalIconSelectorVisible}
|
||||
selectedIcon={value}
|
||||
handleCloseModal={closeModalIconSelector}
|
||||
onIconSelect={onChange}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}}
|
||||
name="icon"
|
||||
/>
|
||||
|
||||
<Button
|
||||
mode="contained"
|
||||
onPress={handleSubmit(onSubmit)}
|
||||
loading={habitCreate.state === "loading"}
|
||||
disabled={habitCreate.state === "loading" || !isValid}
|
||||
style={[{ width: "100%", marginVertical: 15 }]}
|
||||
>
|
||||
Create your habit! 🚀
|
||||
</Button>
|
||||
</ScrollView>
|
||||
|
||||
<Snackbar
|
||||
visible={isVisibleSnackbar}
|
||||
onDismiss={onDismissSnackbar}
|
||||
duration={2_000}
|
||||
>
|
||||
✅ Habit created successfully!
|
||||
</Snackbar>
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
spacing: {
|
||||
marginVertical: 10,
|
||||
},
|
||||
})
|
208
presentation/react-native/components/HabitForm/HabitEditForm.tsx
Normal file
@ -0,0 +1,208 @@
|
||||
import type { IconName } from "@fortawesome/free-solid-svg-icons"
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-native-fontawesome"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useState } from "react"
|
||||
import { Controller, useForm } from "react-hook-form"
|
||||
import { ScrollView, StyleSheet, View } from "react-native"
|
||||
import {
|
||||
Button,
|
||||
HelperText,
|
||||
Snackbar,
|
||||
Text,
|
||||
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 "@/presentation/react/contexts/HabitsTracker"
|
||||
import { useBoolean } from "@/presentation/react/hooks/useBoolean"
|
||||
import { IconSelectorModal } from "./IconSelectorModal"
|
||||
|
||||
export interface HabitEditFormProps {
|
||||
habit: Habit
|
||||
}
|
||||
|
||||
export const HabitEditForm: React.FC<HabitEditFormProps> = ({ habit }) => {
|
||||
const { habitEdit, habitStop, habitsTrackerPresenter } = useHabitsTracker()
|
||||
|
||||
const {
|
||||
control,
|
||||
formState: { errors, isValid },
|
||||
handleSubmit,
|
||||
} = useForm<HabitEditData>({
|
||||
mode: "onChange",
|
||||
resolver: zodResolver(HabitEditSchema),
|
||||
defaultValues: {
|
||||
id: habit.id,
|
||||
userId: habit.userId,
|
||||
name: habit.name,
|
||||
color: habit.color,
|
||||
icon: habit.icon,
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
value: isModalIconSelectorVisible,
|
||||
setTrue: openModalIconSelector,
|
||||
setFalse: closeModalIconSelector,
|
||||
} = useBoolean()
|
||||
|
||||
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: "96%",
|
||||
},
|
||||
]}
|
||||
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: "96%" }]}
|
||||
value={value}
|
||||
onComplete={(value) => {
|
||||
onChange(value.hex)
|
||||
}}
|
||||
>
|
||||
<Preview hideInitialColor />
|
||||
<Panel1 />
|
||||
<HueSlider />
|
||||
</ColorPicker>
|
||||
)
|
||||
}}
|
||||
name="color"
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
flexDirection: "row",
|
||||
gap: 20,
|
||||
marginVertical: 30,
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon size={36} icon={value as IconName} />
|
||||
<Button mode="contained" onPress={openModalIconSelector}>
|
||||
Choose an icon
|
||||
</Button>
|
||||
|
||||
<IconSelectorModal
|
||||
key={isModalIconSelectorVisible ? "visible" : "hidden"}
|
||||
isVisible={isModalIconSelectorVisible}
|
||||
selectedIcon={value}
|
||||
handleCloseModal={closeModalIconSelector}
|
||||
onIconSelect={onChange}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}}
|
||||
name="icon"
|
||||
/>
|
||||
|
||||
<Button
|
||||
mode="contained"
|
||||
onPress={handleSubmit(onSubmit)}
|
||||
loading={habitEdit.state === "loading"}
|
||||
disabled={habitEdit.state === "loading" || !isValid}
|
||||
style={[styles.spacing, { width: "96%" }]}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
|
||||
{habit.endDate == null ? (
|
||||
<Button
|
||||
mode="outlined"
|
||||
onPress={async () => {
|
||||
await habitsTrackerPresenter.habitStop(habit)
|
||||
}}
|
||||
loading={habitStop.state === "loading"}
|
||||
disabled={habitStop.state === "loading"}
|
||||
style={[styles.spacing, { width: "96%" }]}
|
||||
>
|
||||
🛑 Stop Habit (effective tomorrow)
|
||||
</Button>
|
||||
) : (
|
||||
<Text
|
||||
style={{
|
||||
textAlign: "center",
|
||||
marginVertical: 20,
|
||||
fontSize: 20,
|
||||
}}
|
||||
>
|
||||
🛑 The habit has been stopped! (No further progress can be saved)
|
||||
</Text>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
<Snackbar
|
||||
visible={isVisibleSnackbar}
|
||||
onDismiss={onDismissSnackbar}
|
||||
duration={2_000}
|
||||
>
|
||||
✅ Habit Saved successfully!
|
||||
</Snackbar>
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
spacing: {
|
||||
marginVertical: 16,
|
||||
},
|
||||
})
|
@ -0,0 +1,129 @@
|
||||
import type { IconName } from "@fortawesome/fontawesome-svg-core"
|
||||
import { fas } from "@fortawesome/free-solid-svg-icons"
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-native-fontawesome"
|
||||
import { memo, useCallback, useEffect, useState, useTransition } from "react"
|
||||
import { Modal, ScrollView, View } from "react-native"
|
||||
import { Button, List, Text, TextInput } from "react-native-paper"
|
||||
import Fuse from "fuse.js"
|
||||
|
||||
import { IconsList } from "./IconsList"
|
||||
|
||||
export interface IconSelectorModalProps {
|
||||
isVisible?: boolean
|
||||
selectedIcon?: string
|
||||
onIconSelect: (icon: string) => void
|
||||
handleCloseModal?: () => void
|
||||
}
|
||||
|
||||
interface SearchInputProps {
|
||||
searchText: string
|
||||
handleSearch: (text: string) => void
|
||||
}
|
||||
const SearchInputWithoutMemo: React.FC<SearchInputProps> = (props) => {
|
||||
const { searchText, handleSearch } = props
|
||||
return (
|
||||
<TextInput label="Search" value={searchText} onChangeText={handleSearch} />
|
||||
)
|
||||
}
|
||||
const SearchInput = memo(SearchInputWithoutMemo)
|
||||
|
||||
const iconNames = Object.keys(fas).map((key) => {
|
||||
return fas[key]?.iconName ?? key
|
||||
})
|
||||
|
||||
const fuseOptions = {
|
||||
keys: ["iconName"],
|
||||
threshold: 0.4,
|
||||
}
|
||||
|
||||
const fuse = new Fuse(iconNames, fuseOptions)
|
||||
|
||||
const findIconsInLibrary = (icon: string): string[] => {
|
||||
const results = fuse.search(icon).map((result) => {
|
||||
return result.item
|
||||
})
|
||||
const uniqueResults = Array.from(new Set(results))
|
||||
return uniqueResults.slice(0, 50)
|
||||
}
|
||||
|
||||
export const IconSelectorModal: React.FC<IconSelectorModalProps> = ({
|
||||
isVisible = false,
|
||||
selectedIcon,
|
||||
onIconSelect,
|
||||
handleCloseModal,
|
||||
}) => {
|
||||
const [possibleIcons, setPossibleIcons] = useState<string[]>([])
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true)
|
||||
const [searchText, setSearchText] = useState<string>("")
|
||||
const [_isPending, startTransition] = useTransition()
|
||||
|
||||
const handleSearch = useCallback((text: string): void => {
|
||||
setSearchText(text)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handlePossibleIcons = (): void => {
|
||||
startTransition(() => {
|
||||
setPossibleIcons(findIconsInLibrary(searchText))
|
||||
setIsLoading(false)
|
||||
})
|
||||
}
|
||||
const debounceHandleSearch = setTimeout(handlePossibleIcons, 400)
|
||||
|
||||
return () => {
|
||||
return clearTimeout(debounceHandleSearch)
|
||||
}
|
||||
}, [searchText])
|
||||
|
||||
const handleIconSelect = useCallback(
|
||||
(icon: string): void => {
|
||||
onIconSelect(icon)
|
||||
},
|
||||
[onIconSelect],
|
||||
)
|
||||
|
||||
return (
|
||||
<Modal animationType="fade" transparent visible={isVisible}>
|
||||
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
|
||||
<View
|
||||
style={{
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 5,
|
||||
width: "96%",
|
||||
height: "99%",
|
||||
backgroundColor: "white",
|
||||
borderColor: "black",
|
||||
borderWidth: 1,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginVertical: 20,
|
||||
}}
|
||||
>
|
||||
<Text style={{ marginVertical: 8 }}>Selected Icon:</Text>
|
||||
<FontAwesomeIcon size={46} icon={selectedIcon as IconName} />
|
||||
</View>
|
||||
<SearchInput searchText={searchText} handleSearch={handleSearch} />
|
||||
<ScrollView>
|
||||
<List.Section title="Choose an icon:">
|
||||
<IconsList
|
||||
isLoading={isLoading}
|
||||
selectedIcon={selectedIcon}
|
||||
possibleIcons={possibleIcons}
|
||||
handleIconSelect={handleIconSelect}
|
||||
/>
|
||||
</List.Section>
|
||||
</ScrollView>
|
||||
<View style={{ marginVertical: 15 }}>
|
||||
<Button mode="contained" onPress={handleCloseModal}>
|
||||
Save
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
)
|
||||
}
|
74
presentation/react-native/components/HabitForm/IconsList.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import type { IconName } from "@fortawesome/fontawesome-svg-core"
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-native-fontawesome"
|
||||
import React, { memo } from "react"
|
||||
import { View } from "react-native"
|
||||
import { ActivityIndicator, IconButton, Text } from "react-native-paper"
|
||||
|
||||
export interface IconsListProps {
|
||||
selectedIcon?: string
|
||||
possibleIcons: string[]
|
||||
isLoading?: boolean
|
||||
handleIconSelect: (icon: string) => void
|
||||
}
|
||||
|
||||
const IconsListWithoutMemo: React.FC<IconsListProps> = (props) => {
|
||||
const {
|
||||
selectedIcon,
|
||||
possibleIcons,
|
||||
isLoading = false,
|
||||
handleIconSelect,
|
||||
} = props
|
||||
|
||||
if (possibleIcons.length <= 0) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
marginTop: 20,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size="large" />
|
||||
) : (
|
||||
<Text>No results found</Text>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: 15,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{possibleIcons.map((icon) => {
|
||||
return (
|
||||
<IconButton
|
||||
key={icon}
|
||||
containerColor="white"
|
||||
icon={({ size }) => {
|
||||
return (
|
||||
<FontAwesomeIcon
|
||||
icon={icon as IconName}
|
||||
size={size}
|
||||
color={selectedIcon === icon ? "blue" : "black"}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
size={30}
|
||||
onPress={() => {
|
||||
handleIconSelect(icon)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export const IconsList = memo(IconsListWithoutMemo)
|
268
presentation/react-native/components/HabitProgress.tsx
Normal file
@ -0,0 +1,268 @@
|
||||
import { useState } from "react"
|
||||
import { ScrollView, StyleSheet, View } from "react-native"
|
||||
import { Button, Snackbar, Text, TextInput } from "react-native-paper"
|
||||
import { SafeAreaView } from "react-native-safe-area-context"
|
||||
import type { IconName } from "@fortawesome/free-solid-svg-icons"
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-native-fontawesome"
|
||||
|
||||
import type { GoalNumeric } from "@/domain/entities/Goal"
|
||||
import { GoalNumericProgress } from "@/domain/entities/Goal"
|
||||
import type { HabitHistory } from "@/domain/entities/HabitHistory"
|
||||
import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker"
|
||||
import { LOCALE, capitalize } from "@/utils/strings"
|
||||
|
||||
export interface HabitProgressProps {
|
||||
habitHistory: HabitHistory
|
||||
selectedDate: Date
|
||||
}
|
||||
|
||||
export const HabitProgress: React.FC<HabitProgressProps> = ({
|
||||
habitHistory,
|
||||
selectedDate,
|
||||
}) => {
|
||||
const { habitsTrackerPresenter, habitGoalProgressUpdate } = useHabitsTracker()
|
||||
|
||||
const [isVisibleSnackbar, setIsVisibleSnackbar] = useState(false)
|
||||
|
||||
const onDismissSnackbar = (): void => {
|
||||
setIsVisibleSnackbar(false)
|
||||
}
|
||||
|
||||
const goalProgress = habitHistory.getGoalProgressByDate(selectedDate)
|
||||
|
||||
const goalProgresses = habitHistory.getProgressesByDate(selectedDate)
|
||||
|
||||
const values = {
|
||||
progress: 0,
|
||||
min: 0,
|
||||
max: 0,
|
||||
}
|
||||
if (goalProgress.isNumeric()) {
|
||||
values.max = goalProgress.goal.target.value
|
||||
}
|
||||
const [progressValue, setProgressValue] = useState(values.progress)
|
||||
|
||||
if (!goalProgress.isNumeric()) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
const progressTotal = goalProgress.progress + progressValue
|
||||
|
||||
const handleSave = async (): Promise<void> => {
|
||||
setIsVisibleSnackbar(true)
|
||||
await habitsTrackerPresenter.habitUpdateProgress({
|
||||
date: selectedDate,
|
||||
habitHistory,
|
||||
goalProgress: new GoalNumericProgress({
|
||||
goal: habitHistory.habit.goal as GoalNumeric,
|
||||
progress: progressValue,
|
||||
}),
|
||||
})
|
||||
setProgressValue(0)
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, justifyContent: "space-between" }}>
|
||||
<View
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 20,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: 15,
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
size={24}
|
||||
icon={habitHistory.habit.icon as IconName}
|
||||
style={[
|
||||
{
|
||||
width: 30,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Text
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
fontSize: 28,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{habitHistory.habit.name}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text
|
||||
style={{
|
||||
marginTop: 10,
|
||||
fontWeight: "bold",
|
||||
fontSize: 18,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{capitalize(habitHistory.habit.goal.frequency)} Progress
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
textAlign: "center",
|
||||
marginBottom: 15,
|
||||
}}
|
||||
>
|
||||
{selectedDate.toLocaleDateString(LOCALE, {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</Text>
|
||||
|
||||
<View
|
||||
style={{
|
||||
width: "100%",
|
||||
borderBottomWidth: 1,
|
||||
borderColor: "#f57c00",
|
||||
marginVertical: 10,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Text style={{ marginVertical: 10, fontWeight: "bold", fontSize: 18 }}>
|
||||
{goalProgress.progress.toLocaleString(LOCALE)} /{" "}
|
||||
{goalProgress.goal.target.value.toLocaleString(LOCALE)}{" "}
|
||||
{goalProgress.goal.target.unit}
|
||||
</Text>
|
||||
|
||||
<TextInput
|
||||
placeholder="Progress to add (e.g: 5 000)"
|
||||
value={progressValue === 0 ? "" : progressValue.toString()}
|
||||
onChangeText={(text) => {
|
||||
const hasDigits = /\d+$/.test(text)
|
||||
if (text.length <= 0 || !hasDigits) {
|
||||
setProgressValue(0)
|
||||
return
|
||||
}
|
||||
setProgressValue(Number.parseInt(text, 10))
|
||||
}}
|
||||
style={[
|
||||
styles.spacing,
|
||||
{
|
||||
width: "80%",
|
||||
},
|
||||
]}
|
||||
mode="outlined"
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
|
||||
{goalProgress.progress > 0 && progressValue > 0 ? (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
textAlign: "center",
|
||||
marginBottom: 15,
|
||||
}}
|
||||
>
|
||||
{goalProgress.progress.toLocaleString()} +{" "}
|
||||
{progressValue.toLocaleString()} = {progressTotal.toLocaleString()}{" "}
|
||||
{goalProgress.goal.target.unit}
|
||||
</Text>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
<Button
|
||||
mode="contained"
|
||||
onPress={handleSave}
|
||||
loading={habitGoalProgressUpdate.state === "loading"}
|
||||
disabled={
|
||||
habitGoalProgressUpdate.state === "loading" || progressValue === 0
|
||||
}
|
||||
style={[styles.spacing, { width: "80%" }]}
|
||||
>
|
||||
Save Progress ✨
|
||||
</Button>
|
||||
|
||||
<View
|
||||
style={{
|
||||
width: "100%",
|
||||
borderBottomWidth: 1,
|
||||
borderColor: "#f57c00",
|
||||
marginVertical: 10,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Text
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
fontSize: 18,
|
||||
margin: 15,
|
||||
}}
|
||||
>
|
||||
Progress History
|
||||
</Text>
|
||||
|
||||
<ScrollView
|
||||
style={{
|
||||
width: "100%",
|
||||
marginVertical: 20,
|
||||
}}
|
||||
>
|
||||
{goalProgresses.map((habitProgress, index) => {
|
||||
if (!habitProgress.goalProgress.isNumeric()) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
key={habitProgress.id + index}
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 20,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: "#f57c00",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<Text>
|
||||
{habitProgress.date.toLocaleDateString(LOCALE, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</Text>
|
||||
<Text>
|
||||
{habitProgress.goalProgress.progress.toLocaleString(LOCALE)}{" "}
|
||||
{habitProgress.goalProgress.goal.target.unit}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
<Snackbar
|
||||
visible={isVisibleSnackbar}
|
||||
onDismiss={onDismissSnackbar}
|
||||
duration={2_000}
|
||||
>
|
||||
✅ Habit Saved successfully!
|
||||
</Snackbar>
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
spacing: {
|
||||
marginVertical: 16,
|
||||
},
|
||||
})
|
@ -0,0 +1,137 @@
|
||||
import type { IconName } from "@fortawesome/free-solid-svg-icons"
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-native-fontawesome"
|
||||
import { Link, useRouter } from "expo-router"
|
||||
import type LottieView from "lottie-react-native"
|
||||
import { useState } from "react"
|
||||
import { View } from "react-native"
|
||||
import { Button, Checkbox, List, Text } from "react-native-paper"
|
||||
|
||||
import type { GoalBoolean } from "@/domain/entities/Goal"
|
||||
import { GoalBooleanProgress } from "@/domain/entities/Goal"
|
||||
import type { HabitHistory } from "@/domain/entities/HabitHistory"
|
||||
import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker"
|
||||
import { getColorRGBAFromHex } from "@/utils/colors"
|
||||
import { getISODate } from "@/utils/dates"
|
||||
|
||||
export interface HabitCardProps {
|
||||
habitHistory: HabitHistory
|
||||
selectedDate: Date
|
||||
confettiRef: React.MutableRefObject<LottieView | null>
|
||||
}
|
||||
|
||||
export const HabitCard: React.FC<HabitCardProps> = (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,
|
||||
})
|
||||
|
||||
return (
|
||||
<List.Item
|
||||
onPress={() => {
|
||||
router.push({
|
||||
pathname: "/application/habits/[habitId]/",
|
||||
params: {
|
||||
habitId: habit.id,
|
||||
},
|
||||
})
|
||||
}}
|
||||
title={habit.name}
|
||||
style={[
|
||||
{
|
||||
paddingVertical: 20,
|
||||
paddingHorizontal: 10,
|
||||
marginVertical: 10,
|
||||
borderRadius: 10,
|
||||
backgroundColor: habitColor,
|
||||
},
|
||||
]}
|
||||
contentStyle={[
|
||||
{
|
||||
paddingLeft: 12,
|
||||
},
|
||||
]}
|
||||
titleStyle={[
|
||||
{
|
||||
fontSize: 18,
|
||||
},
|
||||
]}
|
||||
left={() => {
|
||||
return (
|
||||
<View style={{ justifyContent: "center", alignItems: "center" }}>
|
||||
<FontAwesomeIcon
|
||||
size={24}
|
||||
icon={habit.icon as IconName}
|
||||
style={[
|
||||
{
|
||||
width: 30,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}}
|
||||
right={() => {
|
||||
if (goalProgress.isNumeric()) {
|
||||
const href = {
|
||||
pathname: "/application/habits/[habitId]/progress/[selectedDate]/",
|
||||
params: {
|
||||
habitId: habit.id,
|
||||
selectedDate: getISODate(selectedDate),
|
||||
},
|
||||
}
|
||||
return (
|
||||
<Link href={href}>
|
||||
<View>
|
||||
<Text>
|
||||
{goalProgress.progress.toLocaleString()} /{" "}
|
||||
{goalProgress.goal.target.value.toLocaleString()}{" "}
|
||||
{goalProgress.goal.target.unit}
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
mode="elevated"
|
||||
onPress={() => {
|
||||
router.push(href)
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</View>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<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,
|
||||
}),
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
@ -0,0 +1,40 @@
|
||||
import { useRouter } from "expo-router"
|
||||
import { View } from "react-native"
|
||||
import { Button, Text } from "react-native-paper"
|
||||
|
||||
export const HabitsEmpty: React.FC = () => {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Text variant="titleLarge">{"Let's begin by adding habits! 🤩"}</Text>
|
||||
<Button
|
||||
mode="contained"
|
||||
style={{
|
||||
marginTop: 16,
|
||||
width: 250,
|
||||
height: 40,
|
||||
}}
|
||||
onPress={() => {
|
||||
router.push("/application/habits/new")
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: "white",
|
||||
fontWeight: "bold",
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
Create your first habit! 🚀
|
||||
</Text>
|
||||
</Button>
|
||||
</View>
|
||||
)
|
||||
}
|
@ -0,0 +1,157 @@
|
||||
import LottieView from "lottie-react-native"
|
||||
import { useRef, useState } from "react"
|
||||
import { Dimensions, ScrollView, View } from "react-native"
|
||||
import { Divider, List, Text } from "react-native-paper"
|
||||
|
||||
import { GOAL_FREQUENCIES, type GoalFrequency } from "@/domain/entities/Goal"
|
||||
import type { HabitHistory } from "@/domain/entities/HabitHistory"
|
||||
import type { HabitsTracker } from "@/domain/entities/HabitsTracker"
|
||||
import { LOCALE, capitalize } from "@/utils/strings"
|
||||
import confettiJSON from "../../../assets/confetti.json"
|
||||
import { HabitCard } from "./HabitCard"
|
||||
|
||||
export interface HabitsListProps {
|
||||
habitsTracker: HabitsTracker
|
||||
selectedDate: Date
|
||||
}
|
||||
|
||||
export const HabitsList: React.FC<HabitsListProps> = (props) => {
|
||||
const { habitsTracker, selectedDate } = props
|
||||
|
||||
const [accordionExpanded, setAccordionExpanded] = useState<{
|
||||
[key in GoalFrequency]: boolean
|
||||
}>({
|
||||
daily: true,
|
||||
weekly: true,
|
||||
monthly: true,
|
||||
})
|
||||
|
||||
const confettiRef = useRef<LottieView | null>(null)
|
||||
|
||||
const habitsHistoriesByFrequency: Record<GoalFrequency, HabitHistory[]> = {
|
||||
daily: habitsTracker.getHabitsHistoriesByDate({
|
||||
selectedDate,
|
||||
frequency: "daily",
|
||||
}),
|
||||
weekly: habitsTracker.getHabitsHistoriesByDate({
|
||||
selectedDate,
|
||||
frequency: "weekly",
|
||||
}),
|
||||
monthly: habitsTracker.getHabitsHistoriesByDate({
|
||||
selectedDate,
|
||||
frequency: "monthly",
|
||||
}),
|
||||
}
|
||||
|
||||
const frequenciesFiltered = GOAL_FREQUENCIES.filter((frequency) => {
|
||||
return habitsHistoriesByFrequency[frequency].length > 0
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<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 />
|
||||
|
||||
<Text
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
fontSize: 22,
|
||||
textAlign: "center",
|
||||
marginTop: 20,
|
||||
}}
|
||||
>
|
||||
{selectedDate.toLocaleDateString(LOCALE, {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</Text>
|
||||
|
||||
{frequenciesFiltered.length > 0 ? (
|
||||
<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,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{habitsHistoriesByFrequency[frequency].map((item) => {
|
||||
return (
|
||||
<HabitCard
|
||||
habitHistory={item}
|
||||
selectedDate={selectedDate}
|
||||
key={item.habit.id + selectedDate.toISOString()}
|
||||
confettiRef={confettiRef}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</List.Accordion>
|
||||
)
|
||||
})}
|
||||
</List.Section>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginVertical: 6,
|
||||
}}
|
||||
>
|
||||
<Text variant="titleLarge">No habits for this date</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</>
|
||||
)
|
||||
}
|
@ -0,0 +1,53 @@
|
||||
import { useState } from "react"
|
||||
import { Agenda } from "react-native-calendars"
|
||||
|
||||
import { GOAL_FREQUENCIES } from "@/domain/entities/Goal"
|
||||
import type { HabitsTracker } from "@/domain/entities/HabitsTracker"
|
||||
import { getISODate, getNowDateUTC } from "@/utils/dates"
|
||||
import { HabitsEmpty } from "./HabitsEmpty"
|
||||
import { HabitsList } from "./HabitsList"
|
||||
|
||||
export interface HabitsMainPageProps {
|
||||
habitsTracker: HabitsTracker
|
||||
}
|
||||
|
||||
export const HabitsMainPage: React.FC<HabitsMainPageProps> = (props) => {
|
||||
const { habitsTracker } = props
|
||||
|
||||
const today = getNowDateUTC()
|
||||
const todayISO = getISODate(today)
|
||||
|
||||
const [selectedDate, setSelectedDate] = useState<Date>(today)
|
||||
const selectedDateISO = getISODate(selectedDate)
|
||||
|
||||
const frequenciesFiltered = GOAL_FREQUENCIES.filter((frequency) => {
|
||||
return habitsTracker.habitsHistory[frequency].length > 0
|
||||
})
|
||||
|
||||
if (frequenciesFiltered.length <= 0) {
|
||||
return <HabitsEmpty />
|
||||
}
|
||||
|
||||
return (
|
||||
<Agenda
|
||||
firstDay={1}
|
||||
showClosingKnob
|
||||
onDayPress={(date) => {
|
||||
setSelectedDate(new Date(date.dateString))
|
||||
}}
|
||||
markedDates={{
|
||||
[todayISO]: { marked: true, today: true },
|
||||
}}
|
||||
maxDate={todayISO}
|
||||
selected={selectedDateISO}
|
||||
renderList={() => {
|
||||
return (
|
||||
<HabitsList
|
||||
habitsTracker={habitsTracker}
|
||||
selectedDate={selectedDate}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
116
presentation/react-native/components/HabitsStatistics.tsx
Normal file
@ -0,0 +1,116 @@
|
||||
import { Card, Divider, Text } from "react-native-paper"
|
||||
import CircularProgress from "react-native-circular-progress-indicator"
|
||||
import { Agenda } from "react-native-calendars"
|
||||
import { useState } from "react"
|
||||
import { ScrollView } from "react-native"
|
||||
|
||||
import { getNowDateUTC, getISODate } from "@/utils/dates"
|
||||
import type { HabitsTracker } from "@/domain/entities/HabitsTracker"
|
||||
import { LOCALE } from "@/utils/strings"
|
||||
import {
|
||||
GOAL_FREQUENCIES,
|
||||
GOAL_FREQUENCIES_TYPES,
|
||||
} from "@/domain/entities/Goal"
|
||||
import { calculateRatio } from "@/utils/maths"
|
||||
|
||||
export interface HabitsStatisticsProps {
|
||||
habitsTracker: HabitsTracker
|
||||
}
|
||||
|
||||
export const HabitsStatistics: React.FC<HabitsStatisticsProps> = (props) => {
|
||||
const { habitsTracker } = props
|
||||
|
||||
const today = getNowDateUTC()
|
||||
const todayISO = getISODate(today)
|
||||
|
||||
const [selectedDate, setSelectedDate] = useState<Date>(today)
|
||||
const selectedDateISO = getISODate(selectedDate)
|
||||
|
||||
return (
|
||||
<Agenda
|
||||
firstDay={1}
|
||||
showClosingKnob
|
||||
onDayPress={(date) => {
|
||||
setSelectedDate(new Date(date.dateString))
|
||||
}}
|
||||
markedDates={{
|
||||
[todayISO]: { marked: true, today: true },
|
||||
}}
|
||||
maxDate={todayISO}
|
||||
selected={selectedDateISO}
|
||||
renderList={() => {
|
||||
return (
|
||||
<ScrollView>
|
||||
<Divider />
|
||||
|
||||
<Text
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
fontSize: 22,
|
||||
textAlign: "center",
|
||||
marginVertical: 10,
|
||||
}}
|
||||
>
|
||||
{selectedDate.toLocaleDateString(LOCALE, {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</Text>
|
||||
|
||||
{GOAL_FREQUENCIES.map((frequency) => {
|
||||
const { totalGoalsSuccess, totalGoals } =
|
||||
habitsTracker.getHabitsStatisticsByDateAndFrequency({
|
||||
selectedDate,
|
||||
frequency,
|
||||
})
|
||||
const percentage =
|
||||
calculateRatio(totalGoalsSuccess, totalGoals) * 100
|
||||
return {
|
||||
totalGoalsSuccess,
|
||||
totalGoals,
|
||||
percentage,
|
||||
frequency,
|
||||
}
|
||||
})
|
||||
.filter(({ totalGoals }) => {
|
||||
return totalGoals > 0
|
||||
})
|
||||
.map(
|
||||
({ frequency, totalGoals, totalGoalsSuccess, percentage }) => {
|
||||
return (
|
||||
<Card
|
||||
key={frequency}
|
||||
mode="elevated"
|
||||
style={{ marginVertical: 8, marginHorizontal: 10 }}
|
||||
>
|
||||
<Card.Content
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Text variant="bodyMedium" style={{ marginBottom: 5 }}>
|
||||
{totalGoalsSuccess} achieved goals in the{" "}
|
||||
{GOAL_FREQUENCIES_TYPES[frequency]} out of{" "}
|
||||
{totalGoals}.
|
||||
</Text>
|
||||
<CircularProgress
|
||||
value={percentage}
|
||||
progressValueColor={"#ecf0f1"}
|
||||
circleBackgroundColor="black"
|
||||
titleColor="white"
|
||||
title="%"
|
||||
/>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
)
|
||||
},
|
||||
)}
|
||||
</ScrollView>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
@ -9,5 +9,5 @@ export const TabBarIcon: React.FC<{
|
||||
name: React.ComponentProps<typeof FontAwesome>["name"]
|
||||
color: string
|
||||
}> = (props) => {
|
||||
return <FontAwesome size={28} style={{ marginBottom: -3 }} {...props} />
|
||||
return <FontAwesome size={28} style={[{ marginBottom: -3 }]} {...props} />
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import renderer from "react-test-renderer"
|
||||
|
||||
import { ExternalLink } from "@/presentation/react/components/ExternalLink"
|
||||
import { ExternalLink } from "@/presentation/react-native/ui/ExternalLink"
|
||||
|
||||
describe("<ExternalLink />", () => {
|
||||
it("renders correctly", () => {
|
@ -1,6 +1,6 @@
|
||||
import renderer from "react-test-renderer"
|
||||
|
||||
import { TabBarIcon } from "@/presentation/react/components/TabBarIcon"
|
||||
import { TabBarIcon } from "@/presentation/react-native/ui/TabBarIcon"
|
||||
|
||||
describe("<TabBarIcon />", () => {
|
||||
it("renders correctly", () => {
|
@ -1,11 +1,11 @@
|
||||
import { createContext, useContext, useEffect } from "react"
|
||||
|
||||
import { usePresenterState } from "@/presentation/react/hooks/usePresenterState"
|
||||
import { authenticationPresenter } from "@/infrastructure/instances"
|
||||
import type {
|
||||
AuthenticationPresenter,
|
||||
AuthenticationPresenterState,
|
||||
} from "@/presentation/presenters/Authentication"
|
||||
import { authenticationPresenter } from "@/infrastructure/instances"
|
||||
import { usePresenterState } from "@/presentation/react/hooks/usePresenterState"
|
||||
|
||||
export interface AuthenticationContextValue
|
||||
extends AuthenticationPresenterState {
|
||||
|
@ -1,11 +1,11 @@
|
||||
import { createContext, useContext, useEffect } from "react"
|
||||
|
||||
import { habitsTrackerPresenter } from "@/infrastructure/instances"
|
||||
import type {
|
||||
HabitsTrackerPresenter,
|
||||
HabitsTrackerPresenterState,
|
||||
} from "@/presentation/presenters/HabitsTracker"
|
||||
import { usePresenterState } from "@/presentation/react/hooks/usePresenterState"
|
||||
import { habitsTrackerPresenter } from "@/infrastructure/instances"
|
||||
import { useAuthentication } from "./Authentication"
|
||||
|
||||
export interface HabitsTrackerContextValue extends HabitsTrackerPresenterState {
|
||||
|
@ -2,8 +2,8 @@ import { act, renderHook } from "@testing-library/react-native"
|
||||
|
||||
import { useBoolean } from "@/presentation/react/hooks/useBoolean"
|
||||
|
||||
describe("hooks/useBoolean", () => {
|
||||
beforeEach(() => {
|
||||
describe("presentation/react/hooks/useBoolean", () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
@ -11,51 +11,76 @@ describe("hooks/useBoolean", () => {
|
||||
|
||||
for (const initialValue of initialValues) {
|
||||
it(`should set the initial value to ${initialValue}`, () => {
|
||||
// Arrange - Given
|
||||
const { result } = renderHook(() => {
|
||||
return useBoolean({ initialValue })
|
||||
})
|
||||
|
||||
// Assert - Then
|
||||
expect(result.current.value).toBe(initialValue)
|
||||
})
|
||||
}
|
||||
|
||||
it("should by default set the initial value to false", () => {
|
||||
// Arrange - Given
|
||||
const { result } = renderHook(() => {
|
||||
return useBoolean()
|
||||
})
|
||||
|
||||
// Assert - Then
|
||||
expect(result.current.value).toBe(false)
|
||||
})
|
||||
|
||||
it("should toggle the value", async () => {
|
||||
// Arrange - Given
|
||||
const { result } = renderHook(() => {
|
||||
return useBoolean({ initialValue: false })
|
||||
})
|
||||
|
||||
// Act - When
|
||||
await act(() => {
|
||||
return result.current.toggle()
|
||||
})
|
||||
|
||||
// Assert - Then
|
||||
expect(result.current.value).toBe(true)
|
||||
|
||||
// Act - When
|
||||
await act(() => {
|
||||
return result.current.toggle()
|
||||
})
|
||||
|
||||
// Assert - Then
|
||||
expect(result.current.value).toBe(false)
|
||||
})
|
||||
|
||||
it("should set the value to true", async () => {
|
||||
// Arrange - Given
|
||||
const { result } = renderHook(() => {
|
||||
return useBoolean({ initialValue: false })
|
||||
})
|
||||
|
||||
// Act - When
|
||||
await act(() => {
|
||||
return result.current.setTrue()
|
||||
})
|
||||
|
||||
// Assert - Then
|
||||
expect(result.current.value).toBe(true)
|
||||
})
|
||||
|
||||
it("should set the value to false", async () => {
|
||||
// Arrange - Given
|
||||
const { result } = renderHook(() => {
|
||||
return useBoolean({ initialValue: true })
|
||||
})
|
||||
|
||||
// Act - When
|
||||
await act(() => {
|
||||
return result.current.setFalse()
|
||||
})
|
||||
|
||||
// Assert - Then
|
||||
expect(result.current.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
75
presentation/react/hooks/__tests__/usePresenterState.test.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { act, renderHook } from "@testing-library/react-native"
|
||||
|
||||
import { Presenter } from "@/presentation/presenters/_Presenter"
|
||||
import { usePresenterState } from "@/presentation/react/hooks/usePresenterState"
|
||||
|
||||
interface MockCountPresenterState {
|
||||
count: number
|
||||
}
|
||||
|
||||
class MockCountPresenter extends Presenter<MockCountPresenterState> {
|
||||
public constructor(initialState: MockCountPresenterState) {
|
||||
super(initialState)
|
||||
}
|
||||
|
||||
public increment(): void {
|
||||
this.setState((state) => {
|
||||
state.count = state.count + 1
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
describe("presentation/react/hooks/usePresenterState", () => {
|
||||
it("should return the initial state from the presenter", async () => {
|
||||
// Arrange - Given
|
||||
const initialState = { count: 0 }
|
||||
const presenter = new MockCountPresenter(initialState)
|
||||
|
||||
// Act - When
|
||||
const { result } = renderHook(() => {
|
||||
return usePresenterState(presenter)
|
||||
})
|
||||
|
||||
// Assert - Then
|
||||
expect(result.current).toEqual(initialState)
|
||||
})
|
||||
|
||||
it("should update state when presenter state changes", async () => {
|
||||
// Arrange - Given
|
||||
const initialState = { count: 0 }
|
||||
const presenter = new MockCountPresenter(initialState)
|
||||
const subscribe = jest.spyOn(presenter, "subscribe")
|
||||
const { result } = renderHook(() => {
|
||||
return usePresenterState(presenter)
|
||||
})
|
||||
|
||||
// Act - When
|
||||
await act(() => {
|
||||
presenter.increment()
|
||||
})
|
||||
|
||||
// Assert - Then
|
||||
expect(result.current.count).toBe(1)
|
||||
expect(subscribe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should unsubscribe from presenter on unmount", async () => {
|
||||
// Arrange - Given
|
||||
const initialState = { count: 0 }
|
||||
const presenter = new MockCountPresenter(initialState)
|
||||
const unsubscribe = jest.spyOn(presenter, "unsubscribe")
|
||||
const { result, unmount } = renderHook(() => {
|
||||
return usePresenterState(presenter)
|
||||
})
|
||||
|
||||
// Act - When
|
||||
unmount()
|
||||
await act(() => {
|
||||
presenter.increment()
|
||||
})
|
||||
|
||||
// Assert - Then
|
||||
expect(result.current.count).toBe(0)
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
@ -2,9 +2,10 @@ import { useState } from "react"
|
||||
|
||||
export interface UseBooleanResult {
|
||||
value: boolean
|
||||
toggle: () => void
|
||||
setValue: React.Dispatch<React.SetStateAction<boolean>>
|
||||
setTrue: () => void
|
||||
setFalse: () => void
|
||||
toggle: () => void
|
||||
}
|
||||
|
||||
export interface UseBooleanOptions {
|
||||
@ -43,6 +44,7 @@ export const useBoolean = (
|
||||
|
||||
return {
|
||||
value,
|
||||
setValue,
|
||||
toggle,
|
||||
setTrue,
|
||||
setFalse,
|
||||
|
@ -2,11 +2,13 @@ import { useEffect, useState } from "react"
|
||||
|
||||
import type { Presenter } from "@/presentation/presenters/_Presenter"
|
||||
|
||||
export const usePresenterState = <S>(presenter: Presenter<S>): S => {
|
||||
const [state, setState] = useState<S>(presenter.initialState)
|
||||
export const usePresenterState = <State>(
|
||||
presenter: Presenter<State>,
|
||||
): State => {
|
||||
const [state, setState] = useState<State>(presenter.initialState)
|
||||
|
||||
useEffect(() => {
|
||||
const presenterSubscription = (state: S): void => {
|
||||
const presenterSubscription = (state: State): void => {
|
||||
setState(state)
|
||||
}
|
||||
|
||||
|
115
tests/mocks/domain/Habit.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import { GoalBoolean, GoalNumeric } from "@/domain/entities/Goal"
|
||||
import type { HabitData } from "@/domain/entities/Habit"
|
||||
import { Habit } from "@/domain/entities/Habit"
|
||||
import { USER_MOCK } from "./User"
|
||||
import { ONE_DAY_MILLISECONDS } from "@/utils/dates"
|
||||
|
||||
interface HabitMockCreateOptions extends Omit<HabitData, "startDate"> {
|
||||
startDate?: Date
|
||||
}
|
||||
const habitMockCreate = (options: HabitMockCreateOptions): Habit => {
|
||||
const {
|
||||
id,
|
||||
userId,
|
||||
name,
|
||||
color,
|
||||
icon,
|
||||
goal,
|
||||
startDate = new Date(),
|
||||
endDate,
|
||||
} = options
|
||||
|
||||
return new Habit({
|
||||
id,
|
||||
userId,
|
||||
name,
|
||||
color,
|
||||
icon,
|
||||
goal,
|
||||
startDate,
|
||||
endDate,
|
||||
})
|
||||
}
|
||||
|
||||
const examplesByNames = {
|
||||
"Wake up at 07h00": habitMockCreate({
|
||||
id: "1",
|
||||
userId: USER_MOCK.example.id,
|
||||
name: "Wake up at 07h00",
|
||||
color: "#006CFF",
|
||||
icon: "bed",
|
||||
goal: new GoalBoolean({
|
||||
frequency: "daily",
|
||||
}),
|
||||
}),
|
||||
"Learn English": habitMockCreate({
|
||||
id: "2",
|
||||
userId: USER_MOCK.example.id,
|
||||
name: "Learn English",
|
||||
color: "#EB4034",
|
||||
icon: "language",
|
||||
goal: new GoalNumeric({
|
||||
frequency: "daily",
|
||||
target: {
|
||||
value: 30,
|
||||
unit: "minutes",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
Walk: habitMockCreate({
|
||||
id: "3",
|
||||
userId: USER_MOCK.example.id,
|
||||
name: "Walk",
|
||||
color: "#228B22",
|
||||
icon: "person-walking",
|
||||
goal: new GoalNumeric({
|
||||
frequency: "daily",
|
||||
target: {
|
||||
value: 5000,
|
||||
unit: "steps",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
"Clean the house": habitMockCreate({
|
||||
id: "4",
|
||||
userId: USER_MOCK.example.id,
|
||||
name: "Clean the house",
|
||||
color: "#808080",
|
||||
icon: "broom",
|
||||
goal: new GoalBoolean({
|
||||
frequency: "weekly",
|
||||
}),
|
||||
}),
|
||||
"Solve Programming Challenges": habitMockCreate({
|
||||
id: "5",
|
||||
userId: USER_MOCK.example.id,
|
||||
name: "Solve Programming Challenges",
|
||||
color: "#DE3163",
|
||||
icon: "code",
|
||||
goal: new GoalNumeric({
|
||||
frequency: "monthly",
|
||||
target: {
|
||||
value: 5,
|
||||
unit: "challenges",
|
||||
},
|
||||
}),
|
||||
endDate: new Date(Date.now() + ONE_DAY_MILLISECONDS),
|
||||
}),
|
||||
} as const
|
||||
|
||||
export const examplesByIds = {
|
||||
[examplesByNames["Wake up at 07h00"].id]: examplesByNames["Wake up at 07h00"],
|
||||
[examplesByNames["Learn English"].id]: examplesByNames["Learn English"],
|
||||
[examplesByNames.Walk.id]: examplesByNames.Walk,
|
||||
[examplesByNames["Clean the house"].id]: examplesByNames["Clean the house"],
|
||||
[examplesByNames["Solve Programming Challenges"].id]:
|
||||
examplesByNames["Solve Programming Challenges"],
|
||||
} as const
|
||||
|
||||
export const HABIT_MOCK = {
|
||||
create: habitMockCreate,
|
||||
example: examplesByNames["Wake up at 07h00"],
|
||||
examplesByNames,
|
||||
examplesByIds,
|
||||
examples: Object.values(examplesByNames),
|
||||
}
|
51
tests/mocks/domain/HabitProgress.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import type { GoalBoolean, GoalNumeric } from "@/domain/entities/Goal"
|
||||
import {
|
||||
GoalBooleanProgress,
|
||||
GoalNumericProgress,
|
||||
} from "@/domain/entities/Goal"
|
||||
import type { HabitProgressData } from "@/domain/entities/HabitProgress"
|
||||
import { HabitProgress } from "@/domain/entities/HabitProgress"
|
||||
import { HABIT_MOCK } from "./Habit"
|
||||
|
||||
interface HabitProgressMockCreateOptions
|
||||
extends Omit<HabitProgressData, "date"> {
|
||||
date?: Date
|
||||
}
|
||||
|
||||
const habitProgressMockCreate = (
|
||||
options: HabitProgressMockCreateOptions,
|
||||
): HabitProgress => {
|
||||
const { id, habitId, goalProgress, date = new Date() } = options
|
||||
|
||||
return new HabitProgress({
|
||||
date,
|
||||
goalProgress,
|
||||
habitId,
|
||||
id,
|
||||
})
|
||||
}
|
||||
|
||||
const exampleByIds = {
|
||||
1: habitProgressMockCreate({
|
||||
id: "1",
|
||||
habitId: HABIT_MOCK.examplesByNames["Clean the house"].id,
|
||||
goalProgress: new GoalBooleanProgress({
|
||||
goal: HABIT_MOCK.examplesByNames["Clean the house"].goal as GoalBoolean,
|
||||
progress: true,
|
||||
}),
|
||||
}),
|
||||
2: habitProgressMockCreate({
|
||||
id: "2",
|
||||
habitId: HABIT_MOCK.examplesByNames.Walk.id,
|
||||
goalProgress: new GoalNumericProgress({
|
||||
goal: HABIT_MOCK.examplesByNames.Walk.goal as GoalNumeric,
|
||||
progress: 4_733,
|
||||
}),
|
||||
}),
|
||||
} as const
|
||||
|
||||
export const HABIT_PROGRESS_MOCK = {
|
||||
create: habitProgressMockCreate,
|
||||
exampleByIds,
|
||||
examples: Object.values(exampleByIds),
|
||||
}
|