chore: initial commit
This commit is contained in:
commit
edd662ce44
11
.editorconfig
Normal file
11
.editorconfig
Normal file
@ -0,0 +1,11 @@
|
||||
# https://editorconfig.org/
|
||||
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
43
.eslintrc.json
Normal file
43
.eslintrc.json
Normal file
@ -0,0 +1,43 @@
|
||||
{
|
||||
"extends": [
|
||||
"conventions",
|
||||
"plugin:react/recommended",
|
||||
"plugin:react-hooks/recommended",
|
||||
"prettier"
|
||||
],
|
||||
"ignorePatterns": ["jest.setup.ts"],
|
||||
"plugins": ["prettier"],
|
||||
"env": {
|
||||
"browser": true,
|
||||
"node": true,
|
||||
"jest": true
|
||||
},
|
||||
"settings": {
|
||||
"react": {
|
||||
"version": "detect"
|
||||
}
|
||||
},
|
||||
"parserOptions": {
|
||||
"project": "./tsconfig.json"
|
||||
},
|
||||
"rules": {
|
||||
"prettier/prettier": "error",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/prop-types": "off",
|
||||
"react/self-closing-comp": [
|
||||
"error",
|
||||
{
|
||||
"component": true,
|
||||
"html": true
|
||||
}
|
||||
],
|
||||
"react/void-dom-elements-no-children": "error",
|
||||
"react/jsx-boolean-value": "error"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts", "*.tsx"],
|
||||
"parser": "@typescript-eslint/parser"
|
||||
}
|
||||
]
|
||||
}
|
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
45
.gitignore
vendored
Normal file
45
.gitignore
vendored
Normal file
@ -0,0 +1,45 @@
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
.npm/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
|
||||
# Native
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# tests
|
||||
coverage/
|
||||
|
||||
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
|
||||
# The following patterns were generated by expo-cli
|
||||
|
||||
expo-env.d.ts
|
||||
# @end expo-cli
|
3
.prettierrc.json
Normal file
3
.prettierrc.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"semi": false
|
||||
}
|
7
.vscode/extensions.json
vendored
Normal file
7
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"editorconfig.editorconfig",
|
||||
"esbenp.prettier-vscode",
|
||||
"dbaeumer.vscode-eslint"
|
||||
]
|
||||
}
|
14
.vscode/settings.json
vendored
Normal file
14
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"editor.bracketPairColorization.enabled": true,
|
||||
"prettier.configPath": ".prettierrc.json",
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "explicit"
|
||||
},
|
||||
"eslint.options": {
|
||||
"ignorePath": ".gitignore"
|
||||
},
|
||||
"prettier.ignorePath": ".gitignore"
|
||||
}
|
39
README.md
Normal file
39
README.md
Normal file
@ -0,0 +1,39 @@
|
||||
# P61 - Projet
|
||||
|
||||
## À propos
|
||||
|
||||
Application mobile en React Native pour le projet du module P61 Développement avancé.
|
||||
|
||||
[Sujet](./SUJET.md)
|
||||
|
||||
## Membres du Groupe 7
|
||||
|
||||
- [Théo LUDWIG](https://git.unistra.fr/t.ludwig)
|
||||
- [Haoxuan LI](https://git.unistra.fr/haoxuan.li)
|
||||
- [Maxime RUMPLER](https://git.unistra.fr/m.rumpler)
|
||||
- [Maxime RICHARD](https://git.unistra.fr/maximerichard)
|
||||
|
||||
## Prérequis
|
||||
|
||||
- [Node.js](https://nodejs.org/) >= 20.0.0
|
||||
- [npm](https://www.npmjs.com/) >= 10.0.0
|
||||
- [Expo Go](https://expo.io/client)
|
||||
|
||||
## Installation
|
||||
|
||||
```sh
|
||||
npm clean-install
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```sh
|
||||
# Run the application
|
||||
npm run start
|
||||
|
||||
# Lint and Test
|
||||
npm run lint:prettier
|
||||
npm run lint:eslint
|
||||
npm run lint:typescript
|
||||
npm run test
|
||||
```
|
35
app.json
Normal file
35
app.json
Normal file
@ -0,0 +1,35 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "p61-project",
|
||||
"slug": "p61-project",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "myapp",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"splash": {
|
||||
"image": "./assets/images/splashscreen.jpg",
|
||||
"resizeMode": "cover",
|
||||
"backgroundColor": "#74b6cb"
|
||||
},
|
||||
"assetBundlePatterns": ["**/*"],
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
}
|
||||
},
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
"output": "static",
|
||||
"favicon": "./assets/images/favicon.png"
|
||||
},
|
||||
"plugins": ["expo-router"],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
}
|
||||
}
|
||||
}
|
34
app/(pages)/__tests__/__snapshots__/index.test.tsx.snap
Normal file
34
app/(pages)/__tests__/__snapshots__/index.test.tsx.snap
Normal file
@ -0,0 +1,34 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<HomePage /> renders correctly 1`] = `
|
||||
<RNCSafeAreaView
|
||||
edges={
|
||||
{
|
||||
"bottom": "additive",
|
||||
"left": "additive",
|
||||
"right": "additive",
|
||||
"top": "additive",
|
||||
}
|
||||
}
|
||||
style={
|
||||
{
|
||||
"alignItems": "center",
|
||||
"flex": 1,
|
||||
"justifyContent": "center",
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text
|
||||
style={
|
||||
{
|
||||
"color": "#006CFF",
|
||||
"fontFamily": "Georama",
|
||||
"fontSize": 36,
|
||||
"marginVertical": 20,
|
||||
}
|
||||
}
|
||||
>
|
||||
P61 Project
|
||||
</Text>
|
||||
</RNCSafeAreaView>
|
||||
`;
|
14
app/(pages)/__tests__/index.test.tsx
Normal file
14
app/(pages)/__tests__/index.test.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import renderer from "react-test-renderer"
|
||||
|
||||
import HomePage from "@/app/(pages)/index"
|
||||
|
||||
describe("<HomePage />", () => {
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("renders correctly", () => {
|
||||
const tree = renderer.create(<HomePage />).toJSON()
|
||||
expect(tree).toMatchSnapshot()
|
||||
})
|
||||
})
|
37
app/(pages)/_layout.tsx
Normal file
37
app/(pages)/_layout.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import FontAwesome from "@expo/vector-icons/FontAwesome"
|
||||
import { Tabs } from "expo-router"
|
||||
import React from "react"
|
||||
|
||||
/**
|
||||
* @see https://icons.expo.fyi/
|
||||
* @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 = () => {
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: "Home",
|
||||
tabBarIcon: ({ color }) => {
|
||||
return <TabBarIcon name="home" color={color} />
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
||||
export default TabLayout
|
29
app/(pages)/index.tsx
Normal file
29
app/(pages)/index.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { StatusBar } from "expo-status-bar"
|
||||
import { StyleSheet, Text } from "react-native"
|
||||
import { SafeAreaView } from "react-native-safe-area-context"
|
||||
|
||||
const HomePage: React.FC = () => {
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<Text style={styles.title}>P61 Project</Text>
|
||||
|
||||
<StatusBar style="auto" />
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
title: {
|
||||
fontFamily: "Canterbury",
|
||||
fontSize: 36,
|
||||
color: "#006CFF",
|
||||
marginVertical: 20,
|
||||
},
|
||||
})
|
||||
|
||||
export default HomePage
|
19
app/+html.tsx
Normal file
19
app/+html.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { ScrollViewStyleReset } from "expo-router/html"
|
||||
|
||||
const Root: React.FC<React.PropsWithChildren> = ({ children }) => {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, shrink-to-fit=no"
|
||||
/>
|
||||
<ScrollViewStyleReset />
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
export default Root
|
56
app/_layout.tsx
Normal file
56
app/_layout.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import { useFonts } from "expo-font"
|
||||
import { Stack } from "expo-router"
|
||||
import * as SplashScreen from "expo-splash-screen"
|
||||
import { useEffect } from "react"
|
||||
|
||||
import CanterburyFont from "../assets/fonts/Canterbury.ttf"
|
||||
import GeoramFont from "../assets/fonts/Georama-Black.ttf"
|
||||
import SpaceMonoFont from "../assets/fonts/SpaceMono-Regular.ttf"
|
||||
|
||||
export { ErrorBoundary } from "expo-router"
|
||||
|
||||
export const unstableSettings = {
|
||||
initialRouteName: "index",
|
||||
}
|
||||
|
||||
SplashScreen.preventAutoHideAsync().catch((error) => {
|
||||
console.error(error)
|
||||
})
|
||||
|
||||
const RootLayout: React.FC = () => {
|
||||
const [loaded, error] = useFonts({
|
||||
Georama: GeoramFont,
|
||||
SpaceMono: SpaceMonoFont,
|
||||
Canterbury: CanterburyFont,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (error != null) {
|
||||
throw error
|
||||
}
|
||||
}, [error])
|
||||
|
||||
useEffect(() => {
|
||||
if (loaded) {
|
||||
SplashScreen.hideAsync().catch((error) => {
|
||||
console.error(error)
|
||||
})
|
||||
}
|
||||
}, [loaded])
|
||||
|
||||
if (!loaded) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="(pages)" />
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
export default RootLayout
|
BIN
assets/fonts/Canterbury.ttf
Executable file
BIN
assets/fonts/Canterbury.ttf
Executable file
Binary file not shown.
BIN
assets/fonts/Georama-Black.ttf
Executable file
BIN
assets/fonts/Georama-Black.ttf
Executable file
Binary file not shown.
BIN
assets/fonts/SpaceMono-Regular.ttf
Executable file
BIN
assets/fonts/SpaceMono-Regular.ttf
Executable file
Binary file not shown.
BIN
assets/images/Universite-Strasbourg.png
Normal file
BIN
assets/images/Universite-Strasbourg.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 11 KiB |
BIN
assets/images/adaptive-icon.png
Normal file
BIN
assets/images/adaptive-icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 17 KiB |
BIN
assets/images/favicon.png
Normal file
BIN
assets/images/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.4 KiB |
BIN
assets/images/icon.png
Normal file
BIN
assets/images/icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 22 KiB |
BIN
assets/images/splash.png
Normal file
BIN
assets/images/splash.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 46 KiB |
BIN
assets/images/splashscreen.jpg
Executable file
BIN
assets/images/splashscreen.jpg
Executable file
Binary file not shown.
After Width: | Height: | Size: 97 KiB |
6
babel.config.js
Normal file
6
babel.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true)
|
||||
return {
|
||||
presets: ["babel-preset-expo"],
|
||||
}
|
||||
}
|
34
components/ButtonCustom.tsx
Normal file
34
components/ButtonCustom.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import type { StyleProp, ViewStyle } from "react-native"
|
||||
import { Pressable, StyleSheet, Text } from "react-native"
|
||||
|
||||
export interface ButtonCustomProps
|
||||
extends React.ComponentProps<typeof Pressable> {
|
||||
children: React.ReactNode
|
||||
style?: StyleProp<ViewStyle>
|
||||
}
|
||||
|
||||
export const ButtonCustom: React.FC<ButtonCustomProps> = (props) => {
|
||||
const { children, style, ...rest } = props
|
||||
|
||||
return (
|
||||
<Pressable style={[styles.button, style]} {...rest}>
|
||||
<Text style={styles.text}>{children}</Text>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
padding: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: "black",
|
||||
borderRadius: 10,
|
||||
marginTop: 10,
|
||||
backgroundColor: "#152B5D",
|
||||
},
|
||||
text: {
|
||||
color: "white",
|
||||
textAlign: "center",
|
||||
fontSize: 15,
|
||||
},
|
||||
})
|
27
components/ExternalLink.tsx
Normal file
27
components/ExternalLink.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { Link } from "expo-router"
|
||||
import * as WebBrowser from "expo-web-browser"
|
||||
import { Platform } from "react-native"
|
||||
|
||||
type LinkProps = React.ComponentProps<typeof Link>
|
||||
|
||||
export const ExternalLink: React.FC<
|
||||
Omit<LinkProps, "href"> & {
|
||||
href: string
|
||||
}
|
||||
> = (props) => {
|
||||
const { href, ...rest } = props
|
||||
|
||||
return (
|
||||
<Link
|
||||
target="_blank"
|
||||
href={href as unknown as LinkProps["href"]}
|
||||
{...rest}
|
||||
onPress={async (event) => {
|
||||
if (Platform.OS !== "web") {
|
||||
event.preventDefault()
|
||||
await WebBrowser.openBrowserAsync(href)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
13
components/MonoText.tsx
Normal file
13
components/MonoText.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { StyleSheet, Text } from "react-native"
|
||||
|
||||
export const MonoText: React.FC<Text["props"]> = (props) => {
|
||||
const { style, ...rest } = props
|
||||
|
||||
return <Text style={[style, styles.text]} {...rest} />
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
text: {
|
||||
fontFamily: "SpaceMono",
|
||||
},
|
||||
})
|
12
components/__tests__/ButtonCustom.test.tsx
Normal file
12
components/__tests__/ButtonCustom.test.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import renderer from "react-test-renderer"
|
||||
|
||||
import { ButtonCustom } from "@/components/ButtonCustom"
|
||||
|
||||
describe("<ButtonCustom />", () => {
|
||||
it("renders correctly", () => {
|
||||
const tree = renderer
|
||||
.create(<ButtonCustom>Awesome Button!</ButtonCustom>)
|
||||
.toJSON()
|
||||
expect(tree).toMatchSnapshot()
|
||||
})
|
||||
})
|
16
components/__tests__/ExternalLink.test.tsx
Normal file
16
components/__tests__/ExternalLink.test.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import renderer from "react-test-renderer"
|
||||
|
||||
import { ExternalLink } from "@/components/ExternalLink"
|
||||
|
||||
describe("<ExternalLink />", () => {
|
||||
it("renders correctly", () => {
|
||||
const tree = renderer
|
||||
.create(
|
||||
<ExternalLink href="https://www.unistra.fr/">
|
||||
Awesome Link!
|
||||
</ExternalLink>,
|
||||
)
|
||||
.toJSON()
|
||||
expect(tree).toMatchSnapshot()
|
||||
})
|
||||
})
|
10
components/__tests__/MonoText.test.tsx
Normal file
10
components/__tests__/MonoText.test.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import renderer from "react-test-renderer"
|
||||
|
||||
import { MonoText } from "@/components/MonoText"
|
||||
|
||||
describe("<MonoText />", () => {
|
||||
it("renders correctly", () => {
|
||||
const tree = renderer.create(<MonoText>Awesome text!</MonoText>).toJSON()
|
||||
expect(tree).toMatchSnapshot()
|
||||
})
|
||||
})
|
@ -0,0 +1,60 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<ButtonCustom /> renders correctly 1`] = `
|
||||
<View
|
||||
accessibilityState={
|
||||
{
|
||||
"busy": undefined,
|
||||
"checked": undefined,
|
||||
"disabled": undefined,
|
||||
"expanded": undefined,
|
||||
"selected": undefined,
|
||||
}
|
||||
}
|
||||
accessibilityValue={
|
||||
{
|
||||
"max": undefined,
|
||||
"min": undefined,
|
||||
"now": undefined,
|
||||
"text": undefined,
|
||||
}
|
||||
}
|
||||
accessible={true}
|
||||
collapsable={false}
|
||||
focusable={true}
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onFocus={[Function]}
|
||||
onResponderGrant={[Function]}
|
||||
onResponderMove={[Function]}
|
||||
onResponderRelease={[Function]}
|
||||
onResponderTerminate={[Function]}
|
||||
onResponderTerminationRequest={[Function]}
|
||||
onStartShouldSetResponder={[Function]}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"backgroundColor": "#152B5D",
|
||||
"borderColor": "black",
|
||||
"borderRadius": 10,
|
||||
"borderWidth": 1,
|
||||
"marginTop": 10,
|
||||
"padding": 10,
|
||||
},
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
>
|
||||
<Text
|
||||
style={
|
||||
{
|
||||
"color": "white",
|
||||
"fontSize": 15,
|
||||
"textAlign": "center",
|
||||
}
|
||||
}
|
||||
>
|
||||
Awesome Button!
|
||||
</Text>
|
||||
</View>
|
||||
`;
|
@ -0,0 +1,11 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<ExternalLink /> renders correctly 1`] = `
|
||||
<Text
|
||||
href="https://www.unistra.fr/"
|
||||
onPress={[Function]}
|
||||
role="link"
|
||||
>
|
||||
Awesome Link!
|
||||
</Text>
|
||||
`;
|
16
components/__tests__/__snapshots__/MonoText.test.tsx.snap
Normal file
16
components/__tests__/__snapshots__/MonoText.test.tsx.snap
Normal file
@ -0,0 +1,16 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<MonoText /> renders correctly 1`] = `
|
||||
<Text
|
||||
style={
|
||||
[
|
||||
undefined,
|
||||
{
|
||||
"fontFamily": "SpaceMono",
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
Awesome text!
|
||||
</Text>
|
||||
`;
|
61
hooks/__tests__/useBoolean.test.ts
Normal file
61
hooks/__tests__/useBoolean.test.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { act, renderHook } from "@testing-library/react-native"
|
||||
|
||||
import { useBoolean } from "@/hooks/useBoolean"
|
||||
|
||||
describe("hooks/useBoolean", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
const initialValues = [true, false]
|
||||
|
||||
for (const initialValue of initialValues) {
|
||||
it(`should set the initial value to ${initialValue}`, () => {
|
||||
const { result } = renderHook(() => {
|
||||
return useBoolean({ initialValue })
|
||||
})
|
||||
expect(result.current.value).toBe(initialValue)
|
||||
})
|
||||
}
|
||||
|
||||
it("should by default set the initial value to false", () => {
|
||||
const { result } = renderHook(() => {
|
||||
return useBoolean()
|
||||
})
|
||||
expect(result.current.value).toBe(false)
|
||||
})
|
||||
|
||||
it("should toggle the value", async () => {
|
||||
const { result } = renderHook(() => {
|
||||
return useBoolean({ initialValue: false })
|
||||
})
|
||||
await act(() => {
|
||||
return result.current.toggle()
|
||||
})
|
||||
expect(result.current.value).toBe(true)
|
||||
await act(() => {
|
||||
return result.current.toggle()
|
||||
})
|
||||
expect(result.current.value).toBe(false)
|
||||
})
|
||||
|
||||
it("should set the value to true", async () => {
|
||||
const { result } = renderHook(() => {
|
||||
return useBoolean({ initialValue: false })
|
||||
})
|
||||
await act(() => {
|
||||
return result.current.setTrue()
|
||||
})
|
||||
expect(result.current.value).toBe(true)
|
||||
})
|
||||
|
||||
it("should set the value to false", async () => {
|
||||
const { result } = renderHook(() => {
|
||||
return useBoolean({ initialValue: true })
|
||||
})
|
||||
await act(() => {
|
||||
return result.current.setFalse()
|
||||
})
|
||||
expect(result.current.value).toBe(false)
|
||||
})
|
||||
})
|
93
hooks/__tests__/useLocalStorage.test.ts
Normal file
93
hooks/__tests__/useLocalStorage.test.ts
Normal file
@ -0,0 +1,93 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react-native"
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage"
|
||||
|
||||
import { useLocalStorage } from "@/hooks/useLocalStorage"
|
||||
|
||||
describe("hooks/useLocalStorage", () => {
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks()
|
||||
await AsyncStorage.clear()
|
||||
})
|
||||
|
||||
it("should get the default value", () => {
|
||||
const key = "key"
|
||||
const givenDefaultValue = { key: "value" }
|
||||
const { result } = renderHook(() => {
|
||||
return useLocalStorage(key, givenDefaultValue)
|
||||
})
|
||||
const [actualValue] = result.current
|
||||
expect(actualValue).toEqual(givenDefaultValue)
|
||||
})
|
||||
|
||||
it("should get the value from the storage", async () => {
|
||||
const key = "key"
|
||||
const givenDefaultValue = { someValue: "value" }
|
||||
const storedValue = { someValue: "value" }
|
||||
const { result } = renderHook(() => {
|
||||
return useLocalStorage(key, givenDefaultValue)
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(AsyncStorage.getItem).toHaveBeenCalledWith(key)
|
||||
})
|
||||
const [actualValue] = result.current
|
||||
expect(actualValue).toEqual(storedValue)
|
||||
})
|
||||
|
||||
it("should set the value to the storage", async () => {
|
||||
const key = "key"
|
||||
const givenDefaultValue = { someValue: "value" }
|
||||
const storedValue = { someValue: "value" }
|
||||
const { result } = renderHook(() => {
|
||||
return useLocalStorage(key, givenDefaultValue)
|
||||
})
|
||||
const [, setValue] = result.current
|
||||
await act(() => {
|
||||
setValue(storedValue)
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
|
||||
key,
|
||||
JSON.stringify(storedValue),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it("should get default value if storage value is not valid JSON", async () => {
|
||||
console.error = jest.fn()
|
||||
const key = "key"
|
||||
const givenDefaultValue = { someValue: "value" }
|
||||
const storedValue = "{not valid JSON"
|
||||
await AsyncStorage.setItem(key, storedValue)
|
||||
const { result } = renderHook(() => {
|
||||
return useLocalStorage(key, givenDefaultValue)
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(AsyncStorage.getItem).toHaveBeenCalledWith(key)
|
||||
})
|
||||
const [actualValue] = result.current
|
||||
expect(actualValue).toEqual(givenDefaultValue)
|
||||
})
|
||||
|
||||
it("should catch the error when setting the value to the storage", async () => {
|
||||
console.error = jest.fn()
|
||||
const key = "key"
|
||||
const givenDefaultValue = { someValue: "value" }
|
||||
const storedValue = { someValue: "value" }
|
||||
const error = new Error("error")
|
||||
;(AsyncStorage.setItem as jest.Mock).mockRejectedValue(error)
|
||||
const { result } = renderHook(() => {
|
||||
return useLocalStorage(key, givenDefaultValue)
|
||||
})
|
||||
const [, setValue] = result.current
|
||||
await act(() => {
|
||||
setValue(storedValue)
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
|
||||
key,
|
||||
JSON.stringify(storedValue),
|
||||
)
|
||||
})
|
||||
expect(console.error).toHaveBeenCalledWith(error)
|
||||
})
|
||||
})
|
50
hooks/useBoolean.ts
Normal file
50
hooks/useBoolean.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { useState } from "react"
|
||||
|
||||
export interface UseBooleanResult {
|
||||
value: boolean
|
||||
toggle: () => void
|
||||
setTrue: () => void
|
||||
setFalse: () => void
|
||||
}
|
||||
|
||||
export interface UseBooleanOptions {
|
||||
/**
|
||||
* The initial value of the boolean.
|
||||
* @default false
|
||||
*/
|
||||
initialValue?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage a boolean state.
|
||||
* @param options
|
||||
* @returns
|
||||
*/
|
||||
export const useBoolean = (
|
||||
options: UseBooleanOptions = {},
|
||||
): UseBooleanResult => {
|
||||
const { initialValue = false } = options
|
||||
|
||||
const [value, setValue] = useState(initialValue)
|
||||
|
||||
const toggle = (): void => {
|
||||
setValue((old) => {
|
||||
return !old
|
||||
})
|
||||
}
|
||||
|
||||
const setTrue = (): void => {
|
||||
setValue(true)
|
||||
}
|
||||
|
||||
const setFalse = (): void => {
|
||||
setValue(false)
|
||||
}
|
||||
|
||||
return {
|
||||
value,
|
||||
toggle,
|
||||
setTrue,
|
||||
setFalse,
|
||||
}
|
||||
}
|
43
hooks/useLocalStorage.ts
Normal file
43
hooks/useLocalStorage.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage"
|
||||
|
||||
type UseLocalStorageResult<T> = [T, React.Dispatch<React.SetStateAction<T>>]
|
||||
|
||||
export const useLocalStorage = <T extends unknown>(
|
||||
key: string,
|
||||
defaultValue: T,
|
||||
): UseLocalStorageResult<T> => {
|
||||
const hasSaved = useRef(false)
|
||||
const [value, setValue] = useState<T>(defaultValue)
|
||||
|
||||
const getFromLocalStorage = useCallback(async (): Promise<T> => {
|
||||
const value = await AsyncStorage.getItem(key)
|
||||
hasSaved.current = true
|
||||
if (value == null) {
|
||||
return defaultValue
|
||||
}
|
||||
return JSON.parse(value) as T
|
||||
}, [key, defaultValue])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasSaved.current) {
|
||||
return
|
||||
}
|
||||
AsyncStorage.setItem(key, JSON.stringify(value)).catch((error) => {
|
||||
console.error(error)
|
||||
})
|
||||
}, [key, value])
|
||||
|
||||
useEffect(() => {
|
||||
getFromLocalStorage()
|
||||
.then((value) => {
|
||||
setValue(value)
|
||||
})
|
||||
.catch((error) => {
|
||||
setValue(defaultValue)
|
||||
console.error(error)
|
||||
})
|
||||
}, [defaultValue, getFromLocalStorage])
|
||||
|
||||
return [value, setValue]
|
||||
}
|
19
jest.config.json
Normal file
19
jest.config.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"preset": "jest-expo",
|
||||
"roots": ["./"],
|
||||
"setupFilesAfterEnv": ["<rootDir>/tests/jest.setup.ts"],
|
||||
"fakeTimers": {
|
||||
"enableGlobally": true
|
||||
},
|
||||
"collectCoverage": true,
|
||||
"coverageDirectory": "coverage",
|
||||
"coverageReporters": ["text", "text-summary", "cobertura"],
|
||||
"collectCoverageFrom": [
|
||||
"<rootDir>/**/*.{ts,tsx}",
|
||||
"!<rootDir>/components/ExternalLink.tsx",
|
||||
"!<rootDir>/.expo",
|
||||
"!<rootDir>/app/+html.tsx",
|
||||
"!<rootDir>/app/**/_layout.tsx",
|
||||
"!**/*.d.ts"
|
||||
]
|
||||
}
|
20466
package-lock.json
generated
Normal file
20466
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
60
package.json
Normal file
60
package.json
Normal file
@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "p61-project",
|
||||
"private": true,
|
||||
"main": "expo-router/entry",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web",
|
||||
"lint:prettier": "prettier . --check",
|
||||
"lint:eslint": "eslint . --max-warnings 0 --report-unused-disable-directives --ignore-path .gitignore",
|
||||
"lint:typescript": "tsc --noEmit",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "14.0.0",
|
||||
"@react-native-async-storage/async-storage": "1.21.0",
|
||||
"@react-navigation/native": "6.1.10",
|
||||
"expo": "50.0.6",
|
||||
"expo-font": "11.10.2",
|
||||
"expo-linking": "6.2.2",
|
||||
"expo-router": "3.4.7",
|
||||
"expo-splash-screen": "0.26.4",
|
||||
"expo-status-bar": "1.11.1",
|
||||
"expo-system-ui": "2.9.3",
|
||||
"expo-web-browser": "12.8.2",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-native": "0.73.4",
|
||||
"react-native-safe-area-context": "4.8.2",
|
||||
"react-native-screens": "3.29.0",
|
||||
"react-native-web": "0.19.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.23.9",
|
||||
"@testing-library/react-native": "12.4.3",
|
||||
"@total-typescript/ts-reset": "0.5.1",
|
||||
"@tsconfig/strictest": "2.0.3",
|
||||
"@types/jest": "29.5.12",
|
||||
"@types/react": "18.2.55",
|
||||
"@types/react-test-renderer": "18.0.7",
|
||||
"@typescript-eslint/eslint-plugin": "7.0.1",
|
||||
"@typescript-eslint/parser": "7.0.1",
|
||||
"eslint": "8.56.0",
|
||||
"eslint-config-conventions": "14.1.0",
|
||||
"eslint-config-prettier": "9.1.0",
|
||||
"eslint-plugin-import": "2.29.1",
|
||||
"eslint-plugin-prettier": "5.1.3",
|
||||
"eslint-plugin-promise": "6.1.1",
|
||||
"eslint-plugin-react": "7.33.2",
|
||||
"eslint-plugin-react-hooks": "4.6.0",
|
||||
"eslint-plugin-react-native": "4.1.0",
|
||||
"eslint-plugin-unicorn": "51.0.1",
|
||||
"jest": "29.7.0",
|
||||
"jest-expo": "50.0.2",
|
||||
"react-test-renderer": "18.2.0",
|
||||
"typescript": "5.3.3"
|
||||
}
|
||||
}
|
5
react-app-env.d.ts
vendored
Normal file
5
react-app-env.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
declare module "*.png"
|
||||
declare module "*.svg"
|
||||
declare module "*.jpeg"
|
||||
declare module "*.jpg"
|
||||
declare module "*.ttf"
|
5
tests/jest.setup.ts
Normal file
5
tests/jest.setup.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import "@testing-library/react-native/extend-expect"
|
||||
|
||||
jest.mock("@react-native-async-storage/async-storage", () => {
|
||||
return require("@react-native-async-storage/async-storage/jest/async-storage-mock")
|
||||
})
|
18
tsconfig.json
Normal file
18
tsconfig.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": ["@tsconfig/strictest/tsconfig.json", "expo/tsconfig.base"],
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"exactOptionalPropertyTypes": false,
|
||||
"allowJs": false,
|
||||
"checkJs": false,
|
||||
"types": [
|
||||
"@total-typescript/ts-reset",
|
||||
"jest",
|
||||
"@testing-library/react-native/extend-expect"
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
|
||||
}
|
Reference in New Issue
Block a user