feat: authentication (register, login, logout)

This commit is contained in:
Théo LUDWIG 2024-03-22 23:41:51 +01:00
parent 90c8c7547f
commit 25f60afb91
Signed by: theoludwig
GPG Key ID: ADFE5A563D718F3B
36 changed files with 2088 additions and 1224 deletions

View File

@ -11,6 +11,7 @@ test:
- "develop" - "develop"
script: script:
- "npm clean-install" - "npm clean-install"
- "npm run expo:typed-routes"
- 'echo "${CI_COMMIT_MESSAGE}" | npm run lint:commit' - 'echo "${CI_COMMIT_MESSAGE}" | npm run lint:commit'
- "npm run lint:prettier" - "npm run lint:prettier"
- "npm run lint:eslint" - "npm run lint:eslint"

View File

@ -1,92 +1,7 @@
import FontAwesome from "@expo/vector-icons/FontAwesome" import { Slot } from "expo-router"
import { Tabs } from "expo-router"
import React from "react"
/** const Layout: React.FC = () => {
* @see https://icons.expo.fyi/ return <Slot />
* @param props
* @returns
*/
const TabBarIcon: React.FC<{
name: React.ComponentProps<typeof FontAwesome>["name"]
color: string
}> = (props) => {
return <FontAwesome size={28} style={{ marginBottom: -3 }} {...props} />
} }
const TabLayout: React.FC = () => { export default Layout
return (
<Tabs
screenOptions={{
headerShown: false,
}}
>
<Tabs.Screen
name="index"
options={{
title: "Home",
tabBarIcon: ({ color }) => {
return <TabBarIcon name="home" color={color} />
},
}}
/>
<Tabs.Screen
name="habits/new"
options={{
title: "New Habit",
tabBarIcon: ({ color }) => {
return <TabBarIcon name="plus-square" color={color} />
},
}}
/>
<Tabs.Screen
name="habits/index"
options={{
headerShown: false,
title: "Habits",
tabBarIcon: ({ color }) => {
return <TabBarIcon name="sticky-note" color={color} />
},
}}
/>
<Tabs.Screen
name="habits/history"
options={{
title: "History",
tabBarIcon: ({ color }) => {
return <TabBarIcon name="history" color={color} />
},
}}
/>
<Tabs.Screen
name="users/settings"
options={{
title: "Settings",
tabBarIcon: ({ color }) => {
return <TabBarIcon name="cog" color={color} />
},
}}
/>
<Tabs.Screen
name="authentication/login"
options={{
title: "Login",
tabBarIcon: ({ color }) => {
return <TabBarIcon name="sign-in" color={color} />
},
}}
/>
<Tabs.Screen
name="authentication/register"
options={{
title: "Register",
tabBarIcon: ({ color }) => {
return <TabBarIcon name="user-plus" color={color} />
},
}}
/>
</Tabs>
)
}
export default TabLayout

View File

@ -0,0 +1,61 @@
import { Redirect, Tabs } from "expo-router"
import React from "react"
import { TabBarIcon } from "@/presentation/react/components/TabBarIcon"
import { useAuthentication } from "@/presentation/react/contexts/Authentication"
const TabLayout: React.FC = () => {
const { user } = useAuthentication()
if (user == null) {
return <Redirect href="/(pages)/authentication/login" />
}
return (
<Tabs
screenOptions={{
headerShown: false,
}}
>
<Tabs.Screen
name="habits/index"
options={{
headerShown: false,
title: "Habits",
tabBarIcon: ({ color }) => {
return <TabBarIcon name="sticky-note" color={color} />
},
}}
/>
<Tabs.Screen
name="habits/new"
options={{
title: "New Habit",
tabBarIcon: ({ color }) => {
return <TabBarIcon name="plus-square" color={color} />
},
}}
/>
<Tabs.Screen
name="habits/history"
options={{
title: "History",
tabBarIcon: ({ color }) => {
return <TabBarIcon name="history" color={color} />
},
}}
/>
<Tabs.Screen
name="users/settings"
options={{
title: "Settings",
tabBarIcon: ({ color }) => {
return <TabBarIcon name="cog" color={color} />
},
}}
/>
</Tabs>
)
}
export default TabLayout

View File

@ -13,7 +13,9 @@ const HabitsPage: React.FC = () => {
return ( return (
<View key={habit.id}> <View key={habit.id}>
<Text>{habit.name}</Text> <Text>
{habit.name} ({habit.goal.type})
</Text>
</View> </View>
) )
})} })}

View File

@ -0,0 +1,38 @@
import { StyleSheet, Text } from "react-native"
import { Button } from "react-native-paper"
import { SafeAreaView } from "react-native-safe-area-context"
import { useAuthentication } from "@/presentation/react/contexts/Authentication"
const SettingsPage: React.FC = () => {
const { logout, authenticationPresenter } = useAuthentication()
const handleLogout = async (): Promise<void> => {
await authenticationPresenter.logout()
}
return (
<SafeAreaView style={styles.container}>
<Text>Settings</Text>
<Button
mode="contained"
onPress={handleLogout}
loading={logout.state === "loading"}
disabled={logout.state === "loading"}
>
Logout
</Button>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
},
})
export default SettingsPage

View File

@ -0,0 +1,42 @@
import { Redirect, Tabs } from "expo-router"
import React from "react"
import { TabBarIcon } from "@/presentation/react/components/TabBarIcon"
import { useAuthentication } from "@/presentation/react/contexts/Authentication"
const TabLayout: React.FC = () => {
const { user } = useAuthentication()
if (user != null) {
return <Redirect href="/(pages)/application/habits" />
}
return (
<Tabs
screenOptions={{
headerShown: false,
}}
>
<Tabs.Screen
name="login"
options={{
title: "Login",
tabBarIcon: ({ color }) => {
return <TabBarIcon name="sign-in" color={color} />
},
}}
/>
<Tabs.Screen
name="register"
options={{
title: "Register",
tabBarIcon: ({ color }) => {
return <TabBarIcon name="user-plus" color={color} />
},
}}
/>
</Tabs>
)
}
export default TabLayout

View File

