Compare commits
No commits in common. "develop" and "v1.0.0-staging.1" have entirely different histories.
develop
...
v1.0.0-sta
@ -1,5 +1,5 @@
|
||||
# Supabase - Local
|
||||
# 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_URL='http://127.0.0.1:54321' # Replace `127.0.0.1` with local IP (`hostname -I` on Linux)
|
||||
# EXPO_PUBLIC_SUPABASE_ANON_KEY=''
|
||||
|
||||
# Supabase - Production
|
||||
|
@ -1,11 +1,12 @@
|
||||
{
|
||||
"root": true,
|
||||
"extends": [
|
||||
"conventions",
|
||||
"plugin:react/recommended",
|
||||
"plugin:react-hooks/recommended"
|
||||
"plugin:react-hooks/recommended",
|
||||
"prettier"
|
||||
],
|
||||
"ignorePatterns": ["jest.setup.ts"],
|
||||
"plugins": ["prettier"],
|
||||
"env": {
|
||||
"browser": true,
|
||||
"node": true,
|
||||
@ -16,7 +17,11 @@
|
||||
"version": "detect"
|
||||
}
|
||||
},
|
||||
"parserOptions": {
|
||||
"project": "./tsconfig.json"
|
||||
},
|
||||
"rules": {
|
||||
"prettier/prettier": "error",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/prop-types": "off",
|
||||
"react/self-closing-comp": [
|
||||
@ -32,11 +37,7 @@
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts", "*.tsx"],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": ["@typescript-eslint"],
|
||||
"parserOptions": {
|
||||
"project": "./tsconfig.json"
|
||||
}
|
||||
"parser": "@typescript-eslint/parser"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
27
.github/workflows/ci.yml
vendored
@ -1,27 +0,0 @@
|
||||
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,7 +3,6 @@
|
||||
# dependencies
|
||||
node_modules/
|
||||
.npm/
|
||||
.temp/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
|
28
.gitlab-ci.yml
Normal file
@ -0,0 +1,28 @@
|
||||
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
@ -1,24 +0,0 @@
|
||||
# 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 @@
|
||||
# Habits Tracker - P61 Projet
|
||||
# P61 - Projet
|
||||
|
||||
## À propos
|
||||
|
||||
@ -6,10 +6,6 @@ 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)
|
||||
@ -21,6 +17,7 @@ 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)
|
||||
|
||||
@ -31,6 +28,9 @@ 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) ~2.31.0
|
||||
- [Expo Go](https://expo.io/client)
|
||||
- [Docker](https://www.docker.com/) (facultatif, utilisé pour lancer [Supabase](https://supabase.io/) en local)
|
||||
|
||||
### Installation
|
||||
|
||||
```sh
|
||||
# Cloner le projet
|
||||
git clone git@github.com:theoludwig/p61-project.git
|
||||
git clone git@git.unistra.fr:rrll/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-cli start
|
||||
npm run supabase
|
||||
```
|
||||
|
||||
#### Principales Commandes Supabase
|
||||
|
||||
```sh
|
||||
# Pour réinitialiser la base de données avec les données de test (seed.sql)
|
||||
npm run supabase-cli db reset
|
||||
npm run supabase db reset
|
||||
|
||||
# Pour synchroniser le modèle (local) avec la base de données (remote)
|
||||
npm run supabase-cli db pull
|
||||
npm run supabase db pull
|
||||
|
||||
# Pour synchroniser la base de données (remote) avec le modèle (local)
|
||||
npm run supabase-cli db push
|
||||
npm run supabase db push
|
||||
|
||||
# Pour générer les types TypeScript
|
||||
npm run supabase-cli gen types typescript -- --local > ./infrastructure/supabase/supabase-types.ts
|
||||
npm run supabase gen types typescript -- --local > ./infrastructure/repositories/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-cli db diff -- -f <name-of-migration>
|
||||
npm run supabase db diff -- -f <name-of-migration>
|
||||
```
|
||||
|
21
app.json
@ -1,29 +1,26 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "Habits Tracker",
|
||||
"name": "p61-project",
|
||||
"slug": "p61-project",
|
||||
"version": "1.1.1",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./presentation/assets/images/icon.png",
|
||||
"scheme": "p61-project",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"splash": {
|
||||
"image": "./presentation/assets/images/splashscreen.png",
|
||||
"image": "./presentation/assets/images/splashscreen.jpg",
|
||||
"resizeMode": "cover",
|
||||
"backgroundColor": "#74b6cb"
|
||||
},
|
||||
"assetBundlePatterns": ["**/*"],
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"buildNumber": "1.1.1"
|
||||
"supportsTablet": true
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./presentation/assets/images/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"package": "com.theoludwig.p61project",
|
||||
"versionCode": 6
|
||||
}
|
||||
},
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
@ -33,14 +30,6 @@
|
||||
"plugins": ["expo-router"],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
},
|
||||
"extra": {
|
||||
"router": {
|
||||
"origin": false
|
||||
},
|
||||
"eas": {
|
||||
"projectId": "5c0a922a-564b-4d62-8231-ce5aef7ff978"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,4 @@
|
||||
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,
|
||||
@ -8,7 +6,6 @@ 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 {
|
||||
@ -22,8 +19,6 @@ export const unstableSettings = {
|
||||
initialRouteName: "index",
|
||||
}
|
||||
|
||||
library.add(fas)
|
||||
|
||||
SplashScreen.preventAutoHideAsync().catch((error) => {
|
||||
console.error(error)
|
||||
})
|
||||
@ -66,9 +61,7 @@ const RootLayout: React.FC = () => {
|
||||
},
|
||||
}}
|
||||
>
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<StackLayout />
|
||||
</GestureHandlerRootView>
|
||||
<StackLayout />
|
||||
|
||||
<StatusBar style="dark" />
|
||||
</PaperProvider>
|
||||
|
@ -1,14 +1,14 @@
|
||||
import { Redirect, Tabs } from "expo-router"
|
||||
import React from "react"
|
||||
|
||||
import { TabBarIcon } from "@/presentation/react-native/ui/TabBarIcon"
|
||||
import { TabBarIcon } from "@/presentation/react/components/TabBarIcon"
|
||||
import { useAuthentication } from "@/presentation/react/contexts/Authentication"
|
||||
|
||||
const TabLayout: React.FC = () => {
|
||||
const { user } = useAuthentication()
|
||||
|
||||
if (user == null) {
|
||||
return <Redirect href="/authentication/about" />
|
||||
return <Redirect href="/authentication/login" />
|
||||
}
|
||||
|
||||
return (
|
||||
@ -31,25 +31,17 @@ 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/[habitId]"
|
||||
name="habits/history"
|
||||
options={{
|
||||
unmountOnBlur: true,
|
||||
href: null,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="habits/statistics"
|
||||
options={{
|
||||
title: "Statistics",
|
||||
title: "History",
|
||||
tabBarIcon: ({ color }) => {
|
||||
return <TabBarIcon name="line-chart" color={color} />
|
||||
return <TabBarIcon name="history" color={color} />
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
@ -1,7 +0,0 @@
|
||||
import { Slot } from "expo-router"
|
||||
|
||||
const HabitLayout: React.FC = () => {
|
||||
return <Slot />
|
||||
}
|
||||
|
||||
export default HabitLayout
|
@ -1,21 +0,0 @@
|
||||
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
|
@ -1,26 +0,0 @@
|
||||
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
|
48
app/application/habits/history.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
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,71 +1,34 @@
|
||||
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, retrieveHabitsTracker, habitsTrackerPresenter } =
|
||||
useHabitsTracker()
|
||||
|
||||
const { user } = useAuthentication()
|
||||
const { habitsTracker } = useHabitsTracker()
|
||||
|
||||
return (
|
||||
<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
|
||||
<SafeAreaView style={styles.container}>
|
||||
{habitsTracker.habitsHistory.map((progressHistory) => {
|
||||
const { habit } = progressHistory
|
||||
|
||||
return (
|
||||
<View key={habit.id}>
|
||||
<Text>
|
||||
{habit.name} ({habit.goal.type})
|
||||
</Text>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<HabitsMainPage habitsTracker={habitsTracker} />
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
})
|
||||
|
||||
export default HabitsPage
|
||||
|
@ -1,14 +1,21 @@
|
||||
import { HabitCreateForm } from "@/presentation/react-native/components/HabitForm/HabitCreateForm"
|
||||
import { useAuthentication } from "@/presentation/react/contexts/Authentication"
|
||||
import { StyleSheet } from "react-native"
|
||||
import { Text } from "react-native-paper"
|
||||
import { SafeAreaView } from "react-native-safe-area-context"
|
||||
|
||||
const NewHabitPage: React.FC = () => {
|
||||
const { user } = useAuthentication()
|
||||
|
||||
if (user == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <HabitCreateForm user={user} />
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<Text>New Habit</Text>
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
})
|
||||
|
||||
export default NewHabitPage
|
||||
|
@ -1,23 +0,0 @@
|
||||
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,59 +1,38 @@
|
||||
import { Button, Text } from "react-native-paper"
|
||||
import { View } from "react-native"
|
||||
import { StyleSheet, Text } from "react-native"
|
||||
import { Button } from "react-native-paper"
|
||||
import { SafeAreaView } from "react-native-safe-area-context"
|
||||
|
||||
import { About } from "@/presentation/react-native/components/About"
|
||||
import { useAuthentication } from "@/presentation/react/contexts/Authentication"
|
||||
|
||||
const SettingsPage: React.FC = () => {
|
||||
const { logout, authenticationPresenter, user } = useAuthentication()
|
||||
const { logout, authenticationPresenter } = useAuthentication()
|
||||
|
||||
const handleLogout = async (): Promise<void> => {
|
||||
await authenticationPresenter.logout()
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
<SafeAreaView style={styles.container}>
|
||||
<Text>Settings</Text>
|
||||
|
||||
<Button
|
||||
mode="contained"
|
||||
onPress={handleLogout}
|
||||
loading={logout.state === "loading"}
|
||||
disabled={logout.state === "loading"}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
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-native/ui/TabBarIcon"
|
||||
import { TabBarIcon } from "@/presentation/react/components/TabBarIcon"
|
||||
import { useAuthentication } from "@/presentation/react/contexts/Authentication"
|
||||
|
||||
const TabLayout: React.FC = () => {
|
||||
@ -17,15 +17,6 @@ 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={{
|
||||
|
@ -1,26 +0,0 @@
|
||||
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,14 +60,13 @@ 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.errors.fields.includes("displayName")) {
|
||||
if (register.errorsFields.includes("displayName")) {
|
||||
return "Display Name is required."
|
||||
}
|
||||
if (register.errors.fields.includes("email")) {
|
||||
if (register.errorsFields.includes("email")) {
|
||||
return "Invalid email."
|
||||
}
|
||||
if (register.errors.fields.includes("password")) {
|
||||
if (register.errorsFields.includes("password")) {
|
||||
return "Password must be at least 6 characters."
|
||||
}
|
||||
return "Invalid credentials."
|
||||
@ -41,10 +41,10 @@ const RegisterPage: React.FC = () => {
|
||||
// }
|
||||
|
||||
return ""
|
||||
}, [register.errors.fields, register.state])
|
||||
}, [register.errorsFields, 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,14 +100,13 @@ 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/about" />
|
||||
return <Redirect href="/authentication/login" />
|
||||
}
|
||||
|
||||
return <Redirect href="/application/habits/" />
|
||||
|
1
docs/ARCHITECTURE.md
Normal file
@ -0,0 +1 @@
|
||||
# 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,12 +49,6 @@ 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 est en place pour vérifier que le code respecte ces bonnes pratiques et que les tests passent.
|
||||
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.
|
||||
|
||||
## GitFlow
|
||||
|
||||
Le projet suit la convention [GitFlow](https://nvie.com/posts/a-successful-git-branching-model/) reposant sur 3 branches principales:
|
||||
Le projet suit la convention [GitFlow](https://nvie.com/posts/a-successful-git-branching-model/) reposant sur 2 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 régulièrement.
|
||||
- `develop`: Contient le code en cours de développement. Les nouvelles fonctionnalités et les correctifs de bugs sont fusionnés ici.
|
||||
|
||||
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.
|
||||
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" (ne bloque pas la CI du projet).
|
||||
- Ne doit pas rendre de dépôt "incohérent" (bloque la CI du projet).
|
||||
|
@ -4,6 +4,14 @@
|
||||
|
||||
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)
|
||||
|
||||
|
Before Width: | Height: | Size: 158 KiB |
@ -1,46 +1,9 @@
|
||||
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 const GOAL_FREQUENCIES = ["daily", "weekly", "monthly"] as const
|
||||
export type GoalFrequency = (typeof GOAL_FREQUENCIES)[number]
|
||||
|
||||
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 const GOAL_TYPES = ["numeric", "boolean"] as const
|
||||
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
|
||||
}
|
||||
@ -57,16 +20,6 @@ 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"
|
||||
}
|
||||
@ -92,24 +45,6 @@ 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 { GoalCreateSchema, type Goal, type GoalBaseJSON } from "./Goal"
|
||||
import type { Goal, GoalBaseJSON } from "./Goal"
|
||||
import { Entity, EntitySchema } from "./_Entity"
|
||||
|
||||
export const HabitSchema = EntitySchema.extend({
|
||||
@ -8,28 +8,23 @@ 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({
|
||||
goal: GoalCreateSchema,
|
||||
}).omit({ id: true })
|
||||
export const HabitCreateSchema = HabitSchema.extend({}).omit({ id: true })
|
||||
export type HabitCreateData = z.infer<typeof HabitCreateSchema>
|
||||
|
||||
export const HabitEditSchema = HabitSchema.extend({})
|
||||
export type HabitEditData = z.infer<typeof HabitEditSchema>
|
||||
type HabitDataBase = z.infer<typeof HabitSchema>
|
||||
|
||||
type HabitBase = z.infer<typeof HabitSchema>
|
||||
|
||||
export interface HabitData extends HabitBase {
|
||||
export interface HabitData extends HabitDataBase {
|
||||
goal: Goal
|
||||
startDate: Date
|
||||
endDate?: Date
|
||||
}
|
||||
|
||||
export interface HabitJSON extends HabitBase {
|
||||
export interface HabitJSON extends HabitDataBase {
|
||||
goal: GoalBaseJSON
|
||||
startDate: string
|
||||
endDate?: string
|
||||
}
|
||||
|
||||
export class Habit extends Entity implements HabitData {
|
||||
@ -62,7 +57,7 @@ export class Habit extends Entity implements HabitData {
|
||||
icon: this.icon,
|
||||
goal: this.goal,
|
||||
startDate: this.startDate.toISOString(),
|
||||
endDate: this?.endDate,
|
||||
endDate: this.endDate?.toISOString(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,3 @@
|
||||
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"
|
||||
|
||||
@ -11,70 +8,11 @@ export interface HabitHistoryJSON {
|
||||
|
||||
export class HabitHistory implements HabitHistoryJSON {
|
||||
public habit: Habit
|
||||
|
||||
private _progressHistory: HabitProgress[] = []
|
||||
public 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 HabitProgressBase extends EntityData {
|
||||
interface HabitProgressDataBase extends EntityData {
|
||||
habitId: Habit["id"]
|
||||
}
|
||||
|
||||
export interface HabitProgressData extends HabitProgressBase {
|
||||
export interface HabitProgressData extends HabitProgressDataBase {
|
||||
goalProgress: GoalProgress
|
||||
date: Date
|
||||
}
|
||||
|
||||
export interface HabitProgressJSON extends HabitProgressBase {
|
||||
export interface HabitProgressJSON extends HabitProgressDataBase {
|
||||
goalProgress: GoalProgressBase
|
||||
date: string
|
||||
}
|
||||
|
@ -1,16 +1,11 @@
|
||||
import type { GoalFrequency } from "./Goal"
|
||||
import type { Habit } from "./Habit"
|
||||
import { HabitHistory } from "./HabitHistory"
|
||||
import type { HabitProgress } from "./HabitProgress"
|
||||
import type { HabitHistory } from "./HabitHistory"
|
||||
|
||||
export interface HabitsTrackerData {
|
||||
habitsHistory: {
|
||||
[key in GoalFrequency]: HabitHistory[]
|
||||
}
|
||||
habitsHistory: HabitHistory[]
|
||||
}
|
||||
|
||||
export class HabitsTracker implements HabitsTrackerData {
|
||||
public habitsHistory: HabitsTrackerData["habitsHistory"]
|
||||
public habitsHistory: HabitHistory[]
|
||||
|
||||
public constructor(options: HabitsTrackerData) {
|
||||
const { habitsHistory } = options
|
||||
@ -18,107 +13,6 @@ export class HabitsTracker implements HabitsTrackerData {
|
||||
}
|
||||
|
||||
public static default(): HabitsTracker {
|
||||
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 }
|
||||
return new HabitsTracker({ habitsHistory: [] })
|
||||
}
|
||||
}
|
||||
|
@ -1,106 +0,0 @@
|
||||
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]],
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
@ -1,9 +0,0 @@
|
||||
import type { Habit, HabitCreateData } from "../entities/Habit"
|
||||
|
||||
export interface HabitCreateOptions {
|
||||
habitCreateData: HabitCreateData
|
||||
}
|
||||
|
||||
export interface HabitCreateRepository {
|
||||
execute: (options: HabitCreateOptions) => Promise<Habit>
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
import type { Habit, HabitEditData } from "../entities/Habit"
|
||||
|
||||
export interface HabitEditOptions {
|
||||
habitEditData: HabitEditData
|
||||
}
|
||||
|
||||
export interface HabitEditRepository {
|
||||
execute: (options: HabitEditOptions) => Promise<Habit>
|
||||
}
|
@ -1,12 +0,0 @@
|
||||
import type {
|
||||
HabitProgress,
|
||||
HabitProgressData,
|
||||
} from "../entities/HabitProgress"
|
||||
|
||||
export interface HabitProgressCreateOptions {
|
||||
habitProgressData: Omit<HabitProgressData, "id">
|
||||
}
|
||||
|
||||
export interface HabitProgressCreateRepository {
|
||||
execute: (options: HabitProgressCreateOptions) => Promise<HabitProgress>
|
||||
}
|
@ -1,12 +0,0 @@
|
||||
import type {
|
||||
HabitProgress,
|
||||
HabitProgressData,
|
||||
} from "../entities/HabitProgress"
|
||||
|
||||
export interface HabitProgressUpdateOptions {
|
||||
habitProgressData: Omit<HabitProgressData, "habitId">
|
||||
}
|
||||
|
||||
export interface HabitProgressUpdateRepository {
|
||||
execute: (options: HabitProgressUpdateOptions) => Promise<HabitProgress>
|
||||
}
|
@ -1,5 +1,8 @@
|
||||
import type { User } from "../entities/User"
|
||||
import { UserLoginSchema, UserRegisterSchema } from "../entities/User"
|
||||
import {
|
||||
UserRegisterSchema,
|
||||
type User,
|
||||
UserLoginSchema,
|
||||
} from "../entities/User"
|
||||
import type { AuthenticationRepository } from "../repositories/Authentication"
|
||||
|
||||
export interface AuthenticationUseCaseDependencyOptions {
|
||||
@ -39,16 +42,16 @@ export class AuthenticationUseCase
|
||||
return await this.authenticationRepository.login(userData)
|
||||
}
|
||||
|
||||
public logout: AuthenticationRepository["logout"] = async () => {
|
||||
public async logout(): Promise<void> {
|
||||
return await this.authenticationRepository.logout()
|
||||
}
|
||||
|
||||
public getUser: AuthenticationRepository["getUser"] = async () => {
|
||||
return await this.authenticationRepository.getUser()
|
||||
public getUser: AuthenticationRepository["getUser"] = async (...args) => {
|
||||
return await this.authenticationRepository.getUser(...args)
|
||||
}
|
||||
|
||||
public onUserStateChange: AuthenticationRepository["onUserStateChange"] =
|
||||
async (callback) => {
|
||||
return this.authenticationRepository.onUserStateChange(callback)
|
||||
async (...args) => {
|
||||
return this.authenticationRepository.onUserStateChange(...args)
|
||||
}
|
||||
}
|
||||
|
@ -1,23 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
@ -1,23 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
@ -1,66 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
@ -1,24 +0,0 @@
|
||||
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,5 +1,4 @@
|
||||
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"
|
||||
@ -39,27 +38,14 @@ export class RetrieveHabitsTrackerUseCase
|
||||
})
|
||||
return new HabitHistory({
|
||||
habit,
|
||||
progressHistory,
|
||||
progressHistory: progressHistory.sort((a, b) => {
|
||||
return a.date.getTime() - b.date.getTime()
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
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,
|
||||
habitsHistory: habitProgressHistories,
|
||||
})
|
||||
return habitsTracker
|
||||
}
|
||||
|
15
eas.json
@ -1,15 +0,0 @@
|
||||
{
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"staging": {
|
||||
"distribution": "internal",
|
||||
"android": {
|
||||
"buildType": "apk"
|
||||
}
|
||||
},
|
||||
"production": {}
|
||||
}
|
||||
}
|
@ -1,19 +1,11 @@
|
||||
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
|
||||
@ -28,22 +20,6 @@ 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
|
||||
@ -51,23 +27,10 @@ const habitProgressUpdateRepository = new HabitProgressUpdateSupabaseRepository(
|
||||
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
|
||||
@ -77,8 +40,4 @@ export const authenticationPresenter = new AuthenticationPresenter({
|
||||
})
|
||||
export const habitsTrackerPresenter = new HabitsTrackerPresenter({
|
||||
retrieveHabitsTrackerUseCase,
|
||||
habitCreateUseCase,
|
||||
habitEditUseCase,
|
||||
habitStopUseCase,
|
||||
habitGoalProgressUpdateUseCase,
|
||||
})
|
||||
|
@ -1,79 +0,0 @@
|
||||
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)
|
||||
})
|
||||
},
|
||||
}
|
@ -1,78 +0,0 @@
|
||||
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)
|
||||
})
|
||||
},
|
||||
}
|
@ -1,100 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
@ -1,22 +0,0 @@
|
||||
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 { User } from "@/domain/entities/User"
|
||||
import type { AuthenticationRepository } from "@/domain/repositories/Authentication"
|
||||
import { SupabaseRepository } from "@/infrastructure/supabase/repositories/_SupabaseRepository"
|
||||
import { SupabaseRepository } from "./_SupabaseRepository"
|
||||
import { User } from "@/domain/entities/User"
|
||||
|
||||
export class AuthenticationSupabaseRepository
|
||||
extends SupabaseRepository
|
||||
|
@ -1,6 +1,11 @@
|
||||
import type { GetHabitProgressHistoryRepository } from "@/domain/repositories/GetHabitProgressHistory"
|
||||
import { SupabaseRepository } from "@/infrastructure/supabase/repositories/_SupabaseRepository"
|
||||
import { habitProgressHistorySupabaseDTO } from "../data-transfer-objects/HabitProgressDTO"
|
||||
import { SupabaseRepository } from "./_SupabaseRepository"
|
||||
import { HabitProgress } from "@/domain/entities/HabitProgress"
|
||||
import type { GoalProgress } from "@/domain/entities/Goal"
|
||||
import {
|
||||
GoalBooleanProgress,
|
||||
GoalNumericProgress,
|
||||
} from "@/domain/entities/Goal"
|
||||
|
||||
export class GetHabitProgressHistorySupabaseRepository
|
||||
extends SupabaseRepository
|
||||
@ -10,15 +15,37 @@ export class GetHabitProgressHistorySupabaseRepository
|
||||
options,
|
||||
) => {
|
||||
const { habit } = options
|
||||
const { data } = await this.supabaseClient
|
||||
const { data, error } = await this.supabaseClient
|
||||
.from("habits_progresses")
|
||||
.select("*")
|
||||
.eq("habit_id", habit.id)
|
||||
.throwOnError()
|
||||
const habitProgressHistory = data as NonNullable<typeof data>
|
||||
return habitProgressHistorySupabaseDTO.fromSupabaseToDomain(
|
||||
habitProgressHistory,
|
||||
habit.goal,
|
||||
)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,8 @@
|
||||
import type { GetHabitsByUserIdRepository } from "@/domain/repositories/GetHabitsByUserId"
|
||||
import { SupabaseRepository } from "@/infrastructure/supabase/repositories/_SupabaseRepository"
|
||||
import { habitsSupabaseDTO } from "../data-transfer-objects/HabitDTO"
|
||||
import { SupabaseRepository } from "./_SupabaseRepository"
|
||||
import { Habit } from "@/domain/entities/Habit"
|
||||
import type { Goal } from "@/domain/entities/Goal"
|
||||
import { GoalBoolean, GoalNumeric } from "@/domain/entities/Goal"
|
||||
|
||||
export class GetHabitsByUserIdSupabaseRepository
|
||||
extends SupabaseRepository
|
||||
@ -8,12 +10,39 @@ export class GetHabitsByUserIdSupabaseRepository
|
||||
{
|
||||
public execute: GetHabitsByUserIdRepository["execute"] = async (options) => {
|
||||
const { userId } = options
|
||||
const { data } = await this.supabaseClient
|
||||
const { data, error } = await this.supabaseClient
|
||||
.from("habits")
|
||||
.select("*")
|
||||
.eq("user_id", userId)
|
||||
.throwOnError()
|
||||
const habits = data as NonNullable<typeof data>
|
||||
return habitsSupabaseDTO.fromSupabaseToDomain(habits)
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -1,22 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
@ -1,23 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
@ -1,29 +0,0 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
@ -1,30 +0,0 @@
|
||||
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() - INTERVAL '3 days'),
|
||||
timezone('utc' :: text, NOW()),
|
||||
NULL,
|
||||
'daily',
|
||||
NULL,
|
||||
@ -144,7 +144,7 @@ VALUES
|
||||
'Learn English',
|
||||
'#EB4034',
|
||||
'language',
|
||||
timezone('utc' :: text, NOW() - INTERVAL '3 days'),
|
||||
timezone('utc' :: text, NOW()),
|
||||
NULL,
|
||||
'daily',
|
||||
30,
|
||||
@ -171,7 +171,7 @@ VALUES
|
||||
'Walk',
|
||||
'#228B22',
|
||||
'person-walking',
|
||||
timezone('utc' :: text, NOW() - INTERVAL '3 days'),
|
||||
timezone('utc' :: text, NOW()),
|
||||
NULL,
|
||||
'daily',
|
||||
5000,
|
||||
@ -198,7 +198,7 @@ VALUES
|
||||
'Clean the house',
|
||||
'#808080',
|
||||
'broom',
|
||||
timezone('utc' :: text, NOW() - INTERVAL '3 days'),
|
||||
timezone('utc' :: text, NOW()),
|
||||
NULL,
|
||||
'weekly',
|
||||
NULL,
|
||||
@ -225,7 +225,7 @@ VALUES
|
||||
'Solve Programming Challenges',
|
||||
'#DE3163',
|
||||
'code',
|
||||
timezone('utc' :: text, NOW() - INTERVAL '3 days'),
|
||||
timezone('utc' :: text, NOW()),
|
||||
NULL,
|
||||
'monthly',
|
||||
5,
|
||||
@ -262,6 +262,3 @@ 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: string[]
|
||||
Returns: unknown
|
||||
}
|
||||
get_size_by_bucket: {
|
||||
Args: Record<PropertyKey, never>
|
||||
|
@ -1,34 +1,12 @@
|
||||
import {
|
||||
createClient,
|
||||
type User as SupabaseUserType,
|
||||
} from "@supabase/supabase-js"
|
||||
import { createClient } 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"
|
||||
|
||||
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"
|
||||
const SUPABASE_URL = process.env["EXPO_PUBLIC_SUPABASE_URL"] ?? ""
|
||||
const SUPABASE_ANON_KEY = process.env["EXPO_PUBLIC_SUPABASE_ANON_KEY"] ?? ""
|
||||
|
||||
export const supabaseClient = createClient<Database>(
|
||||
SUPABASE_URL,
|
||||
|
@ -1,7 +1,7 @@
|
||||
{
|
||||
"preset": "jest-expo",
|
||||
"roots": ["./"],
|
||||
"setupFilesAfterEnv": ["<rootDir>/tests/setup.ts"],
|
||||
"setupFilesAfterEnv": ["@testing-library/react-native/extend-expect"],
|
||||
"fakeTimers": {
|
||||
"enableGlobally": true
|
||||
},
|
||||
@ -10,13 +10,7 @@
|
||||
"coverageReporters": ["text", "text-summary", "cobertura"],
|
||||
"collectCoverageFrom": [
|
||||
"<rootDir>/**/*.{ts,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>/presentation/react/components/ExternalLink.tsx",
|
||||
"!<rootDir>/.expo",
|
||||
"!<rootDir>/app/+html.tsx",
|
||||
"!<rootDir>/app/**/_layout.tsx",
|
||||
|
7903
package-lock.json
generated
96
package.json
@ -2,91 +2,81 @@
|
||||
"name": "p61-project",
|
||||
"private": true,
|
||||
"main": "expo-router/entry",
|
||||
"version": "1.1.1",
|
||||
"version": "1.0.0-staging.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-cli": "supabase --workdir \"./infrastructure\"",
|
||||
"supabase": "supabase --workdir \"./infrastructure\"",
|
||||
"postinstall": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"@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",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"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-hook-form": "7.51.1",
|
||||
"react-native": "0.73.6",
|
||||
"react-native-calendars": "1.1304.1",
|
||||
"react-native-elements": "3.4.3",
|
||||
"react-native-gesture-handler": "2.16.2",
|
||||
"react-native-paper": "5.12.3",
|
||||
"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-safe-area-context": "4.8.2",
|
||||
"react-native-screens": "3.29.0",
|
||||
"react-native-url-polyfill": "2.0.0",
|
||||
"react-native-vector-icons": "10.1.0",
|
||||
"react-native-web": "0.19.11",
|
||||
"reanimated-color-picker": "3.0.3",
|
||||
"zod": "3.23.8"
|
||||
"react-native-vector-icons": "10.0.3",
|
||||
"react-native-web": "0.19.10",
|
||||
"zod": "3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.24.5",
|
||||
"@commitlint/cli": "19.2.2",
|
||||
"@commitlint/config-conventional": "19.2.2",
|
||||
"@testing-library/react-native": "12.5.0",
|
||||
"@babel/core": "7.24.3",
|
||||
"@commitlint/cli": "19.1.0",
|
||||
"@commitlint/config-conventional": "19.1.0",
|
||||
"@testing-library/react-native": "12.4.4",
|
||||
"@total-typescript/ts-reset": "0.5.1",
|
||||
"@tsconfig/strictest": "2.0.5",
|
||||
"@tsconfig/strictest": "2.0.3",
|
||||
"@types/jest": "29.5.12",
|
||||
"@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",
|
||||
"@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",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-conventions": "14.2.0",
|
||||
"eslint-config-conventions": "14.1.0",
|
||||
"eslint-config-prettier": "9.1.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.2",
|
||||
"eslint-plugin-react-hooks": "4.6.0",
|
||||
"eslint-plugin-react-native": "4.1.0",
|
||||
"eslint-plugin-unicorn": "53.0.0",
|
||||
"eslint-plugin-unicorn": "51.0.1",
|
||||
"husky": "9.0.11",
|
||||
"jest": "29.7.0",
|
||||
"jest-expo": "51.0.2",
|
||||
"jest-expo": "50.0.4",
|
||||
"jest-junit": "16.0.0",
|
||||
"lint-staged": "15.2.4",
|
||||
"prettier": "3.2.5",
|
||||
"lint-staged": "15.2.2",
|
||||
"react-test-renderer": "18.2.0",
|
||||
"supabase": "1.167.4",
|
||||
"typescript": "5.3.3"
|
||||
"supabase": "1.150.0",
|
||||
"typescript": "5.4.3"
|
||||
}
|
||||
}
|
||||
|
BIN
presentation/assets/images/Universite-Strasbourg.png
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
presentation/assets/images/splashscreen.jpg
Executable file
After Width: | Height: | Size: 97 KiB |
Before Width: | Height: | Size: 52 KiB |
Before Width: | Height: | Size: 76 KiB |
Before Width: | Height: | Size: 115 KiB |
Before 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 { getErrorsFieldsFromZodError } from "../../utils/zod"
|
||||
import { zodPresenter } from "./utils/ZodPresenter"
|
||||
|
||||
export interface AuthenticationPresenterState {
|
||||
user: User | null
|
||||
@ -20,18 +20,14 @@ export interface AuthenticationPresenterState {
|
||||
|
||||
register: {
|
||||
state: FetchState
|
||||
errors: {
|
||||
fields: Array<keyof UserRegisterData>
|
||||
global: ErrorGlobal
|
||||
}
|
||||
errorsFields: Array<keyof UserRegisterData>
|
||||
errorGlobal: ErrorGlobal
|
||||
}
|
||||
|
||||
login: {
|
||||
state: FetchState
|
||||
errors: {
|
||||
fields: Array<keyof UserLoginData>
|
||||
global: ErrorGlobal
|
||||
}
|
||||
errorsFields: Array<keyof UserLoginData>
|
||||
errorGlobal: ErrorGlobal
|
||||
}
|
||||
|
||||
logout: {
|
||||
@ -56,17 +52,13 @@ export class AuthenticationPresenter
|
||||
hasLoaded: true,
|
||||
register: {
|
||||
state: "idle",
|
||||
errors: {
|
||||
fields: [],
|
||||
global: null,
|
||||
},
|
||||
errorsFields: [],
|
||||
errorGlobal: null,
|
||||
},
|
||||
login: {
|
||||
state: "idle",
|
||||
errors: {
|
||||
fields: [],
|
||||
global: null,
|
||||
},
|
||||
errorsFields: [],
|
||||
errorGlobal: null,
|
||||
},
|
||||
logout: {
|
||||
state: "idle",
|
||||
@ -79,10 +71,8 @@ export class AuthenticationPresenter
|
||||
try {
|
||||
this.setState((state) => {
|
||||
state.register.state = "loading"
|
||||
state.register.errors = {
|
||||
fields: [],
|
||||
global: null,
|
||||
}
|
||||
state.register.errorsFields = []
|
||||
state.register.errorGlobal = null
|
||||
})
|
||||
const user = await this.authenticationUseCase.register(data)
|
||||
this.setState((state) => {
|
||||
@ -93,10 +83,10 @@ export class AuthenticationPresenter
|
||||
this.setState((state) => {
|
||||
state.register.state = "error"
|
||||
if (error instanceof ZodError) {
|
||||
state.register.errors.fields =
|
||||
getErrorsFieldsFromZodError<UserRegisterData>(error)
|
||||
state.register.errorsFields =
|
||||
zodPresenter.getErrorsFieldsFromZodError<UserRegisterData>(error)
|
||||
} else {
|
||||
state.register.errors.global = "unknown"
|
||||
state.register.errorGlobal = "unknown"
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -106,10 +96,8 @@ export class AuthenticationPresenter
|
||||
try {
|
||||
this.setState((state) => {
|
||||
state.login.state = "loading"
|
||||
state.login.errors = {
|
||||
fields: [],
|
||||
global: null,
|
||||
}
|
||||
state.login.errorsFields = []
|
||||
state.login.errorGlobal = null
|
||||
})
|
||||
const user = await this.authenticationUseCase.login(data)
|
||||
this.setState((state) => {
|
||||
@ -120,10 +108,10 @@ export class AuthenticationPresenter
|
||||
this.setState((state) => {
|
||||
state.login.state = "error"
|
||||
if (error instanceof ZodError) {
|
||||
state.login.errors.fields =
|
||||
getErrorsFieldsFromZodError<UserLoginData>(error)
|
||||
state.login.errorsFields =
|
||||
zodPresenter.getErrorsFieldsFromZodError<UserLoginData>(error)
|
||||
} else {
|
||||
state.login.errors.global = "unknown"
|
||||
state.login.errorGlobal = "unknown"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -1,25 +1,10 @@
|
||||
import { ZodError } from "zod"
|
||||
|
||||
import { HabitsTracker } from "@/domain/entities/HabitsTracker"
|
||||
import type { ErrorGlobal, FetchState } from "./_Presenter"
|
||||
import type { 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
|
||||
@ -27,38 +12,10 @@ 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
|
||||
@ -66,126 +23,12 @@ 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,
|
||||
habitCreateUseCase,
|
||||
habitEditUseCase,
|
||||
habitStopUseCase,
|
||||
habitGoalProgressUpdateUseCase,
|
||||
} = options
|
||||
const { retrieveHabitsTrackerUseCase } = options
|
||||
const habitsTracker = HabitsTracker.default()
|
||||
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",
|
||||
},
|
||||
})
|
||||
super({ habitsTracker, retrieveHabitsTracker: { 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(
|
||||
@ -208,26 +51,4 @@ 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,7 +41,10 @@ export abstract class Presenter<State> {
|
||||
|
||||
public unsubscribe(listener: Listener<State>): void {
|
||||
const listenerIndex = this._listeners.indexOf(listener)
|
||||
this._listeners.splice(listenerIndex, 1)
|
||||
const listenerFound = listenerIndex !== -1
|
||||
if (listenerFound) {
|
||||
this._listeners.splice(listenerIndex, 1)
|
||||
}
|
||||
}
|
||||
|
||||
private notifyListeners(): void {
|
||||
|
7
presentation/presenters/utils/ZodPresenter.ts
Normal file
@ -0,0 +1,7 @@
|
||||
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>
|
||||
},
|
||||
}
|
@ -1,90 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
@ -1,346 +0,0 @@
|
||||
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,
|
||||
},
|
||||
})
|
@ -1,208 +0,0 @@
|
||||
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,
|
||||
},
|
||||
})
|
@ -1,129 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
@ -1,74 +0,0 @@
|
||||
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)
|
@ -1,268 +0,0 @@
|
||||
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,
|
||||
},
|
||||
})
|
@ -1,137 +0,0 @@
|
||||
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,
|
||||
}),
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
@ -1,40 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
@ -1,157 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,53 +0,0 @@
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
@ -1,116 +0,0 @@
|
||||
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-native/ui/ExternalLink"
|
||||
import { ExternalLink } from "@/presentation/react/components/ExternalLink"
|
||||
|
||||
describe("<ExternalLink />", () => {
|
||||
it("renders correctly", () => {
|
@ -1,6 +1,6 @@
|
||||
import renderer from "react-test-renderer"
|
||||
|
||||
import { TabBarIcon } from "@/presentation/react-native/ui/TabBarIcon"
|
||||
import { TabBarIcon } from "@/presentation/react/components/TabBarIcon"
|
||||
|
||||
describe("<TabBarIcon />", () => {
|
||||
it("renders correctly", () => {
|
@ -1,11 +1,11 @@
|
||||
import { createContext, useContext, useEffect } from "react"
|
||||
|
||||
import { authenticationPresenter } from "@/infrastructure/instances"
|
||||
import { usePresenterState } from "@/presentation/react/hooks/usePresenterState"
|
||||
import type {
|
||||
AuthenticationPresenter,
|
||||
AuthenticationPresenterState,
|
||||
} from "@/presentation/presenters/Authentication"
|
||||
import { usePresenterState } from "@/presentation/react/hooks/usePresenterState"
|
||||
import { authenticationPresenter } from "@/infrastructure/instances"
|
||||
|
||||
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("presentation/react/hooks/useBoolean", () => {
|
||||
afterEach(() => {
|
||||
describe("hooks/useBoolean", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
@ -11,76 +11,51 @@ describe("presentation/react/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)
|
||||
})
|
||||
})
|
||||
|
@ -1,75 +0,0 @@
|
||||
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,10 +2,9 @@ import { useState } from "react"
|
||||
|
||||
export interface UseBooleanResult {
|
||||
value: boolean
|
||||
setValue: React.Dispatch<React.SetStateAction<boolean>>
|
||||
toggle: () => void
|
||||
setTrue: () => void
|
||||
setFalse: () => void
|
||||
toggle: () => void
|
||||
}
|
||||
|
||||
export interface UseBooleanOptions {
|
||||
@ -44,7 +43,6 @@ export const useBoolean = (
|
||||
|
||||
return {
|
||||
value,
|
||||
setValue,
|
||||
toggle,
|
||||
setTrue,
|
||||
setFalse,
|
||||
|
@ -2,13 +2,11 @@ import { useEffect, useState } from "react"
|
||||
|
||||
import type { Presenter } from "@/presentation/presenters/_Presenter"
|
||||
|
||||
export const usePresenterState = <State>(
|
||||
presenter: Presenter<State>,
|
||||
): State => {
|
||||
const [state, setState] = useState<State>(presenter.initialState)
|
||||
export const usePresenterState = <S>(presenter: Presenter<S>): S => {
|
||||
const [state, setState] = useState<S>(presenter.initialState)
|
||||
|
||||
useEffect(() => {
|
||||
const presenterSubscription = (state: State): void => {
|
||||
const presenterSubscription = (state: S): void => {
|
||||
setState(state)
|
||||
}
|
||||
|
||||
|
@ -1,115 +0,0 @@
|
||||
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),
|
||||
}
|
@ -1,51 +0,0 @@
|
||||
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),
|
||||
}
|