Merge branch 'develop' into feat/stats
This commit is contained in:
commit
2ab7413f32
@ -1,5 +1,5 @@
|
|||||||
# Supabase - Local
|
# Supabase - Local
|
||||||
# EXPO_PUBLIC_SUPABASE_URL='http://127.0.0.1:54321' # Replace `127.0.0.1` with local IP (`hostname -I` on Linux)
|
# EXPO_PUBLIC_SUPABASE_URL='http://127.0.0.1:54321' # Replace `127.0.0.1` with local IP (e.g: `hostname -i` on GNU/Linux)
|
||||||
# EXPO_PUBLIC_SUPABASE_ANON_KEY=''
|
# EXPO_PUBLIC_SUPABASE_ANON_KEY=''
|
||||||
|
|
||||||
# Supabase - Production
|
# Supabase - Production
|
||||||
|
@ -2,11 +2,9 @@
|
|||||||
"extends": [
|
"extends": [
|
||||||
"conventions",
|
"conventions",
|
||||||
"plugin:react/recommended",
|
"plugin:react/recommended",
|
||||||
"plugin:react-hooks/recommended",
|
"plugin:react-hooks/recommended"
|
||||||
"prettier"
|
|
||||||
],
|
],
|
||||||
"ignorePatterns": ["jest.setup.ts"],
|
"ignorePatterns": ["jest.setup.ts"],
|
||||||
"plugins": ["prettier"],
|
|
||||||
"env": {
|
"env": {
|
||||||
"browser": true,
|
"browser": true,
|
||||||
"node": true,
|
"node": true,
|
||||||
@ -21,7 +19,6 @@
|
|||||||
"project": "./tsconfig.json"
|
"project": "./tsconfig.json"
|
||||||
},
|
},
|
||||||
"rules": {
|
"rules": {
|
||||||
"prettier/prettier": "error",
|
|
||||||
"react/react-in-jsx-scope": "off",
|
"react/react-in-jsx-scope": "off",
|
||||||
"react/prop-types": "off",
|
"react/prop-types": "off",
|
||||||
"react/self-closing-comp": [
|
"react/self-closing-comp": [
|
||||||
@ -37,7 +34,10 @@
|
|||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
"files": ["*.ts", "*.tsx"],
|
"files": ["*.ts", "*.tsx"],
|
||||||
"parser": "@typescript-eslint/parser"
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"rules": {
|
||||||
|
"@typescript-eslint/member-delimiter-style": "off"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -3,6 +3,7 @@
|
|||||||
# dependencies
|
# dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
.npm/
|
.npm/
|
||||||
|
.temp/
|
||||||
|
|
||||||
# Expo
|
# Expo
|
||||||
.expo/
|
.expo/
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
default:
|
default:
|
||||||
image: "node:20.11.1"
|
image: "node:20.13.1"
|
||||||
|
|
||||||
stages:
|
stages:
|
||||||
- "test"
|
- "test"
|
||||||
|
20
README.md
20
README.md
@ -1,4 +1,4 @@
|
|||||||
# P61 - Projet
|
# Habits Tracker - P61 Projet
|
||||||
|
|
||||||
## À propos
|
## À propos
|
||||||
|
|
||||||
@ -6,6 +6,10 @@ Application mobile en [React Native](https://reactnative.dev/) pour le projet du
|
|||||||
|
|
||||||
Un tracker d'habitudes pour performer au boulot et dans la vie de tous les jours.
|
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
|
### Membres du Groupe 7
|
||||||
|
|
||||||
- [Théo LUDWIG](https://git.unistra.fr/t.ludwig)
|
- [Théo LUDWIG](https://git.unistra.fr/t.ludwig)
|
||||||
@ -35,7 +39,7 @@ Un tracker d'habitudes pour performer au boulot et dans la vie de tous les jours
|
|||||||
|
|
||||||
- [Node.js](https://nodejs.org/) >= 20.0.0
|
- [Node.js](https://nodejs.org/) >= 20.0.0
|
||||||
- [npm](https://www.npmjs.com/) >= 10.0.0
|
- [npm](https://www.npmjs.com/) >= 10.0.0
|
||||||
- [Expo Go](https://expo.io/client)
|
- [Expo Go](https://expo.io/client) ~2.31.0
|
||||||
- [Docker](https://www.docker.com/) (facultatif, utilisé pour lancer [Supabase](https://supabase.io/) en local)
|
- [Docker](https://www.docker.com/) (facultatif, utilisé pour lancer [Supabase](https://supabase.io/) en local)
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
@ -65,24 +69,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).
|
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
|
```sh
|
||||||
npm run supabase
|
npm run supabase-cli start
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Principales Commandes Supabase
|
#### Principales Commandes Supabase
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# Pour réinitialiser la base de données avec les données de test (seed.sql)
|
# Pour réinitialiser la base de données avec les données de test (seed.sql)
|
||||||
npm run supabase db reset
|
npm run supabase-cli db reset
|
||||||
|
|
||||||
# Pour synchroniser le modèle (local) avec la base de données (remote)
|
# Pour synchroniser le modèle (local) avec la base de données (remote)
|
||||||
npm run supabase db pull
|
npm run supabase-cli db pull
|
||||||
|
|
||||||
# Pour synchroniser la base de données (remote) avec le modèle (local)
|
# Pour synchroniser la base de données (remote) avec le modèle (local)
|
||||||
npm run supabase db push
|
npm run supabase-cli db push
|
||||||
|
|
||||||
# Pour générer les types TypeScript
|
# Pour générer les types TypeScript
|
||||||
npm run supabase gen types typescript -- --local > ./infrastructure/supabase/supabase-types.ts
|
npm run supabase-cli gen types typescript -- --local > ./infrastructure/supabase/supabase-types.ts
|
||||||
|
|
||||||
# Crée un nouveau script de migration à partir des modifications déjà appliquées à votre base de données locale (remplacer `<name-of-migration>` avec le nom de la migration)
|
# Crée un nouveau script de migration à partir des modifications déjà appliquées à votre base de données locale (remplacer `<name-of-migration>` avec le nom de la migration)
|
||||||
npm run supabase db diff -- -f <name-of-migration>
|
npm run supabase-cli db diff -- -f <name-of-migration>
|
||||||
```
|
```
|
||||||
|
21
app.json
21
app.json
@ -1,26 +1,29 @@
|
|||||||
{
|
{
|
||||||
"expo": {
|
"expo": {
|
||||||
"name": "p61-project",
|
"name": "Habits Tracker",
|
||||||
"slug": "p61-project",
|
"slug": "p61-project",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0-staging.4",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./presentation/assets/images/icon.png",
|
"icon": "./presentation/assets/images/icon.png",
|
||||||
"scheme": "p61-project",
|
"scheme": "p61-project",
|
||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
"splash": {
|
"splash": {
|
||||||
"image": "./presentation/assets/images/splashscreen.jpg",
|
"image": "./presentation/assets/images/splashscreen.png",
|
||||||
"resizeMode": "cover",
|
"resizeMode": "cover",
|
||||||
"backgroundColor": "#74b6cb"
|
"backgroundColor": "#74b6cb"
|
||||||
},
|
},
|
||||||
"assetBundlePatterns": ["**/*"],
|
"assetBundlePatterns": ["**/*"],
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true
|
"supportsTablet": true,
|
||||||
|
"buildNumber": "1.0.0"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./presentation/assets/images/adaptive-icon.png",
|
"foregroundImage": "./presentation/assets/images/adaptive-icon.png",
|
||||||
"backgroundColor": "#ffffff"
|
"backgroundColor": "#ffffff"
|
||||||
}
|
},
|
||||||
|
"package": "com.theoludwig.p61project",
|
||||||
|
"versionCode": 3
|
||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
"bundler": "metro",
|
"bundler": "metro",
|
||||||
@ -30,6 +33,14 @@
|
|||||||
"plugins": ["expo-router"],
|
"plugins": ["expo-router"],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true
|
"typedRoutes": true
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"router": {
|
||||||
|
"origin": false
|
||||||
|
},
|
||||||
|
"eas": {
|
||||||
|
"projectId": "5c0a922a-564b-4d62-8231-ce5aef7ff978"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
import { Stack } from "expo-router"
|
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 * as SplashScreen from "expo-splash-screen"
|
||||||
import {
|
import {
|
||||||
MD3LightTheme as DefaultTheme,
|
MD3LightTheme as DefaultTheme,
|
||||||
@ -20,6 +22,8 @@ export const unstableSettings = {
|
|||||||
initialRouteName: "index",
|
initialRouteName: "index",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
library.add(fas)
|
||||||
|
|
||||||
SplashScreen.preventAutoHideAsync().catch((error) => {
|
SplashScreen.preventAutoHideAsync().catch((error) => {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
})
|
})
|
||||||
|
@ -1,14 +1,14 @@
|
|||||||
import { Redirect, Tabs } from "expo-router"
|
import { Redirect, Tabs } from "expo-router"
|
||||||
import React from "react"
|
import React from "react"
|
||||||
|
|
||||||
import { TabBarIcon } from "@/presentation/react/components/TabBarIcon"
|
import { TabBarIcon } from "@/presentation/react-native/ui/TabBarIcon"
|
||||||
import { useAuthentication } from "@/presentation/react/contexts/Authentication"
|
import { useAuthentication } from "@/presentation/react/contexts/Authentication"
|
||||||
|
|
||||||
const TabLayout: React.FC = () => {
|
const TabLayout: React.FC = () => {
|
||||||
const { user } = useAuthentication()
|
const { user } = useAuthentication()
|
||||||
|
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
return <Redirect href="/authentication/login" />
|
return <Redirect href="/authentication/about" />
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -31,6 +31,7 @@ const TabLayout: React.FC = () => {
|
|||||||
name="habits/new"
|
name="habits/new"
|
||||||
options={{
|
options={{
|
||||||
title: "New Habit",
|
title: "New Habit",
|
||||||
|
unmountOnBlur: true,
|
||||||
tabBarIcon: ({ color }) => {
|
tabBarIcon: ({ color }) => {
|
||||||
return <TabBarIcon name="plus-square" color={color} />
|
return <TabBarIcon name="plus-square" color={color} />
|
||||||
},
|
},
|
||||||
@ -39,6 +40,7 @@ const TabLayout: React.FC = () => {
|
|||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="habits/[habitId]"
|
name="habits/[habitId]"
|
||||||
options={{
|
options={{
|
||||||
|
unmountOnBlur: true,
|
||||||
href: null,
|
href: null,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { Redirect, useLocalSearchParams } from "expo-router"
|
import { Redirect, useLocalSearchParams } from "expo-router"
|
||||||
|
|
||||||
import { HabitEditForm } from "@/presentation/react/components/HabitEditForm/HabitEditForm"
|
import { HabitEditForm } from "@/presentation/react-native/components/HabitForm/HabitEditForm"
|
||||||
import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker"
|
import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker"
|
||||||
|
|
||||||
const HabitPage: React.FC = () => {
|
const HabitPage: React.FC = () => {
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { SafeAreaView } from "react-native-safe-area-context"
|
import { SafeAreaView } from "react-native-safe-area-context"
|
||||||
import { ActivityIndicator, Button, Text } from "react-native-paper"
|
import { ActivityIndicator, Button, Text } from "react-native-paper"
|
||||||
|
|
||||||
import { HabitsMainPage } from "@/presentation/react/components/HabitsMainPage/HabitsMainPage"
|
import { HabitsMainPage } from "@/presentation/react-native/components/HabitsMainPage/HabitsMainPage"
|
||||||
import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker"
|
import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker"
|
||||||
import { useAuthentication } from "@/presentation/react/contexts/Authentication"
|
import { useAuthentication } from "@/presentation/react/contexts/Authentication"
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { HabitCreateForm } from "@/presentation/react/components/HabitCreateForm/HabitCreateForm"
|
import { HabitCreateForm } from "@/presentation/react-native/components/HabitForm/HabitCreateForm"
|
||||||
import { useAuthentication } from "@/presentation/react/contexts/Authentication"
|
import { useAuthentication } from "@/presentation/react/contexts/Authentication"
|
||||||
|
|
||||||
const NewHabitPage: React.FC = () => {
|
const NewHabitPage: React.FC = () => {
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { Text } from "react-native"
|
|
||||||
import { Button } from "react-native-paper"
|
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"
|
import { useAuthentication } from "@/presentation/react/contexts/Authentication"
|
||||||
|
|
||||||
const SettingsPage: React.FC = () => {
|
const SettingsPage: React.FC = () => {
|
||||||
@ -12,26 +11,19 @@ const SettingsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView
|
<About
|
||||||
style={[
|
actionButton={
|
||||||
{
|
<Button
|
||||||
flex: 1,
|
mode="contained"
|
||||||
alignItems: "center",
|
labelStyle={{ fontSize: 18 }}
|
||||||
justifyContent: "center",
|
onPress={handleLogout}
|
||||||
},
|
loading={logout.state === "loading"}
|
||||||
]}
|
disabled={logout.state === "loading"}
|
||||||
>
|
>
|
||||||
<Text>Settings</Text>
|
Logout
|
||||||
|
</Button>
|
||||||
<Button
|
}
|
||||||
mode="contained"
|
/>
|
||||||
onPress={handleLogout}
|
|
||||||
loading={logout.state === "loading"}
|
|
||||||
disabled={logout.state === "loading"}
|
|
||||||
>
|
|
||||||
Logout
|
|
||||||
</Button>
|
|
||||||
</SafeAreaView>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { Redirect, Tabs } from "expo-router"
|
import { Redirect, Tabs } from "expo-router"
|
||||||
import React from "react"
|
import React from "react"
|
||||||
|
|
||||||
import { TabBarIcon } from "@/presentation/react/components/TabBarIcon"
|
import { TabBarIcon } from "@/presentation/react-native/ui/TabBarIcon"
|
||||||
import { useAuthentication } from "@/presentation/react/contexts/Authentication"
|
import { useAuthentication } from "@/presentation/react/contexts/Authentication"
|
||||||
|
|
||||||
const TabLayout: React.FC = () => {
|
const TabLayout: React.FC = () => {
|
||||||
@ -17,6 +17,15 @@ const TabLayout: React.FC = () => {
|
|||||||
headerShown: false,
|
headerShown: false,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="about"
|
||||||
|
options={{
|
||||||
|
title: "About",
|
||||||
|
tabBarIcon: ({ color }) => {
|
||||||
|
return <TabBarIcon name="info" color={color} />
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="login"
|
name="login"
|
||||||
options={{
|
options={{
|
||||||
|
26
app/authentication/about.tsx
Normal file
26
app/authentication/about.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { Button } from "react-native-paper"
|
||||||
|
import { useRouter } from "expo-router"
|
||||||
|
|
||||||
|
import { About } from "@/presentation/react-native/components/About"
|
||||||
|
|
||||||
|
const AboutPage: React.FC = () => {
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<About
|
||||||
|
actionButton={
|
||||||
|
<Button
|
||||||
|
mode="contained"
|
||||||
|
labelStyle={{ fontSize: 18 }}
|
||||||
|
onPress={() => {
|
||||||
|
router.push("/authentication/login")
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Get Started 🚀
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AboutPage
|
@ -67,6 +67,7 @@ const LoginPage: React.FC = () => {
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
mode="contained"
|
mode="contained"
|
||||||
|
labelStyle={{ fontSize: 18 }}
|
||||||
onPress={handleSubmit(onSubmit)}
|
onPress={handleSubmit(onSubmit)}
|
||||||
loading={login.state === "loading"}
|
loading={login.state === "loading"}
|
||||||
disabled={login.state === "loading"}
|
disabled={login.state === "loading"}
|
||||||
|
@ -107,6 +107,7 @@ const RegisterPage: React.FC = () => {
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
mode="contained"
|
mode="contained"
|
||||||
|
labelStyle={{ fontSize: 18 }}
|
||||||
onPress={handleSubmit(onSubmit)}
|
onPress={handleSubmit(onSubmit)}
|
||||||
loading={register.state === "loading"}
|
loading={register.state === "loading"}
|
||||||
disabled={register.state === "loading"}
|
disabled={register.state === "loading"}
|
||||||
|
@ -6,7 +6,7 @@ const HomePage: React.FC = () => {
|
|||||||
const { user } = useAuthentication()
|
const { user } = useAuthentication()
|
||||||
|
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
return <Redirect href="/authentication/login" />
|
return <Redirect href="/authentication/about" />
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Redirect href="/application/habits/" />
|
return <Redirect href="/application/habits/" />
|
||||||
|
BIN
docs/screenshots/habits.png
Normal file
BIN
docs/screenshots/habits.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 158 KiB |
@ -27,7 +27,7 @@ export const GoalCreateSchema = z.object({
|
|||||||
z.object({ type: z.literal("boolean") }),
|
z.object({ type: z.literal("boolean") }),
|
||||||
z.object({
|
z.object({
|
||||||
type: z.literal("numeric"),
|
type: z.literal("numeric"),
|
||||||
value: z.number().int().min(0),
|
value: z.number().int().min(1),
|
||||||
unit: z.string().min(1),
|
unit: z.string().min(1),
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
|
@ -8,6 +8,7 @@ export const HabitSchema = EntitySchema.extend({
|
|||||||
name: z.string().min(1).max(50),
|
name: z.string().min(1).max(50),
|
||||||
color: z.string().min(4).max(9).regex(/^#/),
|
color: z.string().min(4).max(9).regex(/^#/),
|
||||||
icon: z.string().min(1),
|
icon: z.string().min(1),
|
||||||
|
endDate: z.date().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const HabitCreateSchema = HabitSchema.extend({
|
export const HabitCreateSchema = HabitSchema.extend({
|
||||||
@ -29,7 +30,6 @@ export interface HabitData extends HabitBase {
|
|||||||
export interface HabitJSON extends HabitBase {
|
export interface HabitJSON extends HabitBase {
|
||||||
goal: GoalBaseJSON
|
goal: GoalBaseJSON
|
||||||
startDate: string
|
startDate: string
|
||||||
endDate?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Habit extends Entity implements HabitData {
|
export class Habit extends Entity implements HabitData {
|
||||||
@ -62,7 +62,7 @@ export class Habit extends Entity implements HabitData {
|
|||||||
icon: this.icon,
|
icon: this.icon,
|
||||||
goal: this.goal,
|
goal: this.goal,
|
||||||
startDate: this.startDate.toISOString(),
|
startDate: this.startDate.toISOString(),
|
||||||
endDate: this.endDate?.toISOString(),
|
endDate: this?.endDate,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { getISODate, getWeekNumber } from "@/utils/dates"
|
import { getISODate, getWeekNumber } from "@/utils/dates"
|
||||||
import type { Habit } from "./Habit"
|
|
||||||
import type { HabitProgress } from "./HabitProgress"
|
|
||||||
import type { GoalProgress } from "./Goal"
|
import type { GoalProgress } from "./Goal"
|
||||||
import { GoalBooleanProgress, GoalNumericProgress } from "./Goal"
|
import { GoalBooleanProgress, GoalNumericProgress } from "./Goal"
|
||||||
|
import type { Habit } from "./Habit"
|
||||||
|
import type { HabitProgress } from "./HabitProgress"
|
||||||
|
|
||||||
export interface HabitHistoryJSON {
|
export interface HabitHistoryJSON {
|
||||||
habit: Habit
|
habit: Habit
|
||||||
|
@ -76,4 +76,24 @@ export class HabitsTracker implements HabitsTrackerData {
|
|||||||
return habitHistory.habit.id === id
|
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))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
106
domain/entities/__tests__/HabitsTracker.test.ts
Normal file
106
domain/entities/__tests__/HabitsTracker.test.ts
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import { HABIT_MOCK } from "@/tests/mocks/domain/Habit"
|
||||||
|
import { GOAL_FREQUENCIES } from "../Goal"
|
||||||
|
import { HabitsTracker } from "../HabitsTracker"
|
||||||
|
import { HabitHistory } from "../HabitHistory"
|
||||||
|
import { HABIT_PROGRESS_MOCK } from "@/tests/mocks/domain/HabitProgress"
|
||||||
|
|
||||||
|
describe("domain/entities/HabitsTracker", () => {
|
||||||
|
describe("HabitsTracker.default", () => {
|
||||||
|
for (const frequency of GOAL_FREQUENCIES) {
|
||||||
|
it(`should return empty habitsHistory for ${frequency}`, () => {
|
||||||
|
const habitsTracker = HabitsTracker.default()
|
||||||
|
expect(habitsTracker.habitsHistory[frequency]).toEqual([])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getAllHabitsHistory", () => {
|
||||||
|
it("should return all habits history", () => {
|
||||||
|
const habitsTracker = HabitsTracker.default()
|
||||||
|
const habit = HABIT_MOCK.examplesByNames.Walk
|
||||||
|
habitsTracker.addHabit(habit)
|
||||||
|
expect(habitsTracker.getAllHabitsHistory()).toEqual([
|
||||||
|
new HabitHistory({
|
||||||
|
habit,
|
||||||
|
progressHistory: [],
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return empty array when no habits are added", () => {
|
||||||
|
const habitsTracker = HabitsTracker.default()
|
||||||
|
expect(habitsTracker.getAllHabitsHistory()).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getHabitHistoryById", () => {
|
||||||
|
it("should return habit history by id", () => {
|
||||||
|
const habitsTracker = HabitsTracker.default()
|
||||||
|
const habit = HABIT_MOCK.examplesByNames.Walk
|
||||||
|
habitsTracker.addHabit(habit)
|
||||||
|
expect(habitsTracker.getHabitHistoryById(habit.id)).toEqual(
|
||||||
|
new HabitHistory({
|
||||||
|
habit,
|
||||||
|
progressHistory: [],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return undefined when habit is not found", () => {
|
||||||
|
const habitsTracker = HabitsTracker.default()
|
||||||
|
expect(habitsTracker.getHabitHistoryById("invalid-id")).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("addHabit", () => {
|
||||||
|
it("should add habit to habitsHistory", () => {
|
||||||
|
const habitsTracker = HabitsTracker.default()
|
||||||
|
const habit = HABIT_MOCK.examplesByNames.Walk
|
||||||
|
habitsTracker.addHabit(habit)
|
||||||
|
expect(habitsTracker.habitsHistory[habit.goal.frequency]).toEqual([
|
||||||
|
new HabitHistory({
|
||||||
|
habit,
|
||||||
|
progressHistory: [],
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("editHabit", () => {
|
||||||
|
it("should edit habit in habitsHistory", () => {
|
||||||
|
const habitsTracker = HabitsTracker.default()
|
||||||
|
const habit = HABIT_MOCK.examplesByNames.Walk
|
||||||
|
habitsTracker.addHabit(habit)
|
||||||
|
habit.name = "Run"
|
||||||
|
habitsTracker.editHabit(habit)
|
||||||
|
expect(habitsTracker.habitsHistory[habit.goal.frequency]).toEqual([
|
||||||
|
new HabitHistory({
|
||||||
|
habit,
|
||||||
|
progressHistory: [],
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not edit habit in habitsHistory when habit is not found", () => {
|
||||||
|
const habitsTracker = HabitsTracker.default()
|
||||||
|
const habit = HABIT_MOCK.examplesByNames.Walk
|
||||||
|
habitsTracker.editHabit(habit)
|
||||||
|
expect(habitsTracker.habitsHistory[habit.goal.frequency]).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("updateHabitProgress", () => {
|
||||||
|
it("should update habit progress in habitsHistory (add new habit progress if not yet added)", () => {
|
||||||
|
const habitsTracker = HabitsTracker.default()
|
||||||
|
const habit = HABIT_MOCK.examplesByNames["Clean the house"]
|
||||||
|
habitsTracker.addHabit(habit)
|
||||||
|
habitsTracker.updateHabitProgress(HABIT_PROGRESS_MOCK.exampleByIds[1])
|
||||||
|
expect(habitsTracker.habitsHistory[habit.goal.frequency]).toEqual([
|
||||||
|
new HabitHistory({
|
||||||
|
habit,
|
||||||
|
progressHistory: [HABIT_PROGRESS_MOCK.exampleByIds[1]],
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
24
domain/use-cases/HabitStop.ts
Normal file
24
domain/use-cases/HabitStop.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import type { Habit } from "../entities/Habit"
|
||||||
|
import type { HabitEditRepository } from "../repositories/HabitEdit"
|
||||||
|
|
||||||
|
export interface HabitStopUseCaseDependencyOptions {
|
||||||
|
habitEditRepository: HabitEditRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HabitStopUseCase implements HabitStopUseCaseDependencyOptions {
|
||||||
|
public habitEditRepository: HabitEditRepository
|
||||||
|
|
||||||
|
public constructor(options: HabitStopUseCaseDependencyOptions) {
|
||||||
|
this.habitEditRepository = options.habitEditRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
public async execute(habitToStop: Habit): Promise<Habit> {
|
||||||
|
const habit = await this.habitEditRepository.execute({
|
||||||
|
habitEditData: {
|
||||||
|
...habitToStop,
|
||||||
|
endDate: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return habit
|
||||||
|
}
|
||||||
|
}
|
15
eas.json
Normal file
15
eas.json
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"build": {
|
||||||
|
"development": {
|
||||||
|
"developmentClient": true,
|
||||||
|
"distribution": "internal"
|
||||||
|
},
|
||||||
|
"staging": {
|
||||||
|
"distribution": "internal",
|
||||||
|
"android": {
|
||||||
|
"buildType": "apk"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"production": {}
|
||||||
|
}
|
||||||
|
}
|
@ -1,18 +1,19 @@
|
|||||||
import { AuthenticationUseCase } from "@/domain/use-cases/Authentication"
|
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 { RetrieveHabitsTrackerUseCase } from "../domain/use-cases/RetrieveHabitsTracker"
|
||||||
import { HabitsTrackerPresenter } from "../presentation/presenters/HabitsTracker"
|
import { HabitsTrackerPresenter } from "../presentation/presenters/HabitsTracker"
|
||||||
import { AuthenticationSupabaseRepository } from "./supabase/repositories/Authentication"
|
import { AuthenticationSupabaseRepository } from "./supabase/repositories/Authentication"
|
||||||
import { GetHabitProgressHistorySupabaseRepository } from "./supabase/repositories/GetHabitProgressHistory"
|
import { GetHabitProgressHistorySupabaseRepository } from "./supabase/repositories/GetHabitProgressHistory"
|
||||||
import { GetHabitsByUserIdSupabaseRepository } from "./supabase/repositories/GetHabitsByUserId"
|
import { GetHabitsByUserIdSupabaseRepository } from "./supabase/repositories/GetHabitsByUserId"
|
||||||
import { supabaseClient } from "./supabase/supabase"
|
|
||||||
import { AuthenticationPresenter } from "@/presentation/presenters/Authentication"
|
|
||||||
import { HabitCreateSupabaseRepository } from "./supabase/repositories/HabitCreate"
|
import { HabitCreateSupabaseRepository } from "./supabase/repositories/HabitCreate"
|
||||||
import { HabitCreateUseCase } from "@/domain/use-cases/HabitCreate"
|
|
||||||
import { HabitEditSupabaseRepository } from "./supabase/repositories/HabitEdit"
|
import { HabitEditSupabaseRepository } from "./supabase/repositories/HabitEdit"
|
||||||
import { HabitEditUseCase } from "@/domain/use-cases/HabitEdit"
|
|
||||||
import { HabitProgressCreateSupabaseRepository } from "./supabase/repositories/HabitProgressCreate"
|
import { HabitProgressCreateSupabaseRepository } from "./supabase/repositories/HabitProgressCreate"
|
||||||
import { HabitProgressUpdateSupabaseRepository } from "./supabase/repositories/HabitProgressUpdate"
|
import { HabitProgressUpdateSupabaseRepository } from "./supabase/repositories/HabitProgressUpdate"
|
||||||
import { HabitGoalProgressUpdateUseCase } from "@/domain/use-cases/HabitGoalProgressUpdate"
|
import { supabaseClient } from "./supabase/supabase"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Repositories
|
* Repositories
|
||||||
@ -64,6 +65,9 @@ const habitGoalProgressUpdateUseCase = new HabitGoalProgressUpdateUseCase({
|
|||||||
habitProgressCreateRepository,
|
habitProgressCreateRepository,
|
||||||
habitProgressUpdateRepository,
|
habitProgressUpdateRepository,
|
||||||
})
|
})
|
||||||
|
const habitStopUseCase = new HabitStopUseCase({
|
||||||
|
habitEditRepository,
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Presenters
|
* Presenters
|
||||||
@ -75,5 +79,6 @@ export const habitsTrackerPresenter = new HabitsTrackerPresenter({
|
|||||||
retrieveHabitsTrackerUseCase,
|
retrieveHabitsTrackerUseCase,
|
||||||
habitCreateUseCase,
|
habitCreateUseCase,
|
||||||
habitEditUseCase,
|
habitEditUseCase,
|
||||||
|
habitStopUseCase,
|
||||||
habitGoalProgressUpdateUseCase,
|
habitGoalProgressUpdateUseCase,
|
||||||
})
|
})
|
||||||
|
79
infrastructure/supabase/data-transfer-objects/HabitDTO.ts
Normal file
79
infrastructure/supabase/data-transfer-objects/HabitDTO.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import type { Goal } from "@/domain/entities/Goal"
|
||||||
|
import { GoalBoolean, GoalNumeric } from "@/domain/entities/Goal"
|
||||||
|
import type { HabitCreateData, HabitEditData } from "@/domain/entities/Habit"
|
||||||
|
import { Habit } from "@/domain/entities/Habit"
|
||||||
|
import type {
|
||||||
|
SupabaseHabit,
|
||||||
|
SupabaseHabitInsert,
|
||||||
|
SupabaseHabitUpdate,
|
||||||
|
} from "../supabase"
|
||||||
|
|
||||||
|
export const habitSupabaseDTO = {
|
||||||
|
fromSupabaseToDomain: (supabaseHabit: SupabaseHabit): Habit => {
|
||||||
|
let goal: Goal
|
||||||
|
if (
|
||||||
|
supabaseHabit.goal_target != null &&
|
||||||
|
supabaseHabit.goal_target_unit != null
|
||||||
|
) {
|
||||||
|
goal = new GoalNumeric({
|
||||||
|
frequency: supabaseHabit.goal_frequency,
|
||||||
|
target: {
|
||||||
|
value: supabaseHabit.goal_target,
|
||||||
|
unit: supabaseHabit.goal_target_unit,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
goal = new GoalBoolean({
|
||||||
|
frequency: supabaseHabit.goal_frequency,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const habit = new Habit({
|
||||||
|
id: supabaseHabit.id.toString(),
|
||||||
|
name: supabaseHabit.name,
|
||||||
|
color: supabaseHabit.color,
|
||||||
|
icon: supabaseHabit.icon,
|
||||||
|
userId: supabaseHabit.user_id.toString(),
|
||||||
|
startDate: new Date(supabaseHabit.start_date),
|
||||||
|
endDate:
|
||||||
|
supabaseHabit.end_date != null
|
||||||
|
? new Date(supabaseHabit.end_date)
|
||||||
|
: undefined,
|
||||||
|
goal,
|
||||||
|
})
|
||||||
|
return habit
|
||||||
|
},
|
||||||
|
fromDomainCreateDataToSupabaseInsert: (
|
||||||
|
habitCreateData: HabitCreateData,
|
||||||
|
): SupabaseHabitInsert => {
|
||||||
|
return {
|
||||||
|
name: habitCreateData.name,
|
||||||
|
color: habitCreateData.color,
|
||||||
|
icon: habitCreateData.icon,
|
||||||
|
goal_frequency: habitCreateData.goal.frequency,
|
||||||
|
...(habitCreateData.goal.target.type === "numeric"
|
||||||
|
? {
|
||||||
|
goal_target: habitCreateData.goal.target.value,
|
||||||
|
goal_target_unit: habitCreateData.goal.target.unit,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fromDomainEditDataToSupabaseUpdate: (
|
||||||
|
habitEditData: HabitEditData,
|
||||||
|
): SupabaseHabitUpdate => {
|
||||||
|
return {
|
||||||
|
name: habitEditData.name,
|
||||||
|
color: habitEditData.color,
|
||||||
|
icon: habitEditData.icon,
|
||||||
|
end_date: habitEditData?.endDate?.toISOString(),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const habitsSupabaseDTO = {
|
||||||
|
fromSupabaseToDomain: (supabaseHabits: SupabaseHabit[]): Habit[] => {
|
||||||
|
return supabaseHabits.map((supabaseHabit) => {
|
||||||
|
return habitSupabaseDTO.fromSupabaseToDomain(supabaseHabit)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
@ -0,0 +1,78 @@
|
|||||||
|
import type { Goal, GoalProgress } from "@/domain/entities/Goal"
|
||||||
|
import {
|
||||||
|
GoalBooleanProgress,
|
||||||
|
GoalNumericProgress,
|
||||||
|
} from "@/domain/entities/Goal"
|
||||||
|
import { HabitProgress } from "@/domain/entities/HabitProgress"
|
||||||
|
import type { HabitProgressCreateOptions } from "@/domain/repositories/HabitProgressCreate"
|
||||||
|
import type { HabitProgressUpdateOptions } from "@/domain/repositories/HabitProgressUpdate"
|
||||||
|
import type {
|
||||||
|
SupabaseHabitProgress,
|
||||||
|
SupabaseHabitProgressInsert,
|
||||||
|
SupabaseHabitProgressUpdate,
|
||||||
|
} from "../supabase"
|
||||||
|
|
||||||
|
export const habitProgressSupabaseDTO = {
|
||||||
|
fromSupabaseToDomain: (
|
||||||
|
supabaseHabitProgress: SupabaseHabitProgress,
|
||||||
|
goal: Goal,
|
||||||
|
): HabitProgress => {
|
||||||
|
let goalProgress: GoalProgress | null = null
|
||||||
|
if (goal.isNumeric()) {
|
||||||
|
goalProgress = new GoalNumericProgress({
|
||||||
|
goal,
|
||||||
|
progress: supabaseHabitProgress.goal_progress,
|
||||||
|
})
|
||||||
|
} else if (goal.isBoolean()) {
|
||||||
|
goalProgress = new GoalBooleanProgress({
|
||||||
|
goal,
|
||||||
|
progress: supabaseHabitProgress.goal_progress === 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const habitProgress = new HabitProgress({
|
||||||
|
id: supabaseHabitProgress.id.toString(),
|
||||||
|
habitId: supabaseHabitProgress.habit_id.toString(),
|
||||||
|
goalProgress: goalProgress as GoalProgress,
|
||||||
|
date: new Date(supabaseHabitProgress.date),
|
||||||
|
})
|
||||||
|
return habitProgress
|
||||||
|
},
|
||||||
|
fromDomainDataToSupabaseInsert: (
|
||||||
|
habitProgressData: HabitProgressCreateOptions["habitProgressData"],
|
||||||
|
): SupabaseHabitProgressInsert => {
|
||||||
|
const { goalProgress, date, habitId } = habitProgressData
|
||||||
|
let goalProgressValue = goalProgress.isCompleted() ? 1 : 0
|
||||||
|
if (goalProgress.isNumeric()) {
|
||||||
|
goalProgressValue = goalProgress.progress
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
habit_id: Number.parseInt(habitId, 10),
|
||||||
|
date: date.toISOString(),
|
||||||
|
goal_progress: goalProgressValue,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fromDomainDataToSupabaseUpdate: (
|
||||||
|
habitProgressData: HabitProgressUpdateOptions["habitProgressData"],
|
||||||
|
): SupabaseHabitProgressUpdate => {
|
||||||
|
const { goalProgress, date } = habitProgressData
|
||||||
|
let goalProgressValue = goalProgress.isCompleted() ? 1 : 0
|
||||||
|
if (goalProgress.isNumeric()) {
|
||||||
|
goalProgressValue = goalProgress.progress
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
date: date.toISOString(),
|
||||||
|
goal_progress: goalProgressValue,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const habitProgressHistorySupabaseDTO = {
|
||||||
|
fromSupabaseToDomain: (
|
||||||
|
supabaseHabitHistory: SupabaseHabitProgress[],
|
||||||
|
goal: Goal,
|
||||||
|
): HabitProgress[] => {
|
||||||
|
return supabaseHabitHistory.map((item) => {
|
||||||
|
return habitProgressSupabaseDTO.fromSupabaseToDomain(item, goal)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
@ -0,0 +1,100 @@
|
|||||||
|
import type { GoalCreateData } from "@/domain/entities/Goal"
|
||||||
|
import { HABIT_MOCK } from "@/tests/mocks/domain/Habit"
|
||||||
|
import { SUPABASE_HABIT_MOCK } from "@/tests/mocks/supabase/Habit"
|
||||||
|
import { habitSupabaseDTO, habitsSupabaseDTO } from "../HabitDTO"
|
||||||
|
|
||||||
|
describe("infrastructure/supabase/data-transfer-objects/HabitDTO", () => {
|
||||||
|
describe("habitSupabaseDTO.fromSupabaseToDomain", () => {
|
||||||
|
for (const example of SUPABASE_HABIT_MOCK.examples) {
|
||||||
|
it(`should return correct Habit entity - ${example.name}`, () => {
|
||||||
|
expect(habitSupabaseDTO.fromSupabaseToDomain(example)).toEqual(
|
||||||
|
HABIT_MOCK.examplesByNames[
|
||||||
|
example.name as keyof typeof HABIT_MOCK.examplesByNames
|
||||||
|
],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("habitSupabaseDTO.fromDomainCreateDataToSupabaseInsert", () => {
|
||||||
|
for (const example of HABIT_MOCK.examples) {
|
||||||
|
it(`should return correct SupabaseHabitInsert entity - ${example.name}`, () => {
|
||||||
|
let goalData = {} as GoalCreateData
|
||||||
|
if (example.goal.isBoolean()) {
|
||||||
|
goalData = {
|
||||||
|
frequency: example.goal.frequency,
|
||||||
|
target: { type: "boolean" },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (example.goal.isNumeric()) {
|
||||||
|
goalData = {
|
||||||
|
frequency: example.goal.frequency,
|
||||||
|
target: {
|
||||||
|
type: "numeric",
|
||||||
|
value: example.goal.target.value,
|
||||||
|
unit: example.goal.target.unit,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabaseData =
|
||||||
|
SUPABASE_HABIT_MOCK.examplesByNames[
|
||||||
|
example.name as keyof typeof SUPABASE_HABIT_MOCK.examplesByNames
|
||||||
|
]
|
||||||
|
expect(
|
||||||
|
habitSupabaseDTO.fromDomainCreateDataToSupabaseInsert({
|
||||||
|
userId: example.userId,
|
||||||
|
name: example.name,
|
||||||
|
color: example.color,
|
||||||
|
icon: example.icon,
|
||||||
|
goal: goalData,
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
name: supabaseData.name,
|
||||||
|
color: supabaseData.color,
|
||||||
|
icon: supabaseData.icon,
|
||||||
|
goal_frequency: supabaseData.goal_frequency,
|
||||||
|
...(supabaseData.goal_target != null &&
|
||||||
|
supabaseData.goal_target_unit != null
|
||||||
|
? {
|
||||||
|
goal_target: supabaseData.goal_target,
|
||||||
|
goal_target_unit: supabaseData.goal_target_unit,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("habitSupabaseDTO.fromDomainEditDataToSupabaseUpdate", () => {
|
||||||
|
for (const example of HABIT_MOCK.examples) {
|
||||||
|
it(`should return correct SupabaseHabitUpdate entity - ${example.name}`, () => {
|
||||||
|
const supabaseData =
|
||||||
|
SUPABASE_HABIT_MOCK.examplesByNames[
|
||||||
|
example.name as keyof typeof SUPABASE_HABIT_MOCK.examplesByNames
|
||||||
|
]
|
||||||
|
expect(
|
||||||
|
habitSupabaseDTO.fromDomainEditDataToSupabaseUpdate({
|
||||||
|
name: example.name,
|
||||||
|
color: example.color,
|
||||||
|
icon: example.icon,
|
||||||
|
id: example.id,
|
||||||
|
userId: example.userId,
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
name: supabaseData.name,
|
||||||
|
color: supabaseData.color,
|
||||||
|
icon: supabaseData.icon,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("habitsSupabaseDTO.fromSupabaseToDomain", () => {
|
||||||
|
it("should return correct Habits entities", () => {
|
||||||
|
expect(
|
||||||
|
habitsSupabaseDTO.fromSupabaseToDomain(SUPABASE_HABIT_MOCK.examples),
|
||||||
|
).toEqual(HABIT_MOCK.examples)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
@ -0,0 +1,22 @@
|
|||||||
|
import type { Habit } from "@/domain/entities/Habit"
|
||||||
|
import { HABIT_MOCK } from "@/tests/mocks/domain/Habit"
|
||||||
|
import { HABIT_PROGRESS_MOCK } from "@/tests/mocks/domain/HabitProgress"
|
||||||
|
import { SUPABASE_HABIT_PROGRESS_MOCK } from "@/tests/mocks/supabase/HabitProgress"
|
||||||
|
import { habitProgressSupabaseDTO } from "../HabitProgressDTO"
|
||||||
|
|
||||||
|
describe("infrastructure/supabase/data-transfer-objects/HabitProgressDTO", () => {
|
||||||
|
describe("habitProgressSupabaseDTO.fromSupabaseToDomain", () => {
|
||||||
|
for (const example of SUPABASE_HABIT_PROGRESS_MOCK.examples) {
|
||||||
|
it(`should return correct HabitProgress entity - ${example.id}`, () => {
|
||||||
|
const habit = HABIT_MOCK.examplesByIds[example.habit_id] as Habit
|
||||||
|
expect(
|
||||||
|
habitProgressSupabaseDTO.fromSupabaseToDomain(example, habit.goal),
|
||||||
|
).toEqual(
|
||||||
|
HABIT_PROGRESS_MOCK.exampleByIds[
|
||||||
|
example.id as keyof typeof HABIT_PROGRESS_MOCK.exampleByIds
|
||||||
|
],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
@ -1,8 +1,8 @@
|
|||||||
import type { Session } from "@supabase/supabase-js"
|
import type { Session } from "@supabase/supabase-js"
|
||||||
|
|
||||||
import type { AuthenticationRepository } from "@/domain/repositories/Authentication"
|
|
||||||
import { SupabaseRepository } from "./_SupabaseRepository"
|
|
||||||
import { User } from "@/domain/entities/User"
|
import { User } from "@/domain/entities/User"
|
||||||
|
import type { AuthenticationRepository } from "@/domain/repositories/Authentication"
|
||||||
|
import { SupabaseRepository } from "@/infrastructure/supabase/repositories/_SupabaseRepository"
|
||||||
|
|
||||||
export class AuthenticationSupabaseRepository
|
export class AuthenticationSupabaseRepository
|
||||||
extends SupabaseRepository
|
extends SupabaseRepository
|
||||||
|
@ -1,11 +1,6 @@
|
|||||||
import type { GetHabitProgressHistoryRepository } from "@/domain/repositories/GetHabitProgressHistory"
|
import type { GetHabitProgressHistoryRepository } from "@/domain/repositories/GetHabitProgressHistory"
|
||||||
import { SupabaseRepository } from "./_SupabaseRepository"
|
import { SupabaseRepository } from "@/infrastructure/supabase/repositories/_SupabaseRepository"
|
||||||
import { HabitProgress } from "@/domain/entities/HabitProgress"
|
import { habitProgressHistorySupabaseDTO } from "../data-transfer-objects/HabitProgressDTO"
|
||||||
import type { GoalProgress } from "@/domain/entities/Goal"
|
|
||||||
import {
|
|
||||||
GoalBooleanProgress,
|
|
||||||
GoalNumericProgress,
|
|
||||||
} from "@/domain/entities/Goal"
|
|
||||||
|
|
||||||
export class GetHabitProgressHistorySupabaseRepository
|
export class GetHabitProgressHistorySupabaseRepository
|
||||||
extends SupabaseRepository
|
extends SupabaseRepository
|
||||||
@ -15,37 +10,15 @@ export class GetHabitProgressHistorySupabaseRepository
|
|||||||
options,
|
options,
|
||||||
) => {
|
) => {
|
||||||
const { habit } = options
|
const { habit } = options
|
||||||
const { data, error } = await this.supabaseClient
|
const { data } = await this.supabaseClient
|
||||||
.from("habits_progresses")
|
.from("habits_progresses")
|
||||||
.select("*")
|
.select("*")
|
||||||
.eq("habit_id", habit.id)
|
.eq("habit_id", habit.id)
|
||||||
if (error != null) {
|
.throwOnError()
|
||||||
throw new Error(error.message)
|
const habitProgressHistory = data as NonNullable<typeof data>
|
||||||
}
|
return habitProgressHistorySupabaseDTO.fromSupabaseToDomain(
|
||||||
const habitProgressHistory = data.map((item) => {
|
habitProgressHistory,
|
||||||
let goalProgress: GoalProgress | null = null
|
habit.goal,
|
||||||
if (habit.goal.isNumeric()) {
|
)
|
||||||
goalProgress = new GoalNumericProgress({
|
|
||||||
goal: habit.goal,
|
|
||||||
progress: item.goal_progress,
|
|
||||||
})
|
|
||||||
} else if (habit.goal.isBoolean()) {
|
|
||||||
goalProgress = new GoalBooleanProgress({
|
|
||||||
goal: habit.goal,
|
|
||||||
progress: item.goal_progress === 1,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (goalProgress == null) {
|
|
||||||
throw new Error("Goal progress is null.")
|
|
||||||
}
|
|
||||||
const habitProgress = new HabitProgress({
|
|
||||||
id: item.id.toString(),
|
|
||||||
habitId: item.habit_id.toString(),
|
|
||||||
goalProgress,
|
|
||||||
date: new Date(item.date),
|
|
||||||
})
|
|
||||||
return habitProgress
|
|
||||||
})
|
|
||||||
return habitProgressHistory
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,6 @@
|
|||||||
import type { GetHabitsByUserIdRepository } from "@/domain/repositories/GetHabitsByUserId"
|
import type { GetHabitsByUserIdRepository } from "@/domain/repositories/GetHabitsByUserId"
|
||||||
import { SupabaseRepository } from "./_SupabaseRepository"
|
import { SupabaseRepository } from "@/infrastructure/supabase/repositories/_SupabaseRepository"
|
||||||
import { Habit } from "@/domain/entities/Habit"
|
import { habitsSupabaseDTO } from "../data-transfer-objects/HabitDTO"
|
||||||
import type { Goal } from "@/domain/entities/Goal"
|
|
||||||
import { GoalBoolean, GoalNumeric } from "@/domain/entities/Goal"
|
|
||||||
|
|
||||||
export class GetHabitsByUserIdSupabaseRepository
|
export class GetHabitsByUserIdSupabaseRepository
|
||||||
extends SupabaseRepository
|
extends SupabaseRepository
|
||||||
@ -10,39 +8,12 @@ export class GetHabitsByUserIdSupabaseRepository
|
|||||||
{
|
{
|
||||||
public execute: GetHabitsByUserIdRepository["execute"] = async (options) => {
|
public execute: GetHabitsByUserIdRepository["execute"] = async (options) => {
|
||||||
const { userId } = options
|
const { userId } = options
|
||||||
const { data, error } = await this.supabaseClient
|
const { data } = await this.supabaseClient
|
||||||
.from("habits")
|
.from("habits")
|
||||||
.select("*")
|
.select("*")
|
||||||
.eq("user_id", userId)
|
.eq("user_id", userId)
|
||||||
if (error != null) {
|
.throwOnError()
|
||||||
throw new Error(error.message)
|
const habits = data as NonNullable<typeof data>
|
||||||
}
|
return habitsSupabaseDTO.fromSupabaseToDomain(habits)
|
||||||
return data.map((item) => {
|
|
||||||
let goal: Goal
|
|
||||||
if (item.goal_target != null && item.goal_target_unit != null) {
|
|
||||||
goal = new GoalNumeric({
|
|
||||||
frequency: item.goal_frequency,
|
|
||||||
target: {
|
|
||||||
value: item.goal_target,
|
|
||||||
unit: item.goal_target_unit,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
goal = new GoalBoolean({
|
|
||||||
frequency: item.goal_frequency,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const habit = new Habit({
|
|
||||||
id: item.id.toString(),
|
|
||||||
name: item.name,
|
|
||||||
color: item.color,
|
|
||||||
icon: item.icon,
|
|
||||||
userId: item.user_id.toString(),
|
|
||||||
startDate: new Date(item.start_date),
|
|
||||||
endDate: item.end_date != null ? new Date(item.end_date) : undefined,
|
|
||||||
goal,
|
|
||||||
})
|
|
||||||
return habit
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { Habit } from "@/domain/entities/Habit"
|
|
||||||
import type { HabitCreateRepository } from "@/domain/repositories/HabitCreate"
|
import type { HabitCreateRepository } from "@/domain/repositories/HabitCreate"
|
||||||
import { SupabaseRepository } from "./_SupabaseRepository"
|
import { SupabaseRepository } from "@/infrastructure/supabase/repositories/_SupabaseRepository"
|
||||||
import { Goal } from "@/domain/entities/Goal"
|
import { habitSupabaseDTO } from "../data-transfer-objects/HabitDTO"
|
||||||
|
|
||||||
export class HabitCreateSupabaseRepository
|
export class HabitCreateSupabaseRepository
|
||||||
extends SupabaseRepository
|
extends SupabaseRepository
|
||||||
@ -9,34 +8,15 @@ export class HabitCreateSupabaseRepository
|
|||||||
{
|
{
|
||||||
public execute: HabitCreateRepository["execute"] = async (options) => {
|
public execute: HabitCreateRepository["execute"] = async (options) => {
|
||||||
const { habitCreateData } = options
|
const { habitCreateData } = options
|
||||||
const { data, error } = await this.supabaseClient
|
const { data } = await this.supabaseClient
|
||||||
.from("habits")
|
.from("habits")
|
||||||
.insert({
|
.insert(
|
||||||
name: habitCreateData.name,
|
habitSupabaseDTO.fromDomainCreateDataToSupabaseInsert(habitCreateData),
|
||||||
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,
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
})
|
|
||||||
.select("*")
|
.select("*")
|
||||||
const insertedHabit = data?.[0]
|
.single()
|
||||||
if (error != null || insertedHabit == null) {
|
.throwOnError()
|
||||||
throw new Error(error?.message ?? "Failed to create habit.")
|
const insertedHabit = data as NonNullable<typeof data>
|
||||||
}
|
return habitSupabaseDTO.fromSupabaseToDomain(insertedHabit)
|
||||||
const habit = new Habit({
|
|
||||||
id: insertedHabit.id.toString(),
|
|
||||||
userId: insertedHabit.user_id.toString(),
|
|
||||||
name: insertedHabit.name,
|
|
||||||
icon: insertedHabit.icon,
|
|
||||||
goal: Goal.create(habitCreateData.goal),
|
|
||||||
color: insertedHabit.color,
|
|
||||||
startDate: new Date(insertedHabit.start_date),
|
|
||||||
})
|
|
||||||
return habit
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { Habit } from "@/domain/entities/Habit"
|
|
||||||
import type { HabitEditRepository } from "@/domain/repositories/HabitEdit"
|
import type { HabitEditRepository } from "@/domain/repositories/HabitEdit"
|
||||||
import { SupabaseRepository } from "./_SupabaseRepository"
|
import { SupabaseRepository } from "@/infrastructure/supabase/repositories/_SupabaseRepository"
|
||||||
import { Goal } from "@/domain/entities/Goal"
|
import { habitSupabaseDTO } from "../data-transfer-objects/HabitDTO"
|
||||||
|
|
||||||
export class HabitEditSupabaseRepository
|
export class HabitEditSupabaseRepository
|
||||||
extends SupabaseRepository
|
extends SupabaseRepository
|
||||||
@ -9,41 +8,16 @@ export class HabitEditSupabaseRepository
|
|||||||
{
|
{
|
||||||
public execute: HabitEditRepository["execute"] = async (options) => {
|
public execute: HabitEditRepository["execute"] = async (options) => {
|
||||||
const { habitEditData } = options
|
const { habitEditData } = options
|
||||||
const { data, error } = await this.supabaseClient
|
const { data } = await this.supabaseClient
|
||||||
.from("habits")
|
.from("habits")
|
||||||
.update({
|
.update(
|
||||||
name: habitEditData.name,
|
habitSupabaseDTO.fromDomainEditDataToSupabaseUpdate(habitEditData),
|
||||||
color: habitEditData.color,
|
)
|
||||||
icon: habitEditData.icon,
|
|
||||||
})
|
|
||||||
.eq("id", habitEditData.id)
|
.eq("id", habitEditData.id)
|
||||||
.select("*")
|
.select("*")
|
||||||
const updatedHabit = data?.[0]
|
.single()
|
||||||
if (error != null || updatedHabit == null) {
|
.throwOnError()
|
||||||
throw new Error(error?.message ?? "Failed to edit habit.")
|
const updatedHabit = data as NonNullable<typeof data>
|
||||||
}
|
return habitSupabaseDTO.fromSupabaseToDomain(updatedHabit)
|
||||||
const habit = new Habit({
|
|
||||||
id: updatedHabit.id.toString(),
|
|
||||||
userId: updatedHabit.user_id.toString(),
|
|
||||||
name: updatedHabit.name,
|
|
||||||
icon: updatedHabit.icon,
|
|
||||||
goal: Goal.create({
|
|
||||||
frequency: updatedHabit.goal_frequency,
|
|
||||||
target:
|
|
||||||
updatedHabit.goal_target != null &&
|
|
||||||
updatedHabit.goal_target_unit != null
|
|
||||||
? {
|
|
||||||
type: "numeric",
|
|
||||||
value: updatedHabit.goal_target,
|
|
||||||
unit: updatedHabit.goal_target_unit,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
type: "boolean",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
color: updatedHabit.color,
|
|
||||||
startDate: new Date(updatedHabit.start_date),
|
|
||||||
})
|
|
||||||
return habit
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import type { HabitProgressCreateRepository } from "@/domain/repositories/HabitProgressCreate"
|
import type { HabitProgressCreateRepository } from "@/domain/repositories/HabitProgressCreate"
|
||||||
import { SupabaseRepository } from "./_SupabaseRepository"
|
import { SupabaseRepository } from "@/infrastructure/supabase/repositories/_SupabaseRepository"
|
||||||
import { HabitProgress } from "@/domain/entities/HabitProgress"
|
import { habitProgressSupabaseDTO } from "../data-transfer-objects/HabitProgressDTO"
|
||||||
|
|
||||||
export class HabitProgressCreateSupabaseRepository
|
export class HabitProgressCreateSupabaseRepository
|
||||||
extends SupabaseRepository
|
extends SupabaseRepository
|
||||||
@ -10,29 +10,20 @@ export class HabitProgressCreateSupabaseRepository
|
|||||||
options,
|
options,
|
||||||
) => {
|
) => {
|
||||||
const { habitProgressData } = options
|
const { habitProgressData } = options
|
||||||
const { goalProgress, date, habitId } = habitProgressData
|
const { data } = await this.supabaseClient
|
||||||
let goalProgressValue = goalProgress.isCompleted() ? 1 : 0
|
|
||||||
if (goalProgress.isNumeric()) {
|
|
||||||
goalProgressValue = goalProgress.progress
|
|
||||||
}
|
|
||||||
const { data, error } = await this.supabaseClient
|
|
||||||
.from("habits_progresses")
|
.from("habits_progresses")
|
||||||
.insert({
|
.insert(
|
||||||
habit_id: Number(habitId),
|
habitProgressSupabaseDTO.fromDomainDataToSupabaseInsert(
|
||||||
date: date.toISOString(),
|
habitProgressData,
|
||||||
goal_progress: goalProgressValue,
|
),
|
||||||
})
|
)
|
||||||
.select("*")
|
.select("*")
|
||||||
const insertedProgress = data?.[0]
|
.single()
|
||||||
if (error != null || insertedProgress == null) {
|
.throwOnError()
|
||||||
throw new Error(error?.message ?? "Failed to create habit progress.")
|
const insertedProgress = data as NonNullable<typeof data>
|
||||||
}
|
return habitProgressSupabaseDTO.fromSupabaseToDomain(
|
||||||
const habitProgress = new HabitProgress({
|
insertedProgress,
|
||||||
id: insertedProgress.id.toString(),
|
habitProgressData.goalProgress.goal,
|
||||||
habitId: insertedProgress.habit_id.toString(),
|
)
|
||||||
date: new Date(insertedProgress.date),
|
|
||||||
goalProgress,
|
|
||||||
})
|
|
||||||
return habitProgress
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import type { HabitProgressUpdateRepository } from "@/domain/repositories/HabitProgressUpdate"
|
import type { HabitProgressUpdateRepository } from "@/domain/repositories/HabitProgressUpdate"
|
||||||
import { SupabaseRepository } from "./_SupabaseRepository"
|
import { SupabaseRepository } from "@/infrastructure/supabase/repositories/_SupabaseRepository"
|
||||||
import { HabitProgress } from "@/domain/entities/HabitProgress"
|
import { habitProgressSupabaseDTO } from "../data-transfer-objects/HabitProgressDTO"
|
||||||
|
|
||||||
export class HabitProgressUpdateSupabaseRepository
|
export class HabitProgressUpdateSupabaseRepository
|
||||||
extends SupabaseRepository
|
extends SupabaseRepository
|
||||||
@ -10,29 +10,21 @@ export class HabitProgressUpdateSupabaseRepository
|
|||||||
options,
|
options,
|
||||||
) => {
|
) => {
|
||||||
const { habitProgressData } = options
|
const { habitProgressData } = options
|
||||||
const { id, goalProgress, date } = habitProgressData
|
const { data } = await this.supabaseClient
|
||||||
let goalProgressValue = goalProgress.isCompleted() ? 1 : 0
|
|
||||||
if (goalProgress.isNumeric()) {
|
|
||||||
goalProgressValue = goalProgress.progress
|
|
||||||
}
|
|
||||||
const { data, error } = await this.supabaseClient
|
|
||||||
.from("habits_progresses")
|
.from("habits_progresses")
|
||||||
.update({
|
.update(
|
||||||
date: date.toISOString(),
|
habitProgressSupabaseDTO.fromDomainDataToSupabaseUpdate(
|
||||||
goal_progress: goalProgressValue,
|
habitProgressData,
|
||||||
})
|
),
|
||||||
.eq("id", id)
|
)
|
||||||
|
.eq("id", habitProgressData.id)
|
||||||
.select("*")
|
.select("*")
|
||||||
const insertedProgress = data?.[0]
|
.single()
|
||||||
if (error != null || insertedProgress == null) {
|
.throwOnError()
|
||||||
throw new Error(error?.message ?? "Failed to update habit progress.")
|
const insertedProgress = data as NonNullable<typeof data>
|
||||||
}
|
return habitProgressSupabaseDTO.fromSupabaseToDomain(
|
||||||
const habitProgress = new HabitProgress({
|
insertedProgress,
|
||||||
id: insertedProgress.id.toString(),
|
habitProgressData.goalProgress.goal,
|
||||||
habitId: insertedProgress.habit_id.toString(),
|
)
|
||||||
date: new Date(insertedProgress.date),
|
|
||||||
goalProgress,
|
|
||||||
})
|
|
||||||
return habitProgress
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -117,7 +117,7 @@ VALUES
|
|||||||
'Wake up at 07h00',
|
'Wake up at 07h00',
|
||||||
'#006CFF',
|
'#006CFF',
|
||||||
'bed',
|
'bed',
|
||||||
timezone('utc' :: text, NOW()),
|
timezone('utc' :: text, NOW() - INTERVAL '3 days'),
|
||||||
NULL,
|
NULL,
|
||||||
'daily',
|
'daily',
|
||||||
NULL,
|
NULL,
|
||||||
@ -144,7 +144,7 @@ VALUES
|
|||||||
'Learn English',
|
'Learn English',
|
||||||
'#EB4034',
|
'#EB4034',
|
||||||
'language',
|
'language',
|
||||||
timezone('utc' :: text, NOW()),
|
timezone('utc' :: text, NOW() - INTERVAL '3 days'),
|
||||||
NULL,
|
NULL,
|
||||||
'daily',
|
'daily',
|
||||||
30,
|
30,
|
||||||
@ -171,7 +171,7 @@ VALUES
|
|||||||
'Walk',
|
'Walk',
|
||||||
'#228B22',
|
'#228B22',
|
||||||
'person-walking',
|
'person-walking',
|
||||||
timezone('utc' :: text, NOW()),
|
timezone('utc' :: text, NOW() - INTERVAL '3 days'),
|
||||||
NULL,
|
NULL,
|
||||||
'daily',
|
'daily',
|
||||||
5000,
|
5000,
|
||||||
@ -198,7 +198,7 @@ VALUES
|
|||||||
'Clean the house',
|
'Clean the house',
|
||||||
'#808080',
|
'#808080',
|
||||||
'broom',
|
'broom',
|
||||||
timezone('utc' :: text, NOW()),
|
timezone('utc' :: text, NOW() - INTERVAL '3 days'),
|
||||||
NULL,
|
NULL,
|
||||||
'weekly',
|
'weekly',
|
||||||
NULL,
|
NULL,
|
||||||
@ -225,7 +225,7 @@ VALUES
|
|||||||
'Solve Programming Challenges',
|
'Solve Programming Challenges',
|
||||||
'#DE3163',
|
'#DE3163',
|
||||||
'code',
|
'code',
|
||||||
timezone('utc' :: text, NOW()),
|
timezone('utc' :: text, NOW() - INTERVAL '3 days'),
|
||||||
NULL,
|
NULL,
|
||||||
'monthly',
|
'monthly',
|
||||||
5,
|
5,
|
||||||
@ -263,5 +263,5 @@ VALUES
|
|||||||
4733
|
4733
|
||||||
);
|
);
|
||||||
|
|
||||||
-- SELECT setval('habits_id_seq', (SELECT coalesce(MAX(id) + 1, 1) FROM habits), false);
|
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);
|
SELECT setval('habits_progresses_id_seq', (SELECT coalesce(MAX(id) + 1, 1) FROM habits_progresses), false);
|
||||||
|
@ -1,10 +1,28 @@
|
|||||||
import { createClient } from "@supabase/supabase-js"
|
import {
|
||||||
|
createClient,
|
||||||
|
type User as SupabaseUserType,
|
||||||
|
} from "@supabase/supabase-js"
|
||||||
import { AppState, Platform } from "react-native"
|
import { AppState, Platform } from "react-native"
|
||||||
import "react-native-url-polyfill/auto"
|
import "react-native-url-polyfill/auto"
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage"
|
import AsyncStorage from "@react-native-async-storage/async-storage"
|
||||||
|
|
||||||
import type { Database } from "./supabase-types"
|
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 =
|
const SUPABASE_URL =
|
||||||
process.env["EXPO_PUBLIC_SUPABASE_URL"] ??
|
process.env["EXPO_PUBLIC_SUPABASE_URL"] ??
|
||||||
"https://wjtwtzxreersqfvfgxrz.supabase.co"
|
"https://wjtwtzxreersqfvfgxrz.supabase.co"
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"preset": "jest-expo",
|
"preset": "jest-expo",
|
||||||
"roots": ["./"],
|
"roots": ["./"],
|
||||||
"setupFilesAfterEnv": ["@testing-library/react-native/extend-expect"],
|
"setupFilesAfterEnv": ["<rootDir>/tests/setup.ts"],
|
||||||
"fakeTimers": {
|
"fakeTimers": {
|
||||||
"enableGlobally": true
|
"enableGlobally": true
|
||||||
},
|
},
|
||||||
@ -10,7 +10,13 @@
|
|||||||
"coverageReporters": ["text", "text-summary", "cobertura"],
|
"coverageReporters": ["text", "text-summary", "cobertura"],
|
||||||
"collectCoverageFrom": [
|
"collectCoverageFrom": [
|
||||||
"<rootDir>/**/*.{ts,tsx}",
|
"<rootDir>/**/*.{ts,tsx}",
|
||||||
"!<rootDir>/presentation/react/components/ExternalLink.tsx",
|
"!<rootDir>/tests/**/*",
|
||||||
|
"!<rootDir>/domain/repositories/**/*",
|
||||||
|
"!<rootDir>/infrastructure/instances.ts",
|
||||||
|
"!<rootDir>/infrastructure/supabase/supabase-types.ts",
|
||||||
|
"!<rootDir>/infrastructure/supabase/supabase.ts",
|
||||||
|
"!<rootDir>/presentation/react-native/ui/ExternalLink.tsx",
|
||||||
|
"!<rootDir>/presentation/react/contexts/**/*",
|
||||||
"!<rootDir>/.expo",
|
"!<rootDir>/.expo",
|
||||||
"!<rootDir>/app/+html.tsx",
|
"!<rootDir>/app/+html.tsx",
|
||||||
"!<rootDir>/app/**/_layout.tsx",
|
"!<rootDir>/app/**/_layout.tsx",
|
||||||
|
7199
package-lock.json
generated
7199
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
88
package.json
88
package.json
@ -2,86 +2,90 @@
|
|||||||
"name": "p61-project",
|
"name": "p61-project",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "expo-router/entry",
|
"main": "expo-router/entry",
|
||||||
"version": "1.0.0-staging.2",
|
"version": "1.0.0-staging.4",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"android": "expo start --android",
|
"android": "expo start --android",
|
||||||
"ios": "expo start --ios",
|
"ios": "expo start --ios",
|
||||||
"web": "expo start --web",
|
"web": "expo start --web",
|
||||||
"expo:typed-routes": "expo customize tsconfig.json",
|
"expo:typed-routes": "expo customize tsconfig.json",
|
||||||
|
"build-staging:android": "eas build --platform=android --profile=staging",
|
||||||
"lint:commit": "commitlint",
|
"lint:commit": "commitlint",
|
||||||
"lint:prettier": "prettier . --check",
|
"lint:prettier": "prettier . --check",
|
||||||
"lint:eslint": "eslint . --max-warnings 0 --report-unused-disable-directives --ignore-path .gitignore",
|
"lint:eslint": "eslint . --max-warnings 0 --report-unused-disable-directives --ignore-path .gitignore",
|
||||||
"lint:typescript": "tsc --noEmit",
|
"lint:typescript": "tsc --noEmit",
|
||||||
"lint:staged": "lint-staged",
|
"lint:staged": "lint-staged",
|
||||||
"test": "jest --reporters=default --reporters=jest-junit",
|
"test": "jest --reporters=default --reporters=jest-junit",
|
||||||
"supabase": "supabase --workdir \"./infrastructure\"",
|
"supabase-cli": "supabase --workdir \"./infrastructure\"",
|
||||||
"postinstall": "husky"
|
"postinstall": "husky"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/vector-icons": "14.0.0",
|
"@expo/vector-icons": "14.0.2",
|
||||||
"@hookform/resolvers": "3.3.4",
|
"@fortawesome/fontawesome-svg-core": "6.5.2",
|
||||||
"@react-native-async-storage/async-storage": "1.21.0",
|
"@fortawesome/free-solid-svg-icons": "6.5.2",
|
||||||
"@react-navigation/native": "6.1.16",
|
"@fortawesome/react-native-fontawesome": "0.3.1",
|
||||||
"@supabase/supabase-js": "2.42.1",
|
"@hookform/resolvers": "3.4.0",
|
||||||
"expo": "50.0.15",
|
"@react-native-async-storage/async-storage": "1.23.1",
|
||||||
"expo-font": "11.10.3",
|
"@react-navigation/native": "6.1.17",
|
||||||
"expo-linking": "6.2.2",
|
"@supabase/supabase-js": "2.43.2",
|
||||||
"expo-router": "3.4.8",
|
"expo": "51.0.8",
|
||||||
"expo-splash-screen": "0.26.4",
|
"expo-linking": "6.3.1",
|
||||||
"expo-status-bar": "1.11.1",
|
"expo-router": "3.5.14",
|
||||||
"expo-system-ui": "2.9.3",
|
"expo-splash-screen": "0.27.4",
|
||||||
"expo-web-browser": "12.8.2",
|
"expo-status-bar": "1.12.1",
|
||||||
"immer": "10.0.4",
|
"expo-system-ui": "3.0.4",
|
||||||
"lottie-react-native": "6.5.1",
|
"expo-web-browser": "13.0.3",
|
||||||
|
"immer": "10.1.1",
|
||||||
|
"lottie-react-native": "6.7.0",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-hook-form": "7.51.2",
|
"react-hook-form": "7.51.4",
|
||||||
"react-native": "0.73.6",
|
"react-native": "0.74.1",
|
||||||
"react-native-calendars": "1.1304.1",
|
"react-native-calendars": "1.1305.0",
|
||||||
"react-native-circular-progress-indicator": "4.4.2",
|
"react-native-circular-progress-indicator": "4.4.2",
|
||||||
"react-native-elements": "3.4.3",
|
"react-native-elements": "3.4.3",
|
||||||
"react-native-gesture-handler": "2.14.1",
|
"react-native-gesture-handler": "2.16.2",
|
||||||
"react-native-paper": "5.12.3",
|
"react-native-paper": "5.12.3",
|
||||||
"react-native-reanimated": "3.6.3",
|
"react-native-reanimated": "3.10.1",
|
||||||
"react-native-safe-area-context": "4.8.2",
|
"react-native-safe-area-context": "4.10.1",
|
||||||
"react-native-screens": "3.29.0",
|
"react-native-screens": "3.31.1",
|
||||||
|
"react-native-svg": "15.2.0",
|
||||||
|
"react-native-svg-transformer": "1.4.0",
|
||||||
"react-native-url-polyfill": "2.0.0",
|
"react-native-url-polyfill": "2.0.0",
|
||||||
"react-native-vector-icons": "10.0.3",
|
"react-native-vector-icons": "10.1.0",
|
||||||
"react-native-web": "0.19.10",
|
"react-native-web": "0.19.11",
|
||||||
"reanimated-color-picker": "3.0.3",
|
"reanimated-color-picker": "3.0.3",
|
||||||
"zod": "3.22.4"
|
"zod": "3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "7.24.4",
|
"@babel/core": "7.24.5",
|
||||||
"@commitlint/cli": "19.1.0",
|
"@commitlint/cli": "19.2.2",
|
||||||
"@commitlint/config-conventional": "19.1.0",
|
"@commitlint/config-conventional": "19.2.2",
|
||||||
"@testing-library/react-native": "12.4.5",
|
"@testing-library/react-native": "12.5.0",
|
||||||
"@total-typescript/ts-reset": "0.5.1",
|
"@total-typescript/ts-reset": "0.5.1",
|
||||||
"@tsconfig/strictest": "2.0.5",
|
"@tsconfig/strictest": "2.0.5",
|
||||||
"@types/jest": "29.5.12",
|
"@types/jest": "29.5.12",
|
||||||
"@types/node": "20.12.7",
|
"@types/node": "20.12.12",
|
||||||
"@types/react": "18.2.76",
|
"@types/react": "18.2.79",
|
||||||
"@types/react-test-renderer": "18.0.7",
|
"@types/react-test-renderer": "18.3.0",
|
||||||
"@typescript-eslint/eslint-plugin": "7.6.0",
|
"@typescript-eslint/eslint-plugin": "7.9.0",
|
||||||
"@typescript-eslint/parser": "7.6.0",
|
"@typescript-eslint/parser": "7.9.0",
|
||||||
"eslint": "8.57.0",
|
"eslint": "8.57.0",
|
||||||
"eslint-config-conventions": "14.1.0",
|
"eslint-config-conventions": "14.1.0",
|
||||||
"eslint-config-prettier": "9.1.0",
|
|
||||||
"eslint-plugin-import": "2.29.1",
|
"eslint-plugin-import": "2.29.1",
|
||||||
"eslint-plugin-prettier": "5.1.3",
|
|
||||||
"eslint-plugin-promise": "6.1.1",
|
"eslint-plugin-promise": "6.1.1",
|
||||||
"eslint-plugin-react": "7.34.1",
|
"eslint-plugin-react": "7.34.1",
|
||||||
"eslint-plugin-react-hooks": "4.6.0",
|
"eslint-plugin-react-hooks": "4.6.2",
|
||||||
"eslint-plugin-react-native": "4.1.0",
|
"eslint-plugin-react-native": "4.1.0",
|
||||||
"eslint-plugin-unicorn": "51.0.1",
|
"eslint-plugin-unicorn": "51.0.1",
|
||||||
"husky": "9.0.11",
|
"husky": "9.0.11",
|
||||||
"jest": "29.7.0",
|
"jest": "29.7.0",
|
||||||
"jest-expo": "50.0.4",
|
"jest-expo": "51.0.2",
|
||||||
"jest-junit": "16.0.0",
|
"jest-junit": "16.0.0",
|
||||||
"lint-staged": "15.2.2",
|
"lint-staged": "15.2.2",
|
||||||
|
"prettier": "3.2.5",
|
||||||
"react-test-renderer": "18.2.0",
|
"react-test-renderer": "18.2.0",
|
||||||
"supabase": "1.153.4",
|
"supabase": "1.167.4",
|
||||||
"typescript": "5.4.5"
|
"typescript": "5.3.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Binary file not shown.
Before Width: | Height: | Size: 11 KiB |
Binary file not shown.
Before Width: | Height: | Size: 97 KiB |
BIN
presentation/assets/images/splashscreen.png
Normal file
BIN
presentation/assets/images/splashscreen.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 52 KiB |
@ -7,10 +7,15 @@ import type {
|
|||||||
RetrieveHabitsTrackerUseCase,
|
RetrieveHabitsTrackerUseCase,
|
||||||
RetrieveHabitsTrackerUseCaseOptions,
|
RetrieveHabitsTrackerUseCaseOptions,
|
||||||
} from "@/domain/use-cases/RetrieveHabitsTracker"
|
} from "@/domain/use-cases/RetrieveHabitsTracker"
|
||||||
import type { HabitCreateData, HabitEditData } from "@/domain/entities/Habit"
|
import type {
|
||||||
|
Habit,
|
||||||
|
HabitCreateData,
|
||||||
|
HabitEditData,
|
||||||
|
} from "@/domain/entities/Habit"
|
||||||
import { getErrorsFieldsFromZodError } from "../../utils/zod"
|
import { getErrorsFieldsFromZodError } from "../../utils/zod"
|
||||||
import type { HabitCreateUseCase } from "@/domain/use-cases/HabitCreate"
|
import type { HabitCreateUseCase } from "@/domain/use-cases/HabitCreate"
|
||||||
import type { HabitEditUseCase } from "@/domain/use-cases/HabitEdit"
|
import type { HabitEditUseCase } from "@/domain/use-cases/HabitEdit"
|
||||||
|
import type { HabitStopUseCase } from "@/domain/use-cases/HabitStop"
|
||||||
import type {
|
import type {
|
||||||
HabitGoalProgressUpdateUseCase,
|
HabitGoalProgressUpdateUseCase,
|
||||||
HabitGoalProgressUpdateUseCaseOptions,
|
HabitGoalProgressUpdateUseCaseOptions,
|
||||||
@ -39,6 +44,10 @@ export interface HabitsTrackerPresenterState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
habitStop: {
|
||||||
|
state: FetchState
|
||||||
|
}
|
||||||
|
|
||||||
habitGoalProgressUpdate: {
|
habitGoalProgressUpdate: {
|
||||||
state: FetchState
|
state: FetchState
|
||||||
}
|
}
|
||||||
@ -48,6 +57,7 @@ export interface HabitsTrackerPresenterOptions {
|
|||||||
retrieveHabitsTrackerUseCase: RetrieveHabitsTrackerUseCase
|
retrieveHabitsTrackerUseCase: RetrieveHabitsTrackerUseCase
|
||||||
habitCreateUseCase: HabitCreateUseCase
|
habitCreateUseCase: HabitCreateUseCase
|
||||||
habitEditUseCase: HabitEditUseCase
|
habitEditUseCase: HabitEditUseCase
|
||||||
|
habitStopUseCase: HabitStopUseCase
|
||||||
habitGoalProgressUpdateUseCase: HabitGoalProgressUpdateUseCase
|
habitGoalProgressUpdateUseCase: HabitGoalProgressUpdateUseCase
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -58,6 +68,7 @@ export class HabitsTrackerPresenter
|
|||||||
public retrieveHabitsTrackerUseCase: RetrieveHabitsTrackerUseCase
|
public retrieveHabitsTrackerUseCase: RetrieveHabitsTrackerUseCase
|
||||||
public habitCreateUseCase: HabitCreateUseCase
|
public habitCreateUseCase: HabitCreateUseCase
|
||||||
public habitEditUseCase: HabitEditUseCase
|
public habitEditUseCase: HabitEditUseCase
|
||||||
|
public habitStopUseCase: HabitStopUseCase
|
||||||
public habitGoalProgressUpdateUseCase: HabitGoalProgressUpdateUseCase
|
public habitGoalProgressUpdateUseCase: HabitGoalProgressUpdateUseCase
|
||||||
|
|
||||||
public constructor(options: HabitsTrackerPresenterOptions) {
|
public constructor(options: HabitsTrackerPresenterOptions) {
|
||||||
@ -65,6 +76,7 @@ export class HabitsTrackerPresenter
|
|||||||
retrieveHabitsTrackerUseCase,
|
retrieveHabitsTrackerUseCase,
|
||||||
habitCreateUseCase,
|
habitCreateUseCase,
|
||||||
habitEditUseCase,
|
habitEditUseCase,
|
||||||
|
habitStopUseCase,
|
||||||
habitGoalProgressUpdateUseCase,
|
habitGoalProgressUpdateUseCase,
|
||||||
} = options
|
} = options
|
||||||
const habitsTracker = HabitsTracker.default()
|
const habitsTracker = HabitsTracker.default()
|
||||||
@ -85,6 +97,9 @@ export class HabitsTrackerPresenter
|
|||||||
global: null,
|
global: null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
habitStop: {
|
||||||
|
state: "idle",
|
||||||
|
},
|
||||||
habitGoalProgressUpdate: {
|
habitGoalProgressUpdate: {
|
||||||
state: "idle",
|
state: "idle",
|
||||||
},
|
},
|
||||||
@ -92,6 +107,7 @@ export class HabitsTrackerPresenter
|
|||||||
this.retrieveHabitsTrackerUseCase = retrieveHabitsTrackerUseCase
|
this.retrieveHabitsTrackerUseCase = retrieveHabitsTrackerUseCase
|
||||||
this.habitCreateUseCase = habitCreateUseCase
|
this.habitCreateUseCase = habitCreateUseCase
|
||||||
this.habitEditUseCase = habitEditUseCase
|
this.habitEditUseCase = habitEditUseCase
|
||||||
|
this.habitStopUseCase = habitStopUseCase
|
||||||
this.habitGoalProgressUpdateUseCase = habitGoalProgressUpdateUseCase
|
this.habitGoalProgressUpdateUseCase = habitGoalProgressUpdateUseCase
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -153,6 +169,25 @@ export class HabitsTrackerPresenter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
public async retrieveHabitsTracker(
|
||||||
options: RetrieveHabitsTrackerUseCaseOptions,
|
options: RetrieveHabitsTrackerUseCaseOptions,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
@ -41,10 +41,7 @@ export abstract class Presenter<State> {
|
|||||||
|
|
||||||
public unsubscribe(listener: Listener<State>): void {
|
public unsubscribe(listener: Listener<State>): void {
|
||||||
const listenerIndex = this._listeners.indexOf(listener)
|
const listenerIndex = this._listeners.indexOf(listener)
|
||||||
const listenerFound = listenerIndex !== -1
|
this._listeners.splice(listenerIndex, 1)
|
||||||
if (listenerFound) {
|
|
||||||
this._listeners.splice(listenerIndex, 1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private notifyListeners(): void {
|
private notifyListeners(): void {
|
||||||
|
81
presentation/react-native/components/About.tsx
Normal file
81
presentation/react-native/components/About.tsx
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
export const About: React.FC<AboutProps> = (props) => {
|
||||||
|
const { actionButton } = props
|
||||||
|
|
||||||
|
const version = getVersion()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
marginVertical: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{actionButton}
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
)
|
||||||
|
}
|
@ -1,6 +1,9 @@
|
|||||||
|
import type { IconName } from "@fortawesome/free-solid-svg-icons"
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-native-fontawesome"
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { useState } from "react"
|
||||||
import { Controller, useForm } from "react-hook-form"
|
import { Controller, useForm } from "react-hook-form"
|
||||||
import { ScrollView, StyleSheet } from "react-native"
|
import { ScrollView, StyleSheet, View } from "react-native"
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
HelperText,
|
HelperText,
|
||||||
@ -15,14 +18,15 @@ import ColorPicker, {
|
|||||||
Panel1,
|
Panel1,
|
||||||
Preview,
|
Preview,
|
||||||
} from "reanimated-color-picker"
|
} from "reanimated-color-picker"
|
||||||
import { useState } from "react"
|
|
||||||
|
|
||||||
import type { GoalFrequency, GoalType } from "@/domain/entities/Goal"
|
import type { GoalFrequency, GoalType } from "@/domain/entities/Goal"
|
||||||
import { GOAL_FREQUENCIES, GOAL_TYPES } from "@/domain/entities/Goal"
|
import { GOAL_FREQUENCIES, GOAL_TYPES } from "@/domain/entities/Goal"
|
||||||
import type { HabitCreateData } from "@/domain/entities/Habit"
|
import type { HabitCreateData } from "@/domain/entities/Habit"
|
||||||
import { HabitCreateSchema } from "@/domain/entities/Habit"
|
import { HabitCreateSchema } from "@/domain/entities/Habit"
|
||||||
import type { User } from "@/domain/entities/User"
|
import type { User } from "@/domain/entities/User"
|
||||||
import { useHabitsTracker } from "../../contexts/HabitsTracker"
|
import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker"
|
||||||
|
import { useBoolean } from "@/presentation/react/hooks/useBoolean"
|
||||||
|
import { IconSelectorModal } from "./IconSelectorModal"
|
||||||
|
|
||||||
export interface HabitCreateFormProps {
|
export interface HabitCreateFormProps {
|
||||||
user: User
|
user: User
|
||||||
@ -33,9 +37,10 @@ export const HabitCreateForm: React.FC<HabitCreateFormProps> = ({ user }) => {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
|
formState: { errors, isValid },
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
formState: { errors },
|
watch,
|
||||||
} = useForm<HabitCreateData>({
|
} = useForm<HabitCreateData>({
|
||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
resolver: zodResolver(HabitCreateSchema),
|
resolver: zodResolver(HabitCreateSchema),
|
||||||
@ -43,7 +48,7 @@ export const HabitCreateForm: React.FC<HabitCreateFormProps> = ({ user }) => {
|
|||||||
userId: user.id,
|
userId: user.id,
|
||||||
name: "",
|
name: "",
|
||||||
color: "#006CFF",
|
color: "#006CFF",
|
||||||
icon: "lightbulb",
|
icon: "circle-question",
|
||||||
goal: {
|
goal: {
|
||||||
frequency: "daily",
|
frequency: "daily",
|
||||||
target: {
|
target: {
|
||||||
@ -53,8 +58,16 @@ export const HabitCreateForm: React.FC<HabitCreateFormProps> = ({ user }) => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const watchGoalType = watch("goal.target.type")
|
||||||
|
|
||||||
const [isVisibleSnackbar, setIsVisibleSnackbar] = useState(false)
|
const [isVisibleSnackbar, setIsVisibleSnackbar] = useState(false)
|
||||||
|
|
||||||
|
const {
|
||||||
|
value: isModalIconSelectorVisible,
|
||||||
|
setTrue: openModalIconSelector,
|
||||||
|
setFalse: closeModalIconSelector,
|
||||||
|
} = useBoolean()
|
||||||
|
|
||||||
const onDismissSnackbar = (): void => {
|
const onDismissSnackbar = (): void => {
|
||||||
setIsVisibleSnackbar(false)
|
setIsVisibleSnackbar(false)
|
||||||
}
|
}
|
||||||
@ -62,6 +75,7 @@ export const HabitCreateForm: React.FC<HabitCreateFormProps> = ({ user }) => {
|
|||||||
const onSubmit = async (data: HabitCreateData): Promise<void> => {
|
const onSubmit = async (data: HabitCreateData): Promise<void> => {
|
||||||
await habitsTrackerPresenter.habitCreate(data)
|
await habitsTrackerPresenter.habitCreate(data)
|
||||||
setIsVisibleSnackbar(true)
|
setIsVisibleSnackbar(true)
|
||||||
|
closeModalIconSelector()
|
||||||
reset()
|
reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -117,7 +131,7 @@ export const HabitCreateForm: React.FC<HabitCreateFormProps> = ({ user }) => {
|
|||||||
style={[
|
style={[
|
||||||
styles.spacing,
|
styles.spacing,
|
||||||
{
|
{
|
||||||
width: "90%",
|
width: "96%",
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
mode="outlined"
|
mode="outlined"
|
||||||
@ -142,7 +156,7 @@ export const HabitCreateForm: React.FC<HabitCreateFormProps> = ({ user }) => {
|
|||||||
<>
|
<>
|
||||||
<Text style={[styles.spacing]}>Habit Frequency</Text>
|
<Text style={[styles.spacing]}>Habit Frequency</Text>
|
||||||
<SegmentedButtons
|
<SegmentedButtons
|
||||||
style={[{ width: "90%" }]}
|
style={[{ width: "96%" }]}
|
||||||
onValueChange={onChange}
|
onValueChange={onChange}
|
||||||
value={value}
|
value={value}
|
||||||
buttons={GOAL_FREQUENCIES.map((frequency) => {
|
buttons={GOAL_FREQUENCIES.map((frequency) => {
|
||||||
@ -164,9 +178,29 @@ export const HabitCreateForm: React.FC<HabitCreateFormProps> = ({ user }) => {
|
|||||||
render={({ field: { onChange, value } }) => {
|
render={({ field: { onChange, value } }) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Text style={[styles.spacing]}>Habit Type</Text>
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.spacing,
|
||||||
|
{ justifyContent: "center", alignContent: "center" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Habit Type
|
||||||
|
{/* <Tooltip
|
||||||
|
title="Routine habits are activities performed regularly, while Target habits involve setting specific objectives to be achieved through repeated actions."
|
||||||
|
enterTouchDelay={50}
|
||||||
|
leaveTouchDelay={25}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
icon="chat-question-outline"
|
||||||
|
selected
|
||||||
|
size={24}
|
||||||
|
onPress={() => {}}
|
||||||
|
style={{ alignSelf: "center" }}
|
||||||
|
/>
|
||||||
|
</Tooltip> */}
|
||||||
|
</Text>
|
||||||
<SegmentedButtons
|
<SegmentedButtons
|
||||||
style={[{ width: "90%" }]}
|
style={[{ width: "96%" }]}
|
||||||
onValueChange={onChange}
|
onValueChange={onChange}
|
||||||
value={value}
|
value={value}
|
||||||
buttons={GOAL_TYPES.map((type) => {
|
buttons={GOAL_TYPES.map((type) => {
|
||||||
@ -183,12 +217,74 @@ export const HabitCreateForm: React.FC<HabitCreateFormProps> = ({ user }) => {
|
|||||||
name="goal.target.type"
|
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
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field: { onChange, value } }) => {
|
render={({ field: { onChange, value } }) => {
|
||||||
return (
|
return (
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
style={[styles.spacing, { width: "90%" }]}
|
style={[{ marginVertical: 15, width: "96%" }]}
|
||||||
value={value}
|
value={value}
|
||||||
onComplete={(value) => {
|
onComplete={(value) => {
|
||||||
onChange(value.hex)
|
onChange(value.hex)
|
||||||
@ -205,16 +301,30 @@ export const HabitCreateForm: React.FC<HabitCreateFormProps> = ({ user }) => {
|
|||||||
|
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field: { onChange, onBlur, value } }) => {
|
render={({ field: { onChange, value } }) => {
|
||||||
return (
|
return (
|
||||||
<TextInput
|
<View
|
||||||
placeholder="Icon"
|
style={{
|
||||||
onBlur={onBlur}
|
justifyContent: "center",
|
||||||
onChangeText={onChange}
|
alignItems: "center",
|
||||||
value={value}
|
flexDirection: "row",
|
||||||
style={[styles.spacing, { width: "90%" }]}
|
gap: 20,
|
||||||
mode="outlined"
|
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"
|
name="icon"
|
||||||
@ -224,8 +334,8 @@ export const HabitCreateForm: React.FC<HabitCreateFormProps> = ({ user }) => {
|
|||||||
mode="contained"
|
mode="contained"
|
||||||
onPress={handleSubmit(onSubmit)}
|
onPress={handleSubmit(onSubmit)}
|
||||||
loading={habitCreate.state === "loading"}
|
loading={habitCreate.state === "loading"}
|
||||||
disabled={habitCreate.state === "loading"}
|
disabled={habitCreate.state === "loading" || !isValid}
|
||||||
style={[styles.spacing, { width: "90%" }]}
|
style={[{ width: "100%", marginVertical: 15 }]}
|
||||||
>
|
>
|
||||||
Create your habit! 🚀
|
Create your habit! 🚀
|
||||||
</Button>
|
</Button>
|
||||||
@ -244,6 +354,6 @@ export const HabitCreateForm: React.FC<HabitCreateFormProps> = ({ user }) => {
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
spacing: {
|
spacing: {
|
||||||
marginVertical: 16,
|
marginVertical: 10,
|
||||||
},
|
},
|
||||||
})
|
})
|
@ -1,8 +1,16 @@
|
|||||||
|
import type { IconName } from "@fortawesome/free-solid-svg-icons"
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-native-fontawesome"
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { Controller, useForm } from "react-hook-form"
|
import { Controller, useForm } from "react-hook-form"
|
||||||
import { ScrollView, StyleSheet } from "react-native"
|
import { ScrollView, StyleSheet, View } from "react-native"
|
||||||
import { Button, HelperText, Snackbar, TextInput } from "react-native-paper"
|
import {
|
||||||
|
Button,
|
||||||
|
HelperText,
|
||||||
|
Snackbar,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
} from "react-native-paper"
|
||||||
import { SafeAreaView } from "react-native-safe-area-context"
|
import { SafeAreaView } from "react-native-safe-area-context"
|
||||||
import ColorPicker, {
|
import ColorPicker, {
|
||||||
HueSlider,
|
HueSlider,
|
||||||
@ -12,19 +20,21 @@ import ColorPicker, {
|
|||||||
|
|
||||||
import type { Habit, HabitEditData } from "@/domain/entities/Habit"
|
import type { Habit, HabitEditData } from "@/domain/entities/Habit"
|
||||||
import { HabitEditSchema } from "@/domain/entities/Habit"
|
import { HabitEditSchema } from "@/domain/entities/Habit"
|
||||||
import { useHabitsTracker } from "../../contexts/HabitsTracker"
|
import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker"
|
||||||
|
import { useBoolean } from "@/presentation/react/hooks/useBoolean"
|
||||||
|
import { IconSelectorModal } from "./IconSelectorModal"
|
||||||
|
|
||||||
export interface HabitEditFormProps {
|
export interface HabitEditFormProps {
|
||||||
habit: Habit
|
habit: Habit
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HabitEditForm: React.FC<HabitEditFormProps> = ({ habit }) => {
|
export const HabitEditForm: React.FC<HabitEditFormProps> = ({ habit }) => {
|
||||||
const { habitEdit, habitsTrackerPresenter } = useHabitsTracker()
|
const { habitEdit, habitStop, habitsTrackerPresenter } = useHabitsTracker()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
|
formState: { errors, isValid },
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { errors },
|
|
||||||
} = useForm<HabitEditData>({
|
} = useForm<HabitEditData>({
|
||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
resolver: zodResolver(HabitEditSchema),
|
resolver: zodResolver(HabitEditSchema),
|
||||||
@ -37,6 +47,12 @@ export const HabitEditForm: React.FC<HabitEditFormProps> = ({ habit }) => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
value: isModalIconSelectorVisible,
|
||||||
|
setTrue: openModalIconSelector,
|
||||||
|
setFalse: closeModalIconSelector,
|
||||||
|
} = useBoolean()
|
||||||
|
|
||||||
const [isVisibleSnackbar, setIsVisibleSnackbar] = useState(false)
|
const [isVisibleSnackbar, setIsVisibleSnackbar] = useState(false)
|
||||||
|
|
||||||
const onDismissSnackbar = (): void => {
|
const onDismissSnackbar = (): void => {
|
||||||
@ -70,7 +86,7 @@ export const HabitEditForm: React.FC<HabitEditFormProps> = ({ habit }) => {
|
|||||||
style={[
|
style={[
|
||||||
styles.spacing,
|
styles.spacing,
|
||||||
{
|
{
|
||||||
width: "90%",
|
width: "96%",
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
mode="outlined"
|
mode="outlined"
|
||||||
@ -93,7 +109,7 @@ export const HabitEditForm: React.FC<HabitEditFormProps> = ({ habit }) => {
|
|||||||
render={({ field: { onChange, value } }) => {
|
render={({ field: { onChange, value } }) => {
|
||||||
return (
|
return (
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
style={[styles.spacing, { width: "90%" }]}
|
style={[styles.spacing, { width: "96%" }]}
|
||||||
value={value}
|
value={value}
|
||||||
onComplete={(value) => {
|
onComplete={(value) => {
|
||||||
onChange(value.hex)
|
onChange(value.hex)
|
||||||
@ -110,16 +126,30 @@ export const HabitEditForm: React.FC<HabitEditFormProps> = ({ habit }) => {
|
|||||||
|
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field: { onChange, onBlur, value } }) => {
|
render={({ field: { onChange, value } }) => {
|
||||||
return (
|
return (
|
||||||
<TextInput
|
<View
|
||||||
placeholder="Icon"
|
style={{
|
||||||
onBlur={onBlur}
|
justifyContent: "center",
|
||||||
onChangeText={onChange}
|
alignItems: "center",
|
||||||
value={value}
|
flexDirection: "row",
|
||||||
style={[styles.spacing, { width: "90%" }]}
|
gap: 20,
|
||||||
mode="outlined"
|
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"
|
name="icon"
|
||||||
@ -129,11 +159,35 @@ export const HabitEditForm: React.FC<HabitEditFormProps> = ({ habit }) => {
|
|||||||
mode="contained"
|
mode="contained"
|
||||||
onPress={handleSubmit(onSubmit)}
|
onPress={handleSubmit(onSubmit)}
|
||||||
loading={habitEdit.state === "loading"}
|
loading={habitEdit.state === "loading"}
|
||||||
disabled={habitEdit.state === "loading"}
|
disabled={habitEdit.state === "loading" || !isValid}
|
||||||
style={[styles.spacing, { width: "90%" }]}
|
style={[styles.spacing, { width: "96%" }]}
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</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>
|
</ScrollView>
|
||||||
|
|
||||||
<Snackbar
|
<Snackbar
|
@ -0,0 +1,121 @@
|
|||||||
|
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 { 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 findIconsInLibrary = (icon: string): string[] => {
|
||||||
|
return iconNames
|
||||||
|
.filter((name, index, self) => {
|
||||||
|
return name.includes(icon) && self.indexOf(name) === index
|
||||||
|
})
|
||||||
|
.slice(0, 50)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IconSelectorModal: React.FC<IconSelectorModalProps> = ({
|
||||||
|
isVisible = false,
|
||||||
|
selectedIcon,
|
||||||
|
onIconSelect,
|
||||||
|
handleCloseModal,
|
||||||
|
}) => {
|
||||||
|
const [possibleIcons, setPossibleIcons] = useState<string[]>([])
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(true)
|
||||||
|
const [searchText, setSearchText] = useState<string>("")
|
||||||
|
const [_isPending, startTransition] = useTransition()
|
||||||
|
|
||||||
|
const handleSearch = useCallback((text: string): void => {
|
||||||
|
setSearchText(text)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePossibleIcons = (): void => {
|
||||||
|
startTransition(() => {
|
||||||
|
setPossibleIcons(findIconsInLibrary(searchText))
|
||||||
|
setIsLoading(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const debounceHandleSearch = setTimeout(handlePossibleIcons, 400)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
return clearTimeout(debounceHandleSearch)
|
||||||
|
}
|
||||||
|
}, [searchText])
|
||||||
|
|
||||||
|
const handleIconSelect = useCallback(
|
||||||
|
(icon: string): void => {
|
||||||
|
onIconSelect(icon)
|
||||||
|
},
|
||||||
|
[onIconSelect],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal animationType="fade" transparent visible={isVisible}>
|
||||||
|
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 5,
|
||||||
|
width: "96%",
|
||||||
|
height: "99%",
|
||||||
|
backgroundColor: "white",
|
||||||
|
borderColor: "black",
|
||||||
|
borderWidth: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
marginVertical: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ marginVertical: 8 }}>Selected Icon:</Text>
|
||||||
|
<FontAwesomeIcon size={46} icon={selectedIcon as IconName} />
|
||||||
|
</View>
|
||||||
|
<SearchInput searchText={searchText} handleSearch={handleSearch} />
|
||||||
|
<ScrollView>
|
||||||
|
<List.Section title="Choose an icon:">
|
||||||
|
<IconsList
|
||||||
|
isLoading={isLoading}
|
||||||
|
selectedIcon={selectedIcon}
|
||||||
|
possibleIcons={possibleIcons}
|
||||||
|
handleIconSelect={handleIconSelect}
|
||||||
|
/>
|
||||||
|
</List.Section>
|
||||||
|
</ScrollView>
|
||||||
|
<View style={{ marginVertical: 15 }}>
|
||||||
|
<Button mode="contained" onPress={handleCloseModal}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
74
presentation/react-native/components/HabitForm/IconsList.tsx
Normal file
74
presentation/react-native/components/HabitForm/IconsList.tsx
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import type { IconName } from "@fortawesome/fontawesome-svg-core"
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-native-fontawesome"
|
||||||
|
import React, { memo } from "react"
|
||||||
|
import { View } from "react-native"
|
||||||
|
import { ActivityIndicator, IconButton, Text } from "react-native-paper"
|
||||||
|
|
||||||
|
export interface IconsListProps {
|
||||||
|
selectedIcon?: string
|
||||||
|
possibleIcons: string[]
|
||||||
|
isLoading?: boolean
|
||||||
|
handleIconSelect: (icon: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const IconsListWithoutMemo: React.FC<IconsListProps> = (props) => {
|
||||||
|
const {
|
||||||
|
selectedIcon,
|
||||||
|
possibleIcons,
|
||||||
|
isLoading = false,
|
||||||
|
handleIconSelect,
|
||||||
|
} = props
|
||||||
|
|
||||||
|
if (possibleIcons.length <= 0) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
marginTop: 20,
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<ActivityIndicator size="large" />
|
||||||
|
) : (
|
||||||
|
<Text>No results found</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 15,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{possibleIcons.map((icon) => {
|
||||||
|
return (
|
||||||
|
<IconButton
|
||||||
|
key={icon}
|
||||||
|
containerColor="white"
|
||||||
|
icon={({ size }) => {
|
||||||
|
return (
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={icon as IconName}
|
||||||
|
size={size}
|
||||||
|
color={selectedIcon === icon ? "blue" : "black"}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
size={30}
|
||||||
|
onPress={() => {
|
||||||
|
handleIconSelect(icon)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IconsList = memo(IconsListWithoutMemo)
|
@ -1,15 +1,16 @@
|
|||||||
import FontAwesome6 from "@expo/vector-icons/FontAwesome6"
|
import type { IconName } from "@fortawesome/free-solid-svg-icons"
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-native-fontawesome"
|
||||||
import { useRouter } from "expo-router"
|
import { useRouter } from "expo-router"
|
||||||
|
import type LottieView from "lottie-react-native"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { View } from "react-native"
|
import { View } from "react-native"
|
||||||
import { Checkbox, List, Text } from "react-native-paper"
|
import { Checkbox, List, Text } from "react-native-paper"
|
||||||
import type LottieView from "lottie-react-native"
|
|
||||||
|
|
||||||
import type { GoalBoolean } from "@/domain/entities/Goal"
|
import type { GoalBoolean } from "@/domain/entities/Goal"
|
||||||
import { GoalBooleanProgress } from "@/domain/entities/Goal"
|
import { GoalBooleanProgress } from "@/domain/entities/Goal"
|
||||||
import type { HabitHistory } from "@/domain/entities/HabitHistory"
|
import type { HabitHistory } from "@/domain/entities/HabitHistory"
|
||||||
|
import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker"
|
||||||
import { getColorRGBAFromHex } from "@/utils/colors"
|
import { getColorRGBAFromHex } from "@/utils/colors"
|
||||||
import { useHabitsTracker } from "../../contexts/HabitsTracker"
|
|
||||||
|
|
||||||
export interface HabitCardProps {
|
export interface HabitCardProps {
|
||||||
habitHistory: HabitHistory
|
habitHistory: HabitHistory
|
||||||
@ -65,9 +66,9 @@ export const HabitCard: React.FC<HabitCardProps> = (props) => {
|
|||||||
left={() => {
|
left={() => {
|
||||||
return (
|
return (
|
||||||
<View style={{ justifyContent: "center", alignItems: "center" }}>
|
<View style={{ justifyContent: "center", alignItems: "center" }}>
|
||||||
<FontAwesome6
|
<FontAwesomeIcon
|
||||||
size={24}
|
size={24}
|
||||||
name={habit.icon}
|
icon={habit.icon as IconName}
|
||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
width: 30,
|
width: 30,
|
@ -0,0 +1,157 @@
|
|||||||
|
import LottieView from "lottie-react-native"
|
||||||
|
import { useRef, useState } from "react"
|
||||||
|
import { Dimensions, ScrollView, View } from "react-native"
|
||||||
|
import { Divider, List, Text } from "react-native-paper"
|
||||||
|
|
||||||
|
import { GOAL_FREQUENCIES, type GoalFrequency } from "@/domain/entities/Goal"
|
||||||
|
import type { HabitHistory } from "@/domain/entities/HabitHistory"
|
||||||
|
import type { HabitsTracker } from "@/domain/entities/HabitsTracker"
|
||||||
|
import { 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("en-US", {
|
||||||
|
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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
@ -3,7 +3,7 @@ import { Agenda } from "react-native-calendars"
|
|||||||
|
|
||||||
import { GOAL_FREQUENCIES } from "@/domain/entities/Goal"
|
import { GOAL_FREQUENCIES } from "@/domain/entities/Goal"
|
||||||
import type { HabitsTracker } from "@/domain/entities/HabitsTracker"
|
import type { HabitsTracker } from "@/domain/entities/HabitsTracker"
|
||||||
import { getISODate, getNowDate } from "@/utils/dates"
|
import { getISODate, getNowDateUTC } from "@/utils/dates"
|
||||||
import { HabitsEmpty } from "./HabitsEmpty"
|
import { HabitsEmpty } from "./HabitsEmpty"
|
||||||
import { HabitsList } from "./HabitsList"
|
import { HabitsList } from "./HabitsList"
|
||||||
|
|
||||||
@ -14,7 +14,7 @@ export interface HabitsMainPageProps {
|
|||||||
export const HabitsMainPage: React.FC<HabitsMainPageProps> = (props) => {
|
export const HabitsMainPage: React.FC<HabitsMainPageProps> = (props) => {
|
||||||
const { habitsTracker } = props
|
const { habitsTracker } = props
|
||||||
|
|
||||||
const today = getNowDate()
|
const today = getNowDateUTC()
|
||||||
const todayISO = getISODate(today)
|
const todayISO = getISODate(today)
|
||||||
|
|
||||||
const [selectedDate, setSelectedDate] = useState<Date>(today)
|
const [selectedDate, setSelectedDate] = useState<Date>(today)
|
||||||
@ -45,7 +45,6 @@ export const HabitsMainPage: React.FC<HabitsMainPageProps> = (props) => {
|
|||||||
<HabitsList
|
<HabitsList
|
||||||
habitsTracker={habitsTracker}
|
habitsTracker={habitsTracker}
|
||||||
selectedDate={selectedDate}
|
selectedDate={selectedDate}
|
||||||
frequenciesFiltered={frequenciesFiltered}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
@ -1,6 +1,6 @@
|
|||||||
import renderer from "react-test-renderer"
|
import renderer from "react-test-renderer"
|
||||||
|
|
||||||
import { ExternalLink } from "@/presentation/react/components/ExternalLink"
|
import { ExternalLink } from "@/presentation/react-native/ui/ExternalLink"
|
||||||
|
|
||||||
describe("<ExternalLink />", () => {
|
describe("<ExternalLink />", () => {
|
||||||
it("renders correctly", () => {
|
it("renders correctly", () => {
|
@ -1,6 +1,6 @@
|
|||||||
import renderer from "react-test-renderer"
|
import renderer from "react-test-renderer"
|
||||||
|
|
||||||
import { TabBarIcon } from "@/presentation/react/components/TabBarIcon"
|
import { TabBarIcon } from "@/presentation/react-native/ui/TabBarIcon"
|
||||||
|
|
||||||
describe("<TabBarIcon />", () => {
|
describe("<TabBarIcon />", () => {
|
||||||
it("renders correctly", () => {
|
it("renders correctly", () => {
|
@ -1,110 +0,0 @@
|
|||||||
import LottieView from "lottie-react-native"
|
|
||||||
import { useRef, useState } from "react"
|
|
||||||
import { Dimensions, ScrollView, View } from "react-native"
|
|
||||||
import { Divider, List } from "react-native-paper"
|
|
||||||
|
|
||||||
import type { GoalFrequency } from "@/domain/entities/Goal"
|
|
||||||
import type { HabitsTracker } from "@/domain/entities/HabitsTracker"
|
|
||||||
import confettiJSON from "../../../assets/confetti.json"
|
|
||||||
import { capitalize } from "@/utils/strings"
|
|
||||||
import { HabitCard } from "./HabitCard"
|
|
||||||
|
|
||||||
export interface HabitsListProps {
|
|
||||||
habitsTracker: HabitsTracker
|
|
||||||
selectedDate: Date
|
|
||||||
frequenciesFiltered: GoalFrequency[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const HabitsList: React.FC<HabitsListProps> = (props) => {
|
|
||||||
const { habitsTracker, selectedDate, frequenciesFiltered } = props
|
|
||||||
|
|
||||||
const [accordionExpanded, setAccordionExpanded] = useState<{
|
|
||||||
[key in GoalFrequency]: boolean
|
|
||||||
}>({
|
|
||||||
daily: true,
|
|
||||||
weekly: true,
|
|
||||||
monthly: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const confettiRef = useRef<LottieView | null>(null)
|
|
||||||
|
|
||||||
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 />
|
|
||||||
|
|
||||||
<List.Section>
|
|
||||||
{frequenciesFiltered.map((frequency) => {
|
|
||||||
return (
|
|
||||||
<List.Accordion
|
|
||||||
expanded={accordionExpanded[frequency]}
|
|
||||||
onPress={() => {
|
|
||||||
setAccordionExpanded((old) => {
|
|
||||||
return {
|
|
||||||
...old,
|
|
||||||
[frequency]: !old[frequency],
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
key={frequency}
|
|
||||||
title={capitalize(frequency)}
|
|
||||||
titleStyle={[
|
|
||||||
{
|
|
||||||
fontSize: 26,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{habitsTracker.habitsHistory[frequency].map((item) => {
|
|
||||||
return (
|
|
||||||
<HabitCard
|
|
||||||
habitHistory={item}
|
|
||||||
selectedDate={selectedDate}
|
|
||||||
key={item.habit.id + selectedDate.toISOString()}
|
|
||||||
confettiRef={confettiRef}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</List.Accordion>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</List.Section>
|
|
||||||
</ScrollView>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
@ -3,7 +3,7 @@ import CircularProgress from "react-native-circular-progress-indicator"
|
|||||||
import { Agenda } from "react-native-calendars"
|
import { Agenda } from "react-native-calendars"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
|
|
||||||
import { getNowDate, getISODate } from "@/utils/dates"
|
import { getNowDateUTC, getISODate } from "@/utils/dates"
|
||||||
import type { HabitsTracker } from "@/domain/entities/HabitsTracker"
|
import type { HabitsTracker } from "@/domain/entities/HabitsTracker"
|
||||||
|
|
||||||
export interface StatsProps {
|
export interface StatsProps {
|
||||||
@ -13,7 +13,7 @@ export interface StatsProps {
|
|||||||
export const Stats: React.FC<StatsProps> = (props) => {
|
export const Stats: React.FC<StatsProps> = (props) => {
|
||||||
const { habitsTracker } = props
|
const { habitsTracker } = props
|
||||||
|
|
||||||
const today = getNowDate()
|
const today = getNowDateUTC()
|
||||||
const todayISO = getISODate(today)
|
const todayISO = getISODate(today)
|
||||||
|
|
||||||
const [selectedDate, setSelectedDate] = useState<Date>(today)
|
const [selectedDate, setSelectedDate] = useState<Date>(today)
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
import { createContext, useContext, useEffect } from "react"
|
import { createContext, useContext, useEffect } from "react"
|
||||||
|
|
||||||
import { usePresenterState } from "@/presentation/react/hooks/usePresenterState"
|
import { authenticationPresenter } from "@/infrastructure/instances"
|
||||||
import type {
|
import type {
|
||||||
AuthenticationPresenter,
|
AuthenticationPresenter,
|
||||||
AuthenticationPresenterState,
|
AuthenticationPresenterState,
|
||||||
} from "@/presentation/presenters/Authentication"
|
} from "@/presentation/presenters/Authentication"
|
||||||
import { authenticationPresenter } from "@/infrastructure/instances"
|
import { usePresenterState } from "@/presentation/react/hooks/usePresenterState"
|
||||||
|
|
||||||
export interface AuthenticationContextValue
|
export interface AuthenticationContextValue
|
||||||
extends AuthenticationPresenterState {
|
extends AuthenticationPresenterState {
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
import { createContext, useContext, useEffect } from "react"
|
import { createContext, useContext, useEffect } from "react"
|
||||||
|
|
||||||
|
import { habitsTrackerPresenter } from "@/infrastructure/instances"
|
||||||
import type {
|
import type {
|
||||||
HabitsTrackerPresenter,
|
HabitsTrackerPresenter,
|
||||||
HabitsTrackerPresenterState,
|
HabitsTrackerPresenterState,
|
||||||
} from "@/presentation/presenters/HabitsTracker"
|
} from "@/presentation/presenters/HabitsTracker"
|
||||||
import { usePresenterState } from "@/presentation/react/hooks/usePresenterState"
|
import { usePresenterState } from "@/presentation/react/hooks/usePresenterState"
|
||||||
import { habitsTrackerPresenter } from "@/infrastructure/instances"
|
|
||||||
import { useAuthentication } from "./Authentication"
|
import { useAuthentication } from "./Authentication"
|
||||||
|
|
||||||
export interface HabitsTrackerContextValue extends HabitsTrackerPresenterState {
|
export interface HabitsTrackerContextValue extends HabitsTrackerPresenterState {
|
||||||
|
@ -2,8 +2,8 @@ import { act, renderHook } from "@testing-library/react-native"
|
|||||||
|
|
||||||
import { useBoolean } from "@/presentation/react/hooks/useBoolean"
|
import { useBoolean } from "@/presentation/react/hooks/useBoolean"
|
||||||
|
|
||||||
describe("hooks/useBoolean", () => {
|
describe("presentation/react/hooks/useBoolean", () => {
|
||||||
beforeEach(() => {
|
afterEach(() => {
|
||||||
jest.clearAllMocks()
|
jest.clearAllMocks()
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -11,51 +11,76 @@ describe("hooks/useBoolean", () => {
|
|||||||
|
|
||||||
for (const initialValue of initialValues) {
|
for (const initialValue of initialValues) {
|
||||||
it(`should set the initial value to ${initialValue}`, () => {
|
it(`should set the initial value to ${initialValue}`, () => {
|
||||||
|
// Arrange - Given
|
||||||
const { result } = renderHook(() => {
|
const { result } = renderHook(() => {
|
||||||
return useBoolean({ initialValue })
|
return useBoolean({ initialValue })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Assert - Then
|
||||||
expect(result.current.value).toBe(initialValue)
|
expect(result.current.value).toBe(initialValue)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
it("should by default set the initial value to false", () => {
|
it("should by default set the initial value to false", () => {
|
||||||
|
// Arrange - Given
|
||||||
const { result } = renderHook(() => {
|
const { result } = renderHook(() => {
|
||||||
return useBoolean()
|
return useBoolean()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Assert - Then
|
||||||
expect(result.current.value).toBe(false)
|
expect(result.current.value).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should toggle the value", async () => {
|
it("should toggle the value", async () => {
|
||||||
|
// Arrange - Given
|
||||||
const { result } = renderHook(() => {
|
const { result } = renderHook(() => {
|
||||||
return useBoolean({ initialValue: false })
|
return useBoolean({ initialValue: false })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Act - When
|
||||||
await act(() => {
|
await act(() => {
|
||||||
return result.current.toggle()
|
return result.current.toggle()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Assert - Then
|
||||||
expect(result.current.value).toBe(true)
|
expect(result.current.value).toBe(true)
|
||||||
|
|
||||||
|
// Act - When
|
||||||
await act(() => {
|
await act(() => {
|
||||||
return result.current.toggle()
|
return result.current.toggle()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Assert - Then
|
||||||
expect(result.current.value).toBe(false)
|
expect(result.current.value).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should set the value to true", async () => {
|
it("should set the value to true", async () => {
|
||||||
|
// Arrange - Given
|
||||||
const { result } = renderHook(() => {
|
const { result } = renderHook(() => {
|
||||||
return useBoolean({ initialValue: false })
|
return useBoolean({ initialValue: false })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Act - When
|
||||||
await act(() => {
|
await act(() => {
|
||||||
return result.current.setTrue()
|
return result.current.setTrue()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Assert - Then
|
||||||
expect(result.current.value).toBe(true)
|
expect(result.current.value).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should set the value to false", async () => {
|
it("should set the value to false", async () => {
|
||||||
|
// Arrange - Given
|
||||||
const { result } = renderHook(() => {
|
const { result } = renderHook(() => {
|
||||||
return useBoolean({ initialValue: true })
|
return useBoolean({ initialValue: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Act - When
|
||||||
await act(() => {
|
await act(() => {
|
||||||
return result.current.setFalse()
|
return result.current.setFalse()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Assert - Then
|
||||||
expect(result.current.value).toBe(false)
|
expect(result.current.value).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
75
presentation/react/hooks/__tests__/usePresenterState.test.ts
Normal file
75
presentation/react/hooks/__tests__/usePresenterState.test.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { act, renderHook } from "@testing-library/react-native"
|
||||||
|
|
||||||
|
import { Presenter } from "@/presentation/presenters/_Presenter"
|
||||||
|
import { usePresenterState } from "@/presentation/react/hooks/usePresenterState"
|
||||||
|
|
||||||
|
interface MockCountPresenterState {
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockCountPresenter extends Presenter<MockCountPresenterState> {
|
||||||
|
public constructor(initialState: MockCountPresenterState) {
|
||||||
|
super(initialState)
|
||||||
|
}
|
||||||
|
|
||||||
|
public increment(): void {
|
||||||
|
this.setState((state) => {
|
||||||
|
state.count = state.count + 1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("presentation/react/hooks/usePresenterState", () => {
|
||||||
|
it("should return the initial state from the presenter", async () => {
|
||||||
|
// Arrange - Given
|
||||||
|
const initialState = { count: 0 }
|
||||||
|
const presenter = new MockCountPresenter(initialState)
|
||||||
|
|
||||||
|
// Act - When
|
||||||
|
const { result } = renderHook(() => {
|
||||||
|
return usePresenterState(presenter)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Assert - Then
|
||||||
|
expect(result.current).toEqual(initialState)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should update state when presenter state changes", async () => {
|
||||||
|
// Arrange - Given
|
||||||
|
const initialState = { count: 0 }
|
||||||
|
const presenter = new MockCountPresenter(initialState)
|
||||||
|
const subscribe = jest.spyOn(presenter, "subscribe")
|
||||||
|
const { result } = renderHook(() => {
|
||||||
|
return usePresenterState(presenter)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Act - When
|
||||||
|
await act(() => {
|
||||||
|
presenter.increment()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Assert - Then
|
||||||
|
expect(result.current.count).toBe(1)
|
||||||
|
expect(subscribe).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should unsubscribe from presenter on unmount", async () => {
|
||||||
|
// Arrange - Given
|
||||||
|
const initialState = { count: 0 }
|
||||||
|
const presenter = new MockCountPresenter(initialState)
|
||||||
|
const unsubscribe = jest.spyOn(presenter, "unsubscribe")
|
||||||
|
const { result, unmount } = renderHook(() => {
|
||||||
|
return usePresenterState(presenter)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Act - When
|
||||||
|
unmount()
|
||||||
|
await act(() => {
|
||||||
|
presenter.increment()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Assert - Then
|
||||||
|
expect(result.current.count).toBe(0)
|
||||||
|
expect(unsubscribe).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
@ -2,9 +2,10 @@ import { useState } from "react"
|
|||||||
|
|
||||||
export interface UseBooleanResult {
|
export interface UseBooleanResult {
|
||||||
value: boolean
|
value: boolean
|
||||||
toggle: () => void
|
setValue: React.Dispatch<React.SetStateAction<boolean>>
|
||||||
setTrue: () => void
|
setTrue: () => void
|
||||||
setFalse: () => void
|
setFalse: () => void
|
||||||
|
toggle: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UseBooleanOptions {
|
export interface UseBooleanOptions {
|
||||||
@ -43,6 +44,7 @@ export const useBoolean = (
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
value,
|
value,
|
||||||
|
setValue,
|
||||||
toggle,
|
toggle,
|
||||||
setTrue,
|
setTrue,
|
||||||
setFalse,
|
setFalse,
|
||||||
|
115
tests/mocks/domain/Habit.ts
Normal file
115
tests/mocks/domain/Habit.ts
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import { GoalBoolean, GoalNumeric } from "@/domain/entities/Goal"
|
||||||
|
import type { HabitData } from "@/domain/entities/Habit"
|
||||||
|
import { Habit } from "@/domain/entities/Habit"
|
||||||
|
import { USER_MOCK } from "./User"
|
||||||
|
import { ONE_DAY_MILLISECONDS } from "@/utils/dates"
|
||||||
|
|
||||||
|
interface HabitMockCreateOptions extends Omit<HabitData, "startDate"> {
|
||||||
|
startDate?: Date
|
||||||
|
}
|
||||||
|
const habitMockCreate = (options: HabitMockCreateOptions): Habit => {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
name,
|
||||||
|
color,
|
||||||
|
icon,
|
||||||
|
goal,
|
||||||
|
startDate = new Date(),
|
||||||
|
endDate,
|
||||||
|
} = options
|
||||||
|
|
||||||
|
return new Habit({
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
name,
|
||||||
|
color,
|
||||||
|
icon,
|
||||||
|
goal,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const examplesByNames = {
|
||||||
|
"Wake up at 07h00": habitMockCreate({
|
||||||
|
id: "1",
|
||||||
|
userId: USER_MOCK.example.id,
|
||||||
|
name: "Wake up at 07h00",
|
||||||
|
color: "#006CFF",
|
||||||
|
icon: "bed",
|
||||||
|
goal: new GoalBoolean({
|
||||||
|
frequency: "daily",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
"Learn English": habitMockCreate({
|
||||||
|
id: "2",
|
||||||
|
userId: USER_MOCK.example.id,
|
||||||
|
name: "Learn English",
|
||||||
|
color: "#EB4034",
|
||||||
|
icon: "language",
|
||||||
|
goal: new GoalNumeric({
|
||||||
|
frequency: "daily",
|
||||||
|
target: {
|
||||||
|
value: 30,
|
||||||
|
unit: "minutes",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
Walk: habitMockCreate({
|
||||||
|
id: "3",
|
||||||
|
userId: USER_MOCK.example.id,
|
||||||
|
name: "Walk",
|
||||||
|
color: "#228B22",
|
||||||
|
icon: "person-walking",
|
||||||
|
goal: new GoalNumeric({
|
||||||
|
frequency: "daily",
|
||||||
|
target: {
|
||||||
|
value: 5000,
|
||||||
|
unit: "steps",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
"Clean the house": habitMockCreate({
|
||||||
|
id: "4",
|
||||||
|
userId: USER_MOCK.example.id,
|
||||||
|
name: "Clean the house",
|
||||||
|
color: "#808080",
|
||||||
|
icon: "broom",
|
||||||
|
goal: new GoalBoolean({
|
||||||
|
frequency: "weekly",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
"Solve Programming Challenges": habitMockCreate({
|
||||||
|
id: "5",
|
||||||
|
userId: USER_MOCK.example.id,
|
||||||
|
name: "Solve Programming Challenges",
|
||||||
|
color: "#DE3163",
|
||||||
|
icon: "code",
|
||||||
|
goal: new GoalNumeric({
|
||||||
|
frequency: "monthly",
|
||||||
|
target: {
|
||||||
|
value: 5,
|
||||||
|
unit: "challenges",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
endDate: new Date(Date.now() + ONE_DAY_MILLISECONDS),
|
||||||
|
}),
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const examplesByIds = {
|
||||||
|
[examplesByNames["Wake up at 07h00"].id]: examplesByNames["Wake up at 07h00"],
|
||||||
|
[examplesByNames["Learn English"].id]: examplesByNames["Learn English"],
|
||||||
|
[examplesByNames.Walk.id]: examplesByNames.Walk,
|
||||||
|
[examplesByNames["Clean the house"].id]: examplesByNames["Clean the house"],
|
||||||
|
[examplesByNames["Solve Programming Challenges"].id]:
|
||||||
|
examplesByNames["Solve Programming Challenges"],
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const HABIT_MOCK = {
|
||||||
|
create: habitMockCreate,
|
||||||
|
example: examplesByNames["Wake up at 07h00"],
|
||||||
|
examplesByNames,
|
||||||
|
examplesByIds,
|
||||||
|
examples: Object.values(examplesByNames),
|
||||||
|
}
|
51
tests/mocks/domain/HabitProgress.ts
Normal file
51
tests/mocks/domain/HabitProgress.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import type { GoalBoolean, GoalNumeric } from "@/domain/entities/Goal"
|
||||||
|
import {
|
||||||
|
GoalBooleanProgress,
|
||||||
|
GoalNumericProgress,
|
||||||
|
} from "@/domain/entities/Goal"
|
||||||
|
import type { HabitProgressData } from "@/domain/entities/HabitProgress"
|
||||||
|
import { HabitProgress } from "@/domain/entities/HabitProgress"
|
||||||
|
import { HABIT_MOCK } from "./Habit"
|
||||||
|
|
||||||
|
interface HabitProgressMockCreateOptions
|
||||||
|
extends Omit<HabitProgressData, "date"> {
|
||||||
|
date?: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
const habitProgressMockCreate = (
|
||||||
|
options: HabitProgressMockCreateOptions,
|
||||||
|
): HabitProgress => {
|
||||||
|
const { id, habitId, goalProgress, date = new Date() } = options
|
||||||
|
|
||||||
|
return new HabitProgress({
|
||||||
|
date,
|
||||||
|
goalProgress,
|
||||||
|
habitId,
|
||||||
|
id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const exampleByIds = {
|
||||||
|
1: habitProgressMockCreate({
|
||||||
|
id: "1",
|
||||||
|
habitId: HABIT_MOCK.examplesByNames["Clean the house"].id,
|
||||||
|
goalProgress: new GoalBooleanProgress({
|
||||||
|
goal: HABIT_MOCK.examplesByNames["Clean the house"].goal as GoalBoolean,
|
||||||
|
progress: true,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
2: habitProgressMockCreate({
|
||||||
|
id: "2",
|
||||||
|
habitId: HABIT_MOCK.examplesByNames.Walk.id,
|
||||||
|
goalProgress: new GoalNumericProgress({
|
||||||
|
goal: HABIT_MOCK.examplesByNames.Walk.goal as GoalNumeric,
|
||||||
|
progress: 4_733,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const HABIT_PROGRESS_MOCK = {
|
||||||
|
create: habitProgressMockCreate,
|
||||||
|
exampleByIds,
|
||||||
|
examples: Object.values(exampleByIds),
|
||||||
|
}
|
30
tests/mocks/domain/User.ts
Normal file
30
tests/mocks/domain/User.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import type { UserData } from "@/domain/entities/User"
|
||||||
|
import { User } from "@/domain/entities/User"
|
||||||
|
|
||||||
|
const USER_MOCK_ID = "ab054ee9-fbb4-473e-942b-bbf4415f4bef"
|
||||||
|
const USER_MOCK_EMAIL = "test@test.com"
|
||||||
|
const USER_MOCK_DISPLAY_NAME = "Test"
|
||||||
|
|
||||||
|
interface UserMockCreateOptions {
|
||||||
|
id?: UserData["id"]
|
||||||
|
email?: UserData["email"]
|
||||||
|
displayName?: UserData["displayName"]
|
||||||
|
}
|
||||||
|
const userMockCreate = (options: UserMockCreateOptions = {}): User => {
|
||||||
|
const {
|
||||||
|
id = USER_MOCK_ID,
|
||||||
|
email = USER_MOCK_EMAIL,
|
||||||
|
displayName = USER_MOCK_DISPLAY_NAME,
|
||||||
|
} = options
|
||||||
|
|
||||||
|
return new User({
|
||||||
|
id,
|
||||||
|
email,
|
||||||
|
displayName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const USER_MOCK = {
|
||||||
|
create: userMockCreate,
|
||||||
|
example: userMockCreate(),
|
||||||
|
}
|
79
tests/mocks/supabase/Habit.ts
Normal file
79
tests/mocks/supabase/Habit.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import type { SupabaseHabit } from "@/infrastructure/supabase/supabase"
|
||||||
|
import { HABIT_MOCK } from "../domain/Habit"
|
||||||
|
import { SUPABASE_USER_MOCK } from "./User"
|
||||||
|
|
||||||
|
interface SupabaseHabitMockCreateOptions {
|
||||||
|
id: SupabaseHabit["id"]
|
||||||
|
userId: SupabaseHabit["user_id"]
|
||||||
|
name: SupabaseHabit["name"]
|
||||||
|
color: SupabaseHabit["color"]
|
||||||
|
icon: SupabaseHabit["icon"]
|
||||||
|
startDate?: Date
|
||||||
|
endDate: Date | null
|
||||||
|
goalFrequency: SupabaseHabit["goal_frequency"]
|
||||||
|
goalTarget: SupabaseHabit["goal_target"] | null
|
||||||
|
goalTargetUnit: SupabaseHabit["goal_target_unit"] | null
|
||||||
|
}
|
||||||
|
const supabaseHabitMockCreate = (
|
||||||
|
options: SupabaseHabitMockCreateOptions,
|
||||||
|
): SupabaseHabit => {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
name,
|
||||||
|
color,
|
||||||
|
icon,
|
||||||
|
startDate = new Date(),
|
||||||
|
endDate,
|
||||||
|
goalFrequency,
|
||||||
|
goalTarget,
|
||||||
|
goalTargetUnit,
|
||||||
|
} = options
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
user_id: userId,
|
||||||
|
name,
|
||||||
|
color,
|
||||||
|
icon,
|
||||||
|
start_date: startDate.toISOString(),
|
||||||
|
end_date: endDate?.toISOString() ?? null,
|
||||||
|
goal_frequency: goalFrequency,
|
||||||
|
goal_target: goalTarget,
|
||||||
|
goal_target_unit: goalTargetUnit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const examplesByNames = Object.fromEntries(
|
||||||
|
Object.entries(HABIT_MOCK.examplesByNames).map(([name, habit]) => {
|
||||||
|
const goalTarget = habit.goal.isNumeric() ? habit.goal.target.value : null
|
||||||
|
const goalTargetUnit = habit.goal.isNumeric()
|
||||||
|
? habit.goal.target.unit
|
||||||
|
: null
|
||||||
|
return [
|
||||||
|
name,
|
||||||
|
supabaseHabitMockCreate({
|
||||||
|
id: Number.parseInt(habit.id, 10),
|
||||||
|
userId: SUPABASE_USER_MOCK.example.id,
|
||||||
|
name: habit.name,
|
||||||
|
color: habit.color,
|
||||||
|
icon: habit.icon,
|
||||||
|
startDate: habit.startDate,
|
||||||
|
endDate: habit.endDate ?? null,
|
||||||
|
goalFrequency: habit.goal.frequency,
|
||||||
|
goalTarget,
|
||||||
|
goalTargetUnit,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
) as {
|
||||||
|
[key in keyof (typeof HABIT_MOCK)["examplesByNames"]]: SupabaseHabit
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SUPABASE_HABIT_MOCK = {
|
||||||
|
create: supabaseHabitMockCreate,
|
||||||
|
example:
|
||||||
|
examplesByNames[HABIT_MOCK.example.name as keyof typeof examplesByNames],
|
||||||
|
examples: Object.values(examplesByNames),
|
||||||
|
examplesByNames,
|
||||||
|
}
|
49
tests/mocks/supabase/HabitProgress.ts
Normal file
49
tests/mocks/supabase/HabitProgress.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import type { SupabaseHabitProgress } from "@/infrastructure/supabase/supabase"
|
||||||
|
import { HABIT_PROGRESS_MOCK } from "../domain/HabitProgress"
|
||||||
|
|
||||||
|
interface SupabaseHabitProgressMockCreateOptions {
|
||||||
|
id: SupabaseHabitProgress["id"]
|
||||||
|
habitId: SupabaseHabitProgress["habit_id"]
|
||||||
|
date?: Date
|
||||||
|
goalProgress: SupabaseHabitProgress["goal_progress"]
|
||||||
|
}
|
||||||
|
const supabaseHabitProgressMockCreate = (
|
||||||
|
options: SupabaseHabitProgressMockCreateOptions,
|
||||||
|
): SupabaseHabitProgress => {
|
||||||
|
const { id, habitId, date = new Date(), goalProgress } = options
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
habit_id: habitId,
|
||||||
|
date: date.toISOString(),
|
||||||
|
goal_progress: goalProgress,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const exampleByIds = Object.fromEntries(
|
||||||
|
Object.entries(HABIT_PROGRESS_MOCK.exampleByIds).map(
|
||||||
|
([id, habitProgress]) => {
|
||||||
|
return [
|
||||||
|
id,
|
||||||
|
supabaseHabitProgressMockCreate({
|
||||||
|
id: Number.parseInt(habitProgress.id, 10),
|
||||||
|
habitId: Number.parseInt(habitProgress.habitId, 10),
|
||||||
|
date: new Date(habitProgress.date),
|
||||||
|
goalProgress: habitProgress.goalProgress.isNumeric()
|
||||||
|
? habitProgress.goalProgress.progress
|
||||||
|
: habitProgress.goalProgress.isCompleted()
|
||||||
|
? 1
|
||||||
|
: 0,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
),
|
||||||
|
) as {
|
||||||
|
[key in keyof (typeof HABIT_PROGRESS_MOCK)["exampleByIds"]]: SupabaseHabitProgress
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SUPABASE_HABIT_PROGRESS_MOCK = {
|
||||||
|
create: supabaseHabitProgressMockCreate,
|
||||||
|
exampleByIds,
|
||||||
|
examples: Object.values(exampleByIds),
|
||||||
|
}
|
63
tests/mocks/supabase/User.ts
Normal file
63
tests/mocks/supabase/User.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import type { SupabaseUser } from "@/infrastructure/supabase/supabase"
|
||||||
|
import { USER_MOCK } from "../domain/User"
|
||||||
|
|
||||||
|
interface SupabaseUserMockCreateOptions {
|
||||||
|
id?: SupabaseUser["id"]
|
||||||
|
email?: SupabaseUser["email"]
|
||||||
|
displayName?: SupabaseUser["user_metadata"]["display_name"]
|
||||||
|
date?: Date
|
||||||
|
}
|
||||||
|
const supabaseUserMockCreate = (
|
||||||
|
options: SupabaseUserMockCreateOptions = {},
|
||||||
|
): SupabaseUser => {
|
||||||
|
const {
|
||||||
|
id = USER_MOCK.example.id,
|
||||||
|
email = USER_MOCK.example.email,
|
||||||
|
displayName = USER_MOCK.example.displayName,
|
||||||
|
date = new Date(),
|
||||||
|
} = options
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
app_metadata: { provider: "email", providers: ["email"] },
|
||||||
|
user_metadata: { display_name: displayName },
|
||||||
|
aud: "authenticated",
|
||||||
|
email,
|
||||||
|
confirmation_sent_at: undefined,
|
||||||
|
recovery_sent_at: undefined,
|
||||||
|
email_change_sent_at: undefined,
|
||||||
|
new_email: "",
|
||||||
|
new_phone: "",
|
||||||
|
invited_at: undefined,
|
||||||
|
action_link: "",
|
||||||
|
created_at: date.toISOString(),
|
||||||
|
confirmed_at: undefined,
|
||||||
|
email_confirmed_at: date.toISOString(),
|
||||||
|
phone_confirmed_at: undefined,
|
||||||
|
last_sign_in_at: undefined,
|
||||||
|
role: "authenticated",
|
||||||
|
updated_at: date.toISOString(),
|
||||||
|
identities: [
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
user_id: id,
|
||||||
|
identity_data: {
|
||||||
|
sub: id,
|
||||||
|
email,
|
||||||
|
},
|
||||||
|
provider: "email",
|
||||||
|
identity_id: id,
|
||||||
|
last_sign_in_at: date.toISOString(),
|
||||||
|
created_at: date.toISOString(),
|
||||||
|
updated_at: date.toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
is_anonymous: false,
|
||||||
|
factors: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SUPABASE_USER_MOCK = {
|
||||||
|
create: supabaseUserMockCreate,
|
||||||
|
example: supabaseUserMockCreate(),
|
||||||
|
}
|
1
tests/setup.ts
Normal file
1
tests/setup.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
import "@testing-library/react-native/extend-expect"
|
44
utils/__tests__/colors.test.ts
Normal file
44
utils/__tests__/colors.test.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { getColorRGBAFromHex } from "../colors"
|
||||||
|
|
||||||
|
describe("utils/colors", () => {
|
||||||
|
describe("getColorRGBAFromHex", () => {
|
||||||
|
it("should return the correct rgba value when given a hex color and opacity (black 0)", () => {
|
||||||
|
// Arrange - Given
|
||||||
|
const hexColor = "#000000"
|
||||||
|
const opacity = 0
|
||||||
|
|
||||||
|
// Act - When
|
||||||
|
const result = getColorRGBAFromHex({ hexColor, opacity })
|
||||||
|
|
||||||
|
// Assert - Then
|
||||||
|
const expected = "rgba(0, 0, 0, 0)"
|
||||||
|
expect(result).toEqual(expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return the correct rgba value when given a hex color and opacity (red 255)", () => {
|
||||||
|
// Arrange - Given
|
||||||
|
const hexColor = "#FF0000"
|
||||||
|
const opacity = 0.5
|
||||||
|
|
||||||
|
// Act - When
|
||||||
|
const result = getColorRGBAFromHex({ hexColor, opacity })
|
||||||
|
|
||||||
|
// Assert - Then
|
||||||
|
const expected = "rgba(255, 0, 0, 0.5)"
|
||||||
|
expect(result).toEqual(expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return the correct rgba value when given a hex color with 3 characters and opacity (red 255)", () => {
|
||||||
|
// Arrange - Given
|
||||||
|
const hexColor = "#F00"
|
||||||
|
const opacity = 0.5
|
||||||
|
|
||||||
|
// Act - When
|
||||||
|
const result = getColorRGBAFromHex({ hexColor, opacity })
|
||||||
|
|
||||||
|
// Assert - Then
|
||||||
|
const expected = "rgba(255, 0, 0, 0.5)"
|
||||||
|
expect(result).toEqual(expected)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
80
utils/__tests__/dates.test.ts
Normal file
80
utils/__tests__/dates.test.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import { getISODate, getNowDateUTC, getWeekNumber } from "../dates"
|
||||||
|
|
||||||
|
describe("utils/dates", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
jest.resetAllMocks()
|
||||||
|
jest.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getISODate", () => {
|
||||||
|
it("should return the correct date in ISO format (e.g: 2012-05-23)", () => {
|
||||||
|
// Arrange - Given
|
||||||
|
const date = new Date("2012-05-23")
|
||||||
|
|
||||||
|
// Act - When
|
||||||
|
const result = getISODate(date)
|
||||||
|
|
||||||
|
// Assert - Then
|
||||||
|
const expected = "2012-05-23"
|
||||||
|
expect(result).toEqual(expected)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getNowDateUTC", () => {
|
||||||
|
it("should return the current UTC date", () => {
|
||||||
|
// Arrange - Given
|
||||||
|
const mockDate = new Date("2024-05-01T12:00:00Z")
|
||||||
|
jest.useFakeTimers({ now: mockDate })
|
||||||
|
Date.UTC = jest.fn(() => {
|
||||||
|
return mockDate.getTime()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Act - When
|
||||||
|
const result = getNowDateUTC()
|
||||||
|
|
||||||
|
// Assert - Then
|
||||||
|
const expected = new Date("2024-05-01T12:00:00.000Z")
|
||||||
|
expect(result).toEqual(expected)
|
||||||
|
expect(Date.UTC).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getWeekNumber", () => {
|
||||||
|
it("should return the correct week number for a given date (e.g: 2020-01-01)", () => {
|
||||||
|
// Arrange - Given
|
||||||
|
const date = new Date("2020-01-01")
|
||||||
|
|
||||||
|
// Act - When
|
||||||
|
const result = getWeekNumber(date)
|
||||||
|
|
||||||
|
// Assert - Then
|
||||||
|
const expected = 1
|
||||||
|
expect(result).toEqual(expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return the correct week number for a given date (e.g: 2020-01-08)", () => {
|
||||||
|
// Arrange - Given
|
||||||
|
const date = new Date("2020-01-08")
|
||||||
|
|
||||||
|
// Act - When
|
||||||
|
const result = getWeekNumber(date)
|
||||||
|
|
||||||
|
// Assert - Then
|
||||||
|
const expected = 2
|
||||||
|
expect(result).toEqual(expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return the correct week number for a given date (e.g: 2020-12-31)", () => {
|
||||||
|
// Arrange - Given
|
||||||
|
const date = new Date("2020-12-31")
|
||||||
|
|
||||||
|
// Act - When
|
||||||
|
const result = getWeekNumber(date)
|
||||||
|
|
||||||
|
// Assert - Then
|
||||||
|
const expected = 53
|
||||||
|
expect(result).toEqual(expected)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
17
utils/__tests__/strings.test.ts
Normal file
17
utils/__tests__/strings.test.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { capitalize } from "../strings"
|
||||||
|
|
||||||
|
describe("utils/strings", () => {
|
||||||
|
describe("capitalize", () => {
|
||||||
|
it("should capitalize the first letter of a string", () => {
|
||||||
|
// Arrange - Given
|
||||||
|
const string = "hello world"
|
||||||
|
|
||||||
|
// Act - When
|
||||||
|
const result = capitalize(string)
|
||||||
|
|
||||||
|
// Assert - Then
|
||||||
|
const expected = "Hello world"
|
||||||
|
expect(result).toEqual(expected)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
39
utils/__tests__/version.test.ts
Normal file
39
utils/__tests__/version.test.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { getVersion } from "../version"
|
||||||
|
import { version } from "@/package.json"
|
||||||
|
|
||||||
|
describe("utils/version", () => {
|
||||||
|
const env = process.env
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetModules()
|
||||||
|
process.env = { ...env }
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = env
|
||||||
|
jest.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return '0.0.0-development' when NODE_ENV is 'development'", () => {
|
||||||
|
// Arrange - Given
|
||||||
|
process.env["NODE_ENV"] = "development"
|
||||||
|
|
||||||
|
// Act - When
|
||||||
|
const result = getVersion()
|
||||||
|
|
||||||
|
// Assert - Then
|
||||||
|
const expected = "0.0.0-development"
|
||||||
|
expect(result).toEqual(expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return the version from package.json when NODE_ENV is not 'development'", () => {
|
||||||
|
// Arrange - Given
|
||||||
|
process.env["NODE_ENV"] = "production"
|
||||||
|
|
||||||
|
// Act - When
|
||||||
|
const result = getVersion()
|
||||||
|
|
||||||
|
// Assert - Then
|
||||||
|
expect(result).toEqual(version)
|
||||||
|
})
|
||||||
|
})
|
39
utils/__tests__/zod.test.ts
Normal file
39
utils/__tests__/zod.test.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import type { ZodIssue } from "zod"
|
||||||
|
import { ZodError } from "zod"
|
||||||
|
|
||||||
|
import { getErrorsFieldsFromZodError } from "../zod"
|
||||||
|
|
||||||
|
const zodIssue: ZodIssue = {
|
||||||
|
code: "too_small",
|
||||||
|
minimum: 1,
|
||||||
|
type: "string",
|
||||||
|
inclusive: true,
|
||||||
|
exact: false,
|
||||||
|
message: "String must contain at least 1 character(s)",
|
||||||
|
path: ["name"],
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("utils/zod", () => {
|
||||||
|
describe("getErrorsFieldsFromZodError", () => {
|
||||||
|
it("should return an array of the fields that have errors", () => {
|
||||||
|
// Arrange - Given
|
||||||
|
const error = new ZodError([
|
||||||
|
{
|
||||||
|
...zodIssue,
|
||||||
|
path: ["field1"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...zodIssue,
|
||||||
|
path: ["field2"],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
// Act - When
|
||||||
|
const result = getErrorsFieldsFromZodError(error)
|
||||||
|
|
||||||
|
// Assert - Then
|
||||||
|
const expected = ["field1", "field2"]
|
||||||
|
expect(result).toEqual(expected)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
@ -11,7 +11,7 @@ export const getISODate = (date: Date): string => {
|
|||||||
return date.toISOString().slice(0, 10)
|
return date.toISOString().slice(0, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getNowDate = (): Date => {
|
export const getNowDateUTC = (): Date => {
|
||||||
const date = new Date()
|
const date = new Date()
|
||||||
const milliseconds = Date.UTC(
|
const milliseconds = Date.UTC(
|
||||||
date.getFullYear(),
|
date.getFullYear(),
|
||||||
@ -28,8 +28,8 @@ export const getNowDate = (): Date => {
|
|||||||
* Get the week number [1-52] for a given date.
|
* Get the week number [1-52] for a given date.
|
||||||
* @param {Date} date
|
* @param {Date} date
|
||||||
* @returns {number}
|
* @returns {number}
|
||||||
* @example getWeekNumber(new Date(2020, 0, 1)) // 1
|
* @example getWeekNumber(new Date("2020-01-01")) // 1
|
||||||
* @example getWeekNumber(new Date(2020, 0, 8)) // 2
|
* @example getWeekNumber(new Date("2020-01-08")) // 2
|
||||||
*/
|
*/
|
||||||
export const getWeekNumber = (date: Date): number => {
|
export const getWeekNumber = (date: Date): number => {
|
||||||
const dateCopy = new Date(date.getTime())
|
const dateCopy = new Date(date.getTime())
|
||||||
|
8
utils/version.ts
Normal file
8
utils/version.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { version } from "@/package.json"
|
||||||
|
|
||||||
|
export const getVersion = (): string => {
|
||||||
|
if (process.env["NODE_ENV"] === "development") {
|
||||||
|
return "0.0.0-development"
|
||||||
|
}
|
||||||
|
return version
|
||||||
|
}
|
@ -3,5 +3,8 @@ import type { ZodError } from "zod"
|
|||||||
export const getErrorsFieldsFromZodError = <T>(
|
export const getErrorsFieldsFromZodError = <T>(
|
||||||
error: ZodError<T>,
|
error: ZodError<T>,
|
||||||
): Array<keyof T> => {
|
): Array<keyof T> => {
|
||||||
return Object.keys(error.format()) as Array<keyof T>
|
const fields = Object.keys(error.format()) as Array<keyof T>
|
||||||
|
return fields.filter((field) => {
|
||||||
|
return field !== "_errors"
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user