Merge branch 'add-simple-tabada-timer' into 'main'

add simple timers tabada

See merge request torpenn/boxons!1
merge-requests/10/head
Torpenn 2024-10-13 12:31:43 +00:00
commit 1e2376fac7
48 changed files with 4148 additions and 546 deletions

3
.eslintignore Normal file
View File

@ -0,0 +1,3 @@
node_modules
package.json
.eslintrc.js

26
.eslintrc.js Normal file
View File

@ -0,0 +1,26 @@
module.exports = {
env: {
browser: true,
es2021: true,
},
extends: ['plugin:react/recommended'],
parser: '@typescript-eslint/parser',
root: true,
parserOptions: {
ecmaFeatures: {
jsx: true,
},
ecmaVersion: 'latest',
sourceType: 'module',
},
plugins: ['react-native', 'jest', 'simple-import-sort', '@typescript-eslint'],
rules: {
"@typescript-eslint/method-signature-style": [
"error",
"property"
],
'react/react-in-jsx-scope': 'off',
'react/no-unescaped-entities': 'off',
},
};

6
.gitignore vendored
View File

@ -33,3 +33,9 @@ yarn-error.*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
# The following patterns were generated by expo-cli
expo-env.d.ts
# @end expo-cli

40
.gitlab-ci.yml Normal file
View File

@ -0,0 +1,40 @@
image: node:20
stages:
- install
- lint
- test
cache:
paths:
- node_modules/
before_script:
- npm install
- apt-get update
install_dependencies:
stage: install
script:
- npm install
artifacts:
paths:
- node_modules/
expire_in: 1 week
lint:
stage: test
script:
- npm run lint
- npm run types
only:
- branches
- merge_requests
test:
stage: test
script:
- npm test
only:
- branches
- merge_requests

1
.husky/pre-push Normal file
View File

@ -0,0 +1 @@
npm run types && npm run lint

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
20

View File

@ -1,59 +0,0 @@
import React from 'react';
import FontAwesome from '@expo/vector-icons/FontAwesome';
import { Link, Tabs } from 'expo-router';
import { Pressable } from 'react-native';
import Colors from '@/constants/Colors';
import { useColorScheme } from '@/components/useColorScheme';
import { useClientOnlyValue } from '@/components/useClientOnlyValue';
// You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
function TabBarIcon(props: {
name: React.ComponentProps<typeof FontAwesome>['name'];
color: string;
}) {
return <FontAwesome size={28} style={{ marginBottom: -3 }} {...props} />;
}
export default function TabLayout() {
const colorScheme = useColorScheme();
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
// Disable the static render of the header on web
// to prevent a hydration error in React Navigation v6.
headerShown: useClientOnlyValue(false, true),
}}>
<Tabs.Screen
name="index"
options={{
title: 'Tab One',
tabBarIcon: ({ color }) => <TabBarIcon name="code" color={color} />,
headerRight: () => (
<Link href="/modal" asChild>
<Pressable>
{({ pressed }) => (
<FontAwesome
name="info-circle"
size={25}
color={Colors[colorScheme ?? 'light'].text}
style={{ marginRight: 15, opacity: pressed ? 0.5 : 1 }}
/>
)}
</Pressable>
</Link>
),
}}
/>
<Tabs.Screen
name="two"
options={{
title: 'Tab Two',
tabBarIcon: ({ color }) => <TabBarIcon name="code" color={color} />,
}}
/>
</Tabs>
);
}

View File

@ -1,31 +0,0 @@
import { StyleSheet } from 'react-native';
import EditScreenInfo from '@/components/EditScreenInfo';
import { Text, View } from '@/components/Themed';
export default function TabOneScreen() {
return (
<View style={styles.container}>
<Text style={styles.title}>Tab One</Text>
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
<EditScreenInfo path="app/(tabs)/index.tsx" />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
title: {
fontSize: 20,
fontWeight: 'bold',
},
separator: {
marginVertical: 30,
height: 1,
width: '80%',
},
});

View File

@ -1,31 +0,0 @@
import { StyleSheet } from 'react-native';
import EditScreenInfo from '@/components/EditScreenInfo';
import { Text, View } from '@/components/Themed';
export default function TabTwoScreen() {
return (
<View style={styles.container}>
<Text style={styles.title}>Tab Two</Text>
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
<EditScreenInfo path="app/(tabs)/two.tsx" />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
title: {
fontSize: 20,
fontWeight: 'bold',
},
separator: {
marginVertical: 30,
height: 1,
width: '80%',
},
});

View File

