convert connection manager to context

This commit is contained in:
Arnaud Vergnet 2021-05-23 14:14:20 +02:00
parent 44aa52b3aa
commit 541c002558
24 changed files with 1610 additions and 1965 deletions

25
App.tsx
View file

@ -21,7 +21,6 @@ import React from 'react';
import { LogBox, Platform } from 'react-native';
import { setSafeBounceHeight } from 'react-navigation-collapsible';
import SplashScreen from 'react-native-splash-screen';
import ConnectionManager from './src/managers/ConnectionManager';
import type { ParsedUrlDataType } from './src/utils/URLHandler';
import URLHandler from './src/utils/URLHandler';
import initLocales from './src/utils/Locales';
@ -48,6 +47,8 @@ import {
ProxiwashPreferencesProvider,
} from './src/components/providers/PreferencesProvider';
import MainApp from './src/screens/MainApp';
import LoginProvider from './src/components/providers/LoginProvider';
import { retrieveLoginToken } from './src/utils/loginToken';
// Native optimizations https://reactnavigation.org/docs/react-native-screens
// Crashes app when navigating away from webview on android 9+
@ -67,6 +68,7 @@ type StateType = {
proxiwash: ProxiwashPreferencesType;
mascot: MascotPreferencesType;
};
loginToken?: string;
};
export default class App extends React.Component<{}, StateType> {
@ -88,6 +90,7 @@ export default class App extends React.Component<{}, StateType> {
proxiwash: defaultProxiwashPreferences,
mascot: defaultMascotPreferences,
},
loginToken: undefined,
};
initLocales();
this.navigatorRef = React.createRef();
@ -136,10 +139,11 @@ export default class App extends React.Component<{}, StateType> {
| PlanexPreferencesType
| ProxiwashPreferencesType
| MascotPreferencesType
| void
| string
| undefined
>
) => {
const [general, planex, proxiwash, mascot] = values;
const [general, planex, proxiwash, mascot, token] = values;
this.setState({
isLoading: false,
initialPreferences: {
@ -148,6 +152,7 @@ export default class App extends React.Component<{}, StateType> {
proxiwash: proxiwash as ProxiwashPreferencesType,
mascot: mascot as MascotPreferencesType,
},
loginToken: token as string | undefined,
});
SplashScreen.hide();
};
@ -175,7 +180,7 @@ export default class App extends React.Component<{}, StateType> {
Object.values(MascotPreferenceKeys),
defaultMascotPreferences
),
ConnectionManager.getInstance().recoverLogin(),
retrieveLoginToken(),
])
.then(this.onLoadFinished)
.catch(this.onLoadFinished);
@ -202,11 +207,13 @@ export default class App extends React.Component<{}, StateType> {
<MascotPreferencesProvider
initialPreferences={this.state.initialPreferences.mascot}
>
<MainApp
ref={this.navigatorRef}
defaultHomeData={this.defaultHomeData}
defaultHomeRoute={this.defaultHomeRoute}
/>
<LoginProvider initialToken={this.state.loginToken}>
<MainApp
ref={this.navigatorRef}
defaultHomeData={this.defaultHomeData}
defaultHomeRoute={this.defaultHomeRoute}
/>
</LoginProvider>
</MascotPreferencesProvider>
</ProxiwashPreferencesProvider>
</PlanexPreferencesProvider>

View file

@ -0,0 +1,231 @@
import React, { useRef, useState } from 'react';
import {
Image,
StyleSheet,
View,
TextInput as RNTextInput,
} from 'react-native';
import {
Button,
Card,
HelperText,
TextInput,
useTheme,
} from 'react-native-paper';
import i18n from 'i18n-js';
import GENERAL_STYLES from '../../../constants/Styles';
type Props = {
loading: boolean;
onSubmit: (email: string, password: string) => void;
onHelpPress: () => void;
onResetPasswordPress: () => void;
};
const ICON_AMICALE = require('../../../assets/amicale.png');
const styles = StyleSheet.create({
card: {
marginTop: 'auto',
marginBottom: 'auto',
},
header: {
fontSize: 36,
marginBottom: 48,
},
text: {
color: '#ffffff',
},
buttonContainer: {
flexWrap: 'wrap',
},
lockButton: {
marginRight: 'auto',
marginBottom: 20,
},
sendButton: {
marginLeft: 'auto',
},
});
const emailRegex = /^.+@.+\..+$/;
/**
* Checks if the entered email is valid (matches the regex)
*
* @returns {boolean}
*/
function isEmailValid(email: string): boolean {
return emailRegex.test(email);
}
/**
* Checks if the user has entered a password
*
* @returns {boolean}
*/
function isPasswordValid(password: string): boolean {
return password !== '';
}
export default function LoginForm(props: Props) {
const theme = useTheme();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isEmailValidated, setIsEmailValidated] = useState(false);
const [isPasswordValidated, setIsPasswordValidated] = useState(false);
const passwordRef = useRef<RNTextInput>(null);
/**
* Checks if we should tell the user his email is invalid.
* We should only show this if his email is invalid and has been checked when un-focusing the input
*
* @returns {boolean|boolean}
*/
const shouldShowEmailError = () => {
return isEmailValidated && !isEmailValid(email);
};
/**
* Checks if we should tell the user his password is invalid.
* We should only show this if his password is invalid and has been checked when un-focusing the input
*
* @returns {boolean|boolean}
*/
const shouldShowPasswordError = () => {
return isPasswordValidated && !isPasswordValid(password);
};
const onEmailSubmit = () => {
if (passwordRef.current) {
passwordRef.current.focus();
}
};
/**
* The user has unfocused the input, his email is ready to be validated
*/
const validateEmail = () => setIsEmailValidated(true);
/**
* The user has unfocused the input, his password is ready to be validated
*/
const validatePassword = () => setIsPasswordValidated(true);
const onEmailChange = (value: string) => {
if (isEmailValidated) {
setIsEmailValidated(false);
}
setEmail(value);
};
const onPasswordChange = (value: string) => {
if (isPasswordValidated) {
setIsPasswordValidated(false);
}
setPassword(value);
};
const shouldEnableLogin = () => {
return isEmailValid(email) && isPasswordValid(password) && !props.loading;
};
const onSubmit = () => {
if (shouldEnableLogin()) {
props.onSubmit(email, password);
}
};
return (
<View style={styles.card}>
<Card.Title
title={i18n.t('screens.login.title')}
titleStyle={styles.text}
subtitle={i18n.t('screens.login.subtitle')}
subtitleStyle={styles.text}
left={({ size }) => (
<Image
source={ICON_AMICALE}
style={{
width: size,
height: size,
}}
/>
)}
/>
<Card.Content>
<View>
<TextInput
label={i18n.t('screens.login.email')}
mode={'outlined'}
value={email}
onChangeText={onEmailChange}
onBlur={validateEmail}
onSubmitEditing={onEmailSubmit}
error={shouldShowEmailError()}
textContentType={'emailAddress'}
autoCapitalize={'none'}
autoCompleteType={'email'}
autoCorrect={false}
keyboardType={'email-address'}
returnKeyType={'next'}
secureTextEntry={false}
/>
<HelperText type={'error'} visible={shouldShowEmailError()}>
{i18n.t('screens.login.emailError')}
</HelperText>
<TextInput
ref={passwordRef}
label={i18n.t('screens.login.password')}
mode={'outlined'}
value={password}
onChangeText={onPasswordChange}
onBlur={validatePassword}
onSubmitEditing={onSubmit}
error={shouldShowPasswordError()}
textContentType={'password'}
autoCapitalize={'none'}
autoCompleteType={'password'}
autoCorrect={false}
keyboardType={'default'}
returnKeyType={'done'}
secureTextEntry={true}
/>
<HelperText type={'error'} visible={shouldShowPasswordError()}>
{i18n.t('screens.login.passwordError')}
</HelperText>
</View>
<Card.Actions style={styles.buttonContainer}>
<Button
icon="lock-question"
mode="contained"
onPress={props.onResetPasswordPress}
color={theme.colors.warning}
style={styles.lockButton}
>
{i18n.t('screens.login.resetPassword')}
</Button>
<Button
icon="send"
mode="contained"
disabled={!shouldEnableLogin()}
loading={props.loading}
onPress={onSubmit}
style={styles.sendButton}
>
{i18n.t('screens.login.title')}
</Button>
</Card.Actions>
<Card.Actions>
<Button
icon="help-circle"
mode="contained"
onPress={props.onHelpPress}
style={GENERAL_STYLES.centerHorizontal}
>
{i18n.t('screens.login.mascotDialog.title')}
</Button>
</Card.Actions>
</Card.Content>
</View>
);
}