@ -1,58 +1,78 @@
import { useState } from "react" import { Controller, useForm } from "react-hook-form"
import { Image, StyleSheet } from "react-native" import { StyleSheet } from "react-native"
import { import { Button, HelperText, TextInput } from "react-native-paper"
ActivityIndicator,
Banner,
Button,
HelperText,
TextInput,
} from "react-native-paper"
import { SafeAreaView } from "react-native-safe-area-context" import { SafeAreaView } from "react-native-safe-area-context"
import type { UserLoginData } from "@/domain/entities/User"
import { useAuthentication } from "@/presentation/react/contexts/Authentication"
const LoginPage: React.FC = () => { const LoginPage: React.FC = () => {
const [hasError, _sethasError] = useState<boolean>(true) const { login, authenticationPresenter } = useAuthentication()
const [errorMessage, _setErrorMessage] = useState<string>("Error message") const { control, handleSubmit } = useForm<UserLoginData>({
defaultValues: {
email: "",
password: "",
},
})
const [isPerfomingLogin, _setIsPerfomingLogin] = useState<boolean>(true) const onSubmit = async (data: unknown): Promise<void> => {
await authenticationPresenter.login(data)
}
return ( return (
<SafeAreaView style={styles.container}> <SafeAreaView style={styles.container}>
<Banner <Controller
visible control={control}
actions={[ render={({ field: { onChange, onBlur, value } }) => {
{
label: "Report this problem",
},
]}
icon={({ size }) => {
return ( return (
<Image <TextInput
source={{ placeholder="Email"
uri: "https://avatars3.githubusercontent.com/u/17571969?s=400&v=4", onBlur={onBlur}
}} onChangeText={onChange}
style={{ value={value}
width: size, style={styles.input}
height: size, mode="outlined"
}}
/> />
) )
}} }}
> name="email"
There was an error while trying to login.
</Banner>
<TextInput label="Email" mode="outlined" style={styles.input} />
<TextInput label="Password" mode="outlined" style={styles.input} />
<HelperText type="error" visible={hasError} style={styles.errorText}>
{errorMessage}
</HelperText>
<Button mode="contained">Login</Button>
<ActivityIndicator
animating={isPerfomingLogin}
color="blue"
size="large"
style={styles.indicator}
/> />
<Controller
control={control}
render={({ field: { onChange, onBlur, value } }) => {
return (
<TextInput
placeholder="Password"
onBlur={onBlur}
onChangeText={onChange}
value={value}
style={styles.input}
mode="outlined"
secureTextEntry
/>
)
}}
name="password"
/>
<HelperText
type="error"
visible={login.state === "error"}
style={styles.helperText}
>
Invalid credentials.
</HelperText>
<Button
mode="contained"
onPress={handleSubmit(onSubmit)}
loading={login.state === "loading"}
disabled={login.state === "loading"}
>
Login
</Button>
</SafeAreaView> </SafeAreaView>
) )
} }
@ -67,12 +87,9 @@ const styles = StyleSheet.create({
width: "80%", width: "80%",
marginBottom: 10, marginBottom: 10,
}, },
errorText: { helperText: {
marginBottom: 10, fontSize: 18,
}, marginVertical: 20,
indicator: {
marginTop: 10,
marginBottom: 10,
}, },
}) })

View File

@ -1,82 +1,115 @@
import { useState } from "react" import { Controller, useForm } from "react-hook-form"
import { Image, StyleSheet } from "react-native" import { StyleSheet } from "react-native"
import { Banner, Button, HelperText, TextInput } from "react-native-paper" import { Button, HelperText, TextInput } from "react-native-paper"
import { SafeAreaView } from "react-native-safe-area-context" import { SafeAreaView } from "react-native-safe-area-context"
import { useMemo } from "react"
import type { UserRegisterData } from "@/domain/entities/User"
import { useAuthentication } from "@/presentation/react/contexts/Authentication"
const RegisterPage: React.FC = () => { const RegisterPage: React.FC = () => {
const regexEmail = /^[\w.-]+@[\d.A-Za-z-]+\.[A-Za-z]{2,4}$/ const { register, authenticationPresenter } = useAuthentication()
const [password, setPassword] = useState<string>("") const { control, handleSubmit } = useForm<UserRegisterData>({
const [isPasswordCorrect, setIsPasswordCorrect] = useState<boolean>(true) defaultValues: {
const [isEmailValid, setIsEmailValid] = useState<boolean>(true) displayName: "",
email: "",
password: "",
},
})
const onSubmit = async (data: unknown): Promise<void> => {
await authenticationPresenter.register(data)
}
const helperMessage = useMemo(() => {
if (register.state === "error") {
if (register.errorsFields.includes("displayName")) {
return "Display Name is required."
}
if (register.errorsFields.includes("email")) {
return "Invalid email."
}
if (register.errorsFields.includes("password")) {
return "Password must be at least 6 characters."
}
return "Invalid credentials."
}
// if (register.state === "success") {
// return "Success! Please verify your email."
// }
return ""
}, [register.errorsFields, register.state])
return ( return (
<SafeAreaView style={styles.container}> <SafeAreaView style={styles.container}>
<Banner <Controller
visible control={control}
actions={[ render={({ field: { onChange, onBlur, value } }) => {
{
label: "Report this problem",
onPress: () => {
return console.log("Pressed")
},
},
]}
icon={({ size }) => {
return ( return (
<Image <TextInput
source={{ placeholder="Display Name"
uri: "https://avatars3.githubusercontent.com/u/17571969?s=400&v=4", onBlur={onBlur}
}} onChangeText={onChange}
style={{ value={value}
width: size, style={styles.input}
height: size, mode="outlined"
}}
/> />
) )
}} }}
name="displayName"
/>
<Controller
control={control}
render={({ field: { onChange, onBlur, value } }) => {
return (
<TextInput
placeholder="Email"
onBlur={onBlur}
onChangeText={onChange}
value={value}
style={styles.input}
mode="outlined"
/>
)
}}
name="email"
/>
<Controller
control={control}
render={({ field: { onChange, onBlur, value } }) => {
return (
<TextInput
placeholder="Password"
onBlur={onBlur}
onChangeText={onChange}
value={value}
style={styles.input}
mode="outlined"
secureTextEntry
/>
)
}}
name="password"
/>
<HelperText
type={register.state === "error" ? "error" : "info"}
visible={register.state === "error" || register.state === "success"}
style={styles.helperText}
> >
There was an error while trying to register. {helperMessage}
</Banner>
<TextInput label="Pseudo" mode="outlined" style={styles.input} />
<TextInput
label="Email"
mode="outlined"
style={styles.input}
onChangeText={(text) => {
setIsEmailValid(regexEmail.test(text))
}}
/>
{isEmailValid ? null : (
<HelperText type="error" visible style={styles.errorText}>
Email address is invalid!
</HelperText>
)}
<TextInput
label="Password"
mode="outlined"
style={styles.input}
onChangeText={(text) => {
setPassword(text)
console.log(text)
}}
/>
<TextInput
label="Confirm password"
mode="outlined"
style={styles.input}
onChangeText={(text) => {
setIsPasswordCorrect(text === password)
}}
/>
<HelperText type="error" visible style={styles.errorText}>
Error message
</HelperText> </HelperText>
<Button <Button
mode="contained" mode="contained"
onPress={() => { onPress={handleSubmit(onSubmit)}
return console.log(isPasswordCorrect ? "Pressed" : "Error") loading={register.state === "loading"}
}} disabled={register.state === "loading"}
> >
Register Register
</Button> </Button>
@ -92,10 +125,11 @@ const styles = StyleSheet.create({
}, },
input: { input: {
width: "80%", width: "80%",
margin: 10, marginBottom: 10,
}, },
errorText: { helperText: {
margin: 10, fontSize: 18,
marginVertical: 20,
}, },
}) })

