Compare commits
34 Commits
v1.0.0-sta
...
develop
Author | SHA1 | Date | |
---|---|---|---|
fbbe74a082 | |||
8029204d44 | |||
15ab592513 | |||
beac8b37dc | |||
0793720f70 | |||
|
b789fad149 | ||
|
671639862c | ||
5099e472bc | |||
|
ab6af07a31 | ||
|
66501cc595 | ||
|
fdbbec3e11 | ||
89aab00e6e | |||
47bf926fd8 | |||
c455326f8e | |||
dbc19d7056 | |||
|
35b3c5b965 | ||
|
1bf5fdeaca | ||
d98f3144cb | |||
e15a3982fd | |||
ca122e9fce | |||
|
42f5623c92 | ||
2ab7413f32 | |||
2b15e9f28e | |||
cd7aa235ab | |||
5462b47112 | |||
|
e68fe6075e | ||
651e8e2633 | |||
49dbd18606 | |||
d596b37be5 | |||
e9afc81bab | |||
71987799c0 | |||
3c0c34d187 | |||
|
c03cd2b96d | ||
|
b6395b71b9 |
@ -1,12 +1,11 @@
|
||||
{
|
||||
"root": true,
|
||||
"extends": [
|
||||
"conventions",
|
||||
"plugin:react/recommended",
|
||||
"plugin:react-hooks/recommended",
|
||||
"prettier"
|
||||
"plugin:react-hooks/recommended"
|
||||
],
|
||||
"ignorePatterns": ["jest.setup.ts"],
|
||||
"plugins": ["prettier"],
|
||||
"env": {
|
||||
"browser": true,
|
||||
"node": true,
|
||||
@ -17,11 +16,7 @@
|
||||
"version": "detect"
|
||||
}
|
||||
},
|
||||
"parserOptions": {
|
||||
"project": "./tsconfig.json"
|
||||
},
|
||||
"rules": {
|
||||
"prettier/prettier": "error",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/prop-types": "off",
|
||||
"react/self-closing-comp": [
|
||||
@ -37,7 +32,11 @@
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts", "*.tsx"],
|
||||
"parser": "@typescript-eslint/parser"
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": ["@typescript-eslint"],
|
||||
"parserOptions": {
|
||||
"project": "./tsconfig.json"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
27
.github/workflows/ci.yml
vendored
Normal file
27
.github/workflows/ci.yml
vendored
Normal file
@ -0,0 +1,27 @@
|
||||
name: "ci"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [develop]
|
||||
pull_request:
|
||||
branches: [main, develop, staging]
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: "ubuntu-latest"
|
||||
steps:
|
||||
- uses: "actions/checkout@v4.1.6"
|
||||
|
||||
- name: "Setup Node.js"
|
||||
uses: "actions/setup-node@v4.0.2"
|
||||
with:
|
||||
node-version: "20.x"
|
||||
cache: "npm"
|
||||
|
||||
- run: "npm clean-install"
|
||||
- run: "npm run expo:typed-routes"
|
||||
- run: 'npm run lint:commit -- --to "${{ github.sha }}"'
|
||||
- run: "npm run lint:prettier"
|
||||
- run: "npm run lint:eslint"
|
||||
- run: "npm run lint:typescript"
|
||||
- run: "npm run test"
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -3,6 +3,7 @@
|
||||
# dependencies
|
||||
node_modules/
|
||||
.npm/
|
||||
.temp/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
|
@ -1,28 +0,0 @@
|
||||
default:
|
||||
image: "node:20.12.2"
|
||||
|
||||
stages:
|
||||
- "test"
|
||||
|
||||
test:
|
||||
stage: "test"
|
||||
only:
|
||||
- "merge_requests"
|
||||
- "develop"
|
||||
script:
|
||||
- "npm clean-install"
|
||||
- "npm run expo:typed-routes"
|
||||
- 'echo "${CI_COMMIT_MESSAGE}" | npm run lint:commit'
|
||||
- "npm run lint:prettier"
|
||||
- "npm run lint:eslint"
|
||||
- "npm run lint:typescript"
|
||||
- "npm run test"
|
||||
# artifacts:
|
||||
# paths:
|
||||
# - "coverage/"
|
||||
# reports:
|
||||
# coverage_report:
|
||||
# coverage_format: "cobertura"
|
||||
# path: "coverage/cobertura-coverage.xml"
|
||||
# junit: "junit.xml"
|
||||
# coverage: '/All files[^|]*\|[^|]*\s+([\d\.]+)/'
|
24
LICENSE
Normal file
24
LICENSE
Normal file
@ -0,0 +1,24 @@
|
||||
# MIT License
|
||||
|
||||
Copyright (c) Théo LUDWIG <contact@theoludwig.fr>
|
||||
Copyright (c) Haoxuan LI <haoxuan.li@etu.unistra.fr>
|
||||
Copyright (c) Maxime RUMPLER <mrumpler68@gmail.com>
|
||||
Copyright (c) Maxime RICHARD <maxime.richard2@etu.unistra.fr>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
23
README.md
23
README.md
@ -1,4 +1,4 @@
|
||||
# P61 - Projet
|
||||
# Habits Tracker - P61 Projet
|
||||
|
||||
## À propos
|
||||
|
||||
@ -6,6 +6,10 @@ Application mobile en [React Native](https://reactnative.dev/) pour le projet du
|
||||
|
||||
Un tracker d'habitudes pour performer au boulot et dans la vie de tous les jours.
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/screenshots/habits.png" alt="Habits Tracker Screenshot" height="400px" />
|
||||
</p>
|
||||
|
||||
### Membres du Groupe 7
|
||||
|
||||
- [Théo LUDWIG](https://git.unistra.fr/t.ludwig)
|
||||
@ -17,7 +21,6 @@ Un tracker d'habitudes pour performer au boulot et dans la vie de tous les jours
|
||||
|
||||
- [Sujet](./docs/SUJET.md) + [Cahier des charges](./docs/CAHIER-DES-CHARGES.md)
|
||||
- [Modèle Logique des Données (MLD)](./docs/MLD.md)
|
||||
- [Architecture](./docs/ARCHITECTURE.md)
|
||||
- [Conventions développement informatique](./docs/CONVENTIONS.md)
|
||||
- [Kanban Board (Trello)](https://trello.com/b/8kYlcLA8/habits-tracker)
|
||||
|
||||
@ -35,14 +38,14 @@ Un tracker d'habitudes pour performer au boulot et dans la vie de tous les jours
|
||||
|
||||
- [Node.js](https://nodejs.org/) >= 20.0.0
|
||||
- [npm](https://www.npmjs.com/) >= 10.0.0
|
||||
- [Expo Go](https://expo.io/client)
|
||||
- [Expo Go](https://expo.io/client) ~2.31.0
|
||||
- [Docker](https://www.docker.com/) (facultatif, utilisé pour lancer [Supabase](https://supabase.io/) en local)
|
||||
|
||||
### Installation
|
||||
|
||||
```sh
|
||||
# Cloner le projet
|
||||
git clone git@git.unistra.fr:rrll/p61-project.git
|
||||
git clone git@github.com:theoludwig/p61-project.git
|
||||
|
||||
# Se déplacer dans le répertoire du projet
|
||||
cd p61-project
|
||||
@ -65,24 +68,24 @@ npm run start
|
||||
Ce n'est pas strictement nécessaire pour le développement de l'application (même si recommandé), de lancer [Supabase](https://supabase.io/) en local, car l'application est déjà déployée sur un serveur [Supabase](https://supabase.io/) en production (`.env.example` est pré-configuré avec cet environnement).
|
||||
|
||||
```sh
|
||||
npm run supabase start
|
||||
npm run supabase-cli start
|
||||
```
|
||||
|
||||
#### Principales Commandes Supabase
|
||||
|
||||
```sh
|
||||
# Pour réinitialiser la base de données avec les données de test (seed.sql)
|
||||
npm run supabase db reset
|
||||
npm run supabase-cli db reset
|
||||
|
||||
# Pour synchroniser le modèle (local) avec la base de données (remote)
|
||||
npm run supabase db pull
|
||||
npm run supabase-cli db pull
|
||||
|
||||
# Pour synchroniser la base de données (remote) avec le modèle (local)
|
||||
npm run supabase db push
|
||||
npm run supabase-cli db push
|
||||
|
||||
# Pour générer les types TypeScript
|
||||
npm run supabase gen types typescript -- --local > ./infrastructure/supabase/supabase-types.ts
|
||||
npm run supabase-cli gen types typescript -- --local > ./infrastructure/supabase/supabase-types.ts
|
||||
|
||||
# Crée un nouveau script de migration à partir des modifications déjà appliquées à votre base de données locale (remplacer `<name-of-migration>` avec le nom de la migration)
|
||||
npm run supabase db diff -- -f <name-of-migration>
|
||||
npm run supabase-cli db diff -- -f <name-of-migration>
|
||||
```
|
||||
|
21
app.json
21
app.json
@ -1,26 +1,29 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "p61-project",
|
||||
"name": "Habits Tracker",
|
||||
"slug": "p61-project",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.1",
|
||||
"orientation": "portrait",
|
||||
"icon": "./presentation/assets/images/icon.png",
|
||||
"scheme": "p61-project",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"splash": {
|
||||
"image": "./presentation/assets/images/splashscreen.jpg",
|
||||
"image": "./presentation/assets/images/splashscreen.png",
|
||||
"resizeMode": "cover",
|
||||
"backgroundColor": "#74b6cb"
|
||||
},
|
||||
"assetBundlePatterns": ["**/*"],
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
"supportsTablet": true,
|
||||
"buildNumber": "1.1.1"
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./presentation/assets/images/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
}
|
||||
},
|
||||
"package": "com.theoludwig.p61project",
|
||||
"versionCode": 6
|
||||
},
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
@ -30,6 +33,14 @@
|
||||
"plugins": ["expo-router"],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
},
|
||||
"extra": {
|
||||
"router": {
|
||||
"origin": false
|
||||
},
|
||||
"eas": {
|
||||
"projectId": "5c0a922a-564b-4d62-8231-ce5aef7ff978"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -8,7 +8,7 @@ const TabLayout: React.FC = () => {
|
||||
const { user } = useAuthentication()
|
||||
|
||||
if (user == null) {
|
||||
return <Redirect href="/authentication/login" />
|
||||
return <Redirect href="/authentication/about" />
|
||||
}
|
||||
|
||||
return (
|
||||
@ -45,11 +45,11 @@ const TabLayout: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="habits/history"
|
||||
name="habits/statistics"
|
||||
options={{
|
||||
title: "History",
|
||||
title: "Statistics",
|
||||
tabBarIcon: ({ color }) => {
|
||||
return <TabBarIcon name="history" color={color} />
|
||||
return <TabBarIcon name="line-chart" color={color} />
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
26
app/application/habits/[habitId]/progress/[selectedDate].tsx
Normal file
26
app/application/habits/[habitId]/progress/[selectedDate].tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import { Redirect, useLocalSearchParams } from "expo-router"
|
||||
|
||||
import { HabitProgress } from "@/presentation/react-native/components/HabitProgress"
|
||||
import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker"
|
||||
|
||||
const HabitProgressPage: React.FC = () => {
|
||||
const { habitId, selectedDate } = useLocalSearchParams()
|
||||
const { habitsTracker } = useHabitsTracker()
|
||||
|
||||
const habitHistory = habitsTracker.getHabitHistoryById(habitId as string)
|
||||
const selectedDateParsed = new Date(selectedDate as string)
|
||||
|
||||
if (habitHistory == null) {
|
||||
return <Redirect href="/application/habits/" />
|
||||
}
|
||||
|
||||
return (
|
||||
<HabitProgress
|
||||
habitHistory={habitHistory}
|
||||
key={habitHistory.habit.id}
|
||||
selectedDate={selectedDateParsed}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default HabitProgressPage
|
@ -1,50 +0,0 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { View } from "react-native"
|
||||
import { Agenda } from "react-native-calendars"
|
||||
import { Text } from "react-native-paper"
|
||||
import { SafeAreaView } from "react-native-safe-area-context"
|
||||
|
||||
import { getISODate, getNowDateUTC } from "@/utils/dates"
|
||||
|
||||
const HistoryPage: React.FC = () => {
|
||||
const today = useMemo(() => {
|
||||
return getNowDateUTC()
|
||||
}, [])
|
||||
const todayISO = getISODate(today)
|
||||
|
||||
const [selectedDate, setSelectedDate] = useState<Date>(today)
|
||||
const selectedISODate = getISODate(selectedDate)
|
||||
|
||||
return (
|
||||
<SafeAreaView
|
||||
style={[
|
||||
{
|
||||
flex: 1,
|
||||
backgroundColor: "white",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Agenda
|
||||
firstDay={1}
|
||||
showClosingKnob
|
||||
showOnlySelectedDayItems
|
||||
onDayPress={(date) => {
|
||||
setSelectedDate(new Date(date.dateString))
|
||||
}}
|
||||
markedDates={{
|
||||
[todayISO]: { marked: true },
|
||||
}}
|
||||
selected={selectedISODate}
|
||||
renderList={() => {
|
||||
return (
|
||||
<View>
|
||||
<Text>{selectedDate.toISOString()}</Text>
|
||||
</View>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
export default HistoryPage
|
23
app/application/habits/statistics.tsx
Normal file
23
app/application/habits/statistics.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { SafeAreaView } from "react-native-safe-area-context"
|
||||
|
||||
import { HabitsStatistics } from "@/presentation/react-native/components/HabitsStatistics"
|
||||
import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker"
|
||||
|
||||
const StatisticsPage: React.FC = () => {
|
||||
const { habitsTracker } = useHabitsTracker()
|
||||
|
||||
return (
|
||||
<SafeAreaView
|
||||
style={[
|
||||
{
|
||||
flex: 1,
|
||||
backgroundColor: "white",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<HabitsStatistics habitsTracker={habitsTracker} />
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
export default StatisticsPage
|
@ -1,37 +1,58 @@
|
||||
import { Text } from "react-native"
|
||||
import { Button } from "react-native-paper"
|
||||
import { SafeAreaView } from "react-native-safe-area-context"
|
||||
import { Button, Text } from "react-native-paper"
|
||||
import { View } from "react-native"
|
||||
|
||||
import { About } from "@/presentation/react-native/components/About"
|
||||
import { useAuthentication } from "@/presentation/react/contexts/Authentication"
|
||||
|
||||
const SettingsPage: React.FC = () => {
|
||||
const { logout, authenticationPresenter } = useAuthentication()
|
||||
const { logout, authenticationPresenter, user } = useAuthentication()
|
||||
|
||||
const handleLogout = async (): Promise<void> => {
|
||||
await authenticationPresenter.logout()
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView
|
||||
style={[
|
||||
{
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text>Settings</Text>
|
||||
|
||||
<Button
|
||||
mode="contained"
|
||||
onPress={handleLogout}
|
||||
loading={logout.state === "loading"}
|
||||
disabled={logout.state === "loading"}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</SafeAreaView>
|
||||
<About
|
||||
actionButton={
|
||||
<Button
|
||||
mode="contained"
|
||||
labelStyle={{ fontSize: 18 }}
|
||||
onPress={handleLogout}
|
||||
loading={logout.state === "loading"}
|
||||
disabled={logout.state === "loading"}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
}
|
||||
footer={
|
||||
<View
|
||||
style={{
|
||||
alignItems: "center",
|
||||
marginVertical: 20,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
fontSize: 18,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
Currenty logged in as
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
marginTop: 6,
|
||||
fontWeight: "bold",
|
||||
fontSize: 16,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{user?.displayName}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -17,6 +17,15 @@ const TabLayout: React.FC = () => {
|
||||
headerShown: false,
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="about"
|
||||
options={{
|
||||
title: "About",
|
||||
tabBarIcon: ({ color }) => {
|
||||
return <TabBarIcon name="info" color={color} />
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="login"
|
||||
options={{
|
||||
|
26
app/authentication/about.tsx
Normal file
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
|
||||
mode="contained"
|
||||
labelStyle={{ fontSize: 18 }}
|
||||
onPress={handleSubmit(onSubmit)}
|
||||
loading={login.state === "loading"}
|
||||
disabled={login.state === "loading"}
|
||||
|
@ -107,6 +107,7 @@ const RegisterPage: React.FC = () => {
|
||||
|
||||
<Button
|
||||
mode="contained"
|
||||
labelStyle={{ fontSize: 18 }}
|
||||
onPress={handleSubmit(onSubmit)}
|
||||
loading={register.state === "loading"}
|
||||
disabled={register.state === "loading"}
|
||||
|
@ -6,7 +6,7 @@ const HomePage: React.FC = () => {
|
||||
const { user } = useAuthentication()
|
||||
|
||||
if (user == null) {
|
||||
return <Redirect href="/authentication/login" />
|
||||
return <Redirect href="/authentication/about" />
|
||||
}
|
||||
|
||||
return <Redirect href="/application/habits/" />
|
||||
|
@ -1 +0,0 @@
|
||||
# Clean Architecture
|
@ -19,7 +19,7 @@ npm run test
|
||||
npm run test -- --u
|
||||
```
|
||||
|
||||
Une pipeline CI ([`.gitlab-ci.yml`](../.gitlab-ci.yml)) est en place pour vérifier que le code respecte ces bonnes pratiques et que les tests passent.
|
||||
Une pipeline CI est en place pour vérifier que le code respecte ces bonnes pratiques et que les tests passent.
|
||||
|
||||
## GitFlow
|
||||
|
||||
|
BIN
docs/screenshots/habits.png
Normal file
BIN
docs/screenshots/habits.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 158 KiB |
@ -11,6 +11,12 @@ export const GOAL_FREQUENCIES = GOAL_FREQUENCIES_ZOD.map((frequency) => {
|
||||
})
|
||||
export type GoalFrequency = (typeof GOAL_FREQUENCIES)[number]
|
||||
|
||||
export const GOAL_FREQUENCIES_TYPES = {
|
||||
daily: "day",
|
||||
weekly: "week",
|
||||
monthly: "month",
|
||||
} as const
|
||||
|
||||
export const GOAL_TYPES_ZOD = [
|
||||
z.literal("boolean"),
|
||||
z.literal("numeric"),
|
||||
|
@ -76,4 +76,49 @@ export class HabitsTracker implements HabitsTrackerData {
|
||||
return habitHistory.habit.id === id
|
||||
})
|
||||
}
|
||||
|
||||
public getHabitsHistoriesByDate({
|
||||
selectedDate,
|
||||
frequency,
|
||||
}: {
|
||||
selectedDate: Date
|
||||
frequency: GoalFrequency
|
||||
}): HabitHistory[] {
|
||||
return this.habitsHistory[frequency].filter((habitItem) => {
|
||||
const startDate = new Date(habitItem.habit.startDate)
|
||||
startDate.setHours(0, 0, 0, 0)
|
||||
|
||||
return (
|
||||
startDate <= selectedDate &&
|
||||
(habitItem.habit.endDate == null ||
|
||||
(habitItem.habit.endDate != null &&
|
||||
habitItem.habit.endDate >= selectedDate))
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
public getHabitsStatisticsByDateAndFrequency({
|
||||
selectedDate,
|
||||
frequency,
|
||||
}: {
|
||||
selectedDate: Date
|
||||
frequency: GoalFrequency
|
||||
}): {
|
||||
totalGoalsSuccess: number
|
||||
totalGoals: number
|
||||
} {
|
||||
const habitsHistory = this.getHabitsHistoriesByDate({
|
||||
selectedDate,
|
||||
frequency,
|
||||
})
|
||||
let totalGoalsSuccess = 0
|
||||
const totalGoals = habitsHistory.length
|
||||
for (const habitHistory of habitsHistory) {
|
||||
const goalProgress = habitHistory.getGoalProgressByDate(selectedDate)
|
||||
if (goalProgress.isCompleted()) {
|
||||
totalGoalsSuccess++
|
||||
}
|
||||
}
|
||||
return { totalGoalsSuccess, totalGoals }
|
||||
}
|
||||
}
|
||||
|
@ -51,6 +51,16 @@ export class HabitGoalProgressUpdateUseCase
|
||||
})
|
||||
}
|
||||
|
||||
if (goalProgress.isNumeric()) {
|
||||
return await this.habitProgressCreateRepository.execute({
|
||||
habitProgressData: {
|
||||
date,
|
||||
goalProgress,
|
||||
habitId: habitHistory.habit.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
throw new Error("Not implemented")
|
||||
}
|
||||
}
|
||||
|
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": {}
|
||||
}
|
||||
}
|
@ -117,7 +117,7 @@ VALUES
|
||||
'Wake up at 07h00',
|
||||
'#006CFF',
|
||||
'bed',
|
||||
timezone('utc' :: text, NOW()),
|
||||
timezone('utc' :: text, NOW() - INTERVAL '3 days'),
|
||||
NULL,
|
||||
'daily',
|
||||
NULL,
|
||||
@ -144,7 +144,7 @@ VALUES
|
||||
'Learn English',
|
||||
'#EB4034',
|
||||
'language',
|
||||
timezone('utc' :: text, NOW()),
|
||||
timezone('utc' :: text, NOW() - INTERVAL '3 days'),
|
||||
NULL,
|
||||
'daily',
|
||||
30,
|
||||
@ -171,7 +171,7 @@ VALUES
|
||||
'Walk',
|
||||
'#228B22',
|
||||
'person-walking',
|
||||
timezone('utc' :: text, NOW()),
|
||||
timezone('utc' :: text, NOW() - INTERVAL '3 days'),
|
||||
NULL,
|
||||
'daily',
|
||||
5000,
|
||||
@ -198,7 +198,7 @@ VALUES
|
||||
'Clean the house',
|
||||
'#808080',
|
||||
'broom',
|
||||
timezone('utc' :: text, NOW()),
|
||||
timezone('utc' :: text, NOW() - INTERVAL '3 days'),
|
||||
NULL,
|
||||
'weekly',
|
||||
NULL,
|
||||
@ -225,7 +225,7 @@ VALUES
|
||||
'Solve Programming Challenges',
|
||||
'#DE3163',
|
||||
'code',
|
||||
timezone('utc' :: text, NOW()),
|
||||
timezone('utc' :: text, NOW() - INTERVAL '3 days'),
|
||||
NULL,
|
||||
'monthly',
|
||||
5,
|
||||
|
6476
package-lock.json
generated
6476
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
79
package.json
79
package.json
@ -2,57 +2,61 @@
|
||||
"name": "p61-project",
|
||||
"private": true,
|
||||
"main": "expo-router/entry",
|
||||
"version": "1.0.0-staging.3",
|
||||
"version": "1.1.1",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web",
|
||||
"expo:typed-routes": "expo customize tsconfig.json",
|
||||
"build-staging:android": "eas build --platform=android --profile=staging",
|
||||
"lint:commit": "commitlint",
|
||||
"lint:prettier": "prettier . --check",
|
||||
"lint:eslint": "eslint . --max-warnings 0 --report-unused-disable-directives --ignore-path .gitignore",
|
||||
"lint:typescript": "tsc --noEmit",
|
||||
"lint:staged": "lint-staged",
|
||||
"test": "jest --reporters=default --reporters=jest-junit",
|
||||
"supabase": "supabase --workdir \"./infrastructure\"",
|
||||
"supabase-cli": "supabase --workdir \"./infrastructure\"",
|
||||
"postinstall": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "14.0.1",
|
||||
"@expo/vector-icons": "14.0.2",
|
||||
"@fortawesome/fontawesome-svg-core": "6.5.2",
|
||||
"@fortawesome/free-solid-svg-icons": "6.5.2",
|
||||
"@fortawesome/react-native-fontawesome": "0.3.0",
|
||||
"@hookform/resolvers": "3.3.4",
|
||||
"@react-native-async-storage/async-storage": "1.21.0",
|
||||
"@fortawesome/react-native-fontawesome": "0.3.1",
|
||||
"@hookform/resolvers": "3.4.2",
|
||||
"@react-native-async-storage/async-storage": "1.23.1",
|
||||
"@react-navigation/native": "6.1.17",
|
||||
"@supabase/supabase-js": "2.43.0",
|
||||
"expo": "50.0.17",
|
||||
"expo-font": "11.10.3",
|
||||
"expo-linking": "6.2.2",
|
||||
"expo-router": "3.4.10",
|
||||
"expo-splash-screen": "0.26.5",
|
||||
"expo-status-bar": "1.11.1",
|
||||
"expo-system-ui": "2.9.4",
|
||||
"expo-web-browser": "12.8.2",
|
||||
"@supabase/supabase-js": "2.43.2",
|
||||
"expo": "51.0.8",
|
||||
"expo-linking": "6.3.1",
|
||||
"expo-router": "3.5.14",
|
||||
"expo-splash-screen": "0.27.4",
|
||||
"expo-status-bar": "1.12.1",
|
||||
"expo-system-ui": "3.0.4",
|
||||
"expo-web-browser": "13.0.3",
|
||||
"fuse.js": "7.0.0",
|
||||
"immer": "10.1.1",
|
||||
"lottie-react-native": "6.5.1",
|
||||
"lottie-react-native": "6.7.0",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-hook-form": "7.51.3",
|
||||
"react-native": "0.73.6",
|
||||
"react-native-calendars": "1.1304.1",
|
||||
"react-hook-form": "7.51.5",
|
||||
"react-native": "0.74.1",
|
||||
"react-native-calendars": "1.1305.0",
|
||||
"react-native-circular-progress-indicator": "4.4.2",
|
||||
"react-native-elements": "3.4.3",
|
||||
"react-native-gesture-handler": "2.14.1",
|
||||
"react-native-gesture-handler": "2.16.2",
|
||||
"react-native-paper": "5.12.3",
|
||||
"react-native-reanimated": "3.6.3",
|
||||
"react-native-safe-area-context": "4.8.2",
|
||||
"react-native-screens": "3.29.0",
|
||||
"react-native-reanimated": "3.10.1",
|
||||
"react-native-safe-area-context": "4.10.1",
|
||||
"react-native-screens": "3.31.1",
|
||||
"react-native-svg": "15.2.0",
|
||||
"react-native-svg-transformer": "1.4.0",
|
||||
"react-native-url-polyfill": "2.0.0",
|
||||
"react-native-vector-icons": "10.0.3",
|
||||
"react-native-vector-icons": "10.1.0",
|
||||
"react-native-web": "0.19.11",
|
||||
"reanimated-color-picker": "3.0.3",
|
||||
"zod": "3.23.5"
|
||||
"zod": "3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.24.5",
|
||||
@ -62,28 +66,27 @@
|
||||
"@total-typescript/ts-reset": "0.5.1",
|
||||
"@tsconfig/strictest": "2.0.5",
|
||||
"@types/jest": "29.5.12",
|
||||
"@types/node": "20.12.7",
|
||||
"@types/react": "18.2.77",
|
||||
"@types/react-test-renderer": "18.0.7",
|
||||
"@typescript-eslint/eslint-plugin": "7.8.0",
|
||||
"@typescript-eslint/parser": "7.8.0",
|
||||
"@types/node": "20.12.12",
|
||||
"@types/react": "18.2.79",
|
||||
"@types/react-test-renderer": "18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "7.10.0",
|
||||
"@typescript-eslint/parser": "7.10.0",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-conventions": "14.1.0",
|
||||
"eslint-config-prettier": "9.1.0",
|
||||
"eslint-config-conventions": "14.2.0",
|
||||
"eslint-plugin-import": "2.29.1",
|
||||
"eslint-plugin-prettier": "5.1.3",
|
||||
"eslint-plugin-promise": "6.1.1",
|
||||
"eslint-plugin-react": "7.34.1",
|
||||
"eslint-plugin-react-hooks": "4.6.2",
|
||||
"eslint-plugin-react-native": "4.1.0",
|
||||
"eslint-plugin-unicorn": "51.0.1",
|
||||
"eslint-plugin-unicorn": "53.0.0",
|
||||
"husky": "9.0.11",
|
||||
"jest": "29.7.0",
|
||||
"jest-expo": "50.0.4",
|
||||
"jest-expo": "51.0.2",
|
||||
"jest-junit": "16.0.0",
|
||||
"lint-staged": "15.2.2",
|
||||
"lint-staged": "15.2.4",
|
||||
"prettier": "3.2.5",
|
||||
"react-test-renderer": "18.2.0",
|
||||
"supabase": "1.164.1",
|
||||
"typescript": "5.4.5"
|
||||
"supabase": "1.167.4",
|
||||
"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 |
90
presentation/react-native/components/About.tsx
Normal file
90
presentation/react-native/components/About.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import { View } from "react-native"
|
||||
import { Text } from "react-native-paper"
|
||||
import { SafeAreaView } from "react-native-safe-area-context"
|
||||
|
||||
import { ExternalLink } from "@/presentation/react-native/ui/ExternalLink"
|
||||
import { getVersion } from "@/utils/version"
|
||||
|
||||
export interface AboutProps {
|
||||
actionButton: React.ReactNode
|
||||
footer?: React.ReactNode
|
||||
}
|
||||
|
||||
export const About: React.FC<AboutProps> = (props) => {
|
||||
const { actionButton, footer } = props
|
||||
|
||||
const version = getVersion()
|
||||
|
||||
return (
|
||||
<SafeAreaView
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingHorizontal: 20,
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
alignItems: "center",
|
||||
marginVertical: 20,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
fontSize: 28,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
Habits Tracker
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
style={{
|
||||
marginTop: 6,
|
||||
fontWeight: "bold",
|
||||
fontSize: 18,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
To perform at work and in everyday life.
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
style={{
|
||||
marginTop: 6,
|
||||
fontWeight: "bold",
|
||||
fontSize: 16,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
v{version}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text variant="bodyLarge" style={{ textAlign: "center" }}>
|
||||
<ExternalLink href="https://unistra.fr" style={{ color: "#006CFF" }}>
|
||||
Université de Strasbourg
|
||||
</ExternalLink>
|
||||
</Text>
|
||||
<Text variant="bodyLarge" style={{ textAlign: "center" }}>
|
||||
BUT Informatique - IUT Robert Schuman
|
||||
</Text>
|
||||
<Text variant="bodyLarge" style={{ textAlign: "center" }}>
|
||||
P61 Mobile Development
|
||||
</Text>
|
||||
|
||||
{footer}
|
||||
|
||||
<View
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginVertical: 20,
|
||||
}}
|
||||
>
|
||||
{actionButton}
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
@ -185,19 +185,6 @@ export const HabitCreateForm: React.FC<HabitCreateFormProps> = ({ user }) => {
|
||||
]}
|
||||
>
|
||||
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
|
||||
style={[{ width: "96%" }]}
|
||||
|
@ -4,6 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-native-fontawesome"
|
||||
import { memo, useCallback, useEffect, useState, useTransition } from "react"
|
||||
import { Modal, ScrollView, View } from "react-native"
|
||||
import { Button, List, Text, TextInput } from "react-native-paper"
|
||||
import Fuse from "fuse.js"
|
||||
|
||||
import { IconsList } from "./IconsList"
|
||||
|
||||
@ -30,12 +31,19 @@ const iconNames = Object.keys(fas).map((key) => {
|
||||
return fas[key]?.iconName ?? key
|
||||
})
|
||||
|
||||
const fuseOptions = {
|
||||
keys: ["iconName"],
|
||||
threshold: 0.4,
|
||||
}
|
||||
|
||||
const fuse = new Fuse(iconNames, fuseOptions)
|
||||
|
||||
const findIconsInLibrary = (icon: string): string[] => {
|
||||
return iconNames
|
||||
.filter((name, index, self) => {
|
||||
return name.includes(icon) && self.indexOf(name) === index
|
||||
})
|
||||
.slice(0, 50)
|
||||
const results = fuse.search(icon).map((result) => {
|
||||
return result.item
|
||||
})
|
||||
const uniqueResults = Array.from(new Set(results))
|
||||
return uniqueResults.slice(0, 50)
|
||||
}
|
||||
|
||||
export const IconSelectorModal: React.FC<IconSelectorModalProps> = ({
|
||||
|
268
presentation/react-native/components/HabitProgress.tsx
Normal file
268
presentation/react-native/components/HabitProgress.tsx
Normal file
@ -0,0 +1,268 @@
|
||||
import { useState } from "react"
|
||||
import { ScrollView, StyleSheet, View } from "react-native"
|
||||
import { Button, Snackbar, Text, TextInput } from "react-native-paper"
|
||||
import { SafeAreaView } from "react-native-safe-area-context"
|
||||
import type { IconName } from "@fortawesome/free-solid-svg-icons"
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-native-fontawesome"
|
||||
|
||||
import type { GoalNumeric } from "@/domain/entities/Goal"
|
||||
import { GoalNumericProgress } from "@/domain/entities/Goal"
|
||||
import type { HabitHistory } from "@/domain/entities/HabitHistory"
|
||||
import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker"
|
||||
import { LOCALE, capitalize } from "@/utils/strings"
|
||||
|
||||
export interface HabitProgressProps {
|
||||
habitHistory: HabitHistory
|
||||
selectedDate: Date
|
||||
}
|
||||
|
||||
export const HabitProgress: React.FC<HabitProgressProps> = ({
|
||||
habitHistory,
|
||||
selectedDate,
|
||||
}) => {
|
||||
const { habitsTrackerPresenter, habitGoalProgressUpdate } = useHabitsTracker()
|
||||
|
||||
const [isVisibleSnackbar, setIsVisibleSnackbar] = useState(false)
|
||||
|
||||
const onDismissSnackbar = (): void => {
|
||||
setIsVisibleSnackbar(false)
|
||||
}
|
||||
|
||||
const goalProgress = habitHistory.getGoalProgressByDate(selectedDate)
|
||||
|
||||
const goalProgresses = habitHistory.getProgressesByDate(selectedDate)
|
||||
|
||||
const values = {
|
||||
progress: 0,
|
||||
min: 0,
|
||||
max: 0,
|
||||
}
|
||||
if (goalProgress.isNumeric()) {
|
||||
values.max = goalProgress.goal.target.value
|
||||
}
|
||||
const [progressValue, setProgressValue] = useState(values.progress)
|
||||
|
||||
if (!goalProgress.isNumeric()) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
const progressTotal = goalProgress.progress + progressValue
|
||||
|
||||
const handleSave = async (): Promise<void> => {
|
||||
setIsVisibleSnackbar(true)
|
||||
await habitsTrackerPresenter.habitUpdateProgress({
|
||||
date: selectedDate,
|
||||
habitHistory,
|
||||
goalProgress: new GoalNumericProgress({
|
||||
goal: habitHistory.habit.goal as GoalNumeric,
|
||||
progress: progressValue,
|
||||
}),
|
||||
})
|
||||
setProgressValue(0)
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, justifyContent: "space-between" }}>
|
||||
<View
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 20,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: 15,
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
size={24}
|
||||
icon={habitHistory.habit.icon as IconName}
|
||||
style={[
|
||||
{
|
||||
width: 30,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Text
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
fontSize: 28,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{habitHistory.habit.name}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text
|
||||
style={{
|
||||
marginTop: 10,
|
||||
fontWeight: "bold",
|
||||
fontSize: 18,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{capitalize(habitHistory.habit.goal.frequency)} Progress
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
textAlign: "center",
|
||||
marginBottom: 15,
|
||||
}}
|
||||
>
|
||||
{selectedDate.toLocaleDateString(LOCALE, {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</Text>
|
||||
|
||||
<View
|
||||
style={{
|
||||
width: "100%",
|
||||
borderBottomWidth: 1,
|
||||
borderColor: "#f57c00",
|
||||
marginVertical: 10,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Text style={{ marginVertical: 10, fontWeight: "bold", fontSize: 18 }}>
|
||||
{goalProgress.progress.toLocaleString(LOCALE)} /{" "}
|
||||
{goalProgress.goal.target.value.toLocaleString(LOCALE)}{" "}
|
||||
{goalProgress.goal.target.unit}
|
||||
</Text>
|
||||
|
||||
<TextInput
|
||||
placeholder="Progress to add (e.g: 5 000)"
|
||||
value={progressValue === 0 ? "" : progressValue.toString()}
|
||||
onChangeText={(text) => {
|
||||
const hasDigits = /\d+$/.test(text)
|
||||
if (text.length <= 0 || !hasDigits) {
|
||||
setProgressValue(0)
|
||||
return
|
||||
}
|
||||
setProgressValue(Number.parseInt(text, 10))
|
||||
}}
|
||||
style={[
|
||||
styles.spacing,
|
||||
{
|
||||
width: "80%",
|
||||
},
|
||||
]}
|
||||
mode="outlined"
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
|
||||
{goalProgress.progress > 0 && progressValue > 0 ? (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 16,
|
||||
textAlign: "center",
|
||||
marginBottom: 15,
|
||||
}}
|
||||
>
|
||||
{goalProgress.progress.toLocaleString()} +{" "}
|
||||
{progressValue.toLocaleString()} = {progressTotal.toLocaleString()}{" "}
|
||||
{goalProgress.goal.target.unit}
|
||||
</Text>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
<Button
|
||||
mode="contained"
|
||||
onPress={handleSave}
|
||||
loading={habitGoalProgressUpdate.state === "loading"}
|
||||
disabled={
|
||||
habitGoalProgressUpdate.state === "loading" || progressValue === 0
|
||||
}
|
||||
style={[styles.spacing, { width: "80%" }]}
|
||||
>
|
||||
Save Progress ✨
|
||||
</Button>
|
||||
|
||||
<View
|
||||
style={{
|
||||
width: "100%",
|
||||
borderBottomWidth: 1,
|
||||
borderColor: "#f57c00",
|
||||
marginVertical: 10,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Text
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
fontSize: 18,
|
||||
margin: 15,
|
||||
}}
|
||||
>
|
||||
Progress History
|
||||
</Text>
|
||||
|
||||
<ScrollView
|
||||
style={{
|
||||
width: "100%",
|
||||
marginVertical: 20,
|
||||
}}
|
||||
>
|
||||
{goalProgresses.map((habitProgress, index) => {
|
||||
if (!habitProgress.goalProgress.isNumeric()) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
key={habitProgress.id + index}
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 20,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: "#f57c00",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<Text>
|
||||
{habitProgress.date.toLocaleDateString(LOCALE, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</Text>
|
||||
<Text>
|
||||
{habitProgress.goalProgress.progress.toLocaleString(LOCALE)}{" "}
|
||||
{habitProgress.goalProgress.goal.target.unit}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
<Snackbar
|
||||
visible={isVisibleSnackbar}
|
||||
onDismiss={onDismissSnackbar}
|
||||
duration={2_000}
|
||||
>
|
||||
✅ Habit Saved successfully!
|
||||
</Snackbar>
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
spacing: {
|
||||
marginVertical: 16,
|
||||
},
|
||||
})
|
@ -1,16 +1,17 @@
|
||||
import type { IconName } from "@fortawesome/free-solid-svg-icons"
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-native-fontawesome"
|
||||
import { useRouter } from "expo-router"
|
||||
import { Link, useRouter } from "expo-router"
|
||||
import type LottieView from "lottie-react-native"
|
||||
import { useState } from "react"
|
||||
import { View } from "react-native"
|
||||
import { Checkbox, List, Text } from "react-native-paper"
|
||||
import { Button, Checkbox, List, Text } from "react-native-paper"
|
||||
|
||||
import type { GoalBoolean } from "@/domain/entities/Goal"
|
||||
import { GoalBooleanProgress } from "@/domain/entities/Goal"
|
||||
import type { HabitHistory } from "@/domain/entities/HabitHistory"
|
||||
import { useHabitsTracker } from "@/presentation/react/contexts/HabitsTracker"
|
||||
import { getColorRGBAFromHex } from "@/utils/colors"
|
||||
import { getISODate } from "@/utils/dates"
|
||||
|
||||
export interface HabitCardProps {
|
||||
habitHistory: HabitHistory
|
||||
@ -80,14 +81,32 @@ export const HabitCard: React.FC<HabitCardProps> = (props) => {
|
||||
}}
|
||||
right={() => {
|
||||
if (goalProgress.isNumeric()) {
|
||||
const href = {
|
||||
pathname: "/application/habits/[habitId]/progress/[selectedDate]/",
|
||||
params: {
|
||||
habitId: habit.id,
|
||||
selectedDate: getISODate(selectedDate),
|
||||
},
|
||||
}
|
||||
return (
|
||||
<View>
|
||||
<Text>
|
||||
{goalProgress.progress.toLocaleString()} /{" "}
|
||||
{goalProgress.goal.target.value.toLocaleString()}{" "}
|
||||
{goalProgress.goal.target.unit}
|
||||
</Text>
|
||||
</View>
|
||||
<Link href={href}>
|
||||
<View>
|
||||
<Text>
|
||||
{goalProgress.progress.toLocaleString()} /{" "}
|
||||
{goalProgress.goal.target.value.toLocaleString()}{" "}
|
||||
{goalProgress.goal.target.unit}
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
mode="elevated"
|
||||
onPress={() => {
|
||||
router.push(href)
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</View>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -1,22 +1,22 @@
|
||||
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 { Divider, List, Text } from "react-native-paper"
|
||||
|
||||
import type { GoalFrequency } from "@/domain/entities/Goal"
|
||||
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 { LOCALE, capitalize } from "@/utils/strings"
|
||||
import confettiJSON from "../../../assets/confetti.json"
|
||||
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 { habitsTracker, selectedDate } = props
|
||||
|
||||
const [accordionExpanded, setAccordionExpanded] = useState<{
|
||||
[key in GoalFrequency]: boolean
|
||||
@ -28,6 +28,25 @@ export const HabitsList: React.FC<HabitsListProps> = (props) => {
|
||||
|
||||
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
|
||||
@ -69,36 +88,45 @@ export const HabitsList: React.FC<HabitsListProps> = (props) => {
|
||||
>
|
||||
<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]
|
||||
.filter((habitItem) => {
|
||||
return (
|
||||
(habitItem.habit.endDate != null &&
|
||||
habitItem.habit.endDate >= selectedDate) ||
|
||||
habitItem.habit.endDate == null
|
||||
)
|
||||
})
|
||||
.map((item) => {
|
||||
<Text
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
fontSize: 22,
|
||||
textAlign: "center",
|
||||
marginTop: 20,
|
||||
}}
|
||||
>
|
||||
{selectedDate.toLocaleDateString(LOCALE, {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</Text>
|
||||
|
||||
{frequenciesFiltered.length > 0 ? (
|
||||
<List.Section>
|
||||
{frequenciesFiltered.map((frequency) => {
|
||||
return (
|
||||
<List.Accordion
|
||||
expanded={accordionExpanded[frequency]}
|
||||
onPress={() => {
|
||||
setAccordionExpanded((old) => {
|
||||
return {
|
||||
...old,
|
||||
[frequency]: !old[frequency],
|
||||
}
|
||||
})
|
||||
}}
|
||||
key={frequency}
|
||||
title={capitalize(frequency)}
|
||||
titleStyle={[
|
||||
{
|
||||
fontSize: 26,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{habitsHistoriesByFrequency[frequency].map((item) => {
|
||||
return (
|
||||
<HabitCard
|
||||
habitHistory={item}
|
||||
@ -108,10 +136,21 @@ export const HabitsList: React.FC<HabitsListProps> = (props) => {
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</List.Accordion>
|
||||
)
|
||||
})}
|
||||
</List.Section>
|
||||
</List.Accordion>
|
||||
)
|
||||
})}
|
||||
</List.Section>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginVertical: 6,
|
||||
}}
|
||||
>
|
||||
<Text variant="titleLarge">No habits for this date</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</>
|
||||
)
|
||||
|
@ -45,7 +45,6 @@ export const HabitsMainPage: React.FC<HabitsMainPageProps> = (props) => {
|
||||
<HabitsList
|
||||
habitsTracker={habitsTracker}
|
||||
selectedDate={selectedDate}
|
||||
frequenciesFiltered={frequenciesFiltered}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
|
116
presentation/react-native/components/HabitsStatistics.tsx
Normal file
116
presentation/react-native/components/HabitsStatistics.tsx
Normal file
@ -0,0 +1,116 @@
|
||||
import { Card, Divider, Text } from "react-native-paper"
|
||||
import CircularProgress from "react-native-circular-progress-indicator"
|
||||
import { Agenda } from "react-native-calendars"
|
||||
import { useState } from "react"
|
||||
import { ScrollView } from "react-native"
|
||||
|
||||
import { getNowDateUTC, getISODate } from "@/utils/dates"
|
||||
import type { HabitsTracker } from "@/domain/entities/HabitsTracker"
|
||||
import { LOCALE } from "@/utils/strings"
|
||||
import {
|
||||
GOAL_FREQUENCIES,
|
||||
GOAL_FREQUENCIES_TYPES,
|
||||
} from "@/domain/entities/Goal"
|
||||
import { calculateRatio } from "@/utils/maths"
|
||||
|
||||
export interface HabitsStatisticsProps {
|
||||
habitsTracker: HabitsTracker
|
||||
}
|
||||
|
||||
export const HabitsStatistics: React.FC<HabitsStatisticsProps> = (props) => {
|
||||
const { habitsTracker } = props
|
||||
|
||||
const today = getNowDateUTC()
|
||||
const todayISO = getISODate(today)
|
||||
|
||||
const [selectedDate, setSelectedDate] = useState<Date>(today)
|
||||
const selectedDateISO = getISODate(selectedDate)
|
||||
|
||||
return (
|
||||
<Agenda
|
||||
firstDay={1}
|
||||
showClosingKnob
|
||||
onDayPress={(date) => {
|
||||
setSelectedDate(new Date(date.dateString))
|
||||
}}
|
||||
markedDates={{
|
||||
[todayISO]: { marked: true, today: true },
|
||||
}}
|
||||
maxDate={todayISO}
|
||||
selected={selectedDateISO}
|
||||
renderList={() => {
|
||||
return (
|
||||
<ScrollView>
|
||||
<Divider />
|
||||
|
||||
<Text
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
fontSize: 22,
|
||||
textAlign: "center",
|
||||
marginVertical: 10,
|
||||
}}
|
||||
>
|
||||
{selectedDate.toLocaleDateString(LOCALE, {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</Text>
|
||||
|
||||
{GOAL_FREQUENCIES.map((frequency) => {
|
||||
const { totalGoalsSuccess, totalGoals } =
|
||||
habitsTracker.getHabitsStatisticsByDateAndFrequency({
|
||||
selectedDate,
|
||||
frequency,
|
||||
})
|
||||
const percentage =
|
||||
calculateRatio(totalGoalsSuccess, totalGoals) * 100
|
||||
return {
|
||||
totalGoalsSuccess,
|
||||
totalGoals,
|
||||
percentage,
|
||||
frequency,
|
||||
}
|
||||
})
|
||||
.filter(({ totalGoals }) => {
|
||||
return totalGoals > 0
|
||||
})
|
||||
.map(
|
||||
({ frequency, totalGoals, totalGoalsSuccess, percentage }) => {
|
||||
return (
|
||||
<Card
|
||||
key={frequency}
|
||||
mode="elevated"
|
||||
style={{ marginVertical: 8, marginHorizontal: 10 }}
|
||||
>
|
||||
<Card.Content
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Text variant="bodyMedium" style={{ marginBottom: 5 }}>
|
||||
{totalGoalsSuccess} achieved goals in the{" "}
|
||||
{GOAL_FREQUENCIES_TYPES[frequency]} out of{" "}
|
||||
{totalGoals}.
|
||||
</Text>
|
||||
<CircularProgress
|
||||
value={percentage}
|
||||
progressValueColor={"#ecf0f1"}
|
||||
circleBackgroundColor="black"
|
||||
titleColor="white"
|
||||
title="%"
|
||||
/>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
)
|
||||
},
|
||||
)}
|
||||
</ScrollView>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
83
utils/__tests__/maths.test.ts
Normal file
83
utils/__tests__/maths.test.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import { calculateRatio } from "../maths"
|
||||
|
||||
describe("utils/maths", () => {
|
||||
describe("calculateRatio", () => {
|
||||
it("should calculate the ratio of a value to a total", () => {
|
||||
// Arrange - Given
|
||||
const value = 3
|
||||
const total = 10
|
||||
|
||||
// Act - When
|
||||
const result = calculateRatio(value, total)
|
||||
|
||||
// Assert - Then
|
||||
const expected = 0.3
|
||||
expect(result).toEqual(expected)
|
||||
})
|
||||
|
||||
it("should return 0 if the total is 0", () => {
|
||||
// Arrange - Given
|
||||
const value = 3
|
||||
const total = 0
|
||||
|
||||
// Act - When
|
||||
const result = calculateRatio(value, total)
|
||||
|
||||
// Assert - Then
|
||||
const expected = 0
|
||||
expect(result).toEqual(expected)
|
||||
})
|
||||
|
||||
it("should return 0 if the total is negative", () => {
|
||||
// Arrange - Given
|
||||
const value = 3
|
||||
const total = -1
|
||||
|
||||
// Act - When
|
||||
const result = calculateRatio(value, total)
|
||||
|
||||
// Assert - Then
|
||||
const expected = 0
|
||||
expect(result).toEqual(expected)
|
||||
})
|
||||
|
||||
it("should return 0 if the value is 0", () => {
|
||||
// Arrange - Given
|
||||
const value = 0
|
||||
const total = 10
|
||||
|
||||
// Act - When
|
||||
const result = calculateRatio(value, total)
|
||||
|
||||
// Assert - Then
|
||||
const expected = 0
|
||||
expect(result).toEqual(expected)
|
||||
})
|
||||
|
||||
it("should return 1 if the value is equal to the total", () => {
|
||||
// Arrange - Given
|
||||
const value = 10
|
||||
const total = 10
|
||||
|
||||
// Act - When
|
||||
const result = calculateRatio(value, total)
|
||||
|
||||
// Assert - Then
|
||||
const expected = 1
|
||||
expect(result).toEqual(expected)
|
||||
})
|
||||
|
||||
it("should return 1 if the value is greater than the total", () => {
|
||||
// Arrange - Given
|
||||
const value = 11
|
||||
const total = 10
|
||||
|
||||
// Act - When
|
||||
const result = calculateRatio(value, total)
|
||||
|
||||
// Assert - Then
|
||||
const expected = 1
|
||||
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)
|
||||
})
|
||||
})
|
9
utils/maths.ts
Normal file
9
utils/maths.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export const calculateRatio = (value: number, total: number): number => {
|
||||
if (total <= 0) {
|
||||
return 0
|
||||
}
|
||||
if (value >= total) {
|
||||
return 1
|
||||
}
|
||||
return value / total
|
||||
}
|
@ -1,3 +1,5 @@
|
||||
export const LOCALE = "en-US"
|
||||
|
||||
export const capitalize = (string: string): string => {
|
||||
return string.charAt(0).toUpperCase() + string.slice(1)
|
||||
}
|
||||
|
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
|
||||
}
|
Reference in New Issue
Block a user