@ -1,7 +1,7 @@
import { Link, Stack } from 'expo-router'; import { Link, Stack } from 'expo-router';
import { StyleSheet } from 'react-native'; import { StyleSheet } from 'react-native';
import { Text, View } from '@/components/Themed'; import { Text, View } from '@/components/shared/Themed';
export default function NotFoundScreen() { export default function NotFoundScreen() {
return ( return (
@ -9,10 +9,6 @@ export default function NotFoundScreen() {
<Stack.Screen options={{ title: 'Oops!' }} /> <Stack.Screen options={{ title: 'Oops!' }} />
<View style={styles.container}> <View style={styles.container}>
<Text style={styles.title}>This screen doesn't exist.</Text> <Text style={styles.title}>This screen doesn't exist.</Text>
<Link href="/" style={styles.link}>
<Text style={styles.linkText}>Go to home screen!</Text>
</Link>
</View> </View>
</> </>
); );

View File

@ -0,0 +1,4 @@
export type RootStackParamList = {
dashboard: undefined;
timer: { reps: number, restTime: number, workTime: number};
};

View File

@ -1,13 +1,12 @@
import FontAwesome from '@expo/vector-icons/FontAwesome'; import FontAwesome from '@expo/vector-icons/FontAwesome';
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'; import { ThemeProvider } from '@emotion/react';
import { theme } from '@/app/shared/theme';
import { useFonts } from 'expo-font'; 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 { useEffect } from 'react';
import 'react-native-reanimated'; import 'react-native-reanimated';
import { useColorScheme } from '@/components/useColorScheme';
export { export {
// Catch any errors thrown by the Layout component. // Catch any errors thrown by the Layout component.
ErrorBoundary, ErrorBoundary,
@ -15,7 +14,7 @@ export {
export const unstable_settings = { export const unstable_settings = {
// Ensure that reloading on `/modal` keeps a back button present. // Ensure that reloading on `/modal` keeps a back button present.
initialRouteName: '(tabs)', initialRouteName: 'dashboard',
}; };
// Prevent the splash screen from auto-hiding before asset loading is complete. // Prevent the splash screen from auto-hiding before asset loading is complete.
@ -46,13 +45,11 @@ export default function RootLayout() {
} }
function RootLayoutNav() { function RootLayoutNav() {
const colorScheme = useColorScheme();
return ( return (
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}> <ThemeProvider theme={theme}>
<Stack> <Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} /> <Stack.Screen name="dashboard" options={{ headerShown: false }} />
<Stack.Screen name="modal" options={{ presentation: 'modal' }} /> <Stack.Screen name="timer" options={{ headerShown: false }} />
</Stack> </Stack>
</ThemeProvider> </ThemeProvider>
); );

134
app/dashboard.tsx Normal file
View File

@ -0,0 +1,134 @@
import React, { useState } from 'react';
import { Text } from '@/components/shared/Themed';
import Card from '@/components/shared/Card';
import styled from '@emotion/native';
import { formatTime } from '@/components/shared/business/timeHelpers';
import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { RootStackParamList } from '@/app/RootStackParamList';
import { VerticalSpacer } from '@/components/shared/Spacers';
import { TimerPickerModal } from 'react-native-timer-picker';
import Button from '@/components/shared/Button';
import NumberSelector from '@/components/shared/NumberSelector';
type NavigationProp = NativeStackNavigationProp<RootStackParamList, 'timer'>;
export default function Dashboard() {
const navigation = useNavigation<NavigationProp>();
const [reps, setReps] = useState<number>(1);
const [workTime, setWorkTime] = useState<number>(0);
const [restTime, setRestTime] = useState<number>(0);
const [showWorkTimePicker, setShowWorkTimePicker] = useState<boolean>(false);
const [showRestTimePicker, setShowRestTimePicker] = useState<boolean>(false);
const totalWorkTime: string = formatTime((workTime + restTime) * reps);
return (
<Container>
<Title>Boxons</Title>
<VerticalSpacer heightUnits={8} />
<CardContainer>
<Card backgroundColor="black">
<CardTextContainer>
<CustomText>Reps.</CustomText>
<NumberSelector reps={reps} setReps={setReps} />
</CardTextContainer>
</Card>
<Card backgroundColor="red" onPress={() => setShowWorkTimePicker(true)}>
<CardTextContainer>
<CustomText>Fight</CustomText>
<CustomText>{formatTime(workTime)}</CustomText>
</CardTextContainer>
</Card>
<TimerPickerModal
hideHours
visible={showWorkTimePicker}
setIsVisible={setShowWorkTimePicker}
onConfirm={(pickedDuration: any) => {
setWorkTime(pickedDuration.minutes * 60 + pickedDuration.seconds);
setShowWorkTimePicker(false);
}}
modalTitle="Set Round Time"
onCancel={() => setShowWorkTimePicker(false)}
closeOnOverlayPress
styles={{
theme: "dark",
}}
modalProps={{
overlayOpacity: 0.2,
}}
/>
<Card backgroundColor="green" onPress={() => setShowRestTimePicker(true)}>
<CardTextContainer>
<CustomText>Repos</CustomText>
<CustomText>{formatTime(restTime)}</CustomText>
</CardTextContainer>
</Card>
<TimerPickerModal
hideHours
visible={showRestTimePicker}
setIsVisible={setShowRestTimePicker}
onConfirm={(pickedDuration: any) => {
setRestTime(pickedDuration.minutes * 60 + pickedDuration.seconds);
setShowRestTimePicker(false);
}}
modalTitle="Set Rest Time"
onCancel={() => setShowRestTimePicker(false)}
closeOnOverlayPress
styles={{
theme: "dark",
}}
modalProps={{
overlayOpacity: 0.2,
}}
/>
</CardContainer>
<VerticalSpacer heightUnits={5} />
<Text>Temps total de travail : {totalWorkTime}</Text>
<VerticalSpacer heightUnits={5} />
<Button label="Commencer" onPress={() => navigation.navigate('timer', { reps, restTime, workTime})} />
</Container>
);
}
const Title = styled(Text)(({ theme }) => ({
textAlign: 'center',
fontSize: 40,
fontWeight: 'bold',
color: theme.colors.black
}));
const Container = styled.View({
padding: 20
})
const CardTextContainer = styled.View({
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center'
})
const CardContainer = styled.View({
flexDirection: 'column',
gap: 10
})
const CustomText = styled(Text)(({ theme }) => ({
fontSize: 20,
fontWeight: 'bold',
textAlign: 'center',
color: theme.colors.white
}))

View File

@ -1,35 +0,0 @@
import { StatusBar } from 'expo-status-bar';
import { Platform, StyleSheet } from 'react-native';
import EditScreenInfo from '@/components/EditScreenInfo';
import { Text, View } from '@/components/Themed';
export default function ModalScreen() {
return (
<View style={styles.container}>
<Text style={styles.title}>Modal</Text>
<View style={styles.separator} lightColor="#eee" darkColor="rgba(255,255,255,0.1)" />
<EditScreenInfo path="app/modal.tsx" />
{/* Use a light status bar on iOS to account for the black space above the modal */}
<StatusBar style={Platform.OS === 'ios' ? 'light' : 'auto'} />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
title: {
fontSize: 20,
fontWeight: 'bold',
},
separator: {
marginVertical: 30,
height: 1,
width: '80%',
},
});

View File

@ -0,0 +1,7 @@
export const colors = {
black: '#000000',
white: '#FFFFFF',
red: '#FF4141',
green: '#3AC26E',
grey: '#F9F9F9',
} as const;

16
app/shared/theme/index.ts Normal file
View File

@ -0,0 +1,16 @@
import { colors } from './colors';
import { layout } from './layout';
export const theme = {
colors,
layout
};
declare module '@emotion/react' {
export interface Theme {
colors: typeof colors;
layout: typeof layout;
}
}
export type Theme = typeof theme;

View File

@ -0,0 +1,23 @@
import { Dimensions, StyleSheet } from 'react-native';
const width = Dimensions.get('window').width;
const height = Dimensions.get('window').height;
export const layout = {
window: {
width,
height,
},
isSmallDevice: width < 375, // 375 but with added 5 px
isRegularDevice: width >= 375 && width <= 420,
isLargeDevice: width > 420,
gridUnit: 4,
iconSize: {
xxl: 80,
larger: 40,
large: 32,
medium: 24,
small: 16,
xsmall: 10
},
};

109
app/timer.tsx Normal file
View File

@ -0,0 +1,109 @@
import { useEffect, useState } from 'react';
import { useRoute } from '@react-navigation/native';
import { View } from '@/components/shared/Themed';
import styled from '@emotion/native';
import TimerContent from '@/components/useCases/timer/view/TimerContent';
import FinishContent from '@/components/useCases/timer/view/FinishContent';
import { TimerBgColor } from '@/components/useCases/timer/business/type';
interface TimerProps {
reps: number;
restTime: number;
workTime: number;
}
export default function Timer() {
const route = useRoute();
const { reps, restTime, workTime } = route.params as TimerProps;
const [currentRep, setCurrentRep] = useState<number>(0);
const [timeLeft, setTimeLeft] = useState<number>(0);
const [isWorkPhase, setIsWorkPhase] = useState<boolean>(true);
const [isRunning, setIsRunning] = useState<boolean>(false);
const [isFinish, setIsFinish] = useState<boolean>(false);
useEffect(() => {
handleStart();
}, []);
useEffect(() => {
let timer: NodeJS.Timeout;
if (isRunning && timeLeft > 0) {
timer = setTimeout(() => {
setTimeLeft(timeLeft - 1);
}, 1000);
} else if (isRunning && timeLeft === 0) {
if (currentRep < reps) {
if (isWorkPhase) {
// If the work phase is over, move on to the rest phase
setIsWorkPhase(false);
setTimeLeft(restTime);
} else {
// If the rest phase is complete, move on to the next work phase
setIsWorkPhase(true);
setTimeLeft(workTime);
setCurrentRep(currentRep + 1);
}
} else {
// Done
setIsRunning(false);
setIsFinish(true);
}
}
return () => clearTimeout(timer);
}, [isRunning, timeLeft, currentRep, isWorkPhase, reps, workTime, restTime]);
const handleStart = () => {
setCurrentRep(1);
setIsWorkPhase(true);
setTimeLeft(workTime);
setIsRunning(true);
setIsFinish(false);
};
const handleContine = () => {
setIsRunning(true);
};
const handleStop = () => {
setIsRunning(false);
};
const renderBgColor: () => TimerBgColor = () => {
if (isFinish) return 'black';
if (isWorkPhase) return 'red';
return 'green';
}
return (
<Container bgColor={renderBgColor()}>
{isFinish && (
<FinishContent handleStart={handleStart} />
)}
{!isFinish && (
<TimerContent
isWorkPhase={isWorkPhase}
timeLeft={timeLeft}
reps={reps}
bgColor={renderBgColor()}
currentRep={currentRep}
isRunning={isRunning}
handleStop={handleStop}
handleContine={handleContine}
/>
)}
</Container>
);
}
const Container = styled(View)<{ bgColor: TimerBgColor }>(({ theme, bgColor }) => ({
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: theme.colors[bgColor]
}));

View File

@ -1,5 +1,6 @@
module.exports = function (api) { module.exports = function (api) {
api.cache(true); api.cache(true);
return { return {
presets: ['babel-preset-expo'] presets: ['babel-preset-expo']
}; };

View File

@ -1,77 +0,0 @@
import React from 'react';
import { StyleSheet } from 'react-native';
import { ExternalLink } from './ExternalLink';
import { MonoText } from './StyledText';
import { Text, View } from './Themed';
import Colors from '@/constants/Colors';
export default function EditScreenInfo({ path }: { path: string }) {
return (
<View>
<View style={styles.getStartedContainer}>
<Text
style={styles.getStartedText}
lightColor="rgba(0,0,0,0.8)"
darkColor="rgba(255,255,255,0.8)">
Open up the code for this screen:
</Text>
<View
style={[styles.codeHighlightContainer, styles.homeScreenFilename]}
darkColor="rgba(255,255,255,0.05)"
lightColor="rgba(0,0,0,0.05)">
<MonoText>{path}</MonoText>
</View>
<Text
style={styles.getStartedText}
lightColor="rgba(0,0,0,0.8)"
darkColor="rgba(255,255,255,0.8)">
Change any of the text, save the file, and your app will automatically update.
</Text>
</View>
<View style={styles.helpContainer}>
<ExternalLink
style={styles.helpLink}
href="https://docs.expo.io/get-started/create-a-new-app/#opening-the-app-on-your-phonetablet">
<Text style={styles.helpLinkText} lightColor={Colors.light.tint}>
Tap here if your app doesn't automatically update after making changes
</Text>
</ExternalLink>
</View>
</View>
);
}
const styles = StyleSheet.create({
getStartedContainer: {
alignItems: 'center',
marginHorizontal: 50,
},
homeScreenFilename: {
marginVertical: 7,
},
codeHighlightContainer: {
borderRadius: 3,
paddingHorizontal: 4,
},
getStartedText: {
fontSize: 17,
lineHeight: 24,
textAlign: 'center',
},
helpContainer: {
marginTop: 15,
marginHorizontal: 20,
alignItems: 'center',
},
helpLink: {
paddingVertical: 15,
},
helpLinkText: {
textAlign: 'center',
},
});

View File

@ -1,25 +0,0 @@
import { Link } from 'expo-router';
import * as WebBrowser from 'expo-web-browser';
import React from 'react';
import { Platform } from 'react-native';
export function ExternalLink(
props: Omit<React.ComponentProps<typeof Link>, 'href'> & { href: string }
) {
return (
<Link
target="_blank"
{...props}
// @ts-expect-error: External URLs are not typed.
href={props.href}
onPress={(e) => {
if (Platform.OS !== 'web') {
// Prevent the default behavior of linking to the default browser on native.
e.preventDefault();
// Open the link in an in-app browser.
WebBrowser.openBrowserAsync(props.href as string);
}
}}
/>
);
}

View File

@ -1,5 +0,0 @@
import { Text, TextProps } from './Themed';
export function MonoText(props: TextProps) {
return <Text {...props} style={[props.style, { fontFamily: 'SpaceMono' }]} />;
}

View File

@ -1,10 +0,0 @@
import * as React from 'react';
import renderer from 'react-test-renderer';
import { MonoText } from '../StyledText';
it(`renders correctly`, () => {
const tree = renderer.create(<MonoText>Snapshot test!</MonoText>).toJSON();
expect(tree).toMatchSnapshot();
});

View File

@ -0,0 +1,100 @@
import React, { useCallback } from 'react';
import styled from '@emotion/native';
import { HorizontalSpacer } from '@/components/shared/Spacers';
import { Text } from '@/components/shared/Themed';
type ButtonColor = 'green' | 'red' | 'grey' | 'white';
type LabelColor = ButtonColor | 'black';
type IconLocation = 'left' | 'right';
interface ButtonProps {
label?: string;
color?: ButtonColor;
labelColor?: LabelColor;
status?: 'ready' | 'disabled' | 'loading';
onPress?: () => void;
secondary?: boolean;
iconLocation?: IconLocation;
icon?: any;
}
const Button: React.FC<ButtonProps> = ({
label,
color = 'white',
labelColor = 'black',
status = 'ready',
onPress,
secondary = false,
icon = undefined,
iconLocation = 'right'
}: ButtonProps) => {
const safeOnPress = useCallback(() => onPress?.(), [onPress]);
return (
<Container
secondary={secondary}
bgColor={color}
onPress={safeOnPress}
disabled={status !== 'ready'}
>
{icon && iconLocation === 'left' && (
<>
<IconContainer>{icon}</IconContainer>
{label && <HorizontalSpacer widthUnits={2} />}
</>
)}
{secondary ? (
<SecondaryLabel bgColor={color}>{label}</SecondaryLabel>
) : (
<Label color={labelColor}>{label}</Label>
)}
{icon && iconLocation === 'right' && (
<>
{label && <HorizontalSpacer widthUnits={2} />}
<IconContainer>{icon}</IconContainer>
</>
)}
</Container>
);
};
export default Button;
const Container = styled.TouchableOpacity<{
bgColor: ButtonColor;
disabled: boolean;
secondary: boolean;
}>(({ theme, bgColor, secondary }) => ({
backgroundColor: theme.colors[bgColor],
borderWidth: secondary ? 1 : 0,
borderColor: theme.colors[bgColor],
borderRadius: 6,
height: theme.layout.gridUnit * 12,
justifyContent: 'center',
paddingHorizontal: theme.layout.gridUnit * 4,
alignItems: 'center',
flexDirection: 'row',
}));
const Label = styled(Text)<{ color: LabelColor }>(({ theme, color }) => ({
color: theme.colors[color],
fontSize: 16,
fontWeight: 'semibold',
}));
const SecondaryLabel = styled(Text)<{ bgColor: ButtonColor }>(({ theme, bgColor }) => ({
color: theme.colors[bgColor],
fontWeight: 'semibold',
}));
const IconContainer = styled.View(({ theme }) => ({
height: theme.layout.iconSize.medium,
aspectRatio: 1,
alignItems: 'center',
justifyContent: 'center',
}));

View File

@ -0,0 +1,49 @@
import styled from '@emotion/native';
import React, { useCallback } from 'react';
import { TouchableOpacity } from 'react-native';
interface CardProps {
children: React.ReactNode;
backgroundColor: CardBackgroundColor;
testID?: string;
onPress?: () => void;
}
type CardBackgroundColor = 'red' | 'green' | 'grey' | 'black';
const Card: React.FC<CardProps> = ({
children,
backgroundColor = 'red',
testID = undefined,
onPress
}) => {
const safeOnPress = useCallback(() => onPress?.(), [onPress]);
const content = (
<Container bgColor={backgroundColor} testID={testID}>
{children}
</Container>
);
const touchableContent = (
<TouchableOpacity onPress={safeOnPress}>
{content}
</TouchableOpacity>
)
return (
onPress ? touchableContent : content
);
};
const Container = styled.View<{
bgColor: CardBackgroundColor;
}>(({ theme, bgColor }) => ({
padding: 30,
width: '100%',
borderRadius: 10,
overflow: 'hidden',
backgroundColor: theme.colors[bgColor]
}));
export default Card;

View File

@ -0,0 +1,47 @@
import { Text, View } from '@/components/shared/Themed';
import styled from '@emotion/native';
import { HorizontalSpacer } from './Spacers';
type FinishContentProps = {
reps: number;
setReps: (reps: number) => void;
};
export default function NumberSelector({
setReps,
reps
}: FinishContentProps) {
const addReps: () => void = () => {
setReps(reps + 1);
}
const removeReps: () => void = () => {
if (reps === 0) return;
setReps(reps - 1);
}
return (
<Row>
<CustomText onPress={removeReps}>-</CustomText>
<HorizontalSpacer widthUnits={5} />
<CustomText>{reps}</CustomText>
<HorizontalSpacer widthUnits={5} />
<CustomText onPress={addReps}>+</CustomText>
</Row>
);
}
const Row = styled(View)(() => ({
flexDirection: 'row',
backgroundColor: 'black'
}));
const CustomText = styled(Text)(({ theme }) => ({
fontSize: 20,
fontWeight: 'bold',
textAlign: 'center',
color: theme.colors.white
}))

View File

@ -0,0 +1,16 @@
import React from 'react';
import styled from '@emotion/native';
import { View, ViewProps } from 'react-native';
const Spacer = (props: Omit<ViewProps, 'pointerEvents'>) => (
<View pointerEvents="none" {...props} />
);
export const VerticalSpacer = styled(Spacer)<{ heightUnits: number }>(({ theme, heightUnits }) => ({
height: theme.layout.gridUnit * heightUnits,
}));
export const HorizontalSpacer = styled(Spacer)<{ widthUnits: number }>(({ theme, widthUnits }) => ({
width: theme.layout.gridUnit * widthUnits,
}));

View File

@ -6,17 +6,17 @@
import { Text as DefaultText, View as DefaultView } from 'react-native'; import { Text as DefaultText, View as DefaultView } from 'react-native';
import Colors from '@/constants/Colors'; import Colors from '@/constants/Colors';
import { useColorScheme } from './useColorScheme'; import { useColorScheme } from '@/components/useColorScheme';
type ThemeProps = { type ThemeProps = {
lightColor?: string; lightColor?: string;
darkColor?: string; darkColor?: string;
}; };
export type TextProps = ThemeProps & DefaultText['props']; type TextProps = ThemeProps & DefaultText['props'];
export type ViewProps = ThemeProps & DefaultView['props']; type ViewProps = ThemeProps & DefaultView['props'];
export function useThemeColor( function useThemeColor(
props: { light?: string; dark?: string }, props: { light?: string; dark?: string },
colorName: keyof typeof Colors.light & keyof typeof Colors.dark colorName: keyof typeof Colors.light & keyof typeof Colors.dark
) { ) {

View File

@ -0,0 +1,63 @@
import React from 'react';
import { fireEvent } from '@testing-library/react-native';
import { render } from '@/components/testUtils';
import Button from '@/components/shared/Button';
const renderComponent = ({ status, onPress } = {}) => {
const base = render(<Button label="Press me" status={status} onPress={onPress} />);
return base;
};
describe('[Component] Button', () => {
describe('Ready state', () => {
test('renders correctly', () => {
const button = renderComponent();
expect(button).toMatchSnapshot();
});
it('executes an action when pressing it', () => {
const action = jest.fn();
const button = renderComponent({ onPress: action });
fireEvent.press(button.getByText('Press me'));
expect(action).toHaveBeenCalledWith();
});
});
describe('Disabled state', () => {
test('renders correctly', () => {
const button = renderComponent({ status: 'disabled' });
expect(button).toMatchSnapshot();
});
test('does not do anything when pressing it', () => {
const action = jest.fn();
const button = renderComponent({ status: 'disabled', onPress: action });
fireEvent.press(button.getByText('Press me'));
expect(action).not.toHaveBeenCalled();
});
});
describe('Loading state', () => {
test('renders correctly', () => {
const button = renderComponent({ status: 'loading' });
expect(button).toMatchSnapshot();
});
test('does not do anything when pressing it', () => {
const action = jest.fn();
const button = renderComponent({ status: 'loading', onPress: action });
fireEvent.press(button.getByText('Press me'));
expect(action).not.toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,60 @@
import React from 'react';
import { fireEvent } from '@testing-library/react-native';
import { render } from '@/components/testUtils';
import Card from '@/components/shared/Card';
import { Text } from 'react-native';
import { theme } from '@/app/shared/theme';
const renderComponent = ({ backgroundColor, onPress = undefined, content = 'content'} = {}) => {
const base = render(
<Card backgroundColor={backgroundColor} onPress={onPress} testID='card'>
<Text>{content}</Text>
</Card>
);
return base;
};
describe('[Component] Card', () => {
test('renders correctly', () => {
const card = renderComponent({ backgroundColor: 'red' });
expect(card).toMatchSnapshot();
});
test('renders children correctly', () => {
const { getByText } = renderComponent({ backgroundColor: 'red', content: 'Test Content'});
expect(getByText('Test Content')).toBeTruthy();
});
test('applies the correct background color', () => {
const { getByTestId } = renderComponent({ backgroundColor: 'green'});
const card = getByTestId('card');
expect(card?.props.style[0].backgroundColor).toEqual(theme.colors.green);
});
test('does not render as TouchableOpacity when onPress is not provided', () => {
const { queryByTestId } = renderComponent({ backgroundColor: 'red' });
// TouchableOpacity ne doit pas exister
expect(queryByTestId('card').props.onPress).toBeUndefined();
});
test('calls onPress when pressed', () => {
const onPressMock = jest.fn();
const { getByTestId } = renderComponent({
backgroundColor: 'green',
onPress: onPressMock,
content: 'Press me'
});
fireEvent.press(getByTestId('card'));
expect(onPressMock).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,72 @@
import React from 'react';
import { fireEvent } from '@testing-library/react-native';
import { render } from '@/components/testUtils';
import NumberSelector from '@/components/shared/NumberSelector';
const renderComponent = ({ reps = 5, setReps = jest.fn() } = {}) => {
const base = render(
<NumberSelector reps={reps} setReps={setReps} />
);
return base;
};
describe('[Component] NumberSelect', () => {
test('renders correctly', () => {
const component = renderComponent();
expect(component).toMatchSnapshot();
});
test('renders the correct initial reps value', () => {
const { getByText } = renderComponent()
expect(getByText('5')).toBeTruthy();
});
test('increments reps when "+" is pressed', () => {
const setRepsMock = jest.fn();
const { getByText } = renderComponent({ setReps: setRepsMock });
fireEvent.press(getByText('+'));
expect(setRepsMock).toHaveBeenCalledWith(6);
});
test('decrements reps when "-" is pressed', () => {
const setRepsMock = jest.fn();
const { getByText } = renderComponent({ setReps: setRepsMock });
fireEvent.press(getByText('-'));
expect(setRepsMock).toHaveBeenCalledWith(4);
});
test('does not decrement reps below 0', () => {
const setRepsMock = jest.fn();
const { getByText } = renderComponent({ reps: 0, setReps: setRepsMock });
fireEvent.press(getByText('-'));
expect(setRepsMock).not.toHaveBeenCalled();
});
test('displays the reps correctly after increment and decrement', () => {
let repsValue = 5;
const setRepsMock = jest.fn((newReps) => {
repsValue = newReps;
});
const { getByText, rerender } = renderComponent({ reps: repsValue, setReps: setRepsMock });
fireEvent.press(getByText('+'));
rerender(<NumberSelector reps={repsValue} setReps={setRepsMock} />);
expect(getByText('6')).toBeTruthy();
fireEvent.press(getByText('-'));
rerender(<NumberSelector reps={repsValue} setReps={setRepsMock} />);
expect(getByText('5')).toBeTruthy();
});
});

View File

@ -0,0 +1,40 @@
import React from 'react';
import { render } from '@/components/testUtils';
import { HorizontalSpacer, VerticalSpacer } from '@/components/shared/Spacers';
import { theme } from '@/app/shared/theme';
describe('[Component] Spacers', () => {
test('renders Vertical correctly', () => {
const component = render(
<VerticalSpacer heightUnits={3} testID="vertical-spacer" />
);
expect(component).toMatchSnapshot();
});
test('renders HorizontalSpacer correctly', () => {
const component = render(
<HorizontalSpacer widthUnits={3} testID="horizontal-spacer" />
);
expect(component).toMatchSnapshot();
});
test('applies correct height for VerticalSpacer based on heightUnits', () => {
const { getByTestId } = render(
<VerticalSpacer heightUnits={3} testID="vertical-spacer" />
);
const spacer = getByTestId('vertical-spacer');
expect(spacer).toHaveStyle({ height: theme.layout.gridUnit * 3 });
});
test('applies correct width for HorizontalSpacer based on widthUnits', () => {
const { getByTestId } = render(
<HorizontalSpacer widthUnits={4} testID="horizontal-spacer" />
);
const spacer = getByTestId('horizontal-spacer');
expect(spacer).toHaveStyle({ width: theme.layout.gridUnit * 4 });
});
});

View File

@ -0,0 +1,202 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`[Component] Button Disabled state renders correctly 1`] = `
<View
accessibilityState={
{
"busy": undefined,
"checked": undefined,
"disabled": true,
"expanded": undefined,
"selected": undefined,
}
}
accessibilityValue={
{
"max": undefined,
"min": undefined,
"now": undefined,
"text": undefined,
}
}
accessible={true}
collapsable={false}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
{
"alignItems": "center",
"backgroundColor": "#FFFFFF",
"borderColor": "#FFFFFF",
"borderRadius": 6,
"borderWidth": 0,
"flexDirection": "row",
"height": 48,
"justifyContent": "center",
"opacity": 1,
"paddingHorizontal": 16,
}
}
>
<Text
color="black"
style={
[
{
"color": "#000",
},
[
{
"color": "#000000",
"fontSize": 16,
"fontWeight": "semibold",
},
undefined,
],
]
}
>
Press me
</Text>
</View>
`;
exports[`[Component] Button Loading state renders correctly 1`] = `
<View
accessibilityState={
{
"busy": undefined,
"checked": undefined,
"disabled": true,
"expanded": undefined,
"selected": undefined,
}
}
accessibilityValue={
{
"max": undefined,
"min": undefined,
"now": undefined,
"text": undefined,
}
}
accessible={true}
collapsable={false}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
{
"alignItems": "center",
"backgroundColor": "#FFFFFF",
"borderColor": "#FFFFFF",
"borderRadius": 6,
"borderWidth": 0,
"flexDirection": "row",
"height": 48,
"justifyContent": "center",
"opacity": 1,
"paddingHorizontal": 16,
}
}
>
<Text
color="black"
style={
[
{
"color": "#000",
},
[
{
"color": "#000000",
"fontSize": 16,
"fontWeight": "semibold",
},
undefined,
],
]
}
>
Press me
</Text>
</View>
`;
exports[`[Component] Button Ready state renders correctly 1`] = `
<View
accessibilityState={
{
"busy": undefined,
"checked": undefined,
"disabled": false,
"expanded": undefined,
"selected": undefined,
}
}
accessibilityValue={
{
"max": undefined,
"min": undefined,
"now": undefined,
"text": undefined,
}
}
accessible={true}
collapsable={false}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
{
"alignItems": "center",
"backgroundColor": "#FFFFFF",
"borderColor": "#FFFFFF",
"borderRadius": 6,
"borderWidth": 0,
"flexDirection": "row",
"height": 48,
"justifyContent": "center",
"opacity": 1,
"paddingHorizontal": 16,
}
}
>
<Text
color="black"
style={
[
{
"color": "#000",
},
[
{
"color": "#000000",
"fontSize": 16,
"fontWeight": "semibold",
},
undefined,
],
]
}
>
Press me
</Text>
</View>
`;

View File

@ -0,0 +1,24 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`[Component] Card renders correctly 1`] = `
<View
bgColor="red"
style={
[
{
"backgroundColor": "#FF4141",
"borderRadius": 10,
"overflow": "hidden",
"padding": 30,
"width": "100%",
},
undefined,
]
}
testID="card"
>
<Text>
content
</Text>
</View>
`;

View File

@ -0,0 +1,107 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`[Component] NumberSelect renders correctly 1`] = `
<View
style={
[
{
"backgroundColor": "#fff",
},
[
{
"backgroundColor": "black",
"flexDirection": "row",
},
undefined,
],
]
}
>
<Text
onPress={[Function]}
style={
[
{
"color": "#000",
},
[
{
"color": "#FFFFFF",
"fontSize": 20,
"fontWeight": "bold",
"textAlign": "center",
},
undefined,
],
]
}
>
-
</Text>
<View
pointerEvents="none"
style={
[
{
"width": 20,
},
undefined,
]
}
widthUnits={5}
/>
<Text
style={
[
{
"color": "#000",
},
[
{
"color": "#FFFFFF",
"fontSize": 20,
"fontWeight": "bold",
"textAlign": "center",
},
undefined,
],
]
}
>
5
</Text>
<View
pointerEvents="none"
style={
[
{
"width": 20,
},
undefined,
]
}
widthUnits={5}
/>
<Text
onPress={[Function]}
style={
[
{
"color": "#000",
},
[
{
"color": "#FFFFFF",
"fontSize": 20,
"fontWeight": "bold",
"textAlign": "center",
},
undefined,
],
]
}
>
+
</Text>
</View>
`;

View File

@ -0,0 +1,33 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`[Component] Spacers renders HorizontalSpacer correctly 1`] = `
<View
pointerEvents="none"
style={
[
{
"width": 12,
},
undefined,
]
}
testID="horizontal-spacer"
widthUnits={3}
/>
`;
exports[`[Component] Spacers renders Vertical correctly 1`] = `
<View
heightUnits={3}
pointerEvents="none"
style={
[
{
"height": 12,
},
undefined,
]
}
testID="vertical-spacer"
/>
`;

View File

@ -0,0 +1,26 @@
import { formatTime } from '../timeHelpers';
describe('formatTime', () => {
test('should format 0 seconds as "00:00"', () => {
expect(formatTime(0)).toBe('00:00');
});
test('should format seconds less than a minute correctly', () => {
expect(formatTime(45)).toBe('00:45');
expect(formatTime(9)).toBe('00:09');
});
test('should format exactly one minute correctly', () => {
expect(formatTime(60)).toBe('01:00');
});
test('should format minutes and seconds correctly', () => {
expect(formatTime(75)).toBe('01:15');
expect(formatTime(125)).toBe('02:05');
});
test('should format larger time values correctly', () => {
expect(formatTime(3600)).toBe('60:00');
expect(formatTime(3661)).toBe('61:01');
});
});

View File

@ -0,0 +1,8 @@
export function formatTime(seconds: number): string {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
const formattedSeconds = remainingSeconds < 10 ? `0${remainingSeconds}` : remainingSeconds;
const formattedMinutes = minutes < 10 ? `0${minutes}` : minutes;
return `${formattedMinutes}:${formattedSeconds}`;
};

20
components/testUtils.tsx Normal file
View File

@ -0,0 +1,20 @@
import React from 'react';
import { ThemeProvider } from '@emotion/react';
import { render as rntlRender, RenderAPI } from '@testing-library/react-native';
import { theme } from '@/app/shared/theme/index';
export const render = (
component: React.ReactElement<unknown>
): RenderAPI => {
const TestProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
return (
<ThemeProvider theme={theme}>
{children}
</ThemeProvider>
)
};
return rntlRender(component, { wrapper: TestProvider });
};

View File

@ -0,0 +1 @@
export type TimerBgColor = 'red' | 'green' | 'black';

View File

@ -0,0 +1,47 @@
import { Text, View } from '@/components/shared/Themed';
import styled from '@emotion/native';
import { useNavigation } from 'expo-router';
import { NativeStackNavigationProp } from 'react-native-screens/lib/typescript/native-stack/types';
import Button from '@/components/shared/Button';
import { HorizontalSpacer, VerticalSpacer } from '@/components/shared/Spacers';
import { RootStackParamList } from '@/app/RootStackParamList';
type FinishContentProps = {
handleStart: () => void;
};
type NavigationProp = NativeStackNavigationProp<RootStackParamList, 'dashboard'>;
export default function FinishContent({
handleStart
}: FinishContentProps) {
const navigation = useNavigation<NavigationProp>();
return (
<>
<Time>FIN</Time>
<VerticalSpacer heightUnits={8} />
<Row>
<Button label="Recommencer" onPress={handleStart} />
<HorizontalSpacer widthUnits={3} />
<Button label="Retour" onPress={() => navigation.navigate('dashboard')} />
</Row>
</>
);
}
const Row = styled(View)(() => ({
flexDirection: 'row',
backgroundColor: 'black'
}));
const Time = styled(Text)(({ theme }) => ({
fontSize: 100,
fontWeight: 'bold',
color: theme.colors.white
}));

View File

@ -0,0 +1,87 @@
import { Text, View } from '@/components/shared/Themed';
import styled from '@emotion/native';
import { formatTime } from '@/components/shared/business/timeHelpers';
import Button from '@/components/shared/Button';
import { VerticalSpacer } from '@/components/shared/Spacers';
import { TimerBgColor } from '@/components/useCases/timer/business/type';
import { useNavigation } from 'expo-router';
import { NativeStackNavigationProp } from 'react-native-screens/lib/typescript/native-stack/types';
import { RootStackParamList } from '@/app/RootStackParamList';
interface TimerContentProps {
isWorkPhase: boolean;
timeLeft: number;
reps: number;
currentRep: number;
isRunning: boolean;
handleStop: () => void;
handleContine: () => void;
bgColor: TimerBgColor;
}
type NavigationProp = NativeStackNavigationProp<RootStackParamList, 'dashboard'>;
export default function TimerContent({
isWorkPhase,
timeLeft,
reps,
currentRep,
isRunning,
handleStop,
handleContine,
bgColor,
}: TimerContentProps ) {
const navigation = useNavigation<NavigationProp>();
return (
<>
<Title>{isWorkPhase ? 'Fight' : 'Repos'}</Title>
<VerticalSpacer heightUnits={8} />
<Time>
{formatTime(timeLeft)}
</Time>
<VerticalSpacer heightUnits={8} />
<Reps>{currentRep} / {reps}</Reps>
<VerticalSpacer heightUnits={8} />
<ButtonContainer bgColor={bgColor}>
{isRunning ? (
<Button label="Stop" onPress={handleStop} />
) : (
<Button label="Start" onPress={handleContine} />
)}
<VerticalSpacer heightUnits={3} />
<Button label="Retour" onPress={() => navigation.navigate('dashboard')} />
</ButtonContainer>
</>
);
}
const ButtonContainer = styled(View)<{ bgColor: TimerBgColor }>(({ theme, bgColor }) => ({
backgroundColor: theme.colors[bgColor]
}));
const Title = styled(Text)(({ theme }) => ({
fontSize: 50,
fontWeight: 'bold',
color: theme.colors.white
}));
const Time = styled(Text)(({ theme }) => ({
fontSize: 100,
fontWeight: 'bold',
color: theme.colors.white
}));
const Reps = styled(Text)(({ theme }) => ({
fontSize: 30,
fontWeight: 'bold',
color: theme.colors.white
}));

View File

@ -1,4 +0,0 @@
// This function is web-only as native doesn't currently support server (or build-time) rendering.
export function useClientOnlyValue<S, C>(server: S, client: C): S | C {
return client;
}

View File

@ -1,12 +0,0 @@
import React from 'react';
// `useEffect` is not invoked during server rendering, meaning
// we can use this to determine if we're on the server or not.
export function useClientOnlyValue<S, C>(server: S, client: C): S | C {
const [value, setValue] = React.useState<S | C>(server);
React.useEffect(() => {
setValue(client);
}, [client]);
return value;
}

View File

@ -1,8 +0,0 @@
// NOTE: The default React Native styling doesn't support server rendering.
// Server rendered styles should not change between the first render of the HTML
// and the first render on the client. Typically, web developers will use CSS media queries
// to render different styles on the client and server, these aren't directly supported in React Native
// but can be achieved using a styling library like Nativewind.
export function useColorScheme() {
return 'light';
}

1
jest.setup.js Normal file
View File

@ -0,0 +1 @@
import '@testing-library/jest-native/extend-expect';

2961
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -7,15 +7,29 @@
"android": "expo start --android", "android": "expo start --android",
"ios": "expo start --ios", "ios": "expo start --ios",
"web": "expo start --web", "web": "expo start --web",
"test": "jest --watchAll" "test:watch": "jest --watchAll",
"test": "jest",
"types": "tsc --noEmit",
"lint": "eslint --ext .ts,.tsx .",
"lint:fix": "eslint --fix --ext .ts,.tsx .",
"prepare": "husky"
}, },
"jest": { "jest": {
"preset": "jest-expo" "preset": "jest-expo",
"setupFilesAfterEnv": [
"<rootDir>/jest.setup.js"
]
},
"overrides": {
"babel-plugin-react-compiler": "0.0.0-experimental-734b737-20241003"
}, },
"overrides": { "babel-plugin-react-compiler": "0.0.0-experimental-734b737-20241003" },
"dependencies": { "dependencies": {
"@emotion/native": "^11.11.0",
"@emotion/react": "^11.13.3",
"@expo/vector-icons": "^14.0.2", "@expo/vector-icons": "^14.0.2",
"@react-native-picker/picker": "^2.8.1",
"@react-navigation/native": "^6.0.2", "@react-navigation/native": "^6.0.2",
"@testing-library/react-native": "^12.7.2",
"expo": "~51.0.28", "expo": "~51.0.28",
"expo-font": "~12.0.9", "expo-font": "~12.0.9",
"expo-linking": "~6.3.1", "expo-linking": "~6.3.1",
@ -27,14 +41,27 @@
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-native": "0.74.5", "react-native": "0.74.5",
"react-native-linear-gradient": "^2.8.3",
"react-native-reanimated": "~3.10.1", "react-native-reanimated": "~3.10.1",
"react-native-safe-area-context": "4.10.5", "react-native-safe-area-context": "4.10.5",
"react-native-screens": "3.31.1", "react-native-screens": "3.31.1",
"react-native-sound": "^0.11.2",
"react-native-timer-picker": "^1.10.3",
"react-native-web": "~0.19.10" "react-native-web": "~0.19.10"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.20.0", "@babel/core": "^7.20.0",
"@testing-library/jest-native": "^5.4.3",
"@types/react": "~18.2.45", "@types/react": "~18.2.45",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
"eslint": "^8.57.1",
"eslint-config-standard": "^17.1.0",
"eslint-plugin-jest": "^28.8.3",
"eslint-plugin-react": "^7.37.1",
"eslint-plugin-react-native": "^4.1.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
"husky": "^9.1.6",
"jest": "^29.2.1", "jest": "^29.2.1",
"jest-expo": "~51.0.3", "jest-expo": "~51.0.3",
"react-test-renderer": "18.2.0", "react-test-renderer": "18.2.0",