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

15
App.tsx
View file

@ -21,7 +21,6 @@ import React from 'react';
import { LogBox, Platform } from 'react-native'; import { LogBox, Platform } from 'react-native';
import { setSafeBounceHeight } from 'react-navigation-collapsible'; import { setSafeBounceHeight } from 'react-navigation-collapsible';
import SplashScreen from 'react-native-splash-screen'; import SplashScreen from 'react-native-splash-screen';
import ConnectionManager from './src/managers/ConnectionManager';
import type { ParsedUrlDataType } from './src/utils/URLHandler'; import type { ParsedUrlDataType } from './src/utils/URLHandler';
import URLHandler from './src/utils/URLHandler'; import URLHandler from './src/utils/URLHandler';
import initLocales from './src/utils/Locales'; import initLocales from './src/utils/Locales';
@ -48,6 +47,8 @@ import {
ProxiwashPreferencesProvider, ProxiwashPreferencesProvider,
} from './src/components/providers/PreferencesProvider'; } from './src/components/providers/PreferencesProvider';
import MainApp from './src/screens/MainApp'; 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 // Native optimizations https://reactnavigation.org/docs/react-native-screens
// Crashes app when navigating away from webview on android 9+ // Crashes app when navigating away from webview on android 9+
@ -67,6 +68,7 @@ type StateType = {
proxiwash: ProxiwashPreferencesType; proxiwash: ProxiwashPreferencesType;
mascot: MascotPreferencesType; mascot: MascotPreferencesType;
}; };
loginToken?: string;
}; };
export default class App extends React.Component<{}, StateType> { export default class App extends React.Component<{}, StateType> {
@ -88,6 +90,7 @@ export default class App extends React.Component<{}, StateType> {
proxiwash: defaultProxiwashPreferences, proxiwash: defaultProxiwashPreferences,
mascot: defaultMascotPreferences, mascot: defaultMascotPreferences,
}, },
loginToken: undefined,
}; };
initLocales(); initLocales();
this.navigatorRef = React.createRef(); this.navigatorRef = React.createRef();
@ -136,10 +139,11 @@ export default class App extends React.Component<{}, StateType> {
| PlanexPreferencesType | PlanexPreferencesType
| ProxiwashPreferencesType | ProxiwashPreferencesType
| MascotPreferencesType | MascotPreferencesType
| void | string
| undefined
> >
) => { ) => {
const [general, planex, proxiwash, mascot] = values; const [general, planex, proxiwash, mascot, token] = values;
this.setState({ this.setState({
isLoading: false, isLoading: false,
initialPreferences: { initialPreferences: {
@ -148,6 +152,7 @@ export default class App extends React.Component<{}, StateType> {
proxiwash: proxiwash as ProxiwashPreferencesType, proxiwash: proxiwash as ProxiwashPreferencesType,
mascot: mascot as MascotPreferencesType, mascot: mascot as MascotPreferencesType,
}, },
loginToken: token as string | undefined,
}); });
SplashScreen.hide(); SplashScreen.hide();
}; };
@ -175,7 +180,7 @@ export default class App extends React.Component<{}, StateType> {
Object.values(MascotPreferenceKeys), Object.values(MascotPreferenceKeys),
defaultMascotPreferences defaultMascotPreferences
), ),
ConnectionManager.getInstance().recoverLogin(), retrieveLoginToken(),
]) ])
.then(this.onLoadFinished) .then(this.onLoadFinished)
.catch(this.onLoadFinished); .catch(this.onLoadFinished);
@ -202,11 +207,13 @@ export default class App extends React.Component<{}, StateType> {
<MascotPreferencesProvider <MascotPreferencesProvider
initialPreferences={this.state.initialPreferences.mascot} initialPreferences={this.state.initialPreferences.mascot}
> >
<LoginProvider initialToken={this.state.loginToken}>
<MainApp <MainApp
ref={this.navigatorRef} ref={this.navigatorRef}
defaultHomeData={this.defaultHomeData} defaultHomeData={this.defaultHomeData}
defaultHomeRoute={this.defaultHomeRoute} defaultHomeRoute={this.defaultHomeRoute}
/> />
</LoginProvider>
</MascotPreferencesProvider> </MascotPreferencesProvider>
</ProxiwashPreferencesProvider> </ProxiwashPreferencesProvider>
</PlanexPreferencesProvider> </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 * as React from 'react';
import i18n from 'i18n-js'; import i18n from 'i18n-js';
import LoadingConfirmDialog from '../Dialogs/LoadingConfirmDialog'; import LoadingConfirmDialog from '../Dialogs/LoadingConfirmDialog';
import ConnectionManager from '../../managers/ConnectionManager'; import { useLogout } from '../../utils/logout';
import { useNavigation } from '@react-navigation/native';
type PropsType = { type PropsType = {
visible: boolean; visible: boolean;
@ -29,20 +28,13 @@ type PropsType = {
}; };
function LogoutDialog(props: 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> => { const onClickAccept = async (): Promise<void> => {
return new Promise((resolve: () => void) => { return new Promise((resolve: () => void) => {
ConnectionManager.getInstance() onLogout();
.disconnect()
.then(() => {
navigation.reset({
index: 0,
routes: [{ name: 'main' }],
});
props.onDismiss();
resolve(); resolve();
}); });
});
}; };
return ( return (

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

View file

@ -20,7 +20,6 @@
import * as React from 'react'; import * as React from 'react';
import { Avatar, List, useTheme } from 'react-native-paper'; import { Avatar, List, useTheme } from 'react-native-paper';
import i18n from 'i18n-js'; import i18n from 'i18n-js';
import { StackNavigationProp } from '@react-navigation/stack';
import type { DeviceType } from '../../../screens/Amicale/Equipment/EquipmentListScreen'; import type { DeviceType } from '../../../screens/Amicale/Equipment/EquipmentListScreen';
import { import {
getFirstEquipmentAvailability, getFirstEquipmentAvailability,
@ -29,9 +28,9 @@ import {
} from '../../../utils/EquipmentBooking'; } from '../../../utils/EquipmentBooking';
import { StyleSheet } from 'react-native'; import { StyleSheet } from 'react-native';
import GENERAL_STYLES from '../../../constants/Styles'; import GENERAL_STYLES from '../../../constants/Styles';
import { useNavigation } from '@react-navigation/native';
type PropsType = { type PropsType = {
navigation: StackNavigationProp<any>;
userDeviceRentDates: [string, string] | null; userDeviceRentDates: [string, string] | null;
item: DeviceType; item: DeviceType;
height: number; height: number;
@ -48,7 +47,8 @@ const styles = StyleSheet.create({
function EquipmentListItem(props: PropsType) { function EquipmentListItem(props: PropsType) {
const theme = useTheme(); const theme = useTheme();
const { item, userDeviceRentDates, navigation, height } = props; const navigation = useNavigation();
const { item, userDeviceRentDates, height } = props;
const isRented = userDeviceRentDates != null; const isRented = userDeviceRentDates != null;
const isAvailable = isEquipmentAvailable(item); const isAvailable = isEquipmentAvailable(item);
const firstAvailability = getFirstEquipmentAvailability(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 { API_REQUEST_CODES, REQUEST_STATUS } from '../../utils/Requests';
import { StackNavigationProp } from '@react-navigation/stack'; import { StackNavigationProp } from '@react-navigation/stack';
import { MainRoutes } from '../../navigation/MainNavigator'; import { MainRoutes } from '../../navigation/MainNavigator';
import ConnectionManager from '../../managers/ConnectionManager'; import { useLogout } from '../../utils/logout';
export type RequestScreenProps<T> = { export type RequestScreenProps<T> = {
request: () => Promise<T>; request: () => Promise<T>;
@ -44,6 +44,7 @@ type Props<T> = RequestScreenProps<T>;
const MIN_REFRESH_TIME = 3 * 1000; const MIN_REFRESH_TIME = 3 * 1000;
export default function RequestScreen<T>(props: Props<T>) { export default function RequestScreen<T>(props: Props<T>) {
const onLogout = useLogout();
const navigation = useNavigation<StackNavigationProp<any>>(); const navigation = useNavigation<StackNavigationProp<any>>();
const route = useRoute(); const route = useRoute();
const refreshInterval = useRef<number>(); const refreshInterval = useRef<number>();
@ -103,13 +104,10 @@ export default function RequestScreen<T>(props: Props<T>) {
useEffect(() => { useEffect(() => {
if (isErrorCritical(code)) { if (isErrorCritical(code)) {
ConnectionManager.getInstance() onLogout();
.disconnect()
.then(() => {
navigation.replace(MainRoutes.Login, { nextScreen: route.name }); navigation.replace(MainRoutes.Login, { nextScreen: route.name });
});
} }
}, [code, navigation, route]); }, [code, navigation, route, onLogout]);
if (data === undefined && loading && props.showLoading !== false) { if (data === undefined && loading && props.showLoading !== false) {
return <BasicLoadingScreen />; 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/>. * 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 { Linking, StyleSheet, View } from 'react-native';
import { import {
Avatar, Avatar,
@ -25,20 +25,21 @@ import {
Card, Card,
Chip, Chip,
Paragraph, Paragraph,
withTheme, useTheme,
} from 'react-native-paper'; } from 'react-native-paper';
import i18n from 'i18n-js'; import i18n from 'i18n-js';
import { StackNavigationProp } from '@react-navigation/stack';
import CustomHTML from '../../../components/Overrides/CustomHTML'; import CustomHTML from '../../../components/Overrides/CustomHTML';
import { TAB_BAR_HEIGHT } from '../../../components/Tabbar/CustomTabBar'; import { TAB_BAR_HEIGHT } from '../../../components/Tabbar/CustomTabBar';
import type { ClubCategoryType, ClubType } from './ClubListScreen'; import type { ClubCategoryType, ClubType } from './ClubListScreen';
import CollapsibleScrollView from '../../../components/Collapsible/CollapsibleScrollView'; import CollapsibleScrollView from '../../../components/Collapsible/CollapsibleScrollView';
import ImageGalleryButton from '../../../components/Media/ImageGalleryButton'; import ImageGalleryButton from '../../../components/Media/ImageGalleryButton';
import RequestScreen from '../../../components/Screens/RequestScreen'; 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 = { type Props = {
navigation: StackNavigationProp<any>;
route: { route: {
params?: { params?: {
data?: ClubType; data?: ClubType;
@ -46,7 +47,6 @@ type PropsType = {
clubId?: number; clubId?: number;
}; };
}; };
theme: ReactNativePaper.Theme;
}; };
type ResponseType = ClubType; 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 data and categories navigation parameters, will use those to display the data.
* If called with clubId parameter, will fetch the information on the server * If called with clubId parameter, will fetch the information on the server
*/ */
class ClubDisplayScreen extends React.Component<PropsType> { function ClubDisplayScreen(props: Props) {
displayData: ClubType | undefined; 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; useFocusEffect(
useCallback(() => {
shouldFetchData: boolean; if (props.route.params?.data && props.route.params?.categories) {
setDisplayData(props.route.params.data);
constructor(props: PropsType) { setCategories(props.route.params.categories);
super(props); setClubId(props.route.params.data.id);
this.displayData = undefined; } else {
this.categories = null; const id = props.route.params?.clubId;
this.clubId = props.route.params?.clubId ? props.route.params.clubId : 0; setClubId(id ? id : 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;
}
} }
}, [props.route.params])
);
/** /**
* Gets the name of the category with the given ID * 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 * @param id The category's ID
* @returns {string|*} * @returns {string|*}
*/ */
getCategoryName(id: number): string { const getCategoryName = (id: number): string => {
let categoryName = ''; let categoryName = '';
if (this.categories !== null) { if (categories) {
this.categories.forEach((item: ClubCategoryType) => { categories.forEach((item: ClubCategoryType) => {
if (id === item.id) { if (id === item.id) {
categoryName = item.name; categoryName = item.name;
} }
}); });
} }
return categoryName; return categoryName;
} };
/** /**
* Gets the view for rendering categories * Gets the view for rendering categories
@ -141,23 +136,23 @@ class ClubDisplayScreen extends React.Component<PropsType> {
* @param categories The categories to display (max 2) * @param categories The categories to display (max 2)
* @returns {null|*} * @returns {null|*}
*/ */
getCategoriesRender(categories: Array<number | null>) { const getCategoriesRender = (c: Array<number | null>) => {
if (this.categories == null) { if (!categories) {
return null; return null;
} }
const final: Array<React.ReactNode> = []; const final: Array<React.ReactNode> = [];
categories.forEach((cat: number | null) => { c.forEach((cat: number | null) => {
if (cat != null) { if (cat != null) {
final.push( final.push(
<Chip style={styles.category} key={cat}> <Chip style={styles.category} key={cat}>
{this.getCategoryName(cat)} {getCategoryName(cat)}
</Chip> </Chip>
); );
} }
}); });
return <View style={styles.categoryContainer}>{final}</View>; return <View style={styles.categoryContainer}>{final}</View>;
} };
/** /**
* Gets the view for rendering club managers if any * Gets the view for rendering club managers if any
@ -166,8 +161,7 @@ class ClubDisplayScreen extends React.Component<PropsType> {
* @param email The club contact email * @param email The club contact email
* @returns {*} * @returns {*}
*/ */
getManagersRender(managers: Array<string>, email: string | null) { const getManagersRender = (managers: Array<string>, email: string | null) => {
const { props } = this;
const managersListView: Array<React.ReactNode> = []; const managersListView: Array<React.ReactNode> = [];
managers.forEach((item: string) => { managers.forEach((item: string) => {
managersListView.push(<Paragraph key={item}>{item}</Paragraph>); managersListView.push(<Paragraph key={item}>{item}</Paragraph>);
@ -191,22 +185,18 @@ class ClubDisplayScreen extends React.Component<PropsType> {
<Avatar.Icon <Avatar.Icon
size={iconProps.size} size={iconProps.size}
style={styles.icon} style={styles.icon}
color={ color={hasManagers ? theme.colors.success : theme.colors.primary}
hasManagers
? props.theme.colors.success
: props.theme.colors.primary
}
icon="account-tie" icon="account-tie"
/> />
)} )}
/> />
<Card.Content> <Card.Content>
{managersListView} {managersListView}
{ClubDisplayScreen.getEmailButton(email, hasManagers)} {getEmailButton(email, hasManagers)}
</Card.Content> </Card.Content>
</Card> </Card>
); );
} };
/** /**
* Gets the email button to contact the club, or the amicale if the club does not have any managers * Gets the email button to contact the club, or the amicale if the club does not have any managers
@ -215,7 +205,7 @@ class ClubDisplayScreen extends React.Component<PropsType> {
* @param hasManagers True if the club has managers * @param hasManagers True if the club has managers
* @returns {*} * @returns {*}
*/ */
static getEmailButton(email: string | null, hasManagers: boolean) { const getEmailButton = (email: string | null, hasManagers: boolean) => {
const destinationEmail = const destinationEmail =
email != null && hasManagers ? email : AMICALE_MAIL; email != null && hasManagers ? email : AMICALE_MAIL;
const text = const text =
@ -236,14 +226,14 @@ class ClubDisplayScreen extends React.Component<PropsType> {
</Button> </Button>
</Card.Actions> </Card.Actions>
); );
} };
getScreen = (data: ResponseType | undefined) => { const getScreen = (data: ResponseType | undefined) => {
if (data) { if (data) {
this.updateHeaderTitle(data); updateHeaderTitle(data);
return ( return (
<CollapsibleScrollView style={styles.scroll} hasTab> <CollapsibleScrollView style={styles.scroll} hasTab>
{this.getCategoriesRender(data.category)} {getCategoriesRender(data.category)}
{data.logo !== null ? ( {data.logo !== null ? (
<ImageGalleryButton <ImageGalleryButton
images={[{ url: data.logo }]} images={[{ url: data.logo }]}
@ -261,7 +251,7 @@ class ClubDisplayScreen extends React.Component<PropsType> {
) : ( ) : (
<View /> <View />
)} )}
{this.getManagersRender(data.responsibles, data.email)} {getManagersRender(data.responsibles, data.email)}
</CollapsibleScrollView> </CollapsibleScrollView>
); );
} }
@ -273,27 +263,22 @@ class ClubDisplayScreen extends React.Component<PropsType> {
* *
* @param data The club data * @param data The club data
*/ */
updateHeaderTitle(data: ClubType) { const updateHeaderTitle = (data: ClubType) => {
const { props } = this; navigation.setOptions({ title: data.name });
props.navigation.setOptions({ title: data.name }); };
}
const request = useAuthenticatedRequest<ClubType>('clubs/info', {
id: clubId,
});
render() {
if (this.shouldFetchData) {
return ( return (
<RequestScreen <RequestScreen
request={() => request={request}
ConnectionManager.getInstance().authenticatedRequest<ResponseType>( render={getScreen}
'clubs/info', cache={displayData}
{ id: this.clubId } onCacheUpdate={setDisplayData}
)
}
render={this.getScreen}
/> />
); );
} }
return this.getScreen(this.displayData);
}
}
export default withTheme(ClubDisplayScreen); export default ClubDisplayScreen;

View file

@ -17,11 +17,10 @@
* along with Campus INSAT. If not, see <https://www.gnu.org/licenses/>. * along with Campus INSAT. If not, see <https://www.gnu.org/licenses/>.
*/ */
import * as React from 'react'; import React, { useLayoutEffect, useRef, useState } from 'react';
import { Platform } from 'react-native'; import { Platform } from 'react-native';
import { Searchbar } from 'react-native-paper'; import { Searchbar } from 'react-native-paper';
import i18n from 'i18n-js'; import i18n from 'i18n-js';
import { StackNavigationProp } from '@react-navigation/stack';
import ClubListItem from '../../../components/Lists/Clubs/ClubListItem'; import ClubListItem from '../../../components/Lists/Clubs/ClubListItem';
import { import {
isItemInCategoryFilter, isItemInCategoryFilter,
@ -31,8 +30,9 @@ import ClubListHeader from '../../../components/Lists/Clubs/ClubListHeader';
import MaterialHeaderButtons, { import MaterialHeaderButtons, {
Item, Item,
} from '../../../components/Overrides/CustomHeaderButton'; } from '../../../components/Overrides/CustomHeaderButton';
import ConnectionManager from '../../../managers/ConnectionManager';
import WebSectionList from '../../../components/Screens/WebSectionList'; import WebSectionList from '../../../components/Screens/WebSectionList';
import { useNavigation } from '@react-navigation/native';
import { useAuthenticatedRequest } from '../../../context/loginContext';
export type ClubCategoryType = { export type ClubCategoryType = {
id: number; id: number;
@ -49,15 +49,6 @@ export type ClubType = {
responsibles: Array<string>; responsibles: Array<string>;
}; };
type PropsType = {
navigation: StackNavigationProp<any>;
};
type StateType = {
currentlySelectedCategories: Array<number>;
currentSearchString: string;
};
type ResponseType = { type ResponseType = {
categories: Array<ClubCategoryType>; categories: Array<ClubCategoryType>;
clubs: Array<ClubType>; clubs: Array<ClubType>;
@ -65,33 +56,52 @@ type ResponseType = {
const LIST_ITEM_HEIGHT = 96; const LIST_ITEM_HEIGHT = 96;
class ClubListScreen extends React.Component<PropsType, StateType> { function ClubListScreen() {
categories: Array<ClubCategoryType>; const navigation = useNavigation();
const request = useAuthenticatedRequest<ResponseType>('clubs/list');
const [
currentlySelectedCategories,
setCurrentlySelectedCategories,
] = useState<Array<number>>([]);
const [currentSearchString, setCurrentSearchString] = useState('');
const categories = useRef<Array<ClubCategoryType>>([]);
constructor(props: PropsType) { useLayoutEffect(() => {
super(props); const getSearchBar = () => {
this.categories = []; return (
this.state = { // @ts-ignore
currentlySelectedCategories: [], <Searchbar
currentSearchString: '', placeholder={i18n.t('screens.proximo.search')}
onChangeText={onSearchStringChange}
/>
);
}; };
} const getHeaderButtons = () => {
return (
/** <MaterialHeaderButtons>
* Creates the header content <Item
*/ title="main"
componentDidMount() { iconName="information"
const { props } = this; onPress={() => navigation.navigate('club-about')}
props.navigation.setOptions({ />
headerTitle: this.getSearchBar, </MaterialHeaderButtons>
headerRight: this.getHeaderButtons, );
};
navigation.setOptions({
headerTitle: getSearchBar,
headerRight: getHeaderButtons,
headerBackTitleVisible: false, headerBackTitleVisible: false,
headerTitleContainerStyle: headerTitleContainerStyle:
Platform.OS === 'ios' Platform.OS === 'ios'
? { marginHorizontal: 0, width: '70%' } ? { marginHorizontal: 0, width: '70%' }
: { marginHorizontal: 0, right: 50, left: 50 }, : { marginHorizontal: 0, right: 50, left: 50 },
}); });
} // eslint-disable-next-line react-hooks/exhaustive-deps
}, [navigation]);
const onSearchStringChange = (str: string) => {
updateFilteredData(str, null);
};
/** /**
* Callback used when clicking an article in the list. * Callback used when clicking an article in the list.
@ -99,61 +109,20 @@ class ClubListScreen extends React.Component<PropsType, StateType> {
* *
* @param item The article pressed * @param item The article pressed
*/ */
onListItemPress(item: ClubType) { const onListItemPress = (item: ClubType) => {
const { props } = this; navigation.navigate('club-information', {
props.navigation.navigate('club-information', {
data: item, data: item,
categories: this.categories, categories: categories.current,
}); });
}
/**
* Callback used when the search changes
*
* @param str The new search string
*/
onSearchStringChange = (str: string) => {
this.updateFilteredData(str, null);
}; };
/** const onChipSelect = (id: number) => {
* Gets the header search bar updateFilteredData(null, id);
*
* @return {*}
*/
getSearchBar = () => {
return (
// @ts-ignore
<Searchbar
placeholder={i18n.t('screens.proximo.search')}
onChangeText={this.onSearchStringChange}
/>
);
}; };
onChipSelect = (id: number) => { const createDataset = (data: ResponseType | undefined) => {
this.updateFilteredData(null, id);
};
/**
* Gets the header button
* @return {*}
*/
getHeaderButtons = () => {
const onPress = () => {
const { props } = this;
props.navigation.navigate('club-about');
};
return (
<MaterialHeaderButtons>
<Item title="main" iconName="information" onPress={onPress} />
</MaterialHeaderButtons>
);
};
createDataset = (data: ResponseType | undefined) => {
if (data) { if (data) {
this.categories = data?.categories; categories.current = data.categories;
return [{ title: '', data: data.clubs }]; return [{ title: '', data: data.clubs }];
} else { } else {
return []; return [];
@ -165,30 +134,23 @@ class ClubListScreen extends React.Component<PropsType, StateType> {
* *
* @returns {*} * @returns {*}
*/ */
getListHeader(data: ResponseType | undefined) { const getListHeader = (data: ResponseType | undefined) => {
const { state } = this;
if (data) { if (data) {
return ( return (
<ClubListHeader <ClubListHeader
categories={this.categories} categories={categories.current}
selectedCategories={state.currentlySelectedCategories} selectedCategories={currentlySelectedCategories}
onChipSelect={this.onChipSelect} onChipSelect={onChipSelect}
/> />
); );
} else { } else {
return null; return null;
} }
} };
/** const getCategoryOfId = (id: number): ClubCategoryType | null => {
* Gets the category object of the given ID
*
* @param id The ID of the category to find
* @returns {*}
*/
getCategoryOfId = (id: number): ClubCategoryType | null => {
let cat = null; let cat = null;
this.categories.forEach((item: ClubCategoryType) => { categories.current.forEach((item: ClubCategoryType) => {
if (id === item.id) { if (id === item.id) {
cat = item; cat = item;
} }
@ -196,14 +158,14 @@ class ClubListScreen extends React.Component<PropsType, StateType> {
return cat; return cat;
}; };
getRenderItem = ({ item }: { item: ClubType }) => { const getRenderItem = ({ item }: { item: ClubType }) => {
const onPress = () => { const onPress = () => {
this.onListItemPress(item); onListItemPress(item);
}; };
if (this.shouldRenderItem(item)) { if (shouldRenderItem(item)) {
return ( return (
<ClubListItem <ClubListItem
categoryTranslator={this.getCategoryOfId} categoryTranslator={getCategoryOfId}
item={item} item={item}
onPress={onPress} onPress={onPress}
height={LIST_ITEM_HEIGHT} height={LIST_ITEM_HEIGHT}
@ -213,7 +175,7 @@ class ClubListScreen extends React.Component<PropsType, StateType> {
return null; return null;
}; };
keyExtractor = (item: ClubType): string => item.id.toString(); const keyExtractor = (item: ClubType): string => item.id.toString();
/** /**
* Updates the search string and category filter, saving them to the State. * Updates the search string and category filter, saving them to the State.
@ -224,10 +186,12 @@ class ClubListScreen extends React.Component<PropsType, StateType> {
* @param filterStr The new filter string to use * @param filterStr The new filter string to use
* @param categoryId The category to add/remove from the filter * @param categoryId The category to add/remove from the filter
*/ */
updateFilteredData(filterStr: string | null, categoryId: number | null) { const updateFilteredData = (
const { state } = this; filterStr: string | null,
const newCategoriesState = [...state.currentlySelectedCategories]; categoryId: number | null
let newStrState = state.currentSearchString; ) => {
const newCategoriesState = [...currentlySelectedCategories];
let newStrState = currentSearchString;
if (filterStr !== null) { if (filterStr !== null) {
newStrState = filterStr; newStrState = filterStr;
} }
@ -240,12 +204,10 @@ class ClubListScreen extends React.Component<PropsType, StateType> {
} }
} }
if (filterStr !== null || categoryId !== null) { if (filterStr !== null || categoryId !== null) {
this.setState({ setCurrentSearchString(newStrState);
currentSearchString: newStrState, setCurrentlySelectedCategories(newCategoriesState);
currentlySelectedCategories: newCategoriesState,
});
}
} }
};
/** /**
* Checks if the given item should be rendered according to current name and category filters * Checks if the given item should be rendered according to current name and category filters
@ -253,35 +215,28 @@ class ClubListScreen extends React.Component<PropsType, StateType> {
* @param item The club to check * @param item The club to check
* @returns {boolean} * @returns {boolean}
*/ */
shouldRenderItem(item: ClubType): boolean { const shouldRenderItem = (item: ClubType): boolean => {
const { state } = this;
let shouldRender = let shouldRender =
state.currentlySelectedCategories.length === 0 || currentlySelectedCategories.length === 0 ||
isItemInCategoryFilter(state.currentlySelectedCategories, item.category); isItemInCategoryFilter(currentlySelectedCategories, item.category);
if (shouldRender) { if (shouldRender) {
shouldRender = stringMatchQuery(item.name, state.currentSearchString); shouldRender = stringMatchQuery(item.name, currentSearchString);
} }
return shouldRender; return shouldRender;
} };
render() {
return ( return (
<WebSectionList <WebSectionList
request={() => request={request}
ConnectionManager.getInstance().authenticatedRequest<ResponseType>( createDataset={createDataset}
'clubs/list' keyExtractor={keyExtractor}
) renderItem={getRenderItem}
} renderListHeaderComponent={getListHeader}
createDataset={this.createDataset}
keyExtractor={this.keyExtractor}
renderItem={this.getRenderItem}
renderListHeaderComponent={(data) => this.getListHeader(data)}
// Performance props, see https://reactnative.dev/docs/optimizing-flatlist-configuration // Performance props, see https://reactnative.dev/docs/optimizing-flatlist-configuration
removeClippedSubviews={true} removeClippedSubviews={true}
itemHeight={LIST_ITEM_HEIGHT} itemHeight={LIST_ITEM_HEIGHT}
/> />
); );
} }
}
export default ClubListScreen; export default ClubListScreen;

View file

@ -17,26 +17,17 @@
* along with Campus INSAT. If not, see <https://www.gnu.org/licenses/>. * along with Campus INSAT. If not, see <https://www.gnu.org/licenses/>.
*/ */
import * as React from 'react'; import React, { useRef, useState } from 'react';
import { StyleSheet, View } from 'react-native'; import { StyleSheet, View } from 'react-native';
import { Button } from 'react-native-paper'; import { Button } from 'react-native-paper';
import { StackNavigationProp } from '@react-navigation/stack';
import i18n from 'i18n-js'; import i18n from 'i18n-js';
import EquipmentListItem from '../../../components/Lists/Equipment/EquipmentListItem'; import EquipmentListItem from '../../../components/Lists/Equipment/EquipmentListItem';
import MascotPopup from '../../../components/Mascot/MascotPopup'; import MascotPopup from '../../../components/Mascot/MascotPopup';
import { MASCOT_STYLE } from '../../../components/Mascot/Mascot'; import { MASCOT_STYLE } from '../../../components/Mascot/Mascot';
import GENERAL_STYLES from '../../../constants/Styles'; import GENERAL_STYLES from '../../../constants/Styles';
import ConnectionManager from '../../../managers/ConnectionManager';
import { ApiRejectType } from '../../../utils/WebData'; import { ApiRejectType } from '../../../utils/WebData';
import WebSectionList from '../../../components/Screens/WebSectionList'; import WebSectionList from '../../../components/Screens/WebSectionList';
import { useAuthenticatedRequest } from '../../../context/loginContext';
type PropsType = {
navigation: StackNavigationProp<any>;
};
type StateType = {
mascotDialogVisible: boolean | undefined;
};
export type DeviceType = { export type DeviceType = {
id: number; id: number;
@ -67,69 +58,62 @@ const styles = StyleSheet.create({
}, },
}); });
class EquipmentListScreen extends React.Component<PropsType, StateType> { function EquipmentListScreen() {
userRents: null | Array<RentedDeviceType>; const userRents = useRef<undefined | Array<RentedDeviceType>>();
const [mascotDialogVisible, setMascotDialogVisible] = useState(false);
constructor(props: PropsType) { const requestAll = useAuthenticatedRequest<{ devices: Array<DeviceType> }>(
super(props); 'location/all'
this.userRents = null; );
this.state = { const requestOwn = useAuthenticatedRequest<{
mascotDialogVisible: undefined, locations: Array<RentedDeviceType>;
}; }>('location/my');
}
getRenderItem = ({ item }: { item: DeviceType }) => { const getRenderItem = ({ item }: { item: DeviceType }) => {
const { navigation } = this.props;
return ( return (
<EquipmentListItem <EquipmentListItem
navigation={navigation}
item={item} item={item}
userDeviceRentDates={this.getUserDeviceRentDates(item)} userDeviceRentDates={getUserDeviceRentDates(item)}
height={LIST_ITEM_HEIGHT} height={LIST_ITEM_HEIGHT}
/> />
); );
}; };
getUserDeviceRentDates(item: DeviceType): [string, string] | null { const getUserDeviceRentDates = (
item: DeviceType
): [string, string] | null => {
let dates = null; let dates = null;
if (this.userRents != null) { if (userRents.current) {
this.userRents.forEach((device: RentedDeviceType) => { userRents.current.forEach((device: RentedDeviceType) => {
if (item.id === device.device_id) { if (item.id === device.device_id) {
dates = [device.begin, device.end]; dates = [device.begin, device.end];
} }
}); });
} }
return dates; return dates;
} };
/** const getListHeader = () => {
* Gets the list header, with explains this screen's purpose
*
* @returns {*}
*/
getListHeader() {
return ( return (
<View style={styles.headerContainer}> <View style={styles.headerContainer}>
<Button <Button
mode="contained" mode="contained"
icon="help-circle" icon="help-circle"
onPress={this.showMascotDialog} onPress={showMascotDialog}
style={GENERAL_STYLES.centerHorizontal} style={GENERAL_STYLES.centerHorizontal}
> >
{i18n.t('screens.equipment.mascotDialog.title')} {i18n.t('screens.equipment.mascotDialog.title')}
</Button> </Button>
</View> </View>
); );
} };
keyExtractor = (item: DeviceType): string => item.id.toString(); const keyExtractor = (item: DeviceType): string => item.id.toString();
createDataset = (data: ResponseType | undefined) => { const createDataset = (data: ResponseType | undefined) => {
if (data) { if (data) {
const userRents = data.locations; if (data.locations) {
userRents.current = data.locations;
if (userRents) {
this.userRents = userRents;
} }
return [{ title: '', data: data.devices }]; return [{ title: '', data: data.devices }];
} else { } else {
@ -137,27 +121,19 @@ class EquipmentListScreen extends React.Component<PropsType, StateType> {
} }
}; };
showMascotDialog = () => { const showMascotDialog = () => setMascotDialogVisible(true);
this.setState({ mascotDialogVisible: true });
};
hideMascotDialog = () => { const hideMascotDialog = () => setMascotDialogVisible(false);
this.setState({ mascotDialogVisible: false });
};
request = () => { const request = () => {
return new Promise( return new Promise(
( (
resolve: (data: ResponseType) => void, resolve: (data: ResponseType) => void,
reject: (error: ApiRejectType) => void reject: (error: ApiRejectType) => void
) => { ) => {
ConnectionManager.getInstance() requestAll()
.authenticatedRequest<{ devices: Array<DeviceType> }>('location/all')
.then((devicesData) => { .then((devicesData) => {
ConnectionManager.getInstance() requestOwn()
.authenticatedRequest<{
locations: Array<RentedDeviceType>;
}>('location/my')
.then((rentsData) => { .then((rentsData) => {
resolve({ resolve({
devices: devicesData.devices, devices: devicesData.devices,
@ -175,19 +151,17 @@ class EquipmentListScreen extends React.Component<PropsType, StateType> {
); );
}; };
render() {
const { state } = this;
return ( return (
<View style={GENERAL_STYLES.flex}> <View style={GENERAL_STYLES.flex}>
<WebSectionList <WebSectionList
request={this.request} request={request}
createDataset={this.createDataset} createDataset={createDataset}
keyExtractor={this.keyExtractor} keyExtractor={keyExtractor}
renderItem={this.getRenderItem} renderItem={getRenderItem}
renderListHeaderComponent={() => this.getListHeader()} renderListHeaderComponent={getListHeader}
/> />
<MascotPopup <MascotPopup
visible={state.mascotDialogVisible} visible={mascotDialogVisible}
title={i18n.t('screens.equipment.mascotDialog.title')} title={i18n.t('screens.equipment.mascotDialog.title')}
message={i18n.t('screens.equipment.mascotDialog.message')} message={i18n.t('screens.equipment.mascotDialog.message')}
icon="vote" icon="vote"
@ -195,7 +169,7 @@ class EquipmentListScreen extends React.Component<PropsType, StateType> {
cancel: { cancel: {
message: i18n.t('screens.equipment.mascotDialog.button'), message: i18n.t('screens.equipment.mascotDialog.button'),
icon: 'check', icon: 'check',
onPress: this.hideMascotDialog, onPress: hideMascotDialog,
}, },
}} }}
emotion={MASCOT_STYLE.WINK} emotion={MASCOT_STYLE.WINK}
@ -203,6 +177,5 @@ class EquipmentListScreen extends React.Component<PropsType, StateType> {
</View> </View>
); );
} }
}
export default EquipmentListScreen; export default EquipmentListScreen;

View file

@ -17,21 +17,20 @@
* along with Campus INSAT. If not, see <https://www.gnu.org/licenses/>. * along with Campus INSAT. If not, see <https://www.gnu.org/licenses/>.
*/ */
import * as React from 'react'; import React, { useCallback, useRef, useState } from 'react';
import { import {
Button, Button,
Caption, Caption,
Card, Card,
Headline, Headline,
Subheading, Subheading,
withTheme, useTheme,
} from 'react-native-paper'; } from 'react-native-paper';
import { StackNavigationProp, StackScreenProps } from '@react-navigation/stack'; import { StackNavigationProp, StackScreenProps } from '@react-navigation/stack';
import { BackHandler, StyleSheet, View } from 'react-native'; import { BackHandler, StyleSheet, View } from 'react-native';
import * as Animatable from 'react-native-animatable'; import * as Animatable from 'react-native-animatable';
import i18n from 'i18n-js'; import i18n from 'i18n-js';
import { CalendarList, PeriodMarking } from 'react-native-calendars'; import { CalendarList, PeriodMarking } from 'react-native-calendars';
import type { DeviceType } from './EquipmentListScreen';
import LoadingConfirmDialog from '../../../components/Dialogs/LoadingConfirmDialog'; import LoadingConfirmDialog from '../../../components/Dialogs/LoadingConfirmDialog';
import ErrorDialog from '../../../components/Dialogs/ErrorDialog'; import ErrorDialog from '../../../components/Dialogs/ErrorDialog';
import { import {
@ -42,34 +41,21 @@ import {
getValidRange, getValidRange,
isEquipmentAvailable, isEquipmentAvailable,
} from '../../../utils/EquipmentBooking'; } from '../../../utils/EquipmentBooking';
import ConnectionManager from '../../../managers/ConnectionManager';
import CollapsibleScrollView from '../../../components/Collapsible/CollapsibleScrollView'; import CollapsibleScrollView from '../../../components/Collapsible/CollapsibleScrollView';
import { MainStackParamsList } from '../../../navigation/MainNavigator'; import { MainStackParamsList } from '../../../navigation/MainNavigator';
import GENERAL_STYLES from '../../../constants/Styles'; import GENERAL_STYLES from '../../../constants/Styles';
import { ApiRejectType } from '../../../utils/WebData'; import { ApiRejectType } from '../../../utils/WebData';
import { REQUEST_STATUS } from '../../../utils/Requests'; import { REQUEST_STATUS } from '../../../utils/Requests';
import { useFocusEffect } from '@react-navigation/core';
import { useNavigation } from '@react-navigation/native';
import { useAuthenticatedRequest } from '../../../context/loginContext';
type EquipmentRentScreenNavigationProp = StackScreenProps< type Props = StackScreenProps<MainStackParamsList, 'equipment-rent'>;
MainStackParamsList,
'equipment-rent'
>;
type Props = EquipmentRentScreenNavigationProp & {
navigation: StackNavigationProp<any>;
theme: ReactNativePaper.Theme;
};
export type MarkedDatesObjectType = { export type MarkedDatesObjectType = {
[key: string]: PeriodMarking; [key: string]: PeriodMarking;
}; };
type StateType = {
dialogVisible: boolean;
errorDialogVisible: boolean;
markedDates: MarkedDatesObjectType;
currentError: ApiRejectType;
};
const styles = StyleSheet.create({ const styles = StyleSheet.create({
titleContainer: { titleContainer: {
marginLeft: 'auto', marginLeft: 'auto',
@ -114,98 +100,101 @@ const styles = StyleSheet.create({
}, },
}); });
class EquipmentRentScreen extends React.Component<Props, StateType> { function EquipmentRentScreen(props: Props) {
item: DeviceType | null; const theme = useTheme();
const navigation = useNavigation<StackNavigationProp<any>>();
const [currentError, setCurrentError] = useState<ApiRejectType>({
status: REQUEST_STATUS.SUCCESS,
});
const [markedDates, setMarkedDates] = useState<MarkedDatesObjectType>({});
const [dialogVisible, setDialogVisible] = useState(false);
bookedDates: Array<string>; const item = props.route.params.item;
bookRef: { current: null | (Animatable.View & View) }; const bookedDates = useRef<Array<string>>([]);
const canBookEquipment = useRef(false);
canBookEquipment: boolean; const bookRef = useRef<Animatable.View & View>(null);
lockedDates: { let lockedDates: {
[key: string]: PeriodMarking; [key: string]: PeriodMarking;
}; } = {};
constructor(props: Props) { if (item) {
super(props);
this.item = null;
this.lockedDates = {};
this.state = {
dialogVisible: false,
errorDialogVisible: false,
markedDates: {},
currentError: { status: REQUEST_STATUS.SUCCESS },
};
this.resetSelection();
this.bookRef = React.createRef();
this.canBookEquipment = false;
this.bookedDates = [];
if (props.route.params != null) {
if (props.route.params.item != null) {
this.item = props.route.params.item;
} else {
this.item = null;
}
}
const { item } = this;
if (item != null) {
this.lockedDates = {};
item.booked_at.forEach((date: { begin: string; end: string }) => { item.booked_at.forEach((date: { begin: string; end: string }) => {
const range = getValidRange( const range = getValidRange(
new Date(date.begin), new Date(date.begin),
new Date(date.end), new Date(date.end),
null null
); );
this.lockedDates = { lockedDates = {
...this.lockedDates, ...lockedDates,
...generateMarkedDates(false, props.theme, range), ...generateMarkedDates(false, theme, range),
}; };
}); });
} }
}
/** useFocusEffect(
* Captures focus and blur events to hook on android back button useCallback(() => {
*/
componentDidMount() {
const { navigation } = this.props;
navigation.addListener('focus', () => {
BackHandler.addEventListener( BackHandler.addEventListener(
'hardwareBackPress', 'hardwareBackPress',
this.onBackButtonPressAndroid onBackButtonPressAndroid
); );
}); return () => {
navigation.addListener('blur', () => {
BackHandler.removeEventListener( BackHandler.removeEventListener(
'hardwareBackPress', 'hardwareBackPress',
this.onBackButtonPressAndroid onBackButtonPressAndroid
);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
); );
});
}
/** /**
* Overrides default android back button behaviour to deselect date if any is selected. * Overrides default android back button behaviour to deselect date if any is selected.
* *
* @return {boolean} * @return {boolean}
*/ */
onBackButtonPressAndroid = (): boolean => { const onBackButtonPressAndroid = (): boolean => {
if (this.bookedDates.length > 0) { if (bookedDates.current.length > 0) {
this.resetSelection(); resetSelection();
this.updateMarkedSelection(); updateMarkedSelection();
return true; return true;
} }
return false; return false;
}; };
onDialogDismiss = () => { const showDialog = () => setDialogVisible(true);
this.setState({ dialogVisible: false });
const onDialogDismiss = () => setDialogVisible(false);
const onErrorDialogDismiss = () =>
setCurrentError({ status: REQUEST_STATUS.SUCCESS });
const getBookStartDate = (): Date | null => {
return bookedDates.current.length > 0
? new Date(bookedDates.current[0])
: null;
}; };
onErrorDialogDismiss = () => { const getBookEndDate = (): Date | null => {
this.setState({ errorDialogVisible: false }); const { length } = bookedDates.current;
return length > 0 ? new Date(bookedDates.current[length - 1]) : null;
}; };
const start = getBookStartDate();
const end = getBookEndDate();
const request = useAuthenticatedRequest(
'location/booking',
item && start && end
? {
device: item.id,
begin: getISODate(start),
end: getISODate(end),
}
: undefined
);
/** /**
* Sends the selected data to the server and waits for a response. * Sends the selected data to the server and waits for a response.
* If the request is a success, navigate to the recap screen. * If the request is a success, navigate to the recap screen.
@ -213,54 +202,37 @@ class EquipmentRentScreen extends React.Component<Props, StateType> {
* *
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
onDialogAccept = (): Promise<void> => { const onDialogAccept = (): Promise<void> => {
return new Promise((resolve: () => void) => { return new Promise((resolve: () => void) => {
const { item, props } = this;
const start = this.getBookStartDate();
const end = this.getBookEndDate();
if (item != null && start != null && end != null) { if (item != null && start != null && end != null) {
ConnectionManager.getInstance() request()
.authenticatedRequest('location/booking', {
device: item.id,
begin: getISODate(start),
end: getISODate(end),
})
.then(() => { .then(() => {
this.onDialogDismiss(); onDialogDismiss();
props.navigation.replace('equipment-confirm', { navigation.replace('equipment-confirm', {
item: this.item, item: item,
dates: [getISODate(start), getISODate(end)], dates: [getISODate(start), getISODate(end)],
}); });
resolve(); resolve();
}) })
.catch((error: ApiRejectType) => { .catch((error: ApiRejectType) => {
this.onDialogDismiss(); onDialogDismiss();
this.showErrorDialog(error); setCurrentError(error);
resolve(); resolve();
}); });
} else { } else {
this.onDialogDismiss(); onDialogDismiss();
resolve(); resolve();
} }
}); });
}; };
getBookStartDate(): Date | null {
return this.bookedDates.length > 0 ? new Date(this.bookedDates[0]) : null;
}
getBookEndDate(): Date | null {
const { length } = this.bookedDates;
return length > 0 ? new Date(this.bookedDates[length - 1]) : null;
}
/** /**
* Selects a new date on the calendar. * Selects a new date on the calendar.
* If both start and end dates are already selected, unselect all. * If both start and end dates are already selected, unselect all.
* *
* @param day The day selected * @param day The day selected
*/ */
selectNewDate = (day: { const selectNewDate = (day: {
dateString: string; dateString: string;
day: number; day: number;
month: number; month: number;
@ -268,84 +240,64 @@ class EquipmentRentScreen extends React.Component<Props, StateType> {
year: number; year: number;
}) => { }) => {
const selected = new Date(day.dateString); const selected = new Date(day.dateString);
const start = this.getBookStartDate();
if (!this.lockedDates[day.dateString] != null) { if (!lockedDates[day.dateString] != null) {
if (start === null) { if (start === null) {
this.updateSelectionRange(selected, selected); updateSelectionRange(selected, selected);
this.enableBooking(); enableBooking();
} else if (start.getTime() === selected.getTime()) { } else if (start.getTime() === selected.getTime()) {
this.resetSelection(); resetSelection();
} else if (this.bookedDates.length === 1) { } else if (bookedDates.current.length === 1) {
this.updateSelectionRange(start, selected); updateSelectionRange(start, selected);
this.enableBooking(); enableBooking();
} else { } else {
this.resetSelection(); resetSelection();
} }
this.updateMarkedSelection(); updateMarkedSelection();
} }
}; };
showErrorDialog = (error: ApiRejectType) => { const showBookButton = () => {
this.setState({ if (bookRef.current && bookRef.current.fadeInUp) {
errorDialogVisible: true, bookRef.current.fadeInUp(500);
currentError: error, }
});
}; };
showDialog = () => { const hideBookButton = () => {
this.setState({ dialogVisible: true }); if (bookRef.current && bookRef.current.fadeOutDown) {
bookRef.current.fadeOutDown(500);
}
}; };
/** const enableBooking = () => {
* Shows the book button by plying a fade animation if (!canBookEquipment.current) {
*/ showBookButton();
showBookButton() { canBookEquipment.current = true;
if (this.bookRef.current && this.bookRef.current.fadeInUp) {
this.bookRef.current.fadeInUp(500);
}
} }
};
/** const resetSelection = () => {
* Hides the book button by plying a fade animation if (canBookEquipment.current) {
*/ hideBookButton();
hideBookButton() {
if (this.bookRef.current && this.bookRef.current.fadeOutDown) {
this.bookRef.current.fadeOutDown(500);
}
} }
canBookEquipment.current = false;
bookedDates.current = [];
};
enableBooking() { const updateSelectionRange = (s: Date, e: Date) => {
if (!this.canBookEquipment) { if (item) {
this.showBookButton(); bookedDates.current = getValidRange(s, e, item);
this.canBookEquipment = true; } else {
} bookedDates.current = [];
} }
};
resetSelection() { const updateMarkedSelection = () => {
if (this.canBookEquipment) { setMarkedDates(generateMarkedDates(true, theme, bookedDates.current));
this.hideBookButton(); };
}
this.canBookEquipment = false;
this.bookedDates = [];
}
updateSelectionRange(start: Date, end: Date) {
this.bookedDates = getValidRange(start, end, this.item);
}
updateMarkedSelection() {
const { theme } = this.props;
this.setState({
markedDates: generateMarkedDates(true, theme, this.bookedDates),
});
}
render() {
const { item, props, state } = this;
const start = this.getBookStartDate();
const end = this.getBookEndDate();
let subHeadingText; let subHeadingText;
if (start == null) { if (start == null) {
subHeadingText = i18n.t('screens.equipment.booking'); subHeadingText = i18n.t('screens.equipment.booking');
} else if (end != null && start.getTime() !== end.getTime()) { } else if (end != null && start.getTime() !== end.getTime()) {
@ -358,7 +310,8 @@ class EquipmentRentScreen extends React.Component<Props, StateType> {
date: getRelativeDateString(start), date: getRelativeDateString(start),
}); });
} }
if (item != null) {
if (item) {
const isAvailable = isEquipmentAvailable(item); const isAvailable = isEquipmentAvailable(item);
const firstAvailability = getFirstEquipmentAvailability(item); const firstAvailability = getFirstEquipmentAvailability(item);
return ( return (
@ -370,9 +323,7 @@ class EquipmentRentScreen extends React.Component<Props, StateType> {
<View style={styles.titleContainer}> <View style={styles.titleContainer}>
<Headline style={styles.title}>{item.name}</Headline> <Headline style={styles.title}>{item.name}</Headline>
<Caption style={styles.caption}> <Caption style={styles.caption}>
( ({i18n.t('screens.equipment.bail', { cost: item.caution })})
{i18n.t('screens.equipment.bail', { cost: item.caution })}
)
</Caption> </Caption>
</View> </View>
</View> </View>
@ -380,9 +331,7 @@ class EquipmentRentScreen extends React.Component<Props, StateType> {
<Button <Button
icon={isAvailable ? 'check-circle-outline' : 'update'} icon={isAvailable ? 'check-circle-outline' : 'update'}
color={ color={
isAvailable isAvailable ? theme.colors.success : theme.colors.primary
? props.theme.colors.success
: props.theme.colors.primary
} }
mode="text" mode="text"
> >
@ -390,9 +339,7 @@ class EquipmentRentScreen extends React.Component<Props, StateType> {
date: getRelativeDateString(firstAvailability), date: getRelativeDateString(firstAvailability),
})} })}
</Button> </Button>
<Subheading style={styles.subtitle}> <Subheading style={styles.subtitle}>{subHeadingText}</Subheading>
{subHeadingText}
</Subheading>
</Card.Content> </Card.Content>
</Card> </Card>
<CalendarList <CalendarList
@ -407,28 +354,28 @@ class EquipmentRentScreen extends React.Component<Props, StateType> {
// Enable paging on horizontal, default = false // Enable paging on horizontal, default = false
pagingEnabled pagingEnabled
// Handler which gets executed on day press. Default = undefined // Handler which gets executed on day press. Default = undefined
onDayPress={this.selectNewDate} onDayPress={selectNewDate}
// If firstDay=1 week starts from Monday. Note that dayNames and dayNamesShort should still start from Sunday. // If firstDay=1 week starts from Monday. Note that dayNames and dayNamesShort should still start from Sunday.
firstDay={1} firstDay={1}
// Hide month navigation arrows. // Hide month navigation arrows.
hideArrows={false} hideArrows={false}
// Date marking style [simple/period/multi-dot/custom]. Default = 'simple' // Date marking style [simple/period/multi-dot/custom]. Default = 'simple'
markingType={'period'} markingType={'period'}
markedDates={{ ...this.lockedDates, ...state.markedDates }} markedDates={{ ...lockedDates, ...markedDates }}
theme={{ theme={{
'backgroundColor': props.theme.colors.agendaBackgroundColor, 'backgroundColor': theme.colors.agendaBackgroundColor,
'calendarBackground': props.theme.colors.background, 'calendarBackground': theme.colors.background,
'textSectionTitleColor': props.theme.colors.agendaDayTextColor, 'textSectionTitleColor': theme.colors.agendaDayTextColor,
'selectedDayBackgroundColor': props.theme.colors.primary, 'selectedDayBackgroundColor': theme.colors.primary,
'selectedDayTextColor': '#ffffff', 'selectedDayTextColor': '#ffffff',
'todayTextColor': props.theme.colors.text, 'todayTextColor': theme.colors.text,
'dayTextColor': props.theme.colors.text, 'dayTextColor': theme.colors.text,
'textDisabledColor': props.theme.colors.agendaDayTextColor, 'textDisabledColor': theme.colors.agendaDayTextColor,
'dotColor': props.theme.colors.primary, 'dotColor': theme.colors.primary,
'selectedDotColor': '#ffffff', 'selectedDotColor': '#ffffff',
'arrowColor': props.theme.colors.primary, 'arrowColor': theme.colors.primary,
'monthTextColor': props.theme.colors.text, 'monthTextColor': theme.colors.text,
'indicatorColor': props.theme.colors.primary, 'indicatorColor': theme.colors.primary,
'textDayFontFamily': 'monospace', 'textDayFontFamily': 'monospace',
'textMonthFontFamily': 'monospace', 'textMonthFontFamily': 'monospace',
'textDayHeaderFontFamily': 'monospace', 'textDayHeaderFontFamily': 'monospace',
@ -451,29 +398,29 @@ class EquipmentRentScreen extends React.Component<Props, StateType> {
/> />
</CollapsibleScrollView> </CollapsibleScrollView>
<LoadingConfirmDialog <LoadingConfirmDialog
visible={state.dialogVisible} visible={dialogVisible}
onDismiss={this.onDialogDismiss} onDismiss={onDialogDismiss}
onAccept={this.onDialogAccept} onAccept={onDialogAccept}
title={i18n.t('screens.equipment.dialogTitle')} title={i18n.t('screens.equipment.dialogTitle')}
titleLoading={i18n.t('screens.equipment.dialogTitleLoading')} titleLoading={i18n.t('screens.equipment.dialogTitleLoading')}
message={i18n.t('screens.equipment.dialogMessage')} message={i18n.t('screens.equipment.dialogMessage')}
/> />
<ErrorDialog <ErrorDialog
visible={state.errorDialogVisible} visible={currentError.status !== REQUEST_STATUS.SUCCESS}
onDismiss={this.onErrorDialogDismiss} onDismiss={onErrorDialogDismiss}
status={state.currentError.status} status={currentError.status}
code={state.currentError.code} code={currentError.code}
/> />
<Animatable.View <Animatable.View
ref={this.bookRef} ref={bookRef}
useNativeDriver useNativeDriver
style={styles.buttonContainer} style={styles.buttonContainer}
> >
<Button <Button
icon="bookmark-check" icon="bookmark-check"
mode="contained" mode="contained"
onPress={this.showDialog} onPress={showDialog}
style={styles.button} style={styles.button}
> >
{i18n.t('screens.equipment.bookButton')} {i18n.t('screens.equipment.bookButton')}
@ -484,6 +431,5 @@ class EquipmentRentScreen extends React.Component<Props, StateType> {
} }
return null; return null;
} }
}
export default withTheme(EquipmentRentScreen); export default EquipmentRentScreen;

View file

@ -17,19 +17,11 @@
* along with Campus INSAT. If not, see <https://www.gnu.org/licenses/>. * along with Campus INSAT. If not, see <https://www.gnu.org/licenses/>.
*/ */
import * as React from 'react'; import React, { useCallback, useState } from 'react';
import { Image, KeyboardAvoidingView, StyleSheet, View } from 'react-native'; import { KeyboardAvoidingView, View } from 'react-native';
import {
Button,
Card,
HelperText,
TextInput,
withTheme,
} from 'react-native-paper';
import i18n from 'i18n-js'; import i18n from 'i18n-js';
import { StackNavigationProp, StackScreenProps } from '@react-navigation/stack'; import { StackNavigationProp, StackScreenProps } from '@react-navigation/stack';
import LinearGradient from 'react-native-linear-gradient'; import LinearGradient from 'react-native-linear-gradient';
import ConnectionManager from '../../managers/ConnectionManager';
import ErrorDialog from '../../components/Dialogs/ErrorDialog'; import ErrorDialog from '../../components/Dialogs/ErrorDialog';
import { MASCOT_STYLE } from '../../components/Mascot/Mascot'; import { MASCOT_STYLE } from '../../components/Mascot/Mascot';
import MascotPopup from '../../components/Mascot/MascotPopup'; import MascotPopup from '../../components/Mascot/MascotPopup';
@ -37,99 +29,32 @@ import CollapsibleScrollView from '../../components/Collapsible/CollapsibleScrol
import { MainStackParamsList } from '../../navigation/MainNavigator'; import { MainStackParamsList } from '../../navigation/MainNavigator';
import GENERAL_STYLES from '../../constants/Styles'; import GENERAL_STYLES from '../../constants/Styles';
import Urls from '../../constants/Urls'; import Urls from '../../constants/Urls';
import { ApiRejectType } from '../../utils/WebData'; import { ApiRejectType, connectToAmicale } from '../../utils/WebData';
import { REQUEST_STATUS } from '../../utils/Requests'; import { REQUEST_STATUS } from '../../utils/Requests';
import LoginForm from '../../components/Amicale/Login/LoginForm';
import { useFocusEffect, useNavigation } from '@react-navigation/native';
import { TabRoutes } from '../../navigation/TabNavigator';
import { useShouldShowMascot } from '../../context/preferencesContext';
type LoginScreenNavigationProp = StackScreenProps<MainStackParamsList, 'login'>; type Props = StackScreenProps<MainStackParamsList, 'login'>;
type Props = LoginScreenNavigationProp & { function LoginScreen(props: Props) {
navigation: StackNavigationProp<any>; const navigation = useNavigation<StackNavigationProp<any>>();
theme: ReactNativePaper.Theme; const [loading, setLoading] = useState(false);
}; const [nextScreen, setNextScreen] = useState<string | undefined>(undefined);
const [mascotDialogVisible, setMascotDialogVisible] = useState(false);
type StateType = { const [currentError, setCurrentError] = useState<ApiRejectType>({
email: string; status: REQUEST_STATUS.SUCCESS,
password: string;
isEmailValidated: boolean;
isPasswordValidated: boolean;
loading: boolean;
dialogVisible: boolean;
dialogError: ApiRejectType;
mascotDialogVisible: boolean | undefined;
};
const ICON_AMICALE = require('../../../assets/amicale.png');
const emailRegex = /^.+@.+\..+$/;
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 homeMascot = useShouldShowMascot(TabRoutes.Home);
class LoginScreen extends React.Component<Props, StateType> { useFocusEffect(
onEmailChange: (value: string) => void; useCallback(() => {
setNextScreen(props.route.params?.nextScreen);
}, [props.route.params])
);
onPasswordChange: (value: string) => void; const onResetPasswordClick = () => {
passwordInputRef: {
// @ts-ignore
current: null | TextInput;
};
nextScreen: string | null;
constructor(props: Props) {
super(props);
this.nextScreen = null;
this.passwordInputRef = React.createRef();
this.onEmailChange = (value: string) => {
this.onInputChange(true, value);
};
this.onPasswordChange = (value: string) => {
this.onInputChange(false, value);
};
props.navigation.addListener('focus', this.onScreenFocus);
this.state = {
email: '',
password: '',
isEmailValidated: false,
isPasswordValidated: false,
loading: false,
dialogVisible: false,
dialogError: { status: REQUEST_STATUS.SUCCESS },
mascotDialogVisible: undefined,
};
}
onScreenFocus = () => {
this.handleNavigationParams();
};
/**
* Navigates to the Amicale website screen with the reset password link as navigation parameters
*/
onResetPasswordClick = () => {
const { navigation } = this.props;
navigation.navigate('website', { navigation.navigate('website', {
host: Urls.websites.amicale, host: Urls.websites.amicale,
path: Urls.amicale.resetPassword, path: Urls.amicale.resetPassword,
@ -137,38 +62,6 @@ class LoginScreen extends React.Component<Props, StateType> {
}); });
}; };
/**
* Called when the user input changes in the email or password field.
* This saves the new value in the State and disabled input validation (to prevent errors to show while typing)
*
* @param isEmail True if the field is the email field
* @param value The new field value
*/
onInputChange(isEmail: boolean, value: string) {
if (isEmail) {
this.setState({
email: value,
isEmailValidated: false,
});
} else {
this.setState({
password: value,
isPasswordValidated: false,
});
}
}
/**
* Focuses the password field when the email field is done
*
* @returns {*}
*/
onEmailSubmit = () => {
if (this.passwordInputRef.current != null) {
this.passwordInputRef.current.focus();
}
};
/** /**
* Called when the user clicks on login or finishes to type his password. * Called when the user clicks on login or finishes to type his password.
* *
@ -176,253 +69,37 @@ class LoginScreen extends React.Component<Props, StateType> {
* then makes the login request and enters a loading state until the request finishes * then makes the login request and enters a loading state until the request finishes
* *
*/ */
onSubmit = () => { const onSubmit = (email: string, password: string) => {
const { email, password } = this.state; setLoading(true);
if (this.shouldEnableLogin()) { connectToAmicale(email, password)
this.setState({ loading: true }); .then(handleSuccess)
ConnectionManager.getInstance() .catch(setCurrentError)
.connect(email, password) .finally(() => setLoading(false));
.then(this.handleSuccess)
.catch(this.showErrorDialog)
.finally(() => {
this.setState({ loading: false });
});
}
}; };
/** const hideMascotDialog = () => setMascotDialogVisible(true);
* Gets the form input
*
* @returns {*}
*/
getFormInput() {
const { email, password } = this.state;
return (
<View>
<TextInput
label={i18n.t('screens.login.email')}
mode="outlined"
value={email}
onChangeText={this.onEmailChange}
onBlur={this.validateEmail}
onSubmitEditing={this.onEmailSubmit}
error={this.shouldShowEmailError()}
textContentType="emailAddress"
autoCapitalize="none"
autoCompleteType="email"
autoCorrect={false}
keyboardType="email-address"
returnKeyType="next"
secureTextEntry={false}
/>
<HelperText type="error" visible={this.shouldShowEmailError()}>
{i18n.t('screens.login.emailError')}
</HelperText>
<TextInput
ref={this.passwordInputRef}
label={i18n.t('screens.login.password')}
mode="outlined"
value={password}
onChangeText={this.onPasswordChange}
onBlur={this.validatePassword}
onSubmitEditing={this.onSubmit}
error={this.shouldShowPasswordError()}
textContentType="password"
autoCapitalize="none"
autoCompleteType="password"
autoCorrect={false}
keyboardType="default"
returnKeyType="done"
secureTextEntry
/>
<HelperText type="error" visible={this.shouldShowPasswordError()}>
{i18n.t('screens.login.passwordError')}
</HelperText>
</View>
);
}
/** const showMascotDialog = () => setMascotDialogVisible(false);
* Gets the card containing the input form
* @returns {*}
*/
getMainCard() {
const { props, state } = this;
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>
{this.getFormInput()}
<Card.Actions style={styles.buttonContainer}>
<Button
icon="lock-question"
mode="contained"
onPress={this.onResetPasswordClick}
color={props.theme.colors.warning}
style={styles.lockButton}
>
{i18n.t('screens.login.resetPassword')}
</Button>
<Button
icon="send"
mode="contained"
disabled={!this.shouldEnableLogin()}
loading={state.loading}
onPress={this.onSubmit}
style={styles.sendButton}
>
{i18n.t('screens.login.title')}
</Button>
</Card.Actions>
<Card.Actions>
<Button
icon="help-circle"
mode="contained"
onPress={this.showMascotDialog}
style={GENERAL_STYLES.centerHorizontal}
>
{i18n.t('screens.login.mascotDialog.title')}
</Button>
</Card.Actions>
</Card.Content>
</View>
);
}
/** const hideErrorDialog = () =>
* The user has unfocused the input, his email is ready to be validated setCurrentError({ status: REQUEST_STATUS.SUCCESS });
*/
validateEmail = () => {
this.setState({ isEmailValidated: true });
};
/**
* The user has unfocused the input, his password is ready to be validated
*/
validatePassword = () => {
this.setState({ isPasswordValidated: true });
};
hideMascotDialog = () => {
this.setState({ mascotDialogVisible: false });
};
showMascotDialog = () => {
this.setState({ mascotDialogVisible: true });
};
/**
* Shows an error dialog with the corresponding login error
*
* @param error The error given by the login request
*/
showErrorDialog = (error: ApiRejectType) => {
console.log(error);
this.setState({
dialogVisible: true,
dialogError: error,
});
};
hideErrorDialog = () => {
this.setState({ dialogVisible: false });
};
/** /**
* Navigates to the screen specified in navigation parameters or simply go back tha stack. * Navigates to the screen specified in navigation parameters or simply go back tha stack.
* Saves in user preferences to not show the login banner again. * Saves in user preferences to not show the login banner again.
*/ */
handleSuccess = () => { const handleSuccess = () => {
const { navigation } = this.props;
// Do not show the home login banner again // Do not show the home login banner again
// TODO if (homeMascot.shouldShow) {
// AsyncStorageManager.set( homeMascot.setShouldShow(false);
// AsyncStorageManager.PREFERENCES.homeShowMascot.key, }
// false if (!nextScreen) {
// );
if (this.nextScreen == null) {
navigation.goBack(); navigation.goBack();
} else { } else {
navigation.replace(this.nextScreen); navigation.replace(nextScreen);
} }
}; };
/**
* Saves the screen to navigate to after a successful login if one was provided in navigation parameters
*/
handleNavigationParams() {
this.nextScreen = this.props.route.params?.nextScreen;
}
/**
* Checks if the entered email is valid (matches the regex)
*
* @returns {boolean}
*/
isEmailValid(): boolean {
const { email } = this.state;
return emailRegex.test(email);
}
/**
* 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}
*/
shouldShowEmailError(): boolean {
const { isEmailValidated } = this.state;
return isEmailValidated && !this.isEmailValid();
}
/**
* Checks if the user has entered a password
*
* @returns {boolean}
*/
isPasswordValid(): boolean {
const { password } = this.state;
return password !== '';
}
/**
* 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}
*/
shouldShowPasswordError(): boolean {
const { isPasswordValidated } = this.state;
return isPasswordValidated && !this.isPasswordValid();
}
/**
* If the email and password are valid, and we are not loading a request, then the login button can be enabled
*
* @returns {boolean}
*/
shouldEnableLogin(): boolean {
const { loading } = this.state;
return this.isEmailValid() && this.isPasswordValid() && !loading;
}
render() {
const { mascotDialogVisible, dialogVisible, dialogError } = this.state;
return ( return (
<LinearGradient <LinearGradient
style={GENERAL_STYLES.flex} style={GENERAL_STYLES.flex}
@ -438,7 +115,14 @@ class LoginScreen extends React.Component<Props, StateType> {
keyboardVerticalOffset={100} keyboardVerticalOffset={100}
> >
<CollapsibleScrollView headerColors={'transparent'}> <CollapsibleScrollView headerColors={'transparent'}>
<View style={GENERAL_STYLES.flex}>{this.getMainCard()}</View> <View style={GENERAL_STYLES.flex}>
<LoginForm
loading={loading}
onSubmit={onSubmit}
onResetPasswordPress={onResetPasswordClick}
onHelpPress={showMascotDialog}
/>
</View>
<MascotPopup <MascotPopup
visible={mascotDialogVisible} visible={mascotDialogVisible}
title={i18n.t('screens.login.mascotDialog.title')} title={i18n.t('screens.login.mascotDialog.title')}
@ -448,22 +132,21 @@ class LoginScreen extends React.Component<Props, StateType> {
cancel: { cancel: {
message: i18n.t('screens.login.mascotDialog.button'), message: i18n.t('screens.login.mascotDialog.button'),
icon: 'check', icon: 'check',
onPress: this.hideMascotDialog, onPress: hideMascotDialog,
}, },
}} }}
emotion={MASCOT_STYLE.NORMAL} emotion={MASCOT_STYLE.NORMAL}
/> />
<ErrorDialog <ErrorDialog
visible={dialogVisible} visible={currentError.status !== REQUEST_STATUS.SUCCESS}
onDismiss={this.hideErrorDialog} onDismiss={hideErrorDialog}
status={dialogError.status} status={currentError.status}
code={dialogError.code} code={currentError.code}
/> />
</CollapsibleScrollView> </CollapsibleScrollView>
</KeyboardAvoidingView> </KeyboardAvoidingView>
</LinearGradient> </LinearGradient>
); );
} }
}
export default withTheme(LoginScreen); export default LoginScreen;

View file

@ -17,52 +17,29 @@
* along with Campus INSAT. If not, see <https://www.gnu.org/licenses/>. * along with Campus INSAT. If not, see <https://www.gnu.org/licenses/>.
*/ */
import * as React from 'react'; import React, { useLayoutEffect, useState } from 'react';
import { FlatList, StyleSheet, View } from 'react-native'; import { View } from 'react-native';
import {
Avatar,
Button,
Card,
Divider,
List,
Paragraph,
withTheme,
} from 'react-native-paper';
import i18n from 'i18n-js';
import { StackNavigationProp } from '@react-navigation/stack';
import LogoutDialog from '../../components/Amicale/LogoutDialog'; import LogoutDialog from '../../components/Amicale/LogoutDialog';
import MaterialHeaderButtons, { import MaterialHeaderButtons, {
Item, Item,
} from '../../components/Overrides/CustomHeaderButton'; } from '../../components/Overrides/CustomHeaderButton';
import CardList from '../../components/Lists/CardList/CardList';
import Mascot, { MASCOT_STYLE } from '../../components/Mascot/Mascot';
import CollapsibleFlatList from '../../components/Collapsible/CollapsibleFlatList'; import CollapsibleFlatList from '../../components/Collapsible/CollapsibleFlatList';
import GENERAL_STYLES from '../../constants/Styles'; import GENERAL_STYLES from '../../constants/Styles';
import Urls from '../../constants/Urls';
import RequestScreen from '../../components/Screens/RequestScreen'; import RequestScreen from '../../components/Screens/RequestScreen';
import ConnectionManager from '../../managers/ConnectionManager'; import ProfileWelcomeCard from '../../components/Amicale/Profile/ProfileWelcomeCard';
import { import ProfilePersonalCard from '../../components/Amicale/Profile/ProfilePersonalCard';
getAmicaleServices, import ProfileClubCard from '../../components/Amicale/Profile/ProfileClubCard';
ServiceItemType, import ProfileMembershipCard from '../../components/Amicale/Profile/ProfileMembershipCard';
SERVICES_KEY, import { useNavigation } from '@react-navigation/core';
} from '../../utils/Services'; import { useAuthenticatedRequest } from '../../context/loginContext';
type PropsType = { export type ProfileClubType = {
navigation: StackNavigationProp<any>;
theme: ReactNativePaper.Theme;
};
type StateType = {
dialogVisible: boolean;
};
type ClubType = {
id: number; id: number;
name: string; name: string;
is_manager: boolean; is_manager: boolean;
}; };
type ProfileDataType = { export type ProfileDataType = {
first_name: string; first_name: string;
last_name: string; last_name: string;
email: string; email: string;
@ -71,87 +48,68 @@ type ProfileDataType = {
branch: string; branch: string;
link: string; link: string;
validity: boolean; validity: boolean;
clubs: Array<ClubType>; clubs: Array<ProfileClubType>;
}; };
const styles = StyleSheet.create({ function ProfileScreen() {
card: { const navigation = useNavigation();
margin: 10, const [dialogVisible, setDialogVisible] = useState(false);
}, const request = useAuthenticatedRequest<ProfileDataType>('user/profile');
icon: {
backgroundColor: 'transparent',
},
editButton: {
marginLeft: 'auto',
},
mascot: {
width: 60,
},
title: {
marginLeft: 10,
},
});
class ProfileScreen extends React.Component<PropsType, StateType> { useLayoutEffect(() => {
data: ProfileDataType | undefined; const getHeaderButton = () => (
flatListData: Array<{ id: string }>;
amicaleDataset: Array<ServiceItemType>;
constructor(props: PropsType) {
super(props);
this.data = undefined;
this.flatListData = [{ id: '0' }, { id: '1' }, { id: '2' }, { id: '3' }];
this.amicaleDataset = getAmicaleServices(props.navigation.navigate, [
SERVICES_KEY.PROFILE,
]);
this.state = {
dialogVisible: false,
};
}
componentDidMount() {
const { navigation } = this.props;
navigation.setOptions({
headerRight: this.getHeaderButton,
});
}
/**
* Gets the logout header button
*
* @returns {*}
*/
getHeaderButton = () => (
<MaterialHeaderButtons> <MaterialHeaderButtons>
<Item <Item
title="logout" title={'logout'}
iconName="logout" iconName={'logout'}
onPress={this.showDisconnectDialog} onPress={showDisconnectDialog}
/> />
</MaterialHeaderButtons> </MaterialHeaderButtons>
); );
navigation.setOptions({
headerRight: getHeaderButton,
});
}, [navigation]);
/** const getScreen = (data: ProfileDataType | undefined) => {
* Gets the main screen component with the fetched data
*
* @param data The data fetched from the server
* @returns {*}
*/
getScreen = (data: ProfileDataType | undefined) => {
const { dialogVisible } = this.state;
if (data) { if (data) {
this.data = data; const flatListData: Array<{
id: string;
render: () => React.ReactElement;
}> = [];
for (let i = 0; i < 4; i++) {
switch (i) {
case 0:
flatListData.push({
id: i.toString(),
render: () => <ProfileWelcomeCard firstname={data?.first_name} />,
});
break;
case 1:
flatListData.push({
id: i.toString(),
render: () => <ProfilePersonalCard profile={data} />,
});
break;
case 2:
flatListData.push({
id: i.toString(),
render: () => <ProfileClubCard clubs={data?.clubs} />,
});
break;
default:
flatListData.push({
id: i.toString(),
render: () => <ProfileMembershipCard valid={data?.validity} />,
});
}
}
return ( return (
<View style={GENERAL_STYLES.flex}> <View style={GENERAL_STYLES.flex}>
<CollapsibleFlatList <CollapsibleFlatList renderItem={getRenderItem} data={flatListData} />
renderItem={this.getRenderItem}
data={this.flatListData}
/>
<LogoutDialog <LogoutDialog
visible={dialogVisible} visible={dialogVisible}
onDismiss={this.hideDisconnectDialog} onDismiss={hideDisconnectDialog}
/> />
</View> </View>
); );
@ -160,346 +118,17 @@ class ProfileScreen extends React.Component<PropsType, StateType> {
} }
}; };
getRenderItem = ({ item }: { item: { id: string } }) => { const getRenderItem = ({
switch (item.id) { item,
case '0': }: {
return this.getWelcomeCard(); item: { id: string; render: () => React.ReactElement };
case '1': }) => item.render();
return this.getPersonalCard();
case '2':
return this.getClubCard();
default:
return this.getMembershipCar();
}
};
/** const showDisconnectDialog = () => setDialogVisible(true);
* Gets the list of services available with the Amicale account
* const hideDisconnectDialog = () => setDialogVisible(false);
* @returns {*}
*/ return <RequestScreen request={request} render={getScreen} />;
getServicesList() {
return <CardList dataset={this.amicaleDataset} isHorizontal />;
} }
/** export default ProfileScreen;
* Gets a card welcoming the user to his account
*
* @returns {*}
*/
getWelcomeCard() {
const { navigation } = this.props;
return (
<Card style={styles.card}>
<Card.Title
title={i18n.t('screens.profile.welcomeTitle', {
name: this.data?.first_name,
})}
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>
{this.getServicesList()}
<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>
);
}
/**
* Gets the given field value.
* If the field does not have a value, returns a placeholder text
*
* @param field The field to get the value from
* @return {*}
*/
static getFieldValue(field?: string): string {
return field ? field : i18n.t('screens.profile.noData');
}
/**
* Gets a list item showing personal information
*
* @param field The field to display
* @param icon The icon to use
* @return {*}
*/
getPersonalListItem(field: string | undefined, icon: string) {
const { theme } = this.props;
const title = field != null ? ProfileScreen.getFieldValue(field) : ':(';
const subtitle = field != null ? '' : ProfileScreen.getFieldValue(field);
return (
<List.Item
title={title}
description={subtitle}
left={(props) => (
<List.Icon
style={props.style}
icon={icon}
color={field != null ? props.color : theme.colors.textDisabled}
/>
)}
/>
);
}
/**
* Gets a card containing user personal information
*
* @return {*}
*/
getPersonalCard() {
const { theme, navigation } = this.props;
return (
<Card style={styles.card}>
<Card.Title
title={`${this.data?.first_name} ${this.data?.last_name}`}
subtitle={this.data?.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>
{this.getPersonalListItem(this.data?.birthday, 'cake-variant')}
{this.getPersonalListItem(this.data?.phone, 'phone')}
{this.getPersonalListItem(this.data?.email, 'email')}
{this.getPersonalListItem(this.data?.branch, 'school')}
</List.Section>
<Divider />
<Card.Actions>
<Button
icon="account-edit"
mode="contained"
onPress={() => {
navigation.navigate('website', {
host: Urls.websites.amicale,
path: this.data?.link,
title: i18n.t('screens.websites.amicale'),
});
}}
style={styles.editButton}
>
{i18n.t('screens.profile.editInformation')}
</Button>
</Card.Actions>
</Card.Content>
</Card>
);
}
/**
* Gets a cars containing clubs the user is part of
*
* @return {*}
*/
getClubCard() {
const { theme } = this.props;
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 />
{this.getClubList(this.data?.clubs)}
</Card.Content>
</Card>
);
}
/**
* Gets a card showing if the user has payed his membership
*
* @return {*}
*/
getMembershipCar() {
const { theme } = this.props;
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>
{this.getMembershipItem(this.data?.validity === true)}
</List.Section>
</Card.Content>
</Card>
);
}
/**
* Gets the item showing if the user has payed his membership
*
* @return {*}
*/
getMembershipItem(state: boolean) {
const { theme } = this.props;
return (
<List.Item
title={
state
? i18n.t('screens.profile.membershipPayed')
: i18n.t('screens.profile.membershipNotPayed')
}
left={(props) => (
<List.Icon
style={props.style}
color={state ? theme.colors.success : theme.colors.danger}
icon={state ? 'check' : 'close'}
/>
)}
/>
);
}
/**
* Gets a list item for the club list
*
* @param item The club to render
* @return {*}
*/
getClubListItem = ({ item }: { item: ClubType }) => {
const { theme } = this.props;
const onPress = () => {
this.openClubDetailsScreen(item.id);
};
let description = i18n.t('screens.profile.isMember');
let icon = (props: {
color: string;
style: {
marginLeft: number;
marginRight: number;
marginVertical?: number;
};
}) => (
<List.Icon color={props.color} style={props.style} icon="chevron-right" />
);
if (item.is_manager) {
description = i18n.t('screens.profile.isManager');
icon = (props) => (
<List.Icon
style={props.style}
icon="star"
color={theme.colors.primary}
/>
);
}
return (
<List.Item
title={item.name}
description={description}
left={icon}
onPress={onPress}
/>
);
};
/**
* Renders the list of clubs the user is part of
*
* @param list The club list
* @return {*}
*/
getClubList(list: Array<ClubType> | undefined) {
if (!list) {
return null;
}
list.sort(this.sortClubList);
return (
<FlatList
renderItem={this.getClubListItem}
keyExtractor={this.clubKeyExtractor}
data={list}
/>
);
}
clubKeyExtractor = (item: ClubType): string => item.name;
sortClubList = (a: ClubType): number => (a.is_manager ? -1 : 1);
showDisconnectDialog = () => {
this.setState({ dialogVisible: true });
};
hideDisconnectDialog = () => {
this.setState({ dialogVisible: false });
};
/**
* Opens the club details screen for the club of given ID
* @param id The club's id to open
*/
openClubDetailsScreen(id: number) {
const { navigation } = this.props;
navigation.navigate('club-information', { clubId: id });
}
render() {
return (
<RequestScreen<ProfileDataType>
request={() =>
ConnectionManager.getInstance().authenticatedRequest('user/profile')
}
render={this.getScreen}
/>
);
}
}
export default withTheme(ProfileScreen);

View file

@ -17,7 +17,7 @@
* along with Campus INSAT. If not, see <https://www.gnu.org/licenses/>. * along with Campus INSAT. If not, see <https://www.gnu.org/licenses/>.
*/ */
import * as React from 'react'; import React, { useRef, useState } from 'react';
import { StyleSheet, View } from 'react-native'; import { StyleSheet, View } from 'react-native';
import i18n from 'i18n-js'; import i18n from 'i18n-js';
import { Button } from 'react-native-paper'; import { Button } from 'react-native-paper';
@ -30,10 +30,10 @@ import { MASCOT_STYLE } from '../../components/Mascot/Mascot';
import MascotPopup from '../../components/Mascot/MascotPopup'; import MascotPopup from '../../components/Mascot/MascotPopup';
import VoteNotAvailable from '../../components/Amicale/Vote/VoteNotAvailable'; import VoteNotAvailable from '../../components/Amicale/Vote/VoteNotAvailable';
import GENERAL_STYLES from '../../constants/Styles'; import GENERAL_STYLES from '../../constants/Styles';
import ConnectionManager from '../../managers/ConnectionManager';
import WebSectionList, { import WebSectionList, {
SectionListDataType, SectionListDataType,
} from '../../components/Screens/WebSectionList'; } from '../../components/Screens/WebSectionList';
import { useAuthenticatedRequest } from '../../context/loginContext';
export type VoteTeamType = { export type VoteTeamType = {
id: number; id: number;
@ -65,6 +65,13 @@ type ResponseType = {
dates?: VoteDatesStringType; dates?: VoteDatesStringType;
}; };
type FlatlistType = {
teams: Array<VoteTeamType>;
hasVoted: boolean;
datesString?: VoteDatesStringType;
dates?: VoteDatesObjectType;
};
// const FAKE_DATE = { // const FAKE_DATE = {
// "date_begin": "2020-08-19 15:50", // "date_begin": "2020-08-19 15:50",
// "date_end": "2020-08-19 15:50", // "date_end": "2020-08-19 15:50",
@ -113,13 +120,6 @@ type ResponseType = {
// ], // ],
// }; // };
type PropsType = {};
type StateType = {
hasVoted: boolean;
mascotDialogVisible: boolean | undefined;
};
const styles = StyleSheet.create({ const styles = StyleSheet.create({
button: { button: {
marginLeft: 'auto', marginLeft: 'auto',
@ -131,38 +131,19 @@ const styles = StyleSheet.create({
/** /**
* Screen displaying vote information and controls * Screen displaying vote information and controls
*/ */
export default class VoteScreen extends React.Component<PropsType, StateType> { export default function VoteScreen() {
teams: Array<VoteTeamType>; const [hasVoted, setHasVoted] = useState(false);
const [mascotDialogVisible, setMascotDialogVisible] = useState(false);
hasVoted: boolean; const datesRequest = useAuthenticatedRequest<VoteDatesStringType>(
'elections/dates'
datesString: undefined | VoteDatesStringType; );
const teamsRequest = useAuthenticatedRequest<TeamResponseType>(
dates: undefined | VoteDatesObjectType; 'elections/teams'
);
today: Date;
mainFlatListData: SectionListDataType<{ key: string }>;
refreshData: () => void;
constructor(props: PropsType) {
super(props);
this.teams = [];
this.datesString = undefined;
this.dates = undefined;
this.state = {
hasVoted: false,
mascotDialogVisible: undefined,
};
this.hasVoted = false;
this.today = new Date();
this.refreshData = () => undefined;
this.mainFlatListData = [
{ title: '', data: [{ key: 'main' }, { key: 'info' }] },
];
}
const today = new Date();
const refresh = useRef<() => void | undefined>();
/** /**
* Gets the string representation of the given date. * Gets the string representation of the given date.
* *
@ -173,22 +154,26 @@ export default class VoteScreen extends React.Component<PropsType, StateType> {
* @param dateString The string representation of the wanted date * @param dateString The string representation of the wanted date
* @returns {string} * @returns {string}
*/ */
getDateString(date: Date, dateString: string): string { const getDateString = (date: Date, dateString: string) => {
if (this.today.getDate() === date.getDate()) { if (today.getDate() === date.getDate()) {
const str = getTimeOnlyString(dateString); const str = getTimeOnlyString(dateString);
return str != null ? str : ''; return str != null ? str : '';
} }
return dateString; return dateString;
} };
getMainRenderItem = ({ item }: { item: { key: string } }) => { const getMainRenderItem = ({
item,
}: {
item: { key: string; data?: FlatlistType };
}) => {
if (item.key === 'info') { if (item.key === 'info') {
return ( return (
<View> <View>
<Button <Button
mode="contained" mode="contained"
icon="help-circle" icon="help-circle"
onPress={this.showMascotDialog} onPress={showMascotDialog}
style={styles.button} style={styles.button}
> >
{i18n.t('screens.vote.mascotDialog.title')} {i18n.t('screens.vote.mascotDialog.title')}
@ -196,10 +181,14 @@ export default class VoteScreen extends React.Component<PropsType, StateType> {
</View> </View>
); );
} }
return this.getContent(); if (item.data) {
return getContent(item.data);
} else {
return <View />;
}
}; };
createDataset = ( const createDataset = (
data: ResponseType | undefined, data: ResponseType | undefined,
_loading: boolean, _loading: boolean,
_lastRefreshDate: Date | undefined, _lastRefreshDate: Date | undefined,
@ -207,157 +196,158 @@ export default class VoteScreen extends React.Component<PropsType, StateType> {
) => { ) => {
// data[0] = FAKE_TEAMS2; // data[0] = FAKE_TEAMS2;
// data[1] = FAKE_DATE; // data[1] = FAKE_DATE;
this.refreshData = refreshData;
const mainFlatListData: SectionListDataType<{
key: string;
data?: FlatlistType;
}> = [
{
title: '',
data: [{ key: 'main' }, { key: 'info' }],
},
];
refresh.current = refreshData;
if (data) { if (data) {
const { teams, dates } = data; const { teams, dates } = data;
const flatlistData: FlatlistType = {
if (dates && dates.date_begin == null) { teams: [],
this.datesString = undefined; hasVoted: false,
} else { };
this.datesString = dates; if (dates && dates.date_begin != null) {
flatlistData.datesString = dates;
} }
if (teams) { if (teams) {
this.teams = teams.teams; flatlistData.teams = teams.teams;
this.hasVoted = teams.has_voted; flatlistData.hasVoted = teams.has_voted;
} }
flatlistData.dates = generateDateObject(flatlistData.datesString);
this.generateDateObject();
} }
return this.mainFlatListData; return mainFlatListData;
}; };
getContent() { const getContent = (data: FlatlistType) => {
const { state } = this; const { dates } = data;
if (!this.isVoteStarted()) { if (!isVoteStarted(dates)) {
return this.getTeaseVoteCard(); return getTeaseVoteCard(data);
} }
if (this.isVoteRunning() && !this.hasVoted && !state.hasVoted) { if (isVoteRunning(dates) && !data.hasVoted && !hasVoted) {
return this.getVoteCard(); return getVoteCard(data);
} }
if (!this.isResultStarted()) { if (!isResultStarted(dates)) {
return this.getWaitVoteCard(); return getWaitVoteCard(data);
} }
if (this.isResultRunning()) { if (isResultRunning(dates)) {
return this.getVoteResultCard(); return getVoteResultCard(data);
} }
return <VoteNotAvailable />; return <VoteNotAvailable />;
} };
onVoteSuccess = (): void => this.setState({ hasVoted: true });
const onVoteSuccess = () => setHasVoted(true);
/** /**
* The user has not voted yet, and the votes are open * The user has not voted yet, and the votes are open
*/ */
getVoteCard() { const getVoteCard = (data: FlatlistType) => {
return ( return (
<VoteSelect <VoteSelect
teams={this.teams} teams={data.teams}
onVoteSuccess={this.onVoteSuccess} onVoteSuccess={onVoteSuccess}
onVoteError={this.refreshData} onVoteError={() => {
if (refresh.current) {
refresh.current();
}
}}
/> />
); );
} };
/** /**
* Votes have ended, results can be displayed * Votes have ended, results can be displayed
*/ */
getVoteResultCard() { const getVoteResultCard = (data: FlatlistType) => {
if (this.dates != null && this.datesString != null) { if (data.dates != null && data.datesString != null) {
return ( return (
<VoteResults <VoteResults
teams={this.teams} teams={data.teams}
dateEnd={this.getDateString( dateEnd={getDateString(
this.dates.date_result_end, data.dates.date_result_end,
this.datesString.date_result_end data.datesString.date_result_end
)} )}
/> />
); );
} }
return <VoteNotAvailable />; return <VoteNotAvailable />;
} };
/** /**
* Vote will open shortly * Vote will open shortly
*/ */
getTeaseVoteCard() { const getTeaseVoteCard = (data: FlatlistType) => {
if (this.dates != null && this.datesString != null) { if (data.dates != null && data.datesString != null) {
return ( return (
<VoteTease <VoteTease
startDate={this.getDateString( startDate={getDateString(
this.dates.date_begin, data.dates.date_begin,
this.datesString.date_begin data.datesString.date_begin
)} )}
/> />
); );
} }
return <VoteNotAvailable />; return <VoteNotAvailable />;
} };
/** /**
* Votes have ended, or user has voted waiting for results * Votes have ended, or user has voted waiting for results
*/ */
getWaitVoteCard() { const getWaitVoteCard = (data: FlatlistType) => {
const { state } = this;
let startDate = null; let startDate = null;
if ( if (
this.dates != null && data.dates != null &&
this.datesString != null && data.datesString != null &&
this.dates.date_result_begin != null data.dates.date_result_begin != null
) { ) {
startDate = this.getDateString( startDate = getDateString(
this.dates.date_result_begin, data.dates.date_result_begin,
this.datesString.date_result_begin data.datesString.date_result_begin
); );
} }
return ( return (
<VoteWait <VoteWait
startDate={startDate} startDate={startDate}
hasVoted={this.hasVoted || state.hasVoted} hasVoted={data.hasVoted}
justVoted={state.hasVoted} justVoted={hasVoted}
isVoteRunning={this.isVoteRunning()} isVoteRunning={isVoteRunning()}
/> />
); );
}
showMascotDialog = () => {
this.setState({ mascotDialogVisible: true });
}; };
hideMascotDialog = () => { const showMascotDialog = () => setMascotDialogVisible(true);
this.setState({ mascotDialogVisible: false });
const hideMascotDialog = () => setMascotDialogVisible(false);
const isVoteStarted = (dates?: VoteDatesObjectType) => {
return dates != null && today > dates.date_begin;
}; };
isVoteStarted(): boolean { const isResultRunning = (dates?: VoteDatesObjectType) => {
return this.dates != null && this.today > this.dates.date_begin;
}
isResultRunning(): boolean {
return ( return (
this.dates != null && dates != null &&
this.today > this.dates.date_result_begin && today > dates.date_result_begin &&
this.today < this.dates.date_result_end today < dates.date_result_end
); );
} };
isResultStarted(): boolean { const isResultStarted = (dates?: VoteDatesObjectType) => {
return this.dates != null && this.today > this.dates.date_result_begin; return dates != null && today > dates.date_result_begin;
} };
isVoteRunning(): boolean { const isVoteRunning = (dates?: VoteDatesObjectType) => {
return ( return dates != null && today > dates.date_begin && today < dates.date_end;
this.dates != null && };
this.today > this.dates.date_begin &&
this.today < this.dates.date_end
);
}
/** /**
* Generates the objects containing string and Date representations of key vote dates * Generates the objects containing string and Date representations of key vote dates
*/ */
generateDateObject() { const generateDateObject = (
const strings = this.datesString; strings?: VoteDatesStringType
if (strings != null) { ): VoteDatesObjectType | undefined => {
if (strings) {
const dateBegin = stringToDate(strings.date_begin); const dateBegin = stringToDate(strings.date_begin);
const dateEnd = stringToDate(strings.date_end); const dateEnd = stringToDate(strings.date_end);
const dateResultBegin = stringToDate(strings.date_result_begin); const dateResultBegin = stringToDate(strings.date_result_begin);
@ -368,27 +358,25 @@ export default class VoteScreen extends React.Component<PropsType, StateType> {
dateResultBegin != null && dateResultBegin != null &&
dateResultEnd != null dateResultEnd != null
) { ) {
this.dates = { return {
date_begin: dateBegin, date_begin: dateBegin,
date_end: dateEnd, date_end: dateEnd,
date_result_begin: dateResultBegin, date_result_begin: dateResultBegin,
date_result_end: dateResultEnd, date_result_end: dateResultEnd,
}; };
} else { } else {
this.dates = undefined; return undefined;
} }
} else { } else {
this.dates = undefined; return undefined;
}
} }
};
request = () => { const request = () => {
return new Promise((resolve: (data: ResponseType) => void) => { return new Promise((resolve: (data: ResponseType) => void) => {
ConnectionManager.getInstance() datesRequest()
.authenticatedRequest<VoteDatesStringType>('elections/dates')
.then((datesData) => { .then((datesData) => {
ConnectionManager.getInstance() teamsRequest()
.authenticatedRequest<TeamResponseType>('elections/teams')
.then((teamsData) => { .then((teamsData) => {
resolve({ resolve({
dates: datesData, dates: datesData,
@ -405,25 +393,16 @@ export default class VoteScreen extends React.Component<PropsType, StateType> {
}); });
}; };
/**
* Renders the authenticated screen.
*
* Teams and dates are not mandatory to allow showing the information box even if api requests fail
*
* @returns {*}
*/
render() {
const { state } = this;
return ( return (
<View style={GENERAL_STYLES.flex}> <View style={GENERAL_STYLES.flex}>
<WebSectionList <WebSectionList
request={this.request} request={request}
createDataset={this.createDataset} createDataset={createDataset}
extraData={state.hasVoted.toString()} extraData={hasVoted.toString()}
renderItem={this.getMainRenderItem} renderItem={getMainRenderItem}
/> />
<MascotPopup <MascotPopup
visible={state.mascotDialogVisible} visible={mascotDialogVisible}
title={i18n.t('screens.vote.mascotDialog.title')} title={i18n.t('screens.vote.mascotDialog.title')}
message={i18n.t('screens.vote.mascotDialog.message')} message={i18n.t('screens.vote.mascotDialog.message')}
icon="vote" icon="vote"
@ -431,7 +410,7 @@ export default class VoteScreen extends React.Component<PropsType, StateType> {
cancel: { cancel: {
message: i18n.t('screens.vote.mascotDialog.button'), message: i18n.t('screens.vote.mascotDialog.button'),
icon: 'check', icon: 'check',
onPress: this.hideMascotDialog, onPress: hideMascotDialog,
}, },
}} }}
emotion={MASCOT_STYLE.CUTE} emotion={MASCOT_STYLE.CUTE}
@ -439,4 +418,3 @@ export default class VoteScreen extends React.Component<PropsType, StateType> {
</View> </View>
); );
} }
}

View file

@ -46,7 +46,6 @@ import MaterialHeaderButtons, {
Item, Item,
} from '../../components/Overrides/CustomHeaderButton'; } from '../../components/Overrides/CustomHeaderButton';
import AnimatedFAB from '../../components/Animations/AnimatedFAB'; import AnimatedFAB from '../../components/Animations/AnimatedFAB';
import ConnectionManager from '../../managers/ConnectionManager';
import LogoutDialog from '../../components/Amicale/LogoutDialog'; import LogoutDialog from '../../components/Amicale/LogoutDialog';
import { MASCOT_STYLE } from '../../components/Mascot/Mascot'; import { MASCOT_STYLE } from '../../components/Mascot/Mascot';
import MascotPopup from '../../components/Mascot/MascotPopup'; import MascotPopup from '../../components/Mascot/MascotPopup';
@ -59,6 +58,7 @@ import { TabRoutes, TabStackParamsList } from '../../navigation/TabNavigator';
import { ServiceItemType } from '../../utils/Services'; import { ServiceItemType } from '../../utils/Services';
import { useCurrentDashboard } from '../../context/preferencesContext'; import { useCurrentDashboard } from '../../context/preferencesContext';
import { MainRoutes } from '../../navigation/MainNavigator'; import { MainRoutes } from '../../navigation/MainNavigator';
import { useLoginState } from '../../context/loginContext';
const FEED_ITEM_HEIGHT = 500; const FEED_ITEM_HEIGHT = 500;
@ -146,9 +146,7 @@ function HomeScreen(props: Props) {
const [dialogVisible, setDialogVisible] = useState(false); const [dialogVisible, setDialogVisible] = useState(false);
const fabRef = useRef<AnimatedFAB>(null); const fabRef = useRef<AnimatedFAB>(null);
const [isLoggedIn, setIsLoggedIn] = useState( const isLoggedIn = useLoginState();
ConnectionManager.getInstance().isLoggedIn()
);
const { currentDashboard } = useCurrentDashboard(); const { currentDashboard } = useCurrentDashboard();
let homeDashboard: FullDashboardType | null = null; let homeDashboard: FullDashboardType | null = null;
@ -199,13 +197,8 @@ function HomeScreen(props: Props) {
} }
} }
}; };
if (ConnectionManager.getInstance().isLoggedIn() !== isLoggedIn) {
setIsLoggedIn(ConnectionManager.getInstance().isLoggedIn());
}
// handle link open when home is not focused or created // handle link open when home is not focused or created
handleNavigationParams(); handleNavigationParams();
return () => {};
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [isLoggedIn]) }, [isLoggedIn])
); );

View file

@ -80,7 +80,8 @@ export function isApiResponseValid<T>(response: ApiResponseType<T>): boolean {
export async function apiRequest<T>( export async function apiRequest<T>(
path: string, path: string,
method: string, method: string,
params?: object params?: object,
token?: string
): Promise<T> { ): Promise<T> {
return new Promise( return new Promise(
(resolve: (data: T) => void, reject: (error: ApiRejectType) => void) => { (resolve: (data: T) => void, reject: (error: ApiRejectType) => void) => {
@ -88,7 +89,9 @@ export async function apiRequest<T>(
if (params != null) { if (params != null) {
requestParams = { ...params }; requestParams = { ...params };
} }
console.log(Urls.amicale.api + path); if (token) {
requestParams = { ...requestParams, token: token };
}
fetch(Urls.amicale.api + path, { fetch(Urls.amicale.api + path, {
method, method,
@ -135,6 +138,33 @@ export async function apiRequest<T>(
); );
} }
export async function connectToAmicale(email: string, password: string) {
return new Promise(
(
resolve: (token: string) => void,
reject: (error: ApiRejectType) => void
) => {
const data = {
email,
password,
};
apiRequest<ApiDataLoginType>('password', 'POST', data)
.then((response: ApiDataLoginType) => {
if (response.token != null) {
resolve(response.token);
} else {
reject({
status: REQUEST_STATUS.SERVER_ERROR,
});
}
})
.catch((err) => {
reject(err);
});
}
);
}
/** /**
* Reads data from the given url and returns it. * Reads data from the given url and returns it.
* *

46
src/utils/loginToken.ts Normal file
View file

@ -0,0 +1,46 @@
import * as Keychain from 'react-native-keychain';
/**
* Tries to recover login token from the secure keychain
*
* @returns Promise<string | undefined>
*/
export async function retrieveLoginToken(): Promise<string | undefined> {
return new Promise((resolve: (token: string | undefined) => void) => {
Keychain.getGenericPassword()
.then((data: Keychain.UserCredentials | false) => {
if (data && data.password) {
resolve(data.password);
} else {
resolve(undefined);
}
})
.catch(() => resolve(undefined));
});
}
/**
* Saves the login token in the secure keychain
*
* @param email
* @param token
* @returns Promise<void>
*/
export async function saveLoginToken(
email: string,
token: string
): Promise<void> {
return new Promise((resolve: () => void, reject: () => void) => {
Keychain.setGenericPassword(email, token).then(resolve).catch(reject);
});
}
/**
* Deletes the login token from the keychain
*
* @returns Promise<void>
*/
export async function deleteLoginToken(): Promise<void> {
return new Promise((resolve: () => void, reject: () => void) => {
Keychain.resetGenericPassword().then(resolve).catch(reject);
});
}

11
src/utils/logout.ts Normal file
View file

@ -0,0 +1,11 @@
import { useCallback } from 'react';
import { useLogin } from '../context/loginContext';
export const useLogout = () => {
const { setLogin } = useLogin();
const onLogout = useCallback(() => {
setLogin(undefined);
}, [setLogin]);
return onLogout;
};