View File

@ -1,20 +1,15 @@
import { StyleSheet, Text } from "react-native" import { Redirect } from "expo-router"
import { SafeAreaView } from "react-native-safe-area-context"
import { useAuthentication } from "@/presentation/react/contexts/Authentication"
const HomePage: React.FC = () => { const HomePage: React.FC = () => {
return ( const { user } = useAuthentication()
<SafeAreaView style={styles.container}>
<Text>Home Page</Text> if (user == null) {
</SafeAreaView> return <Redirect href="/(pages)/authentication/login" />
)
} }
const styles = StyleSheet.create({ return <Redirect href="/(pages)/application/habits" />
container: { }
flex: 1,
alignItems: "center",
justifyContent: "center",
},
})
export default HomePage export default HomePage

View File

@ -1,20 +0,0 @@
import { StyleSheet, Text } from "react-native"
import { SafeAreaView } from "react-native-safe-area-context"
const SettingsPage: React.FC = () => {
return (
<SafeAreaView style={styles.container}>
<Text>Settings</Text>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
},
})
export default SettingsPage

View File

@ -1,17 +1,17 @@
import { useFonts } from "expo-font"
import { Stack } from "expo-router" import { Stack } from "expo-router"
import * as SplashScreen from "expo-splash-screen" import * as SplashScreen from "expo-splash-screen"
import { useEffect } from "react"
import { import {
MD3LightTheme as DefaultTheme, MD3LightTheme as DefaultTheme,
PaperProvider, PaperProvider,
} from "react-native-paper" } from "react-native-paper"
import { StatusBar } from "expo-status-bar" import { StatusBar } from "expo-status-bar"
import { useEffect } from "react"
import CanterburyFont from "../presentation/assets/fonts/Canterbury.ttf"
import GeoramFont from "../presentation/assets/fonts/Georama-Black.ttf"
import SpaceMonoFont from "../presentation/assets/fonts/SpaceMono-Regular.ttf"
import { HabitsTrackerProvider } from "@/presentation/react/contexts/HabitsTracker" import { HabitsTrackerProvider } from "@/presentation/react/contexts/HabitsTracker"
import {
AuthenticationProvider,
useAuthentication,
} from "@/presentation/react/contexts/Authentication"
export { ErrorBoundary } from "expo-router" export { ErrorBoundary } from "expo-router"
@ -23,32 +23,35 @@ SplashScreen.preventAutoHideAsync().catch((error) => {
console.error(error) console.error(error)
}) })
const RootLayout: React.FC = () => { const StackLayout: React.FC = () => {
const [loaded, error] = useFonts({ const { hasLoaded } = useAuthentication()
Georama: GeoramFont,
SpaceMono: SpaceMonoFont,
Canterbury: CanterburyFont,
})
useEffect(() => { useEffect(() => {
if (error != null) { if (!hasLoaded) {
throw error
}
}, [error])
useEffect(() => {
if (loaded) {
SplashScreen.hideAsync().catch((error) => { SplashScreen.hideAsync().catch((error) => {
console.error(error) console.error(error)
}) })
} }
}, [loaded]) }, [hasLoaded])
if (!loaded) { if (hasLoaded) {
return null return <></>
} }
return ( return (
<Stack
screenOptions={{
headerShown: false,
}}
>
<Stack.Screen name="(pages)" />
</Stack>
)
}
const RootLayout: React.FC = () => {
return (
<AuthenticationProvider>
<HabitsTrackerProvider> <HabitsTrackerProvider>
<PaperProvider <PaperProvider
theme={{ theme={{
@ -60,17 +63,12 @@ const RootLayout: React.FC = () => {
}, },
}} }}
> >
<Stack <StackLayout />
screenOptions={{
headerShown: false,
}}
>
<Stack.Screen name="(pages)" />
</Stack>
<StatusBar style="dark" /> <StatusBar style="dark" />
</PaperProvider> </PaperProvider>
</HabitsTrackerProvider> </HabitsTrackerProvider>
</AuthenticationProvider>
) )
} }

View File

