feat: authentication (register, login, logout)
This commit is contained in:
parent
90c8c7547f
commit
25f60afb91
@ -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"
|
||||||
|
@ -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
|
|
||||||
|
61
app/(pages)/application/_layout.tsx
Normal file
61
app/(pages)/application/_layout.tsx
Normal 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
|
@ -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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
38
app/(pages)/application/users/settings.tsx
Normal file
38
app/(pages)/application/users/settings.tsx
Normal 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
|
42
app/(pages)/authentication/_layout.tsx
Normal file
42
app/(pages)/authentication/_layout.tsx
Normal 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
|
@ -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,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -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,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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 {
|
||||||
|
12
domain/repositories/Authentication.ts
Normal file
12
domain/repositories/Authentication.ts
Normal 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
|
||||||
|
}
|
57
domain/use-cases/Authentication.ts
Normal file
57
domain/use-cases/Authentication.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
@ -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,
|
||||||
})
|
})
|
||||||
|
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
@ -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")
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
|
@ -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())
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
@ -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()),
|
||||||
|
@ -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
2036
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
26
package.json
26
package.json
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
151
presentation/presenters/Authentication.ts
Normal file
151
presentation/presenters/Authentication.ts
Normal 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
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
@ -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"
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
@ -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) {
|
||||||
|
7
presentation/presenters/utils/ZodPresenter.ts
Normal file
7
presentation/presenters/utils/ZodPresenter.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import type { ZodError } from "zod"
|
||||||
|
|
||||||
|
export const zodPresenter = {
|
||||||
|
getErrorsFieldsFromZodError: <T>(error: ZodError<T>): Array<keyof T> => {
|
||||||
|
return Object.keys(error.format()) as Array<keyof T>
|
||||||
|
},
|
||||||
|
}
|
13
presentation/react/components/TabBarIcon.tsx
Normal file
13
presentation/react/components/TabBarIcon.tsx
Normal 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} />
|
||||||
|
}
|
56
presentation/react/contexts/Authentication.tsx
Normal file
56
presentation/react/contexts/Authentication.tsx
Normal 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
|
||||||
|
}
|
@ -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`.",
|
||||||
)
|
)
|
||||||
|
Reference in New Issue
Block a user