Compare commits

..

No commits in common. "develop" and "v1.0.0-staging.1" have entirely different histories.

117 changed files with 3141 additions and 14779 deletions

View File

@ -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

View File

@ -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"
}
]
}

View File

@ -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
View File

@ -3,7 +3,6 @@
# dependencies
node_modules/
.npm/
.temp/
# Expo
.expo/

28
.gitlab-ci.yml Normal file
View 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
View File

@ -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.

View File

@ -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>
```

View File

@ -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"
}
}
}
}

View File

@ -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>

View File

@ -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} />
},
}}
/>

View File

@ -1,7 +0,0 @@
import { Slot } from "expo-router"
const HabitLayout: React.FC = () => {
return <Slot />
}
export default HabitLayout

View File

@ -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

View File

@ -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

View 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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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={{

View File

@ -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

View File

@ -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"}

View File

@ -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"}

View File

@ -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
View File

@ -0,0 +1 @@
# Clean Architecture

View File

@ -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 cest normal, les gens sont prêts à payer alors pourquoi sen 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 dusine à gaz sil 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 damé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 dun 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 dinterfaces
![UI Example](../presentation/assets/images/ui-example.png)
![UI Example 2](../presentation/assets/images/ui-example-2.jpg)
![UI Example 3](../presentation/assets/images/ui-example-3.jpg)
![UI Example 4](../presentation/assets/images/ui-example-4.png)

View File

@ -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).

View File

@ -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**

View File

@ -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 Expos 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)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

View File

@ -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 {

View File

@ -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(),
}
}
}

View File

@ -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")
}
}

View File

@ -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
}

View File

@ -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: [] })
}
}

View File

@ -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]],
}),
])
})
})
})

View File

@ -1,9 +0,0 @@
import type { Habit, HabitCreateData } from "../entities/Habit"
export interface HabitCreateOptions {
habitCreateData: HabitCreateData
}
export interface HabitCreateRepository {
execute: (options: HabitCreateOptions) => Promise<Habit>
}

View File

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

View File

@ -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>
}

View File

@ -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>
}

View File

@ -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)
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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")
}
}

View File

@ -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
}
}

View File

@ -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
}

View File

@ -1,15 +0,0 @@
{
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"staging": {
"distribution": "internal",
"android": {
"buildType": "apk"
}
},
"production": {}
}
}

View File

@ -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,
})

View File

@ -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)
})
},
}

View File

@ -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)
})
},
}

View File

@ -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)
})
})
})

View File

@ -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
],
)
})
}
})
})

View File

@ -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

View File

@ -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
}
}

View File

@ -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
})
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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,
)
}
}

View File

@ -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,
)
}
}

View File

@ -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);

View File

@ -266,7 +266,7 @@ export interface Database {
Args: {
name: string
}
Returns: string[]
Returns: unknown
}
get_size_by_bucket: {
Args: Record<PropertyKey, never>

View File

@ -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,

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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"
}
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

View File

@ -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"
}
})
}

View File

@ -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"
}
}
}

View File

@ -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 {

View 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>
},
}

View File

@ -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>
)
}

View File

@ -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,
},
})

View File

@ -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,
},
})

View File

@ -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>
)
}

View File

@ -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)

View File

@ -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,
},
})

View File

@ -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,
}),
})
}}
/>
)
}}
/>
)
}

View File

@ -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>
)
}

View File

@ -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>
</>
)
}

View File

@ -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}
/>
)
}}
/>
)
}

View File

@ -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>
)
}}
/>
)
}

View File

@ -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} />
}

View File

@ -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", () => {

View File

@ -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", () => {

View File

@ -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 {

View File

@ -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 {

View File

@ -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)
})
})

View File

@ -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)
})
})

View File

@ -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,

View File

@ -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)
}

View File

@ -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),
}

View File

@ -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),
}

Some files were not shown because too many files have changed in this diff Show More