@ -10,8 +10,7 @@ On représente ainsi les données sous la forme suivante:
- Le nom de la table est écrit en **gras**. - Le nom de la table est écrit en **gras**.
- Les champs sont listés en dessous du nom de la table. - Les champs sont listés en dessous du nom de la table.
- Les clés primaires sont <u>soulignées</u> et placées au début de la liste des champs. - Les clés primaires sont <u>soulignées</u> et placées au début de la liste des champs.
- Les clés étrangères sont préfixées par un dièse (#), et placées après les clés primaires. Les clés étrangères sont - Les clés étrangères sont préfixées par un dièse (#), et placées après les clés primaires. Les clés étrangères sont suivies entre parenthèses du nom de la table suivi d'une flèche (->) et du nom du champ de la table référencée.
suivies entre parenthèses du nom de la table suivi d'une flèche (->) et du nom du champ de la table référencée.
## Modèle ## Modèle

View File

@ -8,10 +8,13 @@ export const UserSchema = EntitySchema.extend({
}) })
export const UserRegisterSchema = UserSchema.extend({ export const UserRegisterSchema = UserSchema.extend({
password: z.string().min(2), password: z.string().min(6),
}).omit({ id: true }) }).omit({ id: true })
export type UserRegisterData = z.infer<typeof UserRegisterSchema> export type UserRegisterData = z.infer<typeof UserRegisterSchema>
export const UserLoginSchema = UserRegisterSchema.omit({ displayName: true })
export type UserLoginData = z.infer<typeof UserLoginSchema>
export type UserData = z.infer<typeof UserSchema> export type UserData = z.infer<typeof UserSchema>
export class User extends Entity implements UserData { export class User extends Entity implements UserData {

View File

@ -0,0 +1,12 @@
import type { User, UserLoginData, UserRegisterData } from "../entities/User"
export interface AuthenticationRepository {
register: (data: UserRegisterData) => Promise<User>
login: (data: UserLoginData) => Promise<User>
logout: () => Promise<void>
getUser: () => Promise<User | null>
onUserStateChange: (
callback: (user: User | null) => void | Promise<void>,
) => void
}

View File

@ -0,0 +1,57 @@
import {
UserRegisterSchema,
type User,
UserLoginSchema,
} from "../entities/User"
import type { AuthenticationRepository } from "../repositories/Authentication"
export interface AuthenticationUseCaseDependencyOptions {
authenticationRepository: AuthenticationRepository
}
export class AuthenticationUseCase
implements AuthenticationUseCaseDependencyOptions
{
public authenticationRepository: AuthenticationRepository
public constructor(options: AuthenticationUseCaseDependencyOptions) {
this.authenticationRepository = options.authenticationRepository
}
/**
* Register a new user.
* @throws {ZodError} if the data is invalid.
* @throws {Error} if user already exists.
* @param data
* @returns
*/
public async register(data: unknown): Promise<User> {
const userData = await UserRegisterSchema.parseAsync(data)
return await this.authenticationRepository.register(userData)
}
/**
* Login a user.
* @throws {ZodError} if the data is invalid.
* @throws {Error} if invalid credentials.
* @param data
* @returns
*/
public async login(data: unknown): Promise<User> {
const userData = await UserLoginSchema.parseAsync(data)
return await this.authenticationRepository.login(userData)
}
public async logout(): Promise<void> {
return await this.authenticationRepository.logout()
}
public getUser: AuthenticationRepository["getUser"] = async (...args) => {
return await this.authenticationRepository.getUser(...args)
}
public onUserStateChange: AuthenticationRepository["onUserStateChange"] =
async (...args) => {
return this.authenticationRepository.onUserStateChange(...args)
}
}

View File

@ -1,12 +1,18 @@
import { AuthenticationUseCase } from "@/domain/use-cases/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 "./repositories/supabase/lib/AuthenticationRepository"
import { GetHabitProgressHistorySupabaseRepository } from "./repositories/supabase/lib/GetHabitProgressHistory" import { GetHabitProgressHistorySupabaseRepository } from "./repositories/supabase/lib/GetHabitProgressHistory"
import { GetHabitsByUserIdSupabaseRepository } from "./repositories/supabase/lib/GetHabitsByUserId" import { GetHabitsByUserIdSupabaseRepository } from "./repositories/supabase/lib/GetHabitsByUserId"
import { supabaseClient } from "./repositories/supabase/supabase" import { supabaseClient } from "./repositories/supabase/supabase"
import { AuthenticationPresenter } from "@/presentation/presenters/Authentication"
/** /**
* Repositories * Repositories
*/ */
const authenticationRepository = new AuthenticationSupabaseRepository({
supabaseClient,
})
const getHabitProgressesRepository = const getHabitProgressesRepository =
new GetHabitProgressHistorySupabaseRepository({ new GetHabitProgressHistorySupabaseRepository({
supabaseClient, supabaseClient,
@ -18,6 +24,9 @@ const getHabitsByUserIdRepository = new GetHabitsByUserIdSupabaseRepository({
/** /**
* Use Cases * Use Cases
*/ */
const authenticationUseCase = new AuthenticationUseCase({
authenticationRepository,
})
const retrieveHabitsTrackerUseCase = new RetrieveHabitsTrackerUseCase({ const retrieveHabitsTrackerUseCase = new RetrieveHabitsTrackerUseCase({
getHabitProgressHistoryRepository: getHabitProgressesRepository, getHabitProgressHistoryRepository: getHabitProgressesRepository,
getHabitsByUserIdRepository, getHabitsByUserIdRepository,
@ -26,6 +35,9 @@ const retrieveHabitsTrackerUseCase = new RetrieveHabitsTrackerUseCase({
/** /**
* Presenters * Presenters
*/ */
export const authenticationPresenter = new AuthenticationPresenter({
authenticationUseCase,
})
export const habitsTrackerPresenter = new HabitsTrackerPresenter({ export const habitsTrackerPresenter = new HabitsTrackerPresenter({
retrieveHabitsTrackerUseCase, retrieveHabitsTrackerUseCase,
}) })

View File

@ -0,0 +1,86 @@
import type { Session } from "@supabase/supabase-js"
import type { AuthenticationRepository } from "@/domain/repositories/Authentication"
import { SupabaseRepository } from "./_SupabaseRepository"
import { User } from "@/domain/entities/User"
export class AuthenticationSupabaseRepository
extends SupabaseRepository
implements AuthenticationRepository
{
private readonly getUserFromSession = (session: Session | null): User => {
if (session == null || session?.user?.email == null) {
throw new Error("Session is null.")
}
const user = new User({
id: session.user.id,
displayName: session.user.user_metadata["display_name"],
email: session.user.email,
})
return user
}
public register: AuthenticationRepository["register"] = async (data) => {
const { displayName, email, password } = data
const {
data: { session },
error,
} = await this.supabaseClient.auth.signUp({
email,
password,
options: {
data: { display_name: displayName },
},
})
if (error != null) {
throw new Error(error.message)
}
const user = this.getUserFromSession(session)
return user
}
public login: AuthenticationRepository["login"] = async (data) => {
const { email, password } = data
const {
data: { session },
error,
} = await this.supabaseClient.auth.signInWithPassword({
email,
password,
})
if (error != null) {
throw new Error(error.message)
}
const user = this.getUserFromSession(session)
return user
}
public logout: AuthenticationRepository["logout"] = async () => {
await this.supabaseClient.auth.signOut()
}
public getUser: AuthenticationRepository["getUser"] = async () => {
try {
const {
data: { session },
} = await this.supabaseClient.auth.getSession()
const user = this.getUserFromSession(session)
return user
} catch {
return null
}
}
public onUserStateChange: AuthenticationRepository["onUserStateChange"] = (
callback,
) => {
this.supabaseClient.auth.onAuthStateChange(async (_, session) => {
try {
const user = this.getUserFromSession(session)
await callback(user)
} catch {
await callback(null)
}
})
}
}

View File

@ -11,7 +11,9 @@ export class GetHabitProgressHistorySupabaseRepository
extends SupabaseRepository extends SupabaseRepository
implements GetHabitProgressHistoryRepository implements GetHabitProgressHistoryRepository
{ {
execute: GetHabitProgressHistoryRepository["execute"] = async (options) => { public execute: GetHabitProgressHistoryRepository["execute"] = async (
options,
) => {
const { habit } = options const { habit } = options
const { data, error } = await this.supabaseClient const { data, error } = await this.supabaseClient
.from("habits_progresses") .from("habits_progresses")

View File

@ -8,14 +8,12 @@ export class GetHabitsByUserIdSupabaseRepository
extends SupabaseRepository extends SupabaseRepository
implements GetHabitsByUserIdRepository implements GetHabitsByUserIdRepository
{ {
// 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, error } = await this.supabaseClient
// .from("habits") .from("habits")
// .select("*") .select("*")
// .eq("user_id", userId) .eq("user_id", userId)
execute: GetHabitsByUserIdRepository["execute"] = async () => {
const { data, error } = await this.supabaseClient.from("habits").select("*")
if (error != null) { if (error != null) {
throw new Error(error.message) throw new Error(error.message)
} }

View File

@ -10,7 +10,7 @@ CREATE TABLE "public"."habits" (
"goal_frequency" goal_frequency NOT NULL, "goal_frequency" goal_frequency NOT NULL,
"goal_target" bigint, "goal_target" bigint,
"goal_target_unit" text, "goal_target_unit" text,
"user_id" uuid NOT NULL DEFAULT gen_random_uuid() "user_id" uuid NOT NULL DEFAULT auth.uid()
); );
ALTER TABLE ALTER TABLE
@ -190,115 +190,85 @@ GRANT
UPDATE UPDATE
ON TABLE "public"."habits_progresses" TO "service_role"; ON TABLE "public"."habits_progresses" TO "service_role";
CREATE policy "Enable delete for users based on user_id" ON "public"."habits" AS permissive FOR DELETE TO public USING ((auth.uid() = user_id)); CREATE policy "Enable delete for users based on user_id" ON "public"."habits" AS permissive FOR DELETE TO authenticated USING ((auth.uid() = user_id));
CREATE policy "Enable insert for users based on user_id" ON "public"."habits" AS permissive FOR CREATE policy "Enable insert for users based on user_id" ON "public"."habits" AS permissive FOR
INSERT INSERT
TO public WITH CHECK ((auth.uid() = user_id)); TO authenticated WITH CHECK ((auth.uid() = user_id));
CREATE policy "Enable select for users based on user_id" ON "public"."habits" AS permissive FOR CREATE policy "Enable select for users based on user_id" ON "public"."habits" AS permissive FOR
SELECT SELECT
TO public USING ((auth.uid() = user_id)); TO authenticated USING ((auth.uid() = user_id));
CREATE policy "Enable update for users based on user_id" ON "public"."habits" AS permissive FOR CREATE policy "Enable update for users based on user_id" ON "public"."habits" AS permissive FOR
UPDATE UPDATE
TO public USING ((auth.uid() = user_id)) WITH CHECK ((auth.uid() = user_id)); TO authenticated USING ((auth.uid() = user_id)) WITH CHECK ((auth.uid() = user_id));
CREATE policy "Enable delete for users based on user_id" ON "public"."habits_progresses" AS permissive FOR DELETE TO public USING ( CREATE policy "Enable delete for users based on user_id" ON "public"."habits_progresses" AS permissive FOR DELETE TO authenticated USING (
( (
EXISTS ( auth.uid() IN (
SELECT SELECT
1 habits.user_id
FROM FROM
( habits
habits_progresses habit_progress
JOIN habits habit ON ((habit_progress.habit_id = habit.id))
)
WHERE WHERE
( (habits_progresses.habit_id = habits.id)
(habit_progress.id = habits_progresses.id)
AND (habit.user_id = auth.uid())
)
) )
) )
); );
CREATE policy "Enable insert for users based on user_id" ON "public"."habits_progresses" AS permissive FOR CREATE policy "Enable insert for users based on user_id" ON "public"."habits_progresses" AS permissive FOR
INSERT INSERT
TO public WITH CHECK ( TO authenticated WITH CHECK (
( (
EXISTS ( auth.uid() IN (
SELECT SELECT
1 habits.user_id
FROM FROM
( habits
habits_progresses habit_progress
JOIN habits habit ON ((habit_progress.habit_id = habit.id))
)
WHERE WHERE
( (habits_progresses.habit_id = habits.id)
(habit_progress.id = habits_progresses.id)
AND (habit.user_id = auth.uid())
)
) )
) )
); );
CREATE policy "Enable select for users based on user_id" ON "public"."habits_progresses" AS permissive FOR CREATE policy "Enable select for users based on user_id" ON "public"."habits_progresses" AS permissive FOR
SELECT SELECT
TO public USING ( TO authenticated USING (
( (
EXISTS ( auth.uid() IN (
SELECT SELECT
1 habits.user_id
FROM FROM
( habits
habits_progresses habit_progress
JOIN habits habit ON ((habit_progress.habit_id = habit.id))
)
WHERE WHERE
( (habits_progresses.habit_id = habits.id)
(habit_progress.id = habits_progresses.id)
AND (habit.user_id = auth.uid())
)
) )
) )
); );
CREATE policy "Enable update for users based on user_id" ON "public"."habits_progresses" AS permissive FOR CREATE policy "Enable update for users based on user_id" ON "public"."habits_progresses" AS permissive FOR
UPDATE UPDATE
TO public USING ( TO authenticated USING (
( (
EXISTS ( auth.uid() IN (
SELECT SELECT
1 habits.user_id
FROM FROM
( habits
habits_progresses habit_progress
JOIN habits habit ON ((habit_progress.habit_id = habit.id))
)
WHERE WHERE
( (habits_progresses.habit_id = habits.id)
(habit_progress.id = habits_progresses.id)
AND (habit.user_id = auth.uid())
)
) )
) )
) WITH CHECK ( ) WITH CHECK (
( (
EXISTS ( auth.uid() IN (
SELECT SELECT
1 habits.user_id
FROM FROM
( habits
habits_progresses habit_progress
JOIN habits habit ON ((habit_progress.habit_id = habit.id))
)
WHERE WHERE
( (habits_progresses.habit_id = habits.id)
(habit_progress.id = habits_progresses.id)
AND (habit.user_id = auth.uid())
)
) )
) )
); );

View File

@ -55,7 +55,7 @@ VALUES
NULL, NULL,
NULL, NULL,
'{"provider": "email", "providers": ["email"]}', '{"provider": "email", "providers": ["email"]}',
'{}', '{"display_name": "Test"}',
NULL, NULL,
timezone('utc' :: text, NOW()), timezone('utc' :: text, NOW()),
timezone('utc' :: text, NOW()), timezone('utc' :: text, NOW()),
@ -88,7 +88,7 @@ VALUES
( (
'ab054ee9-fbb4-473e-942b-bbf4415f4bef', 'ab054ee9-fbb4-473e-942b-bbf4415f4bef',
'ab054ee9-fbb4-473e-942b-bbf4415f4bef', 'ab054ee9-fbb4-473e-942b-bbf4415f4bef',
'{"sub": "ab054ee9-fbb4-473e-942b-bbf4415f4bef", "email": "test@test.com", "display_name": "Test"}', '{"sub": "ab054ee9-fbb4-473e-942b-bbf4415f4bef", "email": "test@test.com"}',
'email', 'email',
'ab054ee9-fbb4-473e-942b-bbf4415f4bef', 'ab054ee9-fbb4-473e-942b-bbf4415f4bef',
timezone('utc' :: text, NOW()), timezone('utc' :: text, NOW()),

View File

@ -1,4 +1,7 @@
import { createClient } from "@supabase/supabase-js" import { createClient } from "@supabase/supabase-js"
import { AppState, Platform } from "react-native"
import "react-native-url-polyfill/auto"
import AsyncStorage from "@react-native-async-storage/async-storage"
import type { Database } from "./supabase-types" import type { Database } from "./supabase-types"
@ -8,4 +11,26 @@ const SUPABASE_ANON_KEY = process.env["EXPO_PUBLIC_SUPABASE_ANON_KEY"] ?? ""
export const supabaseClient = createClient<Database>( export const supabaseClient = createClient<Database>(
SUPABASE_URL, SUPABASE_URL,
SUPABASE_ANON_KEY, SUPABASE_ANON_KEY,
{
auth: {
// https://github.com/supabase/supabase-js/issues/870
...(Platform.OS !== "web" ? { storage: AsyncStorage } : {}),
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false,
},
},
) )
/**
* Tells Supabase Auth to continuously refresh the session automatically if the app is in the foreground.
* When this is added, you will continue to receive `onAuthStateChange` events with the `TOKEN_REFRESH` or `SIGNED_OUT` event if the user's session is terminated.
* This should only be registered once.
*/
AppState.addEventListener("change", async (state) => {
if (state === "active") {
await supabaseClient.auth.startAutoRefresh()
} else {
await supabaseClient.auth.stopAutoRefresh()
}
})

2036
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -8,6 +8,7 @@
"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",
"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",
@ -20,9 +21,10 @@
"dependencies": { "dependencies": {
"@expo/vector-icons": "14.0.0", "@expo/vector-icons": "14.0.0",
"@hookform/resolvers": "3.3.4", "@hookform/resolvers": "3.3.4",
"@react-native-async-storage/async-storage": "1.21.0",
"@react-navigation/native": "6.1.16", "@react-navigation/native": "6.1.16",
"@supabase/supabase-js": "2.39.8", "@supabase/supabase-js": "2.39.8",
"expo": "50.0.13", "expo": "50.0.14",
"expo-font": "11.10.3", "expo-font": "11.10.3",
"expo-linking": "6.2.2", "expo-linking": "6.2.2",
"expo-router": "3.4.8", "expo-router": "3.4.8",
@ -34,28 +36,30 @@
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-hook-form": "7.51.1", "react-hook-form": "7.51.1",
"react-native": "0.73.5", "react-native": "0.73.6",
"react-native-calendars": "1.1304.1", "react-native-calendars": "1.1304.1",
"react-native-elements": "3.4.3",
"react-native-paper": "5.12.3", "react-native-paper": "5.12.3",
"react-native-safe-area-context": "4.8.2", "react-native-safe-area-context": "4.8.2",
"react-native-screens": "3.29.0", "react-native-screens": "3.29.0",
"react-native-url-polyfill": "2.0.0",
"react-native-vector-icons": "10.0.3", "react-native-vector-icons": "10.0.3",
"react-native-web": "0.19.10", "react-native-web": "0.19.10",
"zod": "3.22.4" "zod": "3.22.4"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.24.0", "@babel/core": "7.24.3",
"@commitlint/cli": "19.2.0", "@commitlint/cli": "19.1.0",
"@commitlint/config-conventional": "19.1.0", "@commitlint/config-conventional": "19.1.0",
"@testing-library/react-native": "12.4.3", "@testing-library/react-native": "12.4.4",
"@total-typescript/ts-reset": "0.5.1", "@total-typescript/ts-reset": "0.5.1",
"@tsconfig/strictest": "2.0.3", "@tsconfig/strictest": "2.0.3",
"@types/jest": "29.5.12", "@types/jest": "29.5.12",
"@types/node": "20.11.28", "@types/node": "20.11.30",
"@types/react": "18.2.66", "@types/react": "18.2.69",
"@types/react-test-renderer": "18.0.7", "@types/react-test-renderer": "18.0.7",
"@typescript-eslint/eslint-plugin": "7.2.0", "@typescript-eslint/eslint-plugin": "7.3.1",
"@typescript-eslint/parser": "7.2.0", "@typescript-eslint/parser": "7.3.1",
"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-config-prettier": "9.1.0",
@ -72,7 +76,7 @@
"jest-junit": "16.0.0", "jest-junit": "16.0.0",
"lint-staged": "15.2.2", "lint-staged": "15.2.2",
"react-test-renderer": "18.2.0", "react-test-renderer": "18.2.0",
"supabase": "1.148.6", "supabase": "1.150.0",
"typescript": "5.4.2" "typescript": "5.4.3"
} }
} }

View File

@ -0,0 +1,151 @@
import { ZodError } from "zod"
import type {
User,
UserLoginData,
UserRegisterData,
} from "@/domain/entities/User"
import type { AuthenticationUseCase } from "@/domain/use-cases/Authentication"
import type { ErrorGlobal, FetchState } from "./_Presenter"
import { Presenter } from "./_Presenter"
import { zodPresenter } from "./utils/ZodPresenter"
export interface AuthenticationPresenterState {
user: User | null
/**
* `true` if the initial authentication state has been loaded.
*/
hasLoaded: boolean
register: {
state: FetchState
errorsFields: Array<keyof UserRegisterData>
errorGlobal: ErrorGlobal
}
login: {
state: FetchState
errorsFields: Array<keyof UserLoginData>
errorGlobal: ErrorGlobal
}
logout: {
state: FetchState
}
}
export interface AuthenticationPresenterOptions {
authenticationUseCase: AuthenticationUseCase
}
export class AuthenticationPresenter
extends Presenter<AuthenticationPresenterState>
implements AuthenticationPresenterOptions
{
public authenticationUseCase: AuthenticationUseCase
public constructor(options: AuthenticationPresenterOptions) {
const { authenticationUseCase } = options
super({
user: null,
hasLoaded: true,
register: {
state: "idle",
errorsFields: [],
errorGlobal: null,
},
login: {
state: "idle",
errorsFields: [],
errorGlobal: null,
},
logout: {
state: "idle",
},
})
this.authenticationUseCase = authenticationUseCase
}
public async register(data: unknown): Promise<void> {
try {
this.setState((state) => {
state.register.state = "loading"
state.register.errorsFields = []
state.register.errorGlobal = null
})
const user = await this.authenticationUseCase.register(data)
this.setState((state) => {
state.register.state = "success"
state.user = user
})
} catch (error) {
this.setState((state) => {
state.register.state = "error"
if (error instanceof ZodError) {
state.register.errorsFields =
zodPresenter.getErrorsFieldsFromZodError<UserRegisterData>(error)
} else {
state.register.errorGlobal = "unknown"
}
})
}
}
public async login(data: unknown): Promise<void> {
try {
this.setState((state) => {
state.login.state = "loading"
state.login.errorsFields = []
state.login.errorGlobal = null
})
const user = await this.authenticationUseCase.login(data)
this.setState((state) => {
state.login.state = "success"
state.user = user
})
} catch (error) {
this.setState((state) => {
state.login.state = "error"
if (error instanceof ZodError) {
state.login.errorsFields =
zodPresenter.getErrorsFieldsFromZodError<UserLoginData>(error)
} else {
state.login.errorGlobal = "unknown"
}
})
}
}
public async logout(): Promise<void> {
try {
this.setState((state) => {
state.logout.state = "loading"
})
await this.authenticationUseCase.logout()
this.setState((state) => {
state.logout.state = "success"
state.user = null
})
} catch (error) {
this.setState((state) => {
state.user = null
state.logout.state = "error"
})
}
}
public async initialAuthStateListener(): Promise<void> {
const user = await this.authenticationUseCase.getUser()
this.setState((state) => {
state.user = user
state.hasLoaded = false
})
this.authenticationUseCase.onUserStateChange((user) => {
this.setState((state) => {
state.user = user
})
})
}
}

View File

@ -1,4 +1,5 @@
import { HabitsTracker } from "@/domain/entities/HabitsTracker" import { HabitsTracker } from "@/domain/entities/HabitsTracker"
import type { FetchState } from "./_Presenter"
import { Presenter } from "./_Presenter" import { Presenter } from "./_Presenter"
import type { import type {
RetrieveHabitsTrackerUseCase, RetrieveHabitsTrackerUseCase,
@ -7,6 +8,10 @@ import type {
export interface HabitsTrackerPresenterState { export interface HabitsTrackerPresenterState {
habitsTracker: HabitsTracker habitsTracker: HabitsTracker
retrieveHabitsTracker: {
state: FetchState
}
} }
export interface HabitsTrackerPresenterOptions { export interface HabitsTrackerPresenterOptions {
@ -22,17 +27,28 @@ export class HabitsTrackerPresenter
public constructor(options: HabitsTrackerPresenterOptions) { public constructor(options: HabitsTrackerPresenterOptions) {
const { retrieveHabitsTrackerUseCase } = options const { retrieveHabitsTrackerUseCase } = options
const habitsTracker = HabitsTracker.default() const habitsTracker = HabitsTracker.default()
super({ habitsTracker }) super({ habitsTracker, retrieveHabitsTracker: { state: "idle" } })
this.retrieveHabitsTrackerUseCase = retrieveHabitsTrackerUseCase this.retrieveHabitsTrackerUseCase = retrieveHabitsTrackerUseCase
} }
public async retrieveHabitsTracker( public async retrieveHabitsTracker(
options: RetrieveHabitsTrackerUseCaseOptions, options: RetrieveHabitsTrackerUseCaseOptions,
): Promise<void> { ): Promise<void> {
this.setState((state) => {
state.retrieveHabitsTracker.state = "loading"
state.habitsTracker = HabitsTracker.default()
})
try {
const habitsTracker = const habitsTracker =
await this.retrieveHabitsTrackerUseCase.execute(options) await this.retrieveHabitsTrackerUseCase.execute(options)
this.setState((state) => { this.setState((state) => {
state.habitsTracker = habitsTracker state.habitsTracker = habitsTracker
state.retrieveHabitsTracker.state = "success"
})
} catch (error) {
this.setState((state) => {
state.retrieveHabitsTracker.state = "error"
}) })
} }
} }
}

View File

@ -1,21 +1,26 @@
import { produce } from "immer" import { produce } from "immer"
type Listener<S> = (state: S) => void type Listener<State> = (state: State) => void
type SetStateAction<S> = (state: S) => void type SetStateAction<State> = (state: State) => void
export abstract class Presenter<S> { export const FETCH_STATES = ["idle", "loading", "error", "success"] as const
private _state: S export type FetchState = (typeof FETCH_STATES)[number]
private readonly _initialState: S
private readonly _listeners: Array<Listener<S>>
public constructor(initialState: S) { export type ErrorGlobal = "unknown" | null
export abstract class Presenter<State> {
private _state: State
private readonly _initialState: State
private readonly _listeners: Array<Listener<State>>
public constructor(initialState: State) {
this._state = initialState this._state = initialState
this._initialState = initialState this._initialState = initialState
this._listeners = [] this._listeners = []
} }
public get initialState(): S { public get initialState(): State {
return this._initialState return this._initialState
} }
@ -24,17 +29,17 @@ export abstract class Presenter<S> {
* @param action A function that receives the current state and can update it by mutating it. * @param action A function that receives the current state and can update it by mutating it.
* @returns The new state. * @returns The new state.
*/ */
public setState(action: SetStateAction<S>): S { public setState(action: SetStateAction<State>): State {
this._state = produce(this._state, action) this._state = produce(this._state, action)
this.notifyListeners() this.notifyListeners()
return this._state return this._state
} }
public subscribe(listener: Listener<S>): void { public subscribe(listener: Listener<State>): void {
this._listeners.push(listener) this._listeners.push(listener)
} }
public unsubscribe(listener: Listener<S>): void { public unsubscribe(listener: Listener<State>): void {
const listenerIndex = this._listeners.indexOf(listener) const listenerIndex = this._listeners.indexOf(listener)
const listenerFound = listenerIndex !== -1 const listenerFound = listenerIndex !== -1
if (listenerFound) { if (listenerFound) {

View File

@ -0,0 +1,7 @@
import type { ZodError } from "zod"
export const zodPresenter = {
getErrorsFieldsFromZodError: <T>(error: ZodError<T>): Array<keyof T> => {
return Object.keys(error.format()) as Array<keyof T>
},
}

View File

@ -0,0 +1,13 @@
import FontAwesome from "@expo/vector-icons/FontAwesome"
/**
* @see https://icons.expo.fyi/
* @param props
* @returns
*/
export const TabBarIcon: React.FC<{
name: React.ComponentProps<typeof FontAwesome>["name"]
color: string
}> = (props) => {
return <FontAwesome size={28} style={{ marginBottom: -3 }} {...props} />
}

View File

@ -0,0 +1,56 @@
import { createContext, useContext, useEffect } from "react"
import { usePresenterState } from "@/presentation/react/hooks/usePresenterState"
import type {
AuthenticationPresenter,
AuthenticationPresenterState,
} from "@/presentation/presenters/Authentication"
import { authenticationPresenter } from "@/infrastructure"
export interface AuthenticationContextValue
extends AuthenticationPresenterState {
authenticationPresenter: AuthenticationPresenter
}
const defaultContextValue = {} as AuthenticationContextValue
const AuthenticationContext =
createContext<AuthenticationContextValue>(defaultContextValue)
interface AuthenticationProviderProps extends React.PropsWithChildren {}
export const AuthenticationProvider: React.FC<AuthenticationProviderProps> = (
props,
) => {
const { children } = props
useEffect(() => {
authenticationPresenter.initialAuthStateListener().catch((error) => {
console.error(error)
})
}, [])
const authenticationPresenterState = usePresenterState(
authenticationPresenter,
)
return (
<AuthenticationContext.Provider
value={{
...authenticationPresenterState,
authenticationPresenter,
}}
>
{children}
</AuthenticationContext.Provider>
)
}
export const useAuthentication = (): AuthenticationContextValue => {
const context = useContext(AuthenticationContext)
if (context === defaultContextValue) {
throw new Error(
"`useAuthentication` must be used within a `AuthenticationProvider`.",
)
}
return context
}

View File

@ -6,15 +6,15 @@ import type {
} 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" import { habitsTrackerPresenter } from "@/infrastructure"
import { useAuthentication } from "./Authentication"
export interface HabitsTrackerContextValue extends HabitsTrackerPresenterState { export interface HabitsTrackerContextValue extends HabitsTrackerPresenterState {
habitsTrackerPresenter: HabitsTrackerPresenter habitsTrackerPresenter: HabitsTrackerPresenter
} }
const defaultHabitsTrackerContextValue = {} as HabitsTrackerContextValue const defaultContextValue = {} as HabitsTrackerContextValue
const HabitsTrackerContext = createContext<HabitsTrackerContextValue>( const HabitsTrackerContext =
defaultHabitsTrackerContextValue, createContext<HabitsTrackerContextValue>(defaultContextValue)
)
interface HabitsTrackerProviderProps extends React.PropsWithChildren {} interface HabitsTrackerProviderProps extends React.PropsWithChildren {}
@ -23,13 +23,18 @@ export const HabitsTrackerProvider: React.FC<HabitsTrackerProviderProps> = (
) => { ) => {
const { children } = props const { children } = props
const { user } = useAuthentication()
useEffect(() => { useEffect(() => {
if (user == null) {
return
}
habitsTrackerPresenter habitsTrackerPresenter
.retrieveHabitsTracker({ userId: "1" }) .retrieveHabitsTracker({ userId: user.id })
.catch((error) => { .catch((error) => {
console.error(error) console.error(error)
}) })
}, []) }, [user])
const habitsTrackerPresenterState = usePresenterState(habitsTrackerPresenter) const habitsTrackerPresenterState = usePresenterState(habitsTrackerPresenter)
@ -47,7 +52,7 @@ export const HabitsTrackerProvider: React.FC<HabitsTrackerProviderProps> = (
export const useHabitsTracker = (): HabitsTrackerContextValue => { export const useHabitsTracker = (): HabitsTrackerContextValue => {
const context = useContext(HabitsTrackerContext) const context = useContext(HabitsTrackerContext)
if (context === defaultHabitsTrackerContextValue) { if (context === defaultContextValue) {
throw new Error( throw new Error(
"`useHabitsTracker` must be used within a `HabitsTrackerProvider`.", "`useHabitsTracker` must be used within a `HabitsTrackerProvider`.",
) )