View file

@ -20,8 +20,7 @@
import * as React from 'react';
import i18n from 'i18n-js';
import LoadingConfirmDialog from '../Dialogs/LoadingConfirmDialog';
import ConnectionManager from '../../managers/ConnectionManager';
import { useNavigation } from '@react-navigation/native';
import { useLogout } from '../../utils/logout';
type PropsType = {
visible: boolean;
@ -29,19 +28,12 @@ type PropsType = {
};
function LogoutDialog(props: PropsType) {
const navigation = useNavigation();
const onLogout = useLogout();
// Use a loading dialog as it can take some time to update the context
const onClickAccept = async (): Promise<void> => {
return new Promise((resolve: () => void) => {
ConnectionManager.getInstance()
.disconnect()
.then(() => {
navigation.reset({
index: 0,
routes: [{ name: 'main' }],
});
props.onDismiss();
resolve();
});
onLogout();
resolve();
});
};

View file

@ -0,0 +1,100 @@
import React from 'react';
import { Card, Avatar, Divider, useTheme, List } from 'react-native-paper';
import i18n from 'i18n-js';
import { FlatList, StyleSheet } from 'react-native';
import { ProfileClubType } from '../../../screens/Amicale/ProfileScreen';
import { useNavigation } from '@react-navigation/core';
type Props = {
clubs?: Array<ProfileClubType>;
};
const styles = StyleSheet.create({
card: {
margin: 10,
},
icon: {
backgroundColor: 'transparent',
},
});
export default function ProfileClubCard(props: Props) {
const theme = useTheme();
const navigation = useNavigation();
const clubKeyExtractor = (item: ProfileClubType) => item.name;
const getClubListItem = ({ item }: { item: ProfileClubType }) => {
const onPress = () =>
navigation.navigate('club-information', { clubId: item.id });
let description = i18n.t('screens.profile.isMember');
let icon = (leftProps: {
color: string;
style: {
marginLeft: number;
marginRight: number;
marginVertical?: number;
};
}) => (
<List.Icon
color={leftProps.color}
style={leftProps.style}
icon="chevron-right"
/>
);
if (item.is_manager) {
description = i18n.t('screens.profile.isManager');
icon = (leftProps) => (
<List.Icon
style={leftProps.style}
icon="star"
color={theme.colors.primary}
/>
);
}
return (
<List.Item
title={item.name}
description={description}
left={icon}
onPress={onPress}
/>
);
};
function getClubList(list: Array<ProfileClubType> | undefined) {
if (!list) {
return null;
}
list.sort((a) => (a.is_manager ? -1 : 1));
return (
<FlatList
renderItem={getClubListItem}
keyExtractor={clubKeyExtractor}
data={list}
/>
);
}
return (
<Card style={styles.card}>
<Card.Title
title={i18n.t('screens.profile.clubs')}
subtitle={i18n.t('screens.profile.clubsSubtitle')}
left={(iconProps) => (
<Avatar.Icon
size={iconProps.size}
icon="account-group"
color={theme.colors.primary}
style={styles.icon}
/>
)}
/>
<Card.Content>
<Divider />
{getClubList(props.clubs)}
</Card.Content>
</Card>
);
}

View file

@ -0,0 +1,56 @@
import React from 'react';
import { Avatar, Card, List, useTheme } from 'react-native-paper';
import i18n from 'i18n-js';
import { StyleSheet } from 'react-native';
type Props = {
valid?: boolean;
};
const styles = StyleSheet.create({
card: {
margin: 10,
},
icon: {
backgroundColor: 'transparent',
},
});
export default function ProfileMembershipCard(props: Props) {
const theme = useTheme();
const state = props.valid === true;
return (
<Card style={styles.card}>
<Card.Title
title={i18n.t('screens.profile.membership')}
subtitle={i18n.t('screens.profile.membershipSubtitle')}
left={(iconProps) => (
<Avatar.Icon
size={iconProps.size}
icon="credit-card"
color={theme.colors.primary}
style={styles.icon}
/>
)}
/>
<Card.Content>
<List.Section>
<List.Item
title={
state
? i18n.t('screens.profile.membershipPayed')
: i18n.t('screens.profile.membershipNotPayed')
}
left={(leftProps) => (
<List.Icon
style={leftProps.style}
color={state ? theme.colors.success : theme.colors.danger}
icon={state ? 'check' : 'close'}
/>
)}
/>
</List.Section>
</Card.Content>
</Card>
);
}

View file

@ -0,0 +1,110 @@
import { useNavigation } from '@react-navigation/core';
import React from 'react';
import { StyleSheet } from 'react-native';
import {
Avatar,
Button,
Card,
Divider,
List,
useTheme,
} from 'react-native-paper';
import Urls from '../../../constants/Urls';
import { ProfileDataType } from '../../../screens/Amicale/ProfileScreen';
import i18n from 'i18n-js';
type Props = {
profile?: ProfileDataType;
};
const styles = StyleSheet.create({
card: {
margin: 10,
},
icon: {
backgroundColor: 'transparent',
},
editButton: {
marginLeft: 'auto',
},
mascot: {
width: 60,
},
title: {
marginLeft: 10,
},
});
function getFieldValue(field?: string): string {
return field ? field : i18n.t('screens.profile.noData');
}
export default function ProfilePersonalCard(props: Props) {
const { profile } = props;
const theme = useTheme();
const navigation = useNavigation();
function getPersonalListItem(field: string | undefined, icon: string) {
const title = field != null ? getFieldValue(field) : ':(';
const subtitle = field != null ? '' : getFieldValue(field);
return (
<List.Item
title={title}
description={subtitle}
left={(leftProps) => (
<List.Icon
style={leftProps.style}
icon={icon}
color={field != null ? leftProps.color : theme.colors.textDisabled}
/>
)}
/>
);
}
return (
<Card style={styles.card}>
<Card.Title
title={`${profile?.first_name} ${profile?.last_name}`}
subtitle={profile?.email}
left={(iconProps) => (
<Avatar.Icon
size={iconProps.size}
icon="account"
color={theme.colors.primary}
style={styles.icon}
/>
)}
/>
<Card.Content>
<Divider />
<List.Section>
<List.Subheader>
{i18n.t('screens.profile.personalInformation')}
</List.Subheader>
{getPersonalListItem(profile?.birthday, 'cake-variant')}
{getPersonalListItem(profile?.phone, 'phone')}
{getPersonalListItem(profile?.email, 'email')}
{getPersonalListItem(profile?.branch, 'school')}
</List.Section>
<Divider />
<Card.Actions>
<Button
icon="account-edit"
mode="contained"
onPress={() => {
navigation.navigate('website', {
host: Urls.websites.amicale,
path: profile?.link,
title: i18n.t('screens.websites.amicale'),
});
}}
style={styles.editButton}
>
{i18n.t('screens.profile.editInformation')}
</Button>
</Card.Actions>
</Card.Content>
</Card>
);
}

View file

@ -0,0 +1,81 @@
import { useNavigation } from '@react-navigation/core';
import React from 'react';
import { Button, Card, Divider, Paragraph } from 'react-native-paper';
import Mascot, { MASCOT_STYLE } from '../../Mascot/Mascot';
import i18n from 'i18n-js';
import { StyleSheet } from 'react-native';
import CardList from '../../Lists/CardList/CardList';
import { getAmicaleServices, SERVICES_KEY } from '../../../utils/Services';
type Props = {
firstname?: string;
};
const styles = StyleSheet.create({
card: {
margin: 10,
},
editButton: {
marginLeft: 'auto',
},
mascot: {
width: 60,
},
title: {
marginLeft: 10,
},
});
function ProfileWelcomeCard(props: Props) {
const navigation = useNavigation();
return (
<Card style={styles.card}>
<Card.Title
title={i18n.t('screens.profile.welcomeTitle', {
name: props.firstname,
})}
left={() => (
<Mascot
style={styles.mascot}
emotion={MASCOT_STYLE.COOL}
animated
entryAnimation={{
animation: 'bounceIn',
duration: 1000,
}}
/>
)}
titleStyle={styles.title}
/>
<Card.Content>
<Divider />
<Paragraph>{i18n.t('screens.profile.welcomeDescription')}</Paragraph>
<CardList
dataset={getAmicaleServices(navigation.navigate, [
SERVICES_KEY.PROFILE,
])}
isHorizontal={true}
/>
<Paragraph>{i18n.t('screens.profile.welcomeFeedback')}</Paragraph>
<Divider />
<Card.Actions>
<Button
icon="bug"
mode="contained"
onPress={() => {
navigation.navigate('feedback');
}}
style={styles.editButton}
>
{i18n.t('screens.feedback.homeButtonTitle')}
</Button>
</Card.Actions>
</Card.Content>
</Card>
);
}
export default React.memo(
ProfileWelcomeCard,
(pp, np) => pp.firstname === np.firstname
);

View file

@ -17,30 +17,23 @@
* along with Campus INSAT. If not, see <https://www.gnu.org/licenses/>.
*/
import * as React from 'react';
import React, { useState } from 'react';
import { Avatar, Button, Card, RadioButton } from 'react-native-paper';
import { FlatList, StyleSheet, View } from 'react-native';
import i18n from 'i18n-js';
import ConnectionManager from '../../../managers/ConnectionManager';
import LoadingConfirmDialog from '../../Dialogs/LoadingConfirmDialog';
import ErrorDialog from '../../Dialogs/ErrorDialog';
import type { VoteTeamType } from '../../../screens/Amicale/VoteScreen';
import { ApiRejectType } from '../../../utils/WebData';
import { REQUEST_STATUS } from '../../../utils/Requests';
import { useAuthenticatedRequest } from '../../../context/loginContext';
type PropsType = {
type Props = {
teams: Array<VoteTeamType>;
onVoteSuccess: () => void;
onVoteError: () => void;
};
type StateType = {
selectedTeam: string;
voteDialogVisible: boolean;
errorDialogVisible: boolean;
currentError: ApiRejectType;
};
const styles = StyleSheet.create({
card: {
margin: 10,
@ -50,118 +43,98 @@ const styles = StyleSheet.create({
},
});
export default class VoteSelect extends React.PureComponent<
PropsType,
StateType
> {
constructor(props: PropsType) {
super(props);
this.state = {
selectedTeam: 'none',
voteDialogVisible: false,
errorDialogVisible: false,
currentError: { status: REQUEST_STATUS.SUCCESS },
};
}
function VoteSelect(props: Props) {
const [selectedTeam, setSelectedTeam] = useState('none');
const [voteDialogVisible, setVoteDialogVisible] = useState(false);
const [currentError, setCurrentError] = useState<ApiRejectType>({
status: REQUEST_STATUS.SUCCESS,
});
const request = useAuthenticatedRequest('elections/vote', {
team: parseInt(selectedTeam, 10),
});
onVoteSelectionChange = (teamName: string): void =>
this.setState({ selectedTeam: teamName });
const voteKeyExtractor = (item: VoteTeamType) => item.id.toString();
voteKeyExtractor = (item: VoteTeamType): string => item.id.toString();
voteRenderItem = ({ item }: { item: VoteTeamType }) => (
const voteRenderItem = ({ item }: { item: VoteTeamType }) => (
<RadioButton.Item label={item.name} value={item.id.toString()} />
);
showVoteDialog = (): void => this.setState({ voteDialogVisible: true });
const showVoteDialog = () => setVoteDialogVisible(true);
onVoteDialogDismiss = (): void => this.setState({ voteDialogVisible: false });
const onVoteDialogDismiss = () => setVoteDialogVisible(false);
onVoteDialogAccept = async (): Promise<void> => {
const onVoteDialogAccept = async (): Promise<void> => {
return new Promise((resolve: () => void) => {
const { state } = this;
ConnectionManager.getInstance()
.authenticatedRequest('elections/vote', {
team: parseInt(state.selectedTeam, 10),
})
request()
.then(() => {
this.onVoteDialogDismiss();
const { props } = this;
onVoteDialogDismiss();
props.onVoteSuccess();
resolve();
})
.catch((error: ApiRejectType) => {
this.onVoteDialogDismiss();
this.showErrorDialog(error);
onVoteDialogDismiss();
setCurrentError(error);
resolve();
});
});
};
showErrorDialog = (error: ApiRejectType): void =>
this.setState({
errorDialogVisible: true,
currentError: error,
});
onErrorDialogDismiss = () => {
this.setState({ errorDialogVisible: false });
const { props } = this;
const onErrorDialogDismiss = () => {
setCurrentError({ status: REQUEST_STATUS.SUCCESS });
props.onVoteError();
};
render() {
const { state, props } = this;
return (
<View>
<Card style={styles.card}>
<Card.Title
title={i18n.t('screens.vote.select.title')}
subtitle={i18n.t('screens.vote.select.subtitle')}
left={(iconProps) => (
<Avatar.Icon size={iconProps.size} icon="alert-decagram" />
)}
/>
<Card.Content>
<RadioButton.Group
onValueChange={this.onVoteSelectionChange}
value={state.selectedTeam}
>
<FlatList
data={props.teams}
keyExtractor={this.voteKeyExtractor}
extraData={state.selectedTeam}
renderItem={this.voteRenderItem}
/>
</RadioButton.Group>
</Card.Content>
<Card.Actions>
<Button
icon="send"
mode="contained"
onPress={this.showVoteDialog}
style={styles.button}
disabled={state.selectedTeam === 'none'}
>
{i18n.t('screens.vote.select.sendButton')}
</Button>
</Card.Actions>
</Card>
<LoadingConfirmDialog
visible={state.voteDialogVisible}
onDismiss={this.onVoteDialogDismiss}
onAccept={this.onVoteDialogAccept}
title={i18n.t('screens.vote.select.dialogTitle')}
titleLoading={i18n.t('screens.vote.select.dialogTitleLoading')}
message={i18n.t('screens.vote.select.dialogMessage')}
return (
<View>
<Card style={styles.card}>
<Card.Title
title={i18n.t('screens.vote.select.title')}
subtitle={i18n.t('screens.vote.select.subtitle')}
left={(iconProps) => (
<Avatar.Icon size={iconProps.size} icon="alert-decagram" />
)}
/>
<ErrorDialog
visible={state.errorDialogVisible}
onDismiss={this.onErrorDialogDismiss}
status={state.currentError.status}
code={state.currentError.code}
/>
</View>
);
}
<Card.Content>
<RadioButton.Group
onValueChange={setSelectedTeam}
value={selectedTeam}
>
<FlatList
data={props.teams}
keyExtractor={voteKeyExtractor}
extraData={selectedTeam}
renderItem={voteRenderItem}
/>
</RadioButton.Group>
</Card.Content>
<Card.Actions>
<Button
icon={'send'}
mode={'contained'}
onPress={showVoteDialog}
style={styles.button}
disabled={selectedTeam === 'none'}
>
{i18n.t('screens.vote.select.sendButton')}
</Button>
</Card.Actions>
</Card>
<LoadingConfirmDialog
visible={voteDialogVisible}
onDismiss={onVoteDialogDismiss}
onAccept={onVoteDialogAccept}
title={i18n.t('screens.vote.select.dialogTitle')}
titleLoading={i18n.t('screens.vote.select.dialogTitleLoading')}
message={i18n.t('screens.vote.select.dialogMessage')}
/>
<ErrorDialog
visible={currentError.status !== REQUEST_STATUS.SUCCESS}
onDismiss={onErrorDialogDismiss}
status={currentError.status}
code={currentError.code}
/>
</View>
);
}
export default VoteSelect;

View file

@ -20,7 +20,6 @@
import * as React from 'react';
import { Avatar, List, useTheme } from 'react-native-paper';
import i18n from 'i18n-js';
import { StackNavigationProp } from '@react-navigation/stack';
import type { DeviceType } from '../../../screens/Amicale/Equipment/EquipmentListScreen';
import {
getFirstEquipmentAvailability,
@ -29,9 +28,9 @@ import {
} from '../../../utils/EquipmentBooking';
import { StyleSheet } from 'react-native';
import GENERAL_STYLES from '../../../constants/Styles';
import { useNavigation } from '@react-navigation/native';
type PropsType = {
navigation: StackNavigationProp<any>;
userDeviceRentDates: [string, string] | null;
item: DeviceType;
height: number;
@ -48,7 +47,8 @@ const styles = StyleSheet.create({
function EquipmentListItem(props: PropsType) {
const theme = useTheme();
const { item, userDeviceRentDates, navigation, height } = props;
const navigation = useNavigation();
const { item, userDeviceRentDates, height } = props;
const isRented = userDeviceRentDates != null;
const isAvailable = isEquipmentAvailable(item);
const firstAvailability = getFirstEquipmentAvailability(item);

View file

@ -11,7 +11,7 @@ import i18n from 'i18n-js';
import { API_REQUEST_CODES, REQUEST_STATUS } from '../../utils/Requests';
import { StackNavigationProp } from '@react-navigation/stack';
import { MainRoutes } from '../../navigation/MainNavigator';
import ConnectionManager from '../../managers/ConnectionManager';
import { useLogout } from '../../utils/logout';
export type RequestScreenProps<T> = {
request: () => Promise<T>;
@ -44,6 +44,7 @@ type Props<T> = RequestScreenProps<T>;
const MIN_REFRESH_TIME = 3 * 1000;
export default function RequestScreen<T>(props: Props<T>) {
const onLogout = useLogout();
const navigation = useNavigation<StackNavigationProp<any>>();
const route = useRoute();
const refreshInterval = useRef<number>();
@ -103,13 +104,10 @@ export default function RequestScreen<T>(props: Props<T>) {
useEffect(() => {
if (isErrorCritical(code)) {
ConnectionManager.getInstance()
.disconnect()
.then(() => {
navigation.replace(MainRoutes.Login, { nextScreen: route.name });
});
onLogout();
navigation.replace(MainRoutes.Login, { nextScreen: route.name });
}
}, [code, navigation, route]);
}, [code, navigation, route, onLogout]);
if (data === undefined && loading && props.showLoading !== false) {
return <BasicLoadingScreen />;

View file

@ -0,0 +1,27 @@
import React, { useState } from 'react';
import { LoginContext, LoginContextType } from '../../context/loginContext';
type Props = {
children: React.ReactChild;
initialToken: string | undefined;
};
export default function LoginProvider(props: Props) {
const setLogin = (token: string | undefined) => {
setLoginState((prevState) => ({
...prevState,
token,
}));
};
const [loginState, setLoginState] = useState<LoginContextType>({
token: props.initialToken,
setLogin: setLogin,
});
return (
<LoginContext.Provider value={loginState}>
{props.children}
</LoginContext.Provider>
);
}

View file

@ -0,0 +1,46 @@
import React, { useContext } from 'react';
import { apiRequest } from '../utils/WebData';
export type LoginContextType = {
token: string | undefined;
setLogin: (token: string | undefined) => void;
};
export const LoginContext = React.createContext<LoginContextType>({
token: undefined,
setLogin: () => undefined,
});
/**
* Hook used to retrieve the user token and puid.
* @returns Login context with token and puid to undefined if user is not logged in
*/
export function useLogin() {
return useContext(LoginContext);
}
/**
* Checks if the user is connected
* @returns True if the user is connected
*/
export function useLoginState() {
const { token } = useLogin();
return token !== undefined;
}
/**
* Gets the current user token.
* @returns The token, or empty string if the user is not logged in
*/
export function useLoginToken() {
const { token } = useLogin();
return token ? token : '';
}
export function useAuthenticatedRequest<T>(
path: string,
params?: { [key: string]: any }
) {
const token = useLoginToken();
return () => apiRequest<T>(path, 'POST', params, token);
}

View file

@ -1,205 +0,0 @@
/*
* Copyright (c) 2019 - 2020 Arnaud Vergnet.
*
* This file is part of Campus INSAT.
*
* Campus INSAT is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Campus INSAT is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Campus INSAT. If not, see <https://www.gnu.org/licenses/>.
*/
import * as Keychain from 'react-native-keychain';
import { REQUEST_STATUS } from '../utils/Requests';
import type { ApiDataLoginType, ApiRejectType } from '../utils/WebData';
import { apiRequest } from '../utils/WebData';
/**
* champ: error
*
* 0 : SUCCESS -> pas d'erreurs
* 1 : BAD_CREDENTIALS -> email ou mdp invalide
* 2 : BAD_TOKEN -> session expirée
* 3 : NO_CONSENT
* 403 : FORBIDDEN -> accès a la ressource interdit
* 500 : SERVER_ERROR -> pb coté serveur
*/
const AUTH_PATH = 'password';
export default class ConnectionManager {
static instance: ConnectionManager | null = null;
private token: string | null;
constructor() {
this.token = null;
}
/**
* Gets this class instance or create one if none is found
*
* @returns {ConnectionManager}
*/
static getInstance(): ConnectionManager {
if (ConnectionManager.instance == null) {
ConnectionManager.instance = new ConnectionManager();
}
return ConnectionManager.instance;
}
/**
* Gets the current token
*
* @returns {string | null}
*/
getToken(): string | null {
return this.token;
}
/**
* Tries to recover login token from the secure keychain
*
* @returns Promise<void>
*/
async recoverLogin(): Promise<void> {
return new Promise((resolve: () => void) => {
const token = this.getToken();
if (token != null) {
resolve();
} else {
Keychain.getGenericPassword()
.then((data: Keychain.UserCredentials | false) => {
if (data && data.password != null) {
this.token = data.password;
}
resolve();
})
.catch(() => resolve());
}
});
}
/**
* Check if the user has a valid token
*
* @returns {boolean}
*/
isLoggedIn(): boolean {
return this.getToken() !== null;
}
/**
* Saves the login token in the secure keychain
*
* @param email
* @param token
* @returns Promise<void>
*/
async saveLogin(_email: string, token: string): Promise<void> {
return new Promise((resolve: () => void, reject: () => void) => {
Keychain.setGenericPassword('token', token)
.then(() => {
this.token = token;
resolve();
})
.catch((): void => reject());
});
}
/**
* Deletes the login token from the keychain
*
* @returns Promise<void>
*/
async disconnect(): Promise<void> {
return new Promise((resolve: () => void, reject: () => void) => {
Keychain.resetGenericPassword()
.then(() => {
this.token = null;
resolve();
})
.catch((): void => reject());
});
}
/**
* Sends the given login and password to the api.
* If the combination is valid, the login token is received and saved in the secure keychain.
* If not, the promise is rejected with the corresponding error code.
*
* @param email
* @param password
* @returns Promise<void>
*/
async connect(email: string, password: string): Promise<void> {
return new Promise(
(resolve: () => void, reject: (error: ApiRejectType) => void) => {
const data = {
email,
password,
};
apiRequest<ApiDataLoginType>(AUTH_PATH, 'POST', data)
.then((response: ApiDataLoginType) => {
if (response.token != null) {
this.saveLogin(email, response.token)
.then(() => resolve())
.catch(() =>
reject({
status: REQUEST_STATUS.TOKEN_SAVE,
})
);
} else {
reject({
status: REQUEST_STATUS.SERVER_ERROR,
});
}
})
.catch((err) => {
reject(err);
});
}
);
}
/**
* Sends an authenticated request with the login token to the API
*
* @param path
* @param params
* @returns Promise<ApiGenericDataType>
*/
async authenticatedRequest<T>(
path: string,
params?: { [key: string]: any }
): Promise<T> {
return new Promise(
(
resolve: (response: T) => void,
reject: (error: ApiRejectType) => void
) => {
if (this.getToken() !== null) {
const data = {
...params,
token: this.getToken(),
};
apiRequest<T>(path, 'POST', data)
.then((response: T) => resolve(response))
.catch(reject);
} else {
reject({
status: REQUEST_STATUS.TOKEN_RETRIEVE,
});
}
}
);
}
}

View file

@ -17,7 +17,7 @@
* along with Campus INSAT. If not, see <https://www.gnu.org/licenses/>.
*/
import * as React from 'react';
import React, { useState } from 'react';
import { Linking, StyleSheet, View } from 'react-native';
import {
Avatar,
@ -25,20 +25,21 @@ import {
Card,
Chip,
Paragraph,
withTheme,
useTheme,
} from 'react-native-paper';
import i18n from 'i18n-js';
import { StackNavigationProp } from '@react-navigation/stack';
import CustomHTML from '../../../components/Overrides/CustomHTML';
import { TAB_BAR_HEIGHT } from '../../../components/Tabbar/CustomTabBar';
import type { ClubCategoryType, ClubType } from './ClubListScreen';
import CollapsibleScrollView from '../../../components/Collapsible/CollapsibleScrollView';
import ImageGalleryButton from '../../../components/Media/ImageGalleryButton';
import RequestScreen from '../../../components/Screens/RequestScreen';
import ConnectionManager from '../../../managers/ConnectionManager';
import { useFocusEffect } from '@react-navigation/core';
import { useCallback } from 'react';
import { useNavigation } from '@react-navigation/native';
import { useAuthenticatedRequest } from '../../../context/loginContext';
type PropsType = {
navigation: StackNavigationProp<any>;
type Props = {
route: {
params?: {
data?: ClubType;
@ -46,7 +47,6 @@ type PropsType = {
clubId?: number;
};
};
theme: ReactNativePaper.Theme;
};
type ResponseType = ClubType;
@ -89,33 +89,28 @@ const styles = StyleSheet.create({
* If called with data and categories navigation parameters, will use those to display the data.
* If called with clubId parameter, will fetch the information on the server
*/
class ClubDisplayScreen extends React.Component<PropsType> {
displayData: ClubType | undefined;
function ClubDisplayScreen(props: Props) {
const navigation = useNavigation();
const theme = useTheme();
categories: Array<ClubCategoryType> | null;
const [displayData, setDisplayData] = useState<ClubType | undefined>();
const [categories, setCategories] = useState<
Array<ClubCategoryType> | undefined
>();
const [clubId, setClubId] = useState<number | undefined>();
clubId: number;
shouldFetchData: boolean;
constructor(props: PropsType) {
super(props);
this.displayData = undefined;
this.categories = null;
this.clubId = props.route.params?.clubId ? props.route.params.clubId : 0;
this.shouldFetchData = true;
if (
props.route.params &&
props.route.params.data &&
props.route.params.categories
) {
this.displayData = props.route.params.data;
this.categories = props.route.params.categories;
this.clubId = props.route.params.data.id;
this.shouldFetchData = false;
}
}
useFocusEffect(
useCallback(() => {
if (props.route.params?.data && props.route.params?.categories) {
setDisplayData(props.route.params.data);
setCategories(props.route.params.categories);
setClubId(props.route.params.data.id);
} else {
const id = props.route.params?.clubId;
setClubId(id ? id : 0);
}
}, [props.route.params])
);
/**
* Gets the name of the category with the given ID
@ -123,17 +118,17 @@ class ClubDisplayScreen extends React.Component<PropsType> {
* @param id The category's ID
* @returns {string|*}
*/
getCategoryName(id: number): string {
const getCategoryName = (id: number): string => {
let categoryName = '';
if (this.categories !== null) {
this.categories.forEach((item: ClubCategoryType) => {
if (categories) {
categories.forEach((item: ClubCategoryType) => {
if (id === item.id) {
categoryName = item.name;
}
});
}
return categoryName;
}
};
/**
* Gets the view for rendering categories
@ -141,23 +136,23 @@ class ClubDisplayScreen extends React.Component<PropsType> {
* @param categories The categories to display (max 2)
* @returns {null|*}
*/
getCategoriesRender(categories: Array<number | null>) {
if (this.categories == null) {
const getCategoriesRender = (c: Array<number | null>) => {
if (!categories) {
return null;
}
const final: Array<React.ReactNode> = [];