Browse Source

convert connection manager to context

Arnaud Vergnet 6 months ago
parent
commit
541c002558

+ 16
- 9
App.tsx View File

@@ -21,7 +21,6 @@ import React from 'react';
21 21
 import { LogBox, Platform } from 'react-native';
22 22
 import { setSafeBounceHeight } from 'react-navigation-collapsible';
23 23
 import SplashScreen from 'react-native-splash-screen';
24
-import ConnectionManager from './src/managers/ConnectionManager';
25 24
 import type { ParsedUrlDataType } from './src/utils/URLHandler';
26 25
 import URLHandler from './src/utils/URLHandler';
27 26
 import initLocales from './src/utils/Locales';
@@ -48,6 +47,8 @@ import {
48 47
   ProxiwashPreferencesProvider,
49 48
 } from './src/components/providers/PreferencesProvider';
50 49
 import MainApp from './src/screens/MainApp';
50
+import LoginProvider from './src/components/providers/LoginProvider';
51
+import { retrieveLoginToken } from './src/utils/loginToken';
51 52
 
52 53
 // Native optimizations https://reactnavigation.org/docs/react-native-screens
53 54
 // Crashes app when navigating away from webview on android 9+
@@ -67,6 +68,7 @@ type StateType = {
67 68
     proxiwash: ProxiwashPreferencesType;
68 69
     mascot: MascotPreferencesType;
69 70
   };
71
+  loginToken?: string;
70 72
 };
71 73
 
72 74
 export default class App extends React.Component<{}, StateType> {
@@ -88,6 +90,7 @@ export default class App extends React.Component<{}, StateType> {
88 90
         proxiwash: defaultProxiwashPreferences,
89 91
         mascot: defaultMascotPreferences,
90 92
       },
93
+      loginToken: undefined,
91 94
     };
92 95
     initLocales();
93 96
     this.navigatorRef = React.createRef();
@@ -136,10 +139,11 @@ export default class App extends React.Component<{}, StateType> {
136 139
       | PlanexPreferencesType
137 140
       | ProxiwashPreferencesType
138 141
       | MascotPreferencesType
139
-      | void
142
+      | string
143
+      | undefined
140 144
     >
141 145
   ) => {
142
-    const [general, planex, proxiwash, mascot] = values;
146
+    const [general, planex, proxiwash, mascot, token] = values;
143 147
     this.setState({
144 148
       isLoading: false,
145 149
       initialPreferences: {
@@ -148,6 +152,7 @@ export default class App extends React.Component<{}, StateType> {
148 152
         proxiwash: proxiwash as ProxiwashPreferencesType,
149 153
         mascot: mascot as MascotPreferencesType,
150 154
       },
155
+      loginToken: token as string | undefined,
151 156
     });
152 157
     SplashScreen.hide();
153 158
   };
@@ -175,7 +180,7 @@ export default class App extends React.Component<{}, StateType> {
175 180
         Object.values(MascotPreferenceKeys),
176 181
         defaultMascotPreferences
177 182
       ),
178
-      ConnectionManager.getInstance().recoverLogin(),
183
+      retrieveLoginToken(),
179 184
     ])
180 185
       .then(this.onLoadFinished)
181 186
       .catch(this.onLoadFinished);
@@ -202,11 +207,13 @@ export default class App extends React.Component<{}, StateType> {
202 207
             <MascotPreferencesProvider
203 208
               initialPreferences={this.state.initialPreferences.mascot}
204 209
             >
205
-              <MainApp
206
-                ref={this.navigatorRef}
207
-                defaultHomeData={this.defaultHomeData}
208
-                defaultHomeRoute={this.defaultHomeRoute}
209
-              />
210
+              <LoginProvider initialToken={this.state.loginToken}>
211
+                <MainApp
212
+                  ref={this.navigatorRef}
213
+                  defaultHomeData={this.defaultHomeData}
214
+                  defaultHomeRoute={this.defaultHomeRoute}
215
+                />
216
+              </LoginProvider>
210 217
             </MascotPreferencesProvider>
211 218
           </ProxiwashPreferencesProvider>
212 219
         </PlanexPreferencesProvider>

+ 231
- 0
src/components/Amicale/Login/LoginForm.tsx View File

@@ -0,0 +1,231 @@
1
+import React, { useRef, useState } from 'react';
2
+import {
3
+  Image,
4
+  StyleSheet,
5
+  View,
6
+  TextInput as RNTextInput,
7
+} from 'react-native';
8
+import {
9
+  Button,
10
+  Card,
11
+  HelperText,
12
+  TextInput,
13
+  useTheme,
14
+} from 'react-native-paper';
15
+import i18n from 'i18n-js';
16
+import GENERAL_STYLES from '../../../constants/Styles';
17
+
18
+type Props = {
19
+  loading: boolean;
20
+  onSubmit: (email: string, password: string) => void;
21
+  onHelpPress: () => void;
22
+  onResetPasswordPress: () => void;
23
+};
24
+
25
+const ICON_AMICALE = require('../../../assets/amicale.png');
26
+
27
+const styles = StyleSheet.create({
28
+  card: {
29
+    marginTop: 'auto',
30
+    marginBottom: 'auto',
31
+  },
32
+  header: {
33
+    fontSize: 36,
34
+    marginBottom: 48,
35
+  },
36
+  text: {
37
+    color: '#ffffff',
38
+  },
39
+  buttonContainer: {
40
+    flexWrap: 'wrap',
41
+  },
42
+  lockButton: {
43
+    marginRight: 'auto',
44
+    marginBottom: 20,
45
+  },
46
+  sendButton: {
47
+    marginLeft: 'auto',
48
+  },
49
+});
50
+
51
+const emailRegex = /^.+@.+\..+$/;
52
+
53
+/**
54
+ * Checks if the entered email is valid (matches the regex)
55
+ *
56
+ * @returns {boolean}
57
+ */
58
+function isEmailValid(email: string): boolean {
59
+  return emailRegex.test(email);
60
+}
61
+
62
+/**
63
+ * Checks if the user has entered a password
64
+ *
65
+ * @returns {boolean}
66
+ */
67
+function isPasswordValid(password: string): boolean {
68
+  return password !== '';
69
+}
70
+
71
+export default function LoginForm(props: Props) {
72
+  const theme = useTheme();
73
+  const [email, setEmail] = useState('');
74
+  const [password, setPassword] = useState('');
75
+  const [isEmailValidated, setIsEmailValidated] = useState(false);
76
+  const [isPasswordValidated, setIsPasswordValidated] = useState(false);
77
+  const passwordRef = useRef<RNTextInput>(null);
78
+  /**
79
+   * Checks if we should tell the user his email is invalid.
80
+   * We should only show this if his email is invalid and has been checked when un-focusing the input
81
+   *
82
+   * @returns {boolean|boolean}
83
+   */
84
+  const shouldShowEmailError = () => {
85
+    return isEmailValidated && !isEmailValid(email);
86
+  };
87
+
88
+  /**
89
+   * Checks if we should tell the user his password is invalid.
90
+   * We should only show this if his password is invalid and has been checked when un-focusing the input
91
+   *
92
+   * @returns {boolean|boolean}
93
+   */
94
+  const shouldShowPasswordError = () => {
95
+    return isPasswordValidated && !isPasswordValid(password);
96
+  };
97
+
98
+  const onEmailSubmit = () => {
99
+    if (passwordRef.current) {
100
+      passwordRef.current.focus();
101
+    }
102
+  };
103
+
104
+  /**
105
+   * The user has unfocused the input, his email is ready to be validated
106
+   */
107
+  const validateEmail = () => setIsEmailValidated(true);
108
+
109
+  /**
110
+   * The user has unfocused the input, his password is ready to be validated
111
+   */
112
+  const validatePassword = () => setIsPasswordValidated(true);
113
+
114
+  const onEmailChange = (value: string) => {
115
+    if (isEmailValidated) {
116
+      setIsEmailValidated(false);
117
+    }
118
+    setEmail(value);
119
+  };
120
+
121
+  const onPasswordChange = (value: string) => {
122
+    if (isPasswordValidated) {
123
+      setIsPasswordValidated(false);
124
+    }
125
+    setPassword(value);
126
+  };
127
+
128
+  const shouldEnableLogin = () => {
129
+    return isEmailValid(email) && isPasswordValid(password) && !props.loading;
130
+  };
131
+
132
+  const onSubmit = () => {
133
+    if (shouldEnableLogin()) {
134
+      props.onSubmit(email, password);
135
+    }
136
+  };
137
+
138
+  return (
139
+    <View style={styles.card}>
140
+      <Card.Title
141
+        title={i18n.t('screens.login.title')}
142
+        titleStyle={styles.text}
143
+        subtitle={i18n.t('screens.login.subtitle')}
144
+        subtitleStyle={styles.text}
145
+        left={({ size }) => (
146
+          <Image
147
+            source={ICON_AMICALE}
148
+            style={{
149
+              width: size,
150
+              height: size,
151
+            }}
152
+          />
153
+        )}
154
+      />
155
+      <Card.Content>
156
+        <View>
157
+          <TextInput
158
+            label={i18n.t('screens.login.email')}
159
+            mode={'outlined'}
160
+            value={email}
161
+            onChangeText={onEmailChange}
162
+            onBlur={validateEmail}
163
+            onSubmitEditing={onEmailSubmit}
164
+            error={shouldShowEmailError()}
165
+            textContentType={'emailAddress'}
166
+            autoCapitalize={'none'}
167
+            autoCompleteType={'email'}
168
+            autoCorrect={false}
169
+            keyboardType={'email-address'}
170
+            returnKeyType={'next'}
171
+            secureTextEntry={false}
172
+          />
173
+          <HelperText type={'error'} visible={shouldShowEmailError()}>
174
+            {i18n.t('screens.login.emailError')}
175
+          </HelperText>
176
+          <TextInput
177
+            ref={passwordRef}
178
+            label={i18n.t('screens.login.password')}
179
+            mode={'outlined'}
180
+            value={password}
181
+            onChangeText={onPasswordChange}
182
+            onBlur={validatePassword}
183
+            onSubmitEditing={onSubmit}
184
+            error={shouldShowPasswordError()}
185
+            textContentType={'password'}
186
+            autoCapitalize={'none'}
187
+            autoCompleteType={'password'}
188
+            autoCorrect={false}
189
+            keyboardType={'default'}
190
+            returnKeyType={'done'}
191
+            secureTextEntry={true}
192
+          />
193
+          <HelperText type={'error'} visible={shouldShowPasswordError()}>
194
+            {i18n.t('screens.login.passwordError')}
195
+          </HelperText>
196
+        </View>
197
+        <Card.Actions style={styles.buttonContainer}>
198
+          <Button
199
+            icon="lock-question"
200
+            mode="contained"
201
+            onPress={props.onResetPasswordPress}
202
+            color={theme.colors.warning}
203
+            style={styles.lockButton}
204
+          >
205
+            {i18n.t('screens.login.resetPassword')}
206
+          </Button>
207
+          <Button
208
+            icon="send"
209
+            mode="contained"
210
+            disabled={!shouldEnableLogin()}
211
+            loading={props.loading}
212
+            onPress={onSubmit}
213
+            style={styles.sendButton}
214
+          >
215
+            {i18n.t('screens.login.title')}
216
+          </Button>
217
+        </Card.Actions>
218
+        <Card.Actions>
219
+          <Button
220
+            icon="help-circle"
221
+            mode="contained"
222
+            onPress={props.onHelpPress}
223
+            style={GENERAL_STYLES.centerHorizontal}
224
+          >
225
+            {i18n.t('screens.login.mascotDialog.title')}
226
+          </Button>
227
+        </Card.Actions>
228
+      </Card.Content>
229
+    </View>
230
+  );
231
+}

+ 5
- 13
src/components/Amicale/LogoutDialog.tsx View File

@@ -20,8 +20,7 @@
20 20
 import * as React from 'react';
21 21
 import i18n from 'i18n-js';
22 22
 import LoadingConfirmDialog from '../Dialogs/LoadingConfirmDialog';
23
-import ConnectionManager from '../../managers/ConnectionManager';
24
-import { useNavigation } from '@react-navigation/native';
23
+import { useLogout } from '../../utils/logout';
25 24
 
26 25
 type PropsType = {
27 26
   visible: boolean;
@@ -29,19 +28,12 @@ type PropsType = {
29 28
 };
30 29
 
31 30
 function LogoutDialog(props: PropsType) {
32
-  const navigation = useNavigation();
31
+  const onLogout = useLogout();
32
+  // Use a loading dialog as it can take some time to update the context
33 33
   const onClickAccept = async (): Promise<void> => {
34 34
     return new Promise((resolve: () => void) => {
35
-      ConnectionManager.getInstance()
36
-        .disconnect()
37
-        .then(() => {
38
-          navigation.reset({
39
-            index: 0,
40
-            routes: [{ name: 'main' }],
41
-          });
42
-          props.onDismiss();
43
-          resolve();
44
-        });
35
+      onLogout();
36
+      resolve();
45 37
     });
46 38
   };
47 39
 

+ 100
- 0
src/components/Amicale/Profile/ProfileClubCard.tsx View File

@@ -0,0 +1,100 @@
1
+import React from 'react';
2
+import { Card, Avatar, Divider, useTheme, List } from 'react-native-paper';
3
+import i18n from 'i18n-js';
4
+import { FlatList, StyleSheet } from 'react-native';
5
+import { ProfileClubType } from '../../../screens/Amicale/ProfileScreen';
6
+import { useNavigation } from '@react-navigation/core';
7
+
8
+type Props = {
9
+  clubs?: Array<ProfileClubType>;
10
+};
11
+
12
+const styles = StyleSheet.create({
13
+  card: {
14
+    margin: 10,
15
+  },
16
+  icon: {
17
+    backgroundColor: 'transparent',
18
+  },
19
+});
20
+
21
+export default function ProfileClubCard(props: Props) {
22
+  const theme = useTheme();
23
+  const navigation = useNavigation();
24
+
25
+  const clubKeyExtractor = (item: ProfileClubType) => item.name;
26
+
27
+  const getClubListItem = ({ item }: { item: ProfileClubType }) => {
28
+    const onPress = () =>
29
+      navigation.navigate('club-information', { clubId: item.id });
30
+    let description = i18n.t('screens.profile.isMember');
31
+    let icon = (leftProps: {
32
+      color: string;
33
+      style: {
34
+        marginLeft: number;
35
+        marginRight: number;
36
+        marginVertical?: number;
37
+      };
38
+    }) => (
39
+      <List.Icon
40
+        color={leftProps.color}
41
+        style={leftProps.style}
42
+        icon="chevron-right"
43
+      />
44
+    );
45
+    if (item.is_manager) {
46
+      description = i18n.t('screens.profile.isManager');
47
+      icon = (leftProps) => (
48
+        <List.Icon
49
+          style={leftProps.style}
50
+          icon="star"
51
+          color={theme.colors.primary}
52
+        />
53
+      );
54
+    }
55
+    return (
56
+      <List.Item
57
+        title={item.name}
58
+        description={description}
59
+        left={icon}
60
+        onPress={onPress}
61
+      />
62
+    );
63
+  };
64
+
65
+  function getClubList(list: Array<ProfileClubType> | undefined) {
66
+    if (!list) {
67
+      return null;
68
+    }
69
+
70
+    list.sort((a) => (a.is_manager ? -1 : 1));
71
+    return (
72
+      <FlatList
73
+        renderItem={getClubListItem}
74
+        keyExtractor={clubKeyExtractor}
75
+        data={list}
76
+      />
77
+    );
78
+  }
79
+
80
+  return (
81
+    <Card style={styles.card}>
82
+      <Card.Title
83
+        title={i18n.t('screens.profile.clubs')}
84
+        subtitle={i18n.t('screens.profile.clubsSubtitle')}
85
+        left={(iconProps) => (
86
+          <Avatar.Icon
87
+            size={iconProps.size}
88
+            icon="account-group"
89
+            color={theme.colors.primary}
90
+            style={styles.icon}
91
+          />
92
+        )}
93
+      />
94
+      <Card.Content>
95
+        <Divider />
96
+        {getClubList(props.clubs)}
97
+      </Card.Content>
98
+    </Card>
99
+  );
100
+}

+ 56
- 0
src/components/Amicale/Profile/ProfileMembershipCard.tsx View File

@@ -0,0 +1,56 @@
1
+import React from 'react';
2
+import { Avatar, Card, List, useTheme } from 'react-native-paper';
3
+import i18n from 'i18n-js';
4
+import { StyleSheet } from 'react-native';
5
+
6
+type Props = {
7
+  valid?: boolean;
8
+};
9
+
10
+const styles = StyleSheet.create({
11
+  card: {
12
+    margin: 10,
13
+  },
14
+  icon: {
15
+    backgroundColor: 'transparent',
16
+  },
17
+});
18
+
19
+export default function ProfileMembershipCard(props: Props) {
20
+  const theme = useTheme();
21
+  const state = props.valid === true;
22
+  return (
23
+    <Card style={styles.card}>
24
+      <Card.Title
25
+        title={i18n.t('screens.profile.membership')}
26
+        subtitle={i18n.t('screens.profile.membershipSubtitle')}
27
+        left={(iconProps) => (
28
+          <Avatar.Icon
29
+            size={iconProps.size}
30
+            icon="credit-card"
31
+            color={theme.colors.primary}
32
+            style={styles.icon}
33
+          />
34
+        )}
35
+      />
36
+      <Card.Content>
37
+        <List.Section>
38
+          <List.Item
39
+            title={
40
+              state
41
+                ? i18n.t('screens.profile.membershipPayed')
42
+                : i18n.t('screens.profile.membershipNotPayed')
43
+            }
44
+            left={(leftProps) => (
45
+              <List.Icon
46
+                style={leftProps.style}
47
+                color={state ? theme.colors.success : theme.colors.danger}
48
+                icon={state ? 'check' : 'close'}
49
+              />
50
+            )}
51
+          />
52
+        </List.Section>
53
+      </Card.Content>
54
+    </Card>
55
+  );
56
+}

+ 110
- 0
src/components/Amicale/Profile/ProfilePersonalCard.tsx View File

@@ -0,0 +1,110 @@
1
+import { useNavigation } from '@react-navigation/core';
2
+import React from 'react';
3
+import { StyleSheet } from 'react-native';
4
+import {
5
+  Avatar,
6
+  Button,
7
+  Card,
8
+  Divider,
9
+  List,
10
+  useTheme,
11
+} from 'react-native-paper';
12
+import Urls from '../../../constants/Urls';
13
+import { ProfileDataType } from '../../../screens/Amicale/ProfileScreen';
14
+import i18n from 'i18n-js';
15
+
16
+type Props = {
17
+  profile?: ProfileDataType;
18
+};
19
+
20
+const styles = StyleSheet.create({
21
+  card: {
22
+    margin: 10,
23
+  },
24
+  icon: {
25
+    backgroundColor: 'transparent',
26
+  },
27
+  editButton: {
28
+    marginLeft: 'auto',
29
+  },
30
+  mascot: {
31
+    width: 60,
32
+  },
33
+  title: {
34
+    marginLeft: 10,
35
+  },
36
+});
37
+
38
+function getFieldValue(field?: string): string {
39
+  return field ? field : i18n.t('screens.profile.noData');
40
+}
41
+
42
+export default function ProfilePersonalCard(props: Props) {
43
+  const { profile } = props;
44
+  const theme = useTheme();
45
+  const navigation = useNavigation();
46
+
47
+  function getPersonalListItem(field: string | undefined, icon: string) {
48
+    const title = field != null ? getFieldValue(field) : ':(';
49
+    const subtitle = field != null ? '' : getFieldValue(field);
50
+    return (
51
+      <List.Item
52
+        title={title}
53
+        description={subtitle}
54
+        left={(leftProps) => (
55
+          <List.Icon
56
+            style={leftProps.style}
57
+            icon={icon}
58
+            color={field != null ? leftProps.color : theme.colors.textDisabled}
59
+          />
60
+        )}
61
+      />
62
+    );
63
+  }
64
+
65
+  return (
66
+    <Card style={styles.card}>
67
+      <Card.Title
68
+        title={`${profile?.first_name} ${profile?.last_name}`}
69
+        subtitle={profile?.email}
70
+        left={(iconProps) => (
71
+          <Avatar.Icon
72
+            size={iconProps.size}
73
+            icon="account"
74
+            color={theme.colors.primary}
75
+            style={styles.icon}
76
+          />
77
+        )}
78
+      />
79
+      <Card.Content>
80
+        <Divider />
81
+        <List.Section>
82
+          <List.Subheader>
83
+            {i18n.t('screens.profile.personalInformation')}
84
+          </List.Subheader>
85
+          {getPersonalListItem(profile?.birthday, 'cake-variant')}
86
+          {getPersonalListItem(profile?.phone, 'phone')}
87
+          {getPersonalListItem(profile?.email, 'email')}
88
+          {getPersonalListItem(profile?.branch, 'school')}
89
+        </List.Section>
90
+        <Divider />
91
+        <Card.Actions>
92
+          <Button
93
+            icon="account-edit"
94
+            mode="contained"
95
+            onPress={() => {
96
+              navigation.navigate('website', {
97
+                host: Urls.websites.amicale,
98
+                path: profile?.link,
99
+                title: i18n.t('screens.websites.amicale'),
100
+              });
101
+            }}
102
+            style={styles.editButton}
103
+          >
104
+            {i18n.t('screens.profile.editInformation')}
105
+          </Button>
106
+        </Card.Actions>
107
+      </Card.Content>
108
+    </Card>
109
+  );
110
+}

+ 81
- 0
src/components/Amicale/Profile/ProfileWelcomeCard.tsx View File

@@ -0,0 +1,81 @@
1
+import { useNavigation } from '@react-navigation/core';
2
+import React from 'react';
3
+import { Button, Card, Divider, Paragraph } from 'react-native-paper';
4
+import Mascot, { MASCOT_STYLE } from '../../Mascot/Mascot';
5
+import i18n from 'i18n-js';
6
+import { StyleSheet } from 'react-native';
7
+import CardList from '../../Lists/CardList/CardList';
8
+import { getAmicaleServices, SERVICES_KEY } from '../../../utils/Services';
9
+
10
+type Props = {
11
+  firstname?: string;
12
+};
13
+
14
+const styles = StyleSheet.create({
15
+  card: {
16
+    margin: 10,
17
+  },
18
+  editButton: {
19
+    marginLeft: 'auto',
20
+  },
21
+  mascot: {
22
+    width: 60,
23
+  },
24
+  title: {
25
+    marginLeft: 10,
26
+  },
27
+});
28
+
29
+function ProfileWelcomeCard(props: Props) {
30
+  const navigation = useNavigation();
31
+  return (
32
+    <Card style={styles.card}>
33
+      <Card.Title
34
+        title={i18n.t('screens.profile.welcomeTitle', {
35
+          name: props.firstname,
36
+        })}
37
+        left={() => (
38
+          <Mascot
39
+            style={styles.mascot}
40
+            emotion={MASCOT_STYLE.COOL}
41
+            animated
42
+            entryAnimation={{
43
+              animation: 'bounceIn',
44
+              duration: 1000,
45
+            }}
46
+          />
47
+        )}
48
+        titleStyle={styles.title}
49
+      />
50
+      <Card.Content>
51
+        <Divider />
52
+        <Paragraph>{i18n.t('screens.profile.welcomeDescription')}</Paragraph>
53
+        <CardList
54
+          dataset={getAmicaleServices(navigation.navigate, [
55
+            SERVICES_KEY.PROFILE,
56
+          ])}
57
+          isHorizontal={true}
58
+        />
59
+        <Paragraph>{i18n.t('screens.profile.welcomeFeedback')}</Paragraph>
60
+        <Divider />
61
+        <Card.Actions>
62
+          <Button
63
+            icon="bug"
64
+            mode="contained"
65
+            onPress={() => {
66
+              navigation.navigate('feedback');
67
+            }}
68
+            style={styles.editButton}
69
+          >
70
+            {i18n.t('screens.feedback.homeButtonTitle')}
71
+          </Button>
72
+        </Card.Actions>
73
+      </Card.Content>
74
+    </Card>
75
+  );
76
+}
77
+
78
+export default React.memo(
79
+  ProfileWelcomeCard,
80
+  (pp, np) => pp.firstname === np.firstname
81
+);

+ 75
- 102
src/components/Amicale/Vote/VoteSelect.tsx View File

@@ -17,30 +17,23 @@
17 17
  * along with Campus INSAT.  If not, see <https://www.gnu.org/licenses/>.
18 18
  */
19 19
 
20
-import * as React from 'react';
20
+import React, { useState } from 'react';
21 21
 import { Avatar, Button, Card, RadioButton } from 'react-native-paper';
22 22
 import { FlatList, StyleSheet, View } from 'react-native';
23 23
 import i18n from 'i18n-js';
24
-import ConnectionManager from '../../../managers/ConnectionManager';
25 24
 import LoadingConfirmDialog from '../../Dialogs/LoadingConfirmDialog';
26 25
 import ErrorDialog from '../../Dialogs/ErrorDialog';
27 26
 import type { VoteTeamType } from '../../../screens/Amicale/VoteScreen';
28 27
 import { ApiRejectType } from '../../../utils/WebData';
29 28
 import { REQUEST_STATUS } from '../../../utils/Requests';
29
+import { useAuthenticatedRequest } from '../../../context/loginContext';
30 30
 
31
-type PropsType = {
31
+type Props = {
32 32
   teams: Array<VoteTeamType>;
33 33
   onVoteSuccess: () => void;
34 34
   onVoteError: () => void;
35 35
 };
36 36
 
37
-type StateType = {
38
-  selectedTeam: string;
39
-  voteDialogVisible: boolean;
40
-  errorDialogVisible: boolean;
41
-  currentError: ApiRejectType;
42
-};
43
-
44 37
 const styles = StyleSheet.create({
45 38
   card: {
46 39
     margin: 10,
@@ -50,118 +43,98 @@ const styles = StyleSheet.create({
50 43
   },
51 44
 });
52 45
 
53
-export default class VoteSelect extends React.PureComponent<
54
-  PropsType,
55
-  StateType
56
-> {
57
-  constructor(props: PropsType) {
58
-    super(props);
59
-    this.state = {
60
-      selectedTeam: 'none',
61
-      voteDialogVisible: false,
62
-      errorDialogVisible: false,
63
-      currentError: { status: REQUEST_STATUS.SUCCESS },
64
-    };
65
-  }
66
-
67
-  onVoteSelectionChange = (teamName: string): void =>
68
-    this.setState({ selectedTeam: teamName });
46
+function VoteSelect(props: Props) {
47
+  const [selectedTeam, setSelectedTeam] = useState('none');
48
+  const [voteDialogVisible, setVoteDialogVisible] = useState(false);
49
+  const [currentError, setCurrentError] = useState<ApiRejectType>({
50
+    status: REQUEST_STATUS.SUCCESS,
51
+  });
52
+  const request = useAuthenticatedRequest('elections/vote', {
53
+    team: parseInt(selectedTeam, 10),
54
+  });
69 55
 
70
-  voteKeyExtractor = (item: VoteTeamType): string => item.id.toString();
56
+  const voteKeyExtractor = (item: VoteTeamType) => item.id.toString();
71 57
 
72
-  voteRenderItem = ({ item }: { item: VoteTeamType }) => (
58
+  const voteRenderItem = ({ item }: { item: VoteTeamType }) => (
73 59
     <RadioButton.Item label={item.name} value={item.id.toString()} />
74 60
   );
75 61
 
76
-  showVoteDialog = (): void => this.setState({ voteDialogVisible: true });
62
+  const showVoteDialog = () => setVoteDialogVisible(true);
77 63
 
78
-  onVoteDialogDismiss = (): void => this.setState({ voteDialogVisible: false });
64
+  const onVoteDialogDismiss = () => setVoteDialogVisible(false);
79 65
 
80
-  onVoteDialogAccept = async (): Promise<void> => {
66
+  const onVoteDialogAccept = async (): Promise<void> => {
81 67
     return new Promise((resolve: () => void) => {
82
-      const { state } = this;
83
-      ConnectionManager.getInstance()
84
-        .authenticatedRequest('elections/vote', {
85
-          team: parseInt(state.selectedTeam, 10),
86
-        })
68
+      request()
87 69
         .then(() => {
88
-          this.onVoteDialogDismiss();
89
-          const { props } = this;
70
+          onVoteDialogDismiss();
90 71
           props.onVoteSuccess();
91 72
           resolve();
92 73
         })
93 74
         .catch((error: ApiRejectType) => {
94
-          this.onVoteDialogDismiss();
95
-          this.showErrorDialog(error);
75
+          onVoteDialogDismiss();
76
+          setCurrentError(error);
96 77
           resolve();
97 78
         });
98 79
     });
99 80
   };
100 81
 
101
-  showErrorDialog = (error: ApiRejectType): void =>
102
-    this.setState({
103
-      errorDialogVisible: true,
104
-      currentError: error,
105
-    });
106
-
107
-  onErrorDialogDismiss = () => {
108
-    this.setState({ errorDialogVisible: false });
109
-    const { props } = this;
82
+  const onErrorDialogDismiss = () => {
83
+    setCurrentError({ status: REQUEST_STATUS.SUCCESS });
110 84
     props.onVoteError();
111 85
   };
112 86
 
113
-  render() {
114
-    const { state, props } = this;
115
-    return (
116
-      <View>
117
-        <Card style={styles.card}>
118
-          <Card.Title
119
-            title={i18n.t('screens.vote.select.title')}
120
-            subtitle={i18n.t('screens.vote.select.subtitle')}
121
-            left={(iconProps) => (
122
-              <Avatar.Icon size={iconProps.size} icon="alert-decagram" />
123
-            )}
124
-          />
125
-          <Card.Content>
126
-            <RadioButton.Group
127
-              onValueChange={this.onVoteSelectionChange}
128
-              value={state.selectedTeam}
129
-            >
130
-              <FlatList
131
-                data={props.teams}
132
-                keyExtractor={this.voteKeyExtractor}
133
-                extraData={state.selectedTeam}
134
-                renderItem={this.voteRenderItem}
135
-              />
136
-            </RadioButton.Group>
137
-          </Card.Content>
138
-          <Card.Actions>
139
-            <Button
140
-              icon="send"
141
-              mode="contained"
142
-              onPress={this.showVoteDialog}
143
-              style={styles.button}
144
-              disabled={state.selectedTeam === 'none'}
145
-            >
146
-              {i18n.t('screens.vote.select.sendButton')}
147
-            </Button>
148
-          </Card.Actions>
149
-        </Card>
150
-        <LoadingConfirmDialog
151
-          visible={state.voteDialogVisible}
152
-          onDismiss={this.onVoteDialogDismiss}
153
-          onAccept={this.onVoteDialogAccept}
154
-          title={i18n.t('screens.vote.select.dialogTitle')}
155
-          titleLoading={i18n.t('screens.vote.select.dialogTitleLoading')}
156
-          message={i18n.t('screens.vote.select.dialogMessage')}
157
-        />
158
-        <ErrorDialog
159
-          visible={state.errorDialogVisible}
160
-          onDismiss={this.onErrorDialogDismiss}
161
-          status={state.currentError.status}
162
-          code={state.currentError.code}
87
+  return (
88
+    <View>
89
+      <Card style={styles.card}>
90
+        <Card.Title
91
+          title={i18n.t('screens.vote.select.title')}
92
+          subtitle={i18n.t('screens.vote.select.subtitle')}
93
+          left={(iconProps) => (
94
+            <Avatar.Icon size={iconProps.size} icon="alert-decagram" />
95
+          )}
163 96
         />
164
-      </View>
165
-    );
166
-  }
97
+        <Card.Content>
98
+          <RadioButton.Group
99
+            onValueChange={setSelectedTeam}
100
+            value={selectedTeam}
101
+          >
102
+            <FlatList
103
+              data={props.teams}
104
+              keyExtractor={voteKeyExtractor}
105
+              extraData={selectedTeam}
106
+              renderItem={voteRenderItem}
107
+            />
108
+          </RadioButton.Group>
109
+        </Card.Content>
110
+        <Card.Actions>
111
+          <Button
112
+            icon={'send'}
113
+            mode={'contained'}
114
+            onPress={showVoteDialog}
115
+            style={styles.button}
116
+            disabled={selectedTeam === 'none'}
117
+          >
118
+            {i18n.t('screens.vote.select.sendButton')}
119
+          </Button>
120
+        </Card.Actions>
121
+      </Card>
122
+      <LoadingConfirmDialog
123
+        visible={voteDialogVisible}
124
+        onDismiss={onVoteDialogDismiss}
125
+        onAccept={onVoteDialogAccept}
126
+        title={i18n.t('screens.vote.select.dialogTitle')}
127
+        titleLoading={i18n.t('screens.vote.select.dialogTitleLoading')}
128
+        message={i18n.t('screens.vote.select.dialogMessage')}
129
+      />
130
+      <ErrorDialog
131
+        visible={currentError.status !== REQUEST_STATUS.SUCCESS}
132
+        onDismiss={onErrorDialogDismiss}
133
+        status={currentError.status}
134
+        code={currentError.code}
135
+      />
136
+    </View>
137
+  );
167 138
 }
139
+
140
+export default VoteSelect;

+ 3
- 3
src/components/Lists/Equipment/EquipmentListItem.tsx View File

@@ -20,7 +20,6 @@
20 20
 import * as React from 'react';
21 21
 import { Avatar, List, useTheme } from 'react-native-paper';
22 22
 import i18n from 'i18n-js';
23
-import { StackNavigationProp } from '@react-navigation/stack';
24 23
 import type { DeviceType } from '../../../screens/Amicale/Equipment/EquipmentListScreen';
25 24
 import {
26 25
   getFirstEquipmentAvailability,
@@ -29,9 +28,9 @@ import {
29 28
 } from '../../../utils/EquipmentBooking';
30 29
 import { StyleSheet } from 'react-native';
31 30
 import GENERAL_STYLES from '../../../constants/Styles';
31
+import { useNavigation } from '@react-navigation/native';
32 32
 
33 33
 type PropsType = {
34
-  navigation: StackNavigationProp<any>;
35 34
   userDeviceRentDates: [string, string] | null;
36 35
   item: DeviceType;
37 36
   height: number;
@@ -48,7 +47,8 @@ const styles = StyleSheet.create({
48 47
 
49 48
 function EquipmentListItem(props: PropsType) {
50 49
   const theme = useTheme();
51
-  const { item, userDeviceRentDates, navigation, height } = props;
50
+  const navigation = useNavigation();
51
+  const { item, userDeviceRentDates, height } = props;
52 52
   const isRented = userDeviceRentDates != null;
53 53
   const isAvailable = isEquipmentAvailable(item);
54 54
   const firstAvailability = getFirstEquipmentAvailability(item);

+ 5
- 7
src/components/Screens/RequestScreen.tsx View File

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

+ 27
- 0
src/components/providers/LoginProvider.tsx View File

@@ -0,0 +1,27 @@
1
+import React, { useState } from 'react';
2
+import { LoginContext, LoginContextType } from '../../context/loginContext';
3
+
4
+type Props = {
5
+  children: React.ReactChild;
6
+  initialToken: string | undefined;
7
+};
8
+
9
+export default function LoginProvider(props: Props) {
10
+  const setLogin = (token: string | undefined) => {
11
+    setLoginState((prevState) => ({
12
+      ...prevState,
13
+      token,
14
+    }));
15
+  };
16
+
17
+  const [loginState, setLoginState] = useState<LoginContextType>({
18
+    token: props.initialToken,
19
+    setLogin: setLogin,
20
+  });
21
+
22
+  return (
23
+    <LoginContext.Provider value={loginState}>
24
+      {props.children}
25
+    </LoginContext.Provider>
26
+  );
27
+}

+ 46
- 0
src/context/loginContext.ts View File

@@ -0,0 +1,46 @@
1
+import React, { useContext } from 'react';
2
+import { apiRequest } from '../utils/WebData';
3
+
4
+export type LoginContextType = {
5
+  token: string | undefined;
6
+  setLogin: (token: string | undefined) => void;
7
+};
8
+
9
+export const LoginContext = React.createContext<LoginContextType>({
10
+  token: undefined,
11
+  setLogin: () => undefined,
12
+});
13
+
14
+/**
15
+ * Hook used to retrieve the user token and puid.
16
+ * @returns Login context with token and puid to undefined if user is not logged in
17
+ */
18
+export function useLogin() {
19
+  return useContext(LoginContext);
20
+}
21
+
22
+/**
23
+ * Checks if the user is connected
24
+ * @returns True if the user is connected
25
+ */
26
+export function useLoginState() {
27
+  const { token } = useLogin();
28
+  return token !== undefined;
29
+}
30
+
31
+/**
32
+ * Gets the current user token.
33
+ * @returns The token, or empty string if the user is not logged in
34
+ */
35
+export function useLoginToken() {
36
+  const { token } = useLogin();
37
+  return token ? token : '';
38
+}
39
+
40
+export function useAuthenticatedRequest<T>(
41
+  path: string,
42
+  params?: { [key: string]: any }
43
+) {
44
+  const token = useLoginToken();
45
+  return () => apiRequest<T>(path, 'POST', params, token);
46
+}

+ 0
- 205
src/managers/ConnectionManager.ts View File

@@ -1,205 +0,0 @@
1
-/*
2
- * Copyright (c) 2019 - 2020 Arnaud Vergnet.
3
- *
4
- * This file is part of Campus INSAT.
5
- *
6
- * Campus INSAT is free software: you can redistribute it and/or modify
7
- *  it under the terms of the GNU General Public License as published by
8
- * the Free Software Foundation, either version 3 of the License, or
9
- * (at your option) any later version.
10
- *
11
- * Campus INSAT is distributed in the hope that it will be useful,
12
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
13
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
- * GNU General Public License for more details.
15
- *
16
- * You should have received a copy of the GNU General Public License
17
- * along with Campus INSAT.  If not, see <https://www.gnu.org/licenses/>.
18
- */
19
-
20
-import * as Keychain from 'react-native-keychain';
21
-import { REQUEST_STATUS } from '../utils/Requests';
22
-import type { ApiDataLoginType, ApiRejectType } from '../utils/WebData';
23
-import { apiRequest } from '../utils/WebData';
24
-
25
-/**
26
- * champ: error
27
- *
28
- * 0 : SUCCESS -> pas d'erreurs
29
- * 1 : BAD_CREDENTIALS -> email ou mdp invalide
30
- * 2 : BAD_TOKEN -> session expirée
31
- * 3 : NO_CONSENT
32
- * 403 : FORBIDDEN -> accès a la ressource interdit
33
- * 500 : SERVER_ERROR -> pb coté serveur
34
- */
35
-
36
-const AUTH_PATH = 'password';
37
-
38
-export default class ConnectionManager {
39
-  static instance: ConnectionManager | null = null;
40
-
41
-  private token: string | null;
42
-
43
-  constructor() {
44
-    this.token = null;
45
-  }
46
-
47
-  /**
48
-   * Gets this class instance or create one if none is found
49
-   *
50
-   * @returns {ConnectionManager}
51
-   */
52
-  static getInstance(): ConnectionManager {
53
-    if (ConnectionManager.instance == null) {
54
-      ConnectionManager.instance = new ConnectionManager();
55
-    }
56
-    return ConnectionManager.instance;
57
-  }
58
-
59
-  /**
60
-   * Gets the current token
61
-   *
62
-   * @returns {string | null}
63
-   */
64
-  getToken(): string | null {
65
-    return this.token;
66
-  }
67
-
68
-  /**
69
-   * Tries to recover login token from the secure keychain
70
-   *
71
-   * @returns Promise<void>
72
-   */
73
-  async recoverLogin(): Promise<void> {
74
-    return new Promise((resolve: () => void) => {
75
-      const token = this.getToken();
76
-      if (token != null) {
77
-        resolve();
78
-      } else {
79
-        Keychain.getGenericPassword()
80
-          .then((data: Keychain.UserCredentials | false) => {
81
-            if (data && data.password != null) {
82
-              this.token = data.password;
83
-            }
84
-            resolve();
85
-          })
86
-          .catch(() => resolve());
87
-      }
88
-    });
89
-  }
90
-
91
-  /**
92
-   * Check if the user has a valid token
93
-   *
94
-   * @returns {boolean}
95
-   */
96
-  isLoggedIn(): boolean {
97
-    return this.getToken() !== null;
98
-  }
99
-
100
-  /**
101
-   * Saves the login token in the secure keychain
102
-   *
103
-   * @param email
104
-   * @param token
105
-   * @returns Promise<void>
106
-   */
107
-  async saveLogin(_email: string, token: string): Promise<void> {
108
-    return new Promise((resolve: () => void, reject: () => void) => {
109
-      Keychain.setGenericPassword('token', token)
110
-        .then(() => {
111
-          this.token = token;
112
-          resolve();
113
-        })
114
-        .catch((): void => reject());
115
-    });
116
-  }
117
-
118
-  /**
119
-   * Deletes the login token from the keychain
120
-   *
121
-   * @returns Promise<void>
122
-   */
123
-  async disconnect(): Promise<void> {
124
-    return new Promise((resolve: () => void, reject: () => void) => {
125
-      Keychain.resetGenericPassword()
126
-        .then(() => {
127
-          this.token = null;
128
-          resolve();
129
-        })
130
-        .catch((): void => reject());
131
-    });
132
-  }
133
-
134
-  /**
135
-   * Sends the given login and password to the api.
136
-   * If the combination is valid, the login token is received and saved in the secure keychain.
137
-   * If not, the promise is rejected with the corresponding error code.
138
-   *
139
-   * @param email
140
-   * @param password
141
-   * @returns Promise<void>
142
-   */
143
-  async connect(email: string, password: string): Promise<void> {
144
-    return new Promise(
145
-      (resolve: () => void, reject: (error: ApiRejectType) => void) => {
146
-        const data = {
147
-          email,
148
-          password,
149
-        };
150
-        apiRequest<ApiDataLoginType>(AUTH_PATH, 'POST', data)
151
-          .then((response: ApiDataLoginType) => {
152
-            if (response.token != null) {
153
-              this.saveLogin(email, response.token)
154
-                .then(() => resolve())
155
-                .catch(() =>
156
-                  reject({
157
-                    status: REQUEST_STATUS.TOKEN_SAVE,
158
-                  })
159
-                );
160
-            } else {
161
-              reject({
162
-                status: REQUEST_STATUS.SERVER_ERROR,
163
-              });
164
-            }
165
-          })
166
-          .catch((err) => {
167
-            reject(err);
168
-          });
169
-      }
170
-    );
171
-  }
172
-
173
-  /**
174
-   * Sends an authenticated request with the login token to the API
175
-   *
176
-   * @param path
177
-   * @param params
178
-   * @returns Promise<ApiGenericDataType>
179
-   */
180
-  async authenticatedRequest<T>(
181
-    path: string,
182
-    params?: { [key: string]: any }
183
-  ): Promise<T> {
184
-    return new Promise(
185
-      (
186
-        resolve: (response: T) => void,
187
-        reject: (error: ApiRejectType) => void
188
-      ) => {
189
-        if (this.getToken() !== null) {
190
-          const data = {
191
-            ...params,
192
-            token: this.getToken(),
193
-          };
194
-          apiRequest<T>(path, 'POST', data)
195
-            .then((response: T) => resolve(response))
196
-            .catch(reject);
197
-        } else {
198
-          reject({
199
-            status: REQUEST_STATUS.TOKEN_RETRIEVE,
200
-          });
201
-        }
202
-      }
203
-    );
204
-  }
205
-}

+ 62
- 77
src/screens/Amicale/Clubs/ClubDisplayScreen.tsx View File

@@ -17,7 +17,7 @@
17 17
  * along with Campus INSAT.  If not, see <https://www.gnu.org/licenses/>.
18 18
  */
19 19
 
20
-import * as React from 'react';
20
+import React, { useState } from 'react';
21 21
 import { Linking, StyleSheet, View } from 'react-native';
22 22
 import {
23 23
   Avatar,
@@ -25,20 +25,21 @@ import {
25 25
   Card,
26 26
   Chip,
27 27
   Paragraph,
28
-  withTheme,
28
+  useTheme,
29 29
 } from 'react-native-paper';
30 30
 import i18n from 'i18n-js';
31
-import { StackNavigationProp } from '@react-navigation/stack';
32 31
 import CustomHTML from '../../../components/Overrides/CustomHTML';
33 32
 import { TAB_BAR_HEIGHT } from '../../../components/Tabbar/CustomTabBar';
34 33
 import type { ClubCategoryType, ClubType } from './ClubListScreen';
35 34
 import CollapsibleScrollView from '../../../components/Collapsible/CollapsibleScrollView';
36 35
 import ImageGalleryButton from '../../../components/Media/ImageGalleryButton';
37 36
 import RequestScreen from '../../../components/Screens/RequestScreen';
38
-import ConnectionManager from '../../../managers/ConnectionManager';
37
+import { useFocusEffect } from '@react-navigation/core';
38
+import { useCallback } from 'react';
39
+import { useNavigation } from '@react-navigation/native';
40
+import { useAuthenticatedRequest } from '../../../context/loginContext';
39 41
 
40
-type PropsType = {
41
-  navigation: StackNavigationProp<any>;
42
+type Props = {
42 43
   route: {
43 44
     params?: {
44 45
       data?: ClubType;
@@ -46,7 +47,6 @@ type PropsType = {
46 47
       clubId?: number;
47 48
     };
48 49
   };
49
-  theme: ReactNativePaper.Theme;
50 50
 };
51 51
 
52 52
 type ResponseType = ClubType;
@@ -89,33 +89,28 @@ const styles = StyleSheet.create({
89 89
  * If called with data and categories navigation parameters, will use those to display the data.
90 90
  * If called with clubId parameter, will fetch the information on the server
91 91
  */
92
-class ClubDisplayScreen extends React.Component<PropsType> {
93
-  displayData: ClubType | undefined;
92
+function ClubDisplayScreen(props: Props) {
93
+  const navigation = useNavigation();
94
+  const theme = useTheme();
94 95
 
95
-  categories: Array<ClubCategoryType> | null;
96
+  const [displayData, setDisplayData] = useState<ClubType | undefined>();
97
+  const [categories, setCategories] = useState<
98
+    Array<ClubCategoryType> | undefined
99
+  >();
100
+  const [clubId, setClubId] = useState<number | undefined>();
96 101
 
97
-  clubId: number;
98
-
99
-  shouldFetchData: boolean;
100
-
101
-  constructor(props: PropsType) {
102
-    super(props);
103
-    this.displayData = undefined;
104
-    this.categories = null;
105
-    this.clubId = props.route.params?.clubId ? props.route.params.clubId : 0;
106
-    this.shouldFetchData = true;
107
-
108
-    if (
109
-      props.route.params &&
110
-      props.route.params.data &&
111
-      props.route.params.categories
112
-    ) {
113
-      this.displayData = props.route.params.data;
114
-      this.categories = props.route.params.categories;
115
-      this.clubId = props.route.params.data.id;
116
-      this.shouldFetchData = false;
117
-    }
118
-  }
102
+  useFocusEffect(
103
+    useCallback(() => {
104
+      if (props.route.params?.data && props.route.params?.categories) {
105
+        setDisplayData(props.route.params.data);
106
+        setCategories(props.route.params.categories);
107
+        setClubId(props.route.params.data.id);
108
+      } else {
109
+        const id = props.route.params?.clubId;
110
+        setClubId(id ? id : 0);
111
+      }
112
+    }, [props.route.params])
113
+  );
119 114
 
120 115
   /**
121 116
    * Gets the name of the category with the given ID
@@ -123,17 +118,17 @@ class ClubDisplayScreen extends React.Component<PropsType> {
123 118
    * @param id The category's ID
124 119
    * @returns {string|*}
125 120
    */
126
-  getCategoryName(id: number): string {
121
+  const getCategoryName = (id: number): string => {
127 122
     let categoryName = '';
128
-    if (this.categories !== null) {
129
-      this.categories.forEach((item: ClubCategoryType) => {
123
+    if (categories) {
124
+      categories.forEach((item: ClubCategoryType) => {
130 125
         if (id === item.id) {
131 126
           categoryName = item.name;
132 127
         }
133 128
       });
134 129
     }
135 130
     return categoryName;
136
-  }
131
+  };
137 132
 
138 133
   /**
139 134
    * Gets the view for rendering categories
@@ -141,23 +136,23 @@ class ClubDisplayScreen extends React.Component<PropsType> {
141 136
    * @param categories The categories to display (max 2)
142 137
    * @returns {null|*}
143 138
    */
144
-  getCategoriesRender(categories: Array<number | null>) {
145
-    if (this.categories == null) {
139
+  const getCategoriesRender = (c: Array<number | null>) => {
140
+    if (!categories) {
146 141
       return null;
147 142
     }
148 143
 
149 144
     const final: Array<React.ReactNode> = [];
150
-    categories.forEach((cat: number | null) => {
145
+    c.forEach((cat: number | null) => {
151 146
       if (cat != null) {
152 147
         final.push(
153 148
           <Chip style={styles.category} key={cat}>
154
-            {this.getCategoryName(cat)}
149
+            {getCategoryName(cat)}
155 150
           </Chip>
156 151
         );
157 152
       }
158 153
     });
159 154
     return <View style={styles.categoryContainer}>{final}</View>;
160
-  }
155
+  };
161 156
 
162 157
   /**
163 158
    * Gets the view for rendering club managers if any
@@ -166,8 +161,7 @@ class ClubDisplayScreen extends React.Component<PropsType> {
166 161
    * @param email The club contact email
167 162
    * @returns {*}
168 163
    */
169
-  getManagersRender(managers: Array<string>, email: string | null) {
170
-    const { props } = this;
164
+  const getManagersRender = (managers: Array<string>, email: string | null) => {
171 165
     const managersListView: Array<React.ReactNode> = [];
172 166
     managers.forEach((item: string) => {
173 167
       managersListView.push(<Paragraph key={item}>{item}</Paragraph>);
@@ -191,22 +185,18 @@ class ClubDisplayScreen extends React.Component<PropsType> {
191 185
             <Avatar.Icon
192 186
               size={iconProps.size}
193 187
               style={styles.icon}
194
-              color={
195
-                hasManagers
196
-                  ? props.theme.colors.success
197
-                  : props.theme.colors.primary
198
-              }
188
+              color={hasManagers ? theme.colors.success : theme.colors.primary}
199 189
               icon="account-tie"
200 190
             />
201 191
           )}
202 192
         />
203 193
         <Card.Content>
204 194
           {managersListView}
205
-          {ClubDisplayScreen.getEmailButton(email, hasManagers)}
195
+          {getEmailButton(email, hasManagers)}
206 196
         </Card.Content>
207 197
       </Card>
208 198
     );
209
-  }
199
+  };
210 200
 
211 201
   /**
212 202
    * 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> {
215 205
    * @param hasManagers True if the club has managers
216 206
    * @returns {*}
217 207
    */
218
-  static getEmailButton(email: string | null, hasManagers: boolean) {
208
+  const getEmailButton = (email: string | null, hasManagers: boolean) => {
219 209
     const destinationEmail =
220 210
       email != null && hasManagers ? email : AMICALE_MAIL;
221 211
     const text =
@@ -236,14 +226,14 @@ class ClubDisplayScreen extends React.Component<PropsType> {
236 226
         </Button>
237 227
       </Card.Actions>
238 228
     );
239
-  }
229
+  };
240 230
 
241
-  getScreen = (data: ResponseType | undefined) => {
231
+  const getScreen = (data: ResponseType | undefined) => {
242 232
     if (data) {
243
-      this.updateHeaderTitle(data);
233
+      updateHeaderTitle(data);
244 234
       return (
245 235
         <CollapsibleScrollView style={styles.scroll} hasTab>
246
-          {this.getCategoriesRender(data.category)}
236
+          {getCategoriesRender(data.category)}
247 237
           {data.logo !== null ? (
248 238
             <ImageGalleryButton
249 239
               images={[{ url: data.logo }]}
@@ -261,7 +251,7 @@ class ClubDisplayScreen extends React.Component<PropsType> {
261 251
           ) : (
262 252
             <View />
263 253
           )}
264
-          {this.getManagersRender(data.responsibles, data.email)}
254
+          {getManagersRender(data.responsibles, data.email)}
265 255
         </CollapsibleScrollView>
266 256
       );
267 257
     }
@@ -273,27 +263,22 @@ class ClubDisplayScreen extends React.Component<PropsType> {
273 263
    *
274 264
    * @param data The club data
275 265
    */
276
-  updateHeaderTitle(data: ClubType) {
277
-    const { props } = this;
278
-    props.navigation.setOptions({ title: data.name });
279
-  }
266
+  const updateHeaderTitle = (data: ClubType) => {
267
+    navigation.setOptions({ title: data.name });
268
+  };
280 269
 
281
-  render() {
282
-    if (this.shouldFetchData) {
283
-      return (
284
-        <RequestScreen
285
-          request={() =>
286
-            ConnectionManager.getInstance().authenticatedRequest<ResponseType>(
287
-              'clubs/info',
288
-              { id: this.clubId }
289
-            )
290
-          }
291
-          render={this.getScreen}
292
-        />
293
-      );
294
-    }
295
-    return this.getScreen(this.displayData);
296
-  }
270
+  const request = useAuthenticatedRequest<ClubType>('clubs/info', {
271
+    id: clubId,
272
+  });
273
+
274
+  return (
275
+    <RequestScreen
276
+      request={request}
277
+      render={getScreen}
278
+      cache={displayData}
279
+      onCacheUpdate={setDisplayData}
280
+    />
281
+  );
297 282
 }
298 283
 
299
-export default withTheme(ClubDisplayScreen);
284
+export default ClubDisplayScreen;

+ 87
- 132
src/screens/Amicale/Clubs/ClubListScreen.tsx View File

@@ -17,11 +17,10 @@
17 17
  * along with Campus INSAT.  If not, see <https://www.gnu.org/licenses/>.
18 18
  */
19 19
 
20
-import * as React from 'react';
20
+import React, { useLayoutEffect, useRef, useState } from 'react';
21 21
 import { Platform } from 'react-native';
22 22
 import { Searchbar } from 'react-native-paper';
23 23
 import i18n from 'i18n-js';
24
-import { StackNavigationProp } from '@react-navigation/stack';
25 24
 import ClubListItem from '../../../components/Lists/Clubs/ClubListItem';
26 25
 import {
27 26
   isItemInCategoryFilter,
@@ -31,8 +30,9 @@ import ClubListHeader from '../../../components/Lists/Clubs/ClubListHeader';
31 30
 import MaterialHeaderButtons, {
32 31
   Item,
33 32
 } from '../../../components/Overrides/CustomHeaderButton';
34
-import ConnectionManager from '../../../managers/ConnectionManager';
35 33
 import WebSectionList from '../../../components/Screens/WebSectionList';
34
+import { useNavigation } from '@react-navigation/native';
35
+import { useAuthenticatedRequest } from '../../../context/loginContext';
36 36
 
37 37
 export type ClubCategoryType = {
38 38
   id: number;
@@ -49,15 +49,6 @@ export type ClubType = {
49 49
   responsibles: Array<string>;
50 50
 };
51 51
 
52
-type PropsType = {
53
-  navigation: StackNavigationProp<any>;
54
-};
55
-
56
-type StateType = {
57
-  currentlySelectedCategories: Array<number>;
58
-  currentSearchString: string;
59
-};
60
-
61 52
 type ResponseType = {
62 53
   categories: Array<ClubCategoryType>;
63 54
   clubs: Array<ClubType>;
@@ -65,33 +56,52 @@ type ResponseType = {
65 56
 
66 57
 const LIST_ITEM_HEIGHT = 96;
67 58
 
68
-class ClubListScreen extends React.Component<PropsType, StateType> {
69
-  categories: Array<ClubCategoryType>;
70
-
71
-  constructor(props: PropsType) {
72
-    super(props);
73
-    this.categories = [];
74
-    this.state = {
75
-      currentlySelectedCategories: [],
76
-      currentSearchString: '',
59
+function ClubListScreen() {
60
+  const navigation = useNavigation();
61
+  const request = useAuthenticatedRequest<ResponseType>('clubs/list');
62
+  const [
63
+    currentlySelectedCategories,
64
+    setCurrentlySelectedCategories,
65
+  ] = useState<Array<number>>([]);
66
+  const [currentSearchString, setCurrentSearchString] = useState('');
67
+  const categories = useRef<Array<ClubCategoryType>>([]);
68
+
69
+  useLayoutEffect(() => {
70
+    const getSearchBar = () => {
71
+      return (
72
+        // @ts-ignore
73
+        <Searchbar
74
+          placeholder={i18n.t('screens.proximo.search')}
75
+          onChangeText={onSearchStringChange}
76
+        />
77
+      );
77 78
     };
78
-  }
79
-
80
-  /**
81
-   * Creates the header content
82
-   */
83
-  componentDidMount() {
84
-    const { props } = this;
85
-    props.navigation.setOptions({
86
-      headerTitle: this.getSearchBar,
87
-      headerRight: this.getHeaderButtons,
79
+    const getHeaderButtons = () => {
80
+      return (
81
+        <MaterialHeaderButtons>
82
+          <Item
83
+            title="main"
84
+            iconName="information"
85
+            onPress={() => navigation.navigate('club-about')}
86
+          />
87
+        </MaterialHeaderButtons>
88
+      );
89
+    };
90
+    navigation.setOptions({
91
+      headerTitle: getSearchBar,
92
+      headerRight: getHeaderButtons,
88 93
       headerBackTitleVisible: false,
89 94
       headerTitleContainerStyle:
90 95
         Platform.OS === 'ios'
91 96
           ? { marginHorizontal: 0, width: '70%' }
92 97
           : { marginHorizontal: 0, right: 50, left: 50 },
93 98
     });
94
-  }
99
+    // eslint-disable-next-line react-hooks/exhaustive-deps
100
+  }, [navigation]);
101
+
102
+  const onSearchStringChange = (str: string) => {
103
+    updateFilteredData(str, null);
104
+  };
95 105
 
96 106
   /**
97 107
    * Callback used when clicking an article in the list.
@@ -99,61 +109,20 @@ class ClubListScreen extends React.Component<PropsType, StateType> {
99 109
    *
100 110
    * @param item The article pressed
101 111
    */
102
-  onListItemPress(item: ClubType) {
103
-    const { props } = this;
104
-    props.navigation.navigate('club-information', {
112
+  const onListItemPress = (item: ClubType) => {
113
+    navigation.navigate('club-information', {
105 114
       data: item,
106
-      categories: this.categories,
115
+      categories: categories.current,
107 116
     });
108
-  }
109
-
110
-  /**
111
-   * Callback used when the search changes
112
-   *
113
-   * @param str The new search string
114
-   */
115
-  onSearchStringChange = (str: string) => {
116
-    this.updateFilteredData(str, null);
117
-  };
118
-
119
-  /**
120
-   * Gets the header search bar
121
-   *
122
-   * @return {*}
123
-   */
124
-  getSearchBar = () => {
125
-    return (
126
-      // @ts-ignore
127
-      <Searchbar
128
-        placeholder={i18n.t('screens.proximo.search')}
129
-        onChangeText={this.onSearchStringChange}
130
-      />
131
-    );
132 117
   };
133 118
 
134
-  onChipSelect = (id: number) => {
135
-    this.updateFilteredData(null, id);
119
+  const onChipSelect = (id: number) => {
120
+    updateFilteredData(null, id);
136 121
   };
137 122
 
138
-  /**
139
-   * Gets the header button
140
-   * @return {*}
141
-   */
142
-  getHeaderButtons = () => {
143
-    const onPress = () => {
144
-      const { props } = this;
145
-      props.navigation.navigate('club-about');
146
-    };
147
-    return (
148
-      <MaterialHeaderButtons>
149
-        <Item title="main" iconName="information" onPress={onPress} />
150
-      </MaterialHeaderButtons>
151
-    );
152
-  };
153
-
154
-  createDataset = (data: ResponseType | undefined) => {
123
+  const createDataset = (data: ResponseType | undefined) => {
155 124
     if (data) {
156
-      this.categories = data?.categories;
125
+      categories.current = data.categories;
157 126
       return [{ title: '', data: data.clubs }];
158 127
     } else {
159 128
       return [];
@@ -165,30 +134,23 @@ class ClubListScreen extends React.Component<PropsType, StateType> {
165 134
    *
166 135
    * @returns {*}
167 136
    */
168
-  getListHeader(data: ResponseType | undefined) {
169
-    const { state } = this;
137
+  const getListHeader = (data: ResponseType | undefined) => {
170 138
     if (data) {
171 139
       return (
172 140
         <ClubListHeader
173
-          categories={this.categories}
174
-          selectedCategories={state.currentlySelectedCategories}
175
-          onChipSelect={this.onChipSelect}
141
+          categories={categories.current}
142
+          selectedCategories={currentlySelectedCategories}
143
+          onChipSelect={onChipSelect}
176 144
         />
177 145
       );
178 146
     } else {
179 147
       return null;
180 148
     }
181
-  }
149
+  };
182 150
 
183
-  /**
184
-   * Gets the category object of the given ID
185
-   *
186
-   * @param id The ID of the category to find
187
-   * @returns {*}
188
-   */
189
-  getCategoryOfId = (id: number): ClubCategoryType | null => {
151
+  const getCategoryOfId = (id: number): ClubCategoryType | null => {
190 152
     let cat = null;
191
-    this.categories.forEach((item: ClubCategoryType) => {
153
+    categories.current.forEach((item: ClubCategoryType) => {
192 154
       if (id === item.id) {
193 155
         cat = item;
194 156
       }
@@ -196,14 +158,14 @@ class ClubListScreen extends React.Component<PropsType, StateType> {
196 158
     return cat;
197 159
   };
198 160
 
199
-  getRenderItem = ({ item }: { item: ClubType }) => {
161
+  const getRenderItem = ({ item }: { item: ClubType }) => {
200 162
     const onPress = () => {
201
-      this.onListItemPress(item);
163
+      onListItemPress(item);
202 164
     };
203
-    if (this.shouldRenderItem(item)) {
165
+    if (shouldRenderItem(item)) {
204 166
       return (
205 167
         <ClubListItem
206
-          categoryTranslator={this.getCategoryOfId}
168
+          categoryTranslator={getCategoryOfId}
207 169
           item={item}
208 170
           onPress={onPress}
209 171
           height={LIST_ITEM_HEIGHT}
@@ -213,7 +175,7 @@ class ClubListScreen extends React.Component<PropsType, StateType> {
213 175
     return null;
214 176
   };
215 177
 
216
-  keyExtractor = (item: ClubType): string => item.id.toString();
178
+  const keyExtractor = (item: ClubType): string => item.id.toString();
217 179
 
218 180
   /**
219 181
    * Updates the search string and category filter, saving them to the State.
@@ -224,10 +186,12 @@ class ClubListScreen extends React.Component<PropsType, StateType> {
224 186
    * @param filterStr The new filter string to use
225 187
    * @param categoryId The category to add/remove from the filter
226 188
    */
227
-  updateFilteredData(filterStr: string | null, categoryId: number | null) {
228
-    const { state } = this;
229
-    const newCategoriesState = [...state.currentlySelectedCategories];
230
-    let newStrState = state.currentSearchString;
189
+  const updateFilteredData = (
190
+    filterStr: string | null,
191
+    categoryId: number | null
192
+  ) => {
193
+    const newCategoriesState = [...currentlySelectedCategories];
194
+    let newStrState = currentSearchString;
231 195
     if (filterStr !== null) {
232 196
       newStrState = filterStr;
233 197
     }
@@ -240,12 +204,10 @@ class ClubListScreen extends React.Component<PropsType, StateType> {
240 204
       }
241 205
     }
242 206
     if (filterStr !== null || categoryId !== null) {
243
-      this.setState({
244
-        currentSearchString: newStrState,
245
-        currentlySelectedCategories: newCategoriesState,
246
-      });
207
+      setCurrentSearchString(newStrState);
208
+      setCurrentlySelectedCategories(newCategoriesState);
247 209
     }
248
-  }
210
+  };
249 211
 
250 212
   /**
251 213
    * 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> {
253 215
    * @param item The club to check
254 216
    * @returns {boolean}
255 217
    */
256
-  shouldRenderItem(item: ClubType): boolean {
257
-    const { state } = this;
218
+  const shouldRenderItem = (item: ClubType): boolean => {
258 219
     let shouldRender =
259
-      state.currentlySelectedCategories.length === 0 ||
260
-      isItemInCategoryFilter(state.currentlySelectedCategories, item.category);
220
+      currentlySelectedCategories.length === 0 ||
221
+      isItemInCategoryFilter(currentlySelectedCategories, item.category);
261 222
     if (shouldRender) {
262
-      shouldRender = stringMatchQuery(item.name, state.currentSearchString);
223
+      shouldRender = stringMatchQuery(item.name, currentSearchString);
263 224
     }
264 225
     return shouldRender;
265
-  }
226
+  };
266 227
 
267
-  render() {
268
-    return (
269
-      <WebSectionList
270
-        request={() =>
271
-          ConnectionManager.getInstance().authenticatedRequest<ResponseType>(
272
-            'clubs/list'
273
-          )
274
-        }
275
-        createDataset={this.createDataset}
276
-        keyExtractor={this.keyExtractor}
277
-        renderItem={this.getRenderItem}
278
-        renderListHeaderComponent={(data) => this.getListHeader(data)}
279
-        // Performance props, see https://reactnative.dev/docs/optimizing-flatlist-configuration
280
-        removeClippedSubviews={true}
281
-        itemHeight={LIST_ITEM_HEIGHT}
282
-      />
283
-    );
284
-  }
228
+  return (
229
+    <WebSectionList
230
+      request={request}
231
+      createDataset={createDataset}
232
+      keyExtractor={keyExtractor}
233
+      renderItem={getRenderItem}
234
+      renderListHeaderComponent={getListHeader}
235
+      // Performance props, see https://reactnative.dev/docs/optimizing-flatlist-configuration
236
+      removeClippedSubviews={true}
237
+      itemHeight={LIST_ITEM_HEIGHT}
238
+    />
239
+  );
285 240
 }
286 241
 
287 242
 export default ClubListScreen;

+ 57
- 84
src/screens/Amicale/Equipment/EquipmentListScreen.tsx View File

@@ -17,26 +17,17 @@
17 17
  * along with Campus INSAT.  If not, see <https://www.gnu.org/licenses/>.
18 18
  */
19 19
 
20
-import * as React from 'react';
20
+import React, { useRef, useState } from 'react';
21 21
 import { StyleSheet, View } from 'react-native';
22 22
 import { Button } from 'react-native-paper';
23
-import { StackNavigationProp } from '@react-navigation/stack';
24 23
 import i18n from 'i18n-js';
25 24
 import EquipmentListItem from '../../../components/Lists/Equipment/EquipmentListItem';
26 25
 import MascotPopup from '../../../components/Mascot/MascotPopup';
27 26
 import { MASCOT_STYLE } from '../../../components/Mascot/Mascot';
28 27
 import GENERAL_STYLES from '../../../constants/Styles';
29
-import ConnectionManager from '../../../managers/ConnectionManager';
30 28
 import { ApiRejectType } from '../../../utils/WebData';
31 29
 import WebSectionList from '../../../components/Screens/WebSectionList';
32
-
33
-type PropsType = {
34
-  navigation: StackNavigationProp<any>;
35
-};
36
-
37
-type StateType = {
38
-  mascotDialogVisible: boolean | undefined;
39
-};
30
+import { useAuthenticatedRequest } from '../../../context/loginContext';
40 31
 
41 32
 export type DeviceType = {
42 33
   id: number;
@@ -67,69 +58,62 @@ const styles = StyleSheet.create({
67 58
   },
68 59
 });
69 60
 
70
-class EquipmentListScreen extends React.Component<PropsType, StateType> {
71
-  userRents: null | Array<RentedDeviceType>;
61
+function EquipmentListScreen() {
62
+  const userRents = useRef<undefined | Array<RentedDeviceType>>();
63
+  const [mascotDialogVisible, setMascotDialogVisible] = useState(false);
72 64
 
73
-  constructor(props: PropsType) {
74
-    super(props);
75
-    this.userRents = null;
76
-    this.state = {
77
-      mascotDialogVisible: undefined,
78
-    };
79
-  }
65
+  const requestAll = useAuthenticatedRequest<{ devices: Array<DeviceType> }>(
66
+    'location/all'
67
+  );
68
+  const requestOwn = useAuthenticatedRequest<{
69
+    locations: Array<RentedDeviceType>;
70
+  }>('location/my');
80 71
 
81
-  getRenderItem = ({ item }: { item: DeviceType }) => {
82
-    const { navigation } = this.props;
72
+  const getRenderItem = ({ item }: { item: DeviceType }) => {
83 73
     return (
84 74
       <EquipmentListItem
85
-        navigation={navigation}
86 75
         item={item}
87
-        userDeviceRentDates={this.getUserDeviceRentDates(item)}
76
+        userDeviceRentDates={getUserDeviceRentDates(item)}
88 77
         height={LIST_ITEM_HEIGHT}
89 78
       />
90 79
     );
91 80
   };
92 81
 
93
-  getUserDeviceRentDates(item: DeviceType): [string, string] | null {
82
+  const getUserDeviceRentDates = (
83
+    item: DeviceType
84
+  ): [string, string] | null => {
94 85
     let dates = null;
95
-    if (this.userRents != null) {
96
-      this.userRents.forEach((device: RentedDeviceType) => {
86
+    if (userRents.current) {
87
+      userRents.current.forEach((device: RentedDeviceType) => {
97 88
         if (item.id === device.device_id) {
98 89
           dates = [device.begin, device.end];
99 90
         }
100 91
       });
101 92
     }
102 93
     return dates;
103
-  }
104
-
105
-  /**
106
-   * Gets the list header, with explains this screen's purpose
107
-   *
108
-   * @returns {*}
109
-   */
110
-  getListHeader() {
94
+  };
95
+
96
+  const getListHeader = () => {
111 97
     return (
112 98
       <View style={styles.headerContainer}>
113 99
         <Button
114 100
           mode="contained"
115 101
           icon="help-circle"
116
-          onPress={this.showMascotDialog}
102
+          onPress={showMascotDialog}
117 103
           style={GENERAL_STYLES.centerHorizontal}
118 104
         >
119 105
           {i18n.t('screens.equipment.mascotDialog.title')}
120 106
         </Button>
121 107
       </View>
122 108
     );
123
-  }
109
+  };
124 110
 
125
-  keyExtractor = (item: DeviceType): string => item.id.toString();
111
+  const keyExtractor = (item: DeviceType): string => item.id.toString();
126 112
 
127
-  createDataset = (data: ResponseType | undefined) => {
113
+  const createDataset = (data: ResponseType | undefined) => {
128 114
     if (data) {
129
-      const userRents = data.locations;
130
-
131
-      if (userRents) {
132
-        this.userRents = userRents;
115
+      if (data.locations) {
116
+        userRents.current = data.locations;
133 117
       }
134 118
       return [{ title: '', data: data.devices }];
135 119
     } else {
@@ -137,27 +121,19 @@ class EquipmentListScreen extends React.Component<PropsType, StateType> {
137 121
     }
138 122
   };
139 123
 
140
-  showMascotDialog = () => {
141
-    this.setState({ mascotDialogVisible: true });
142
-  };
124
+  const showMascotDialog = () => setMascotDialogVisible(true);
143 125
 
144
-  hideMascotDialog = () => {
145
-    this.setState({ mascotDialogVisible: false });
146
-  };
126
+  const hideMascotDialog = () => setMascotDialogVisible(false);
147 127
 
148
-  request = () => {
128
+  const request = () => {
149 129
     return new Promise(
150 130
       (
151 131
         resolve: (data: ResponseType) => void,
152 132
         reject: (error: ApiRejectType) => void
153 133
       ) => {
154
-        ConnectionManager.getInstance()
155
-          .authenticatedRequest<{ devices: Array<DeviceType> }>('location/all')
134
+        requestAll()
156 135
           .then((devicesData) => {
157
-            ConnectionManager.getInstance()
158
-              .authenticatedRequest<{
159
-                locations: Array<RentedDeviceType>;
160
-              }>('location/my')
136
+            requestOwn()
161 137
               .then((rentsData) => {
162 138
                 resolve({
163 139
                   devices: devicesData.devices,
@@ -175,34 +151,31 @@ class EquipmentListScreen extends React.Component<PropsType, StateType> {
175 151
     );
176 152
   };
177 153
 
178
-  render() {
179
-    const { state } = this;
180
-    return (
181
-      <View style={GENERAL_STYLES.flex}>
182
-        <WebSectionList
183
-          request={this.request}
184
-          createDataset={this.createDataset}
185
-          keyExtractor={this.keyExtractor}
186
-          renderItem={this.getRenderItem}
187
-          renderListHeaderComponent={() => this.getListHeader()}
188
-        />
189
-        <MascotPopup
190
-          visible={state.mascotDialogVisible}
191
-          title={i18n.t('screens.equipment.mascotDialog.title')}
192
-          message={i18n.t('screens.equipment.mascotDialog.message')}
193
-          icon="vote"
194
-          buttons={{
195
-            cancel: {
196
-              message: i18n.t('screens.equipment.mascotDialog.button'),
197
-              icon: 'check',
198
-              onPress: this.hideMascotDialog,
199
-            },
200
-          }}
201
-          emotion={MASCOT_STYLE.WINK}
202
-        />
203
-      </View>
204
-    );
205
-  }
154
+  return (
155
+    <View style={GENERAL_STYLES.flex}>
156
+      <WebSectionList
157
+        request={request}
158
+        createDataset={createDataset}
159
+        keyExtractor={keyExtractor}
160
+        renderItem={getRenderItem}
161
+        renderListHeaderComponent={getListHeader}
162
+      />
163
+      <MascotPopup
164
+        visible={mascotDialogVisible}
165
+        title={i18n.t('screens.equipment.mascotDialog.title')}
166
+        message={i18n.t('screens.equipment.mascotDialog.message')}
167
+        icon="vote"
168
+        buttons={{
169
+          cancel: {
170
+            message: i18n.t('screens.equipment.mascotDialog.button'),
171
+            icon: 'check',
172
+            onPress: hideMascotDialog,
173
+          },
174
+        }}
175
+        emotion={MASCOT_STYLE.WINK}
176
+      />
177
+    </View>
178
+  );
206 179
 }
207 180
 
208 181
 export default EquipmentListScreen;

+ 248
- 302
src/screens/Amicale/Equipment/EquipmentRentScreen.tsx View File

@@ -17,21 +17,20 @@
17 17
  * along with Campus INSAT.  If not, see <https://www.gnu.org/licenses/>.
18 18
  */
19 19
 
20
-import * as React from 'react';
20
+import React, { useCallback, useRef, useState } from 'react';
21 21
 import {
22 22
   Button,
23 23
   Caption,
24 24
   Card,
25 25
   Headline,
26 26
   Subheading,
27
-  withTheme,
27
+  useTheme,
28 28
 } from 'react-native-paper';
29 29
 import { StackNavigationProp, StackScreenProps } from '@react-navigation/stack';
30 30
 import { BackHandler, StyleSheet, View } from 'react-native';
31 31
 import * as Animatable from 'react-native-animatable';
32 32
 import i18n from 'i18n-js';
33 33
 import { CalendarList, PeriodMarking } from 'react-native-calendars';
34
-import type { DeviceType } from './EquipmentListScreen';
35 34
 import LoadingConfirmDialog from '../../../components/Dialogs/LoadingConfirmDialog';
36 35
 import ErrorDialog from '../../../components/Dialogs/ErrorDialog';
37 36
 import {
@@ -42,34 +41,21 @@ import {
42 41
   getValidRange,
43 42
   isEquipmentAvailable,
44 43
 } from '../../../utils/EquipmentBooking';
45
-import ConnectionManager from '../../../managers/ConnectionManager';
46 44
 import CollapsibleScrollView from '../../../components/Collapsible/CollapsibleScrollView';
47 45
 import { MainStackParamsList } from '../../../navigation/MainNavigator';
48 46
 import GENERAL_STYLES from '../../../constants/Styles';
49 47
 import { ApiRejectType } from '../../../utils/WebData';
50 48
 import { REQUEST_STATUS } from '../../../utils/Requests';
49
+import { useFocusEffect } from '@react-navigation/core';
50
+import { useNavigation } from '@react-navigation/native';
51
+import { useAuthenticatedRequest } from '../../../context/loginContext';
51 52
 
52
-type EquipmentRentScreenNavigationProp = StackScreenProps<
53
-  MainStackParamsList,
54
-  'equipment-rent'
55
->;
56
-
57
-type Props = EquipmentRentScreenNavigationProp & {
58
-  navigation: StackNavigationProp<any>;
59
-  theme: ReactNativePaper.Theme;
60
-};
53
+type Props = StackScreenProps<MainStackParamsList, 'equipment-rent'>;
61 54
 
62 55
 export type MarkedDatesObjectType = {
63 56
   [key: string]: PeriodMarking;
64 57
 };
65 58
 
66
-type StateType = {
67
-  dialogVisible: boolean;
68
-  errorDialogVisible: boolean;
69
-  markedDates: MarkedDatesObjectType;
70
-  currentError: ApiRejectType;
71
-};
72
-
73 59
 const styles = StyleSheet.create({
74 60
   titleContainer: {
75 61
     marginLeft: 'auto',
@@ -114,98 +100,101 @@ const styles = StyleSheet.create({
114 100
   },
115 101
 });
116 102
 
117
-class EquipmentRentScreen extends React.Component<Props, StateType> {
118
-  item: DeviceType | null;
103
+function EquipmentRentScreen(props: Props) {
104
+  const theme = useTheme();
105
+  const navigation = useNavigation<StackNavigationProp<any>>();
106
+  const [currentError, setCurrentError] = useState<ApiRejectType>({
107
+    status: REQUEST_STATUS.SUCCESS,
108
+  });
109
+  const [markedDates, setMarkedDates] = useState<MarkedDatesObjectType>({});
110
+  const [dialogVisible, setDialogVisible] = useState(false);
119 111
 
120
-  bookedDates: Array<string>;
112
+  const item = props.route.params.item;
121 113
 
122
-  bookRef: { current: null | (Animatable.View & View) };
114
+  const bookedDates = useRef<Array<string>>([]);
115
+  const canBookEquipment = useRef(false);
123 116
 
124
-  canBookEquipment: boolean;
117
+  const bookRef = useRef<Animatable.View & View>(null);
125 118
 
126
-  lockedDates: {
119
+  let lockedDates: {
127 120
     [key: string]: PeriodMarking;
128
-  };
129
-
130
-  constructor(props: Props) {
131
-    super(props);
132
-    this.item = null;
133
-    this.lockedDates = {};
134
-    this.state = {
135
-      dialogVisible: false,
136
-      errorDialogVisible: false,
137
-      markedDates: {},
138
-      currentError: { status: REQUEST_STATUS.SUCCESS },
139
-    };
140
-    this.resetSelection();
141
-    this.bookRef = React.createRef();
142
-    this.canBookEquipment = false;
143
-    this.bookedDates = [];
144
-    if (props.route.params != null) {
145
-      if (props.route.params.item != null) {
146
-        this.item = props.route.params.item;
147
-      } else {
148
-        this.item = null;
149
-      }
150
-    }
151
-    const { item } = this;
152
-    if (item != null) {
153
-      this.lockedDates = {};
154
-      item.booked_at.forEach((date: { begin: string; end: string }) => {
155
-        const range = getValidRange(
156
-          new Date(date.begin),
157
-          new Date(date.end),
158
-          null
159
-        );
160
-        this.lockedDates = {
161
-          ...this.lockedDates,
162
-          ...generateMarkedDates(false, props.theme, range),
163
-        };
164
-      });
165
-    }
121
+  } = {};
122
+
123
+  if (item) {
124
+    item.booked_at.forEach((date: { begin: string; end: string }) => {
125
+      const range = getValidRange(
126
+        new Date(date.begin),
127
+        new Date(date.end),
128
+        null
129
+      );
130
+      lockedDates = {
131
+        ...lockedDates,
132
+        ...generateMarkedDates(false, theme, range),
133
+      };
134
+    });
166 135
   }
167 136
 
168
-  /**
169
-   * Captures focus and blur events to hook on android back button
170
-   */
171
-  componentDidMount() {
172
-    const { navigation } = this.props;
173
-    navigation.addListener('focus', () => {
137
+  useFocusEffect(
138
+    useCallback(() => {
174 139
       BackHandler.addEventListener(
175 140
         'hardwareBackPress',
176
-        this.onBackButtonPressAndroid
177
-      );
178
-    });
179
-    navigation.addListener('blur', () => {
180
-      BackHandler.removeEventListener(
181
-        'hardwareBackPress',
182
-        this.onBackButtonPressAndroid
141
+        onBackButtonPressAndroid
183 142
       );
184
-    });
185
-  }
143
+      return () => {
144
+        BackHandler.removeEventListener(
145
+          'hardwareBackPress',
146
+          onBackButtonPressAndroid
147
+        );
148
+      };
149
+      // eslint-disable-next-line react-hooks/exhaustive-deps
150
+    }, [])
151
+  );
186 152
 
187 153
   /**
188 154
    * Overrides default android back button behaviour to deselect date if any is selected.
189 155
    *
190 156
    * @return {boolean}
191 157
    */
192
-  onBackButtonPressAndroid = (): boolean => {
193
-    if (this.bookedDates.length > 0) {
194
-      this.resetSelection();
195
-      this.updateMarkedSelection();
158
+  const onBackButtonPressAndroid = (): boolean => {
159
+    if (bookedDates.current.length > 0) {
160
+      resetSelection();
161
+      updateMarkedSelection();
196 162
       return true;
197 163
     }
198 164
     return false;
199 165
   };
200 166
 
201
-  onDialogDismiss = () => {
202
-    this.setState({ dialogVisible: false });
167
+  const showDialog = () => setDialogVisible(true);
168
+
169
+  const onDialogDismiss = () => setDialogVisible(false);
170
+
171
+  const onErrorDialogDismiss = () =>
172
+    setCurrentError({ status: REQUEST_STATUS.SUCCESS });
173
+
174
+  const getBookStartDate = (): Date | null => {
175
+    return bookedDates.current.length > 0
176
+      ? new Date(bookedDates.current[0])
177
+      : null;
203 178
   };
204 179
 
205
-  onErrorDialogDismiss = () => {
206
-    this.setState({ errorDialogVisible: false });
180
+  const getBookEndDate = (): Date | null => {
181
+    const { length } = bookedDates.current;
182
+    return length > 0 ? new Date(bookedDates.current[length - 1]) : null;
207 183
   };
208 184
 
185
+  const start = getBookStartDate();
186
+  const end = getBookEndDate();
187
+  const request = useAuthenticatedRequest(
188
+    'location/booking',
189
+    item && start && end
190
+      ? {
191
+          device: item.id,
192
+          begin: getISODate(start),
193
+          end: getISODate(end),
194
+        }
195
+      : undefined
196
+  );
197
+
209 198
   /**
210 199
    * Sends the selected data to the server and waits for a response.
211 200
    * If the request is a success, navigate to the recap screen.
@@ -213,54 +202,37 @@ class EquipmentRentScreen extends React.Component<Props, StateType> {
213 202
    *
214 203
    * @returns {Promise<void>}
215 204
    */
216
-  onDialogAccept = (): Promise<void> => {
205
+  const onDialogAccept = (): Promise<void> => {
217 206
     return new Promise((resolve: () => void) => {
218
-      const { item, props } = this;
219
-      const start = this.getBookStartDate();
220
-      const end = this.getBookEndDate();
221 207
       if (item != null && start != null && end != null) {
222
-        ConnectionManager.getInstance()
223
-          .authenticatedRequest('location/booking', {
224
-            device: item.id,
225
-            begin: getISODate(start),
226
-            end: getISODate(end),
227
-          })
208
+        request()
228 209
           .then(() => {
229
-            this.onDialogDismiss();
230
-            props.navigation.replace('equipment-confirm', {
231
-              item: this.item,
210
+            onDialogDismiss();
211
+            navigation.replace('equipment-confirm', {
212
+              item: item,
232 213
               dates: [getISODate(start), getISODate(end)],
233 214
             });
234 215
             resolve();
235 216
           })
236 217
           .catch((error: ApiRejectType) => {
237
-            this.onDialogDismiss();
238
-            this.showErrorDialog(error);
218
+            onDialogDismiss();
219
+            setCurrentError(error);
239 220
             resolve();
240 221
           });
241 222
       } else {
242
-        this.onDialogDismiss();
223
+        onDialogDismiss();
243 224
         resolve();
244 225
       }
245 226
     });
246 227
   };
247 228
 
248
-  getBookStartDate(): Date | null {
249
-    return this.bookedDates.length > 0 ? new Date(this.bookedDates[0]) : null;
250
-  }
251
-
252
-  getBookEndDate(): Date | null {
253
-    const { length } = this.bookedDates;
254
-    return length > 0 ? new Date(this.bookedDates[length - 1]) : null;
255
-  }
256
-
257 229
   /**
258 230
    * Selects a new date on the calendar.
259 231
    * If both start and end dates are already selected, unselect all.
260 232
    *
261 233
    * @param day The day selected
262 234
    */
263
-  selectNewDate = (day: {
235
+  const selectNewDate = (day: {
264 236
     dateString: string;
265 237
     day: number;
266 238
     month: number;
@@ -268,222 +240,196 @@ class EquipmentRentScreen extends React.Component<Props, StateType> {
268 240
     year: number;
269 241
   }) => {
270 242
     const selected = new Date(day.dateString);
271
-    const start = this.getBookStartDate();
272 243
 
273
-    if (!this.lockedDates[day.dateString] != null) {
244
+    if (!lockedDates[day.dateString] != null) {
274 245
       if (start === null) {
275
-        this.updateSelectionRange(selected, selected);
276
-        this.enableBooking();
246
+        updateSelectionRange(selected, selected);
247
+        enableBooking();
277 248
       } else if (start.getTime() === selected.getTime()) {
278
-        this.resetSelection();
279
-      } else if (this.bookedDates.length === 1) {
280
-        this.updateSelectionRange(start, selected);
281
-        this.enableBooking();
249
+        resetSelection();
250
+      } else if (bookedDates.current.length === 1) {
251
+        updateSelectionRange(start, selected);
252
+        enableBooking();
282 253
       } else {
283
-        this.resetSelection();
254
+        resetSelection();
284 255
       }
285
-      this.updateMarkedSelection();
256
+      updateMarkedSelection();
286 257
     }
287 258
   };
288 259
 
289
-  showErrorDialog = (error: ApiRejectType) => {
290
-    this.setState({
291
-      errorDialogVisible: true,
292
-      currentError: error,
293
-    });
260
+  const showBookButton = () => {
261
+    if (bookRef.current && bookRef.current.fadeInUp) {
262
+      bookRef.current.fadeInUp(500);
263
+    }
294 264
   };
295 265
 
296
-  showDialog = () => {
297
-    this.setState({ dialogVisible: true });
266
+  const hideBookButton = () => {
267
+    if (bookRef.current && bookRef.current.fadeOutDown) {
268
+      bookRef.current.fadeOutDown(500);
269
+    }
298 270
   };
299 271
 
300
-  /**
301
-   * Shows the book button by plying a fade animation
302
-   */
303
-  showBookButton() {
304
-    if (this.bookRef.current && this.bookRef.current.fadeInUp) {
305
-      this.bookRef.current.fadeInUp(500);
272
+  const enableBooking = () => {
273
+    if (!canBookEquipment.current) {
274
+      showBookButton();
275
+      canBookEquipment.current = true;
306 276
     }
307
-  }
277
+  };
308 278
 
309
-  /**
310
-   * Hides the book button by plying a fade animation
311
-   */
312
-  hideBookButton() {
313
-    if (this.bookRef.current && this.bookRef.current.fadeOutDown) {
314
-      this.bookRef.current.fadeOutDown(500);
279
+  const resetSelection = () => {
280
+    if (canBookEquipment.current) {
281
+      hideBookButton();
315 282
     }
316
-  }
283
+    canBookEquipment.current = false;
284
+    bookedDates.current = [];
285
+  };
317 286
 
318
-  enableBooking() {
319
-    if (!this.canBookEquipment) {
320
-      this.showBookButton();
321
-      this.canBookEquipment = true;
287
+  const updateSelectionRange = (s: Date, e: Date) => {
288
+    if (item) {
289
+      bookedDates.current = getValidRange(s, e, item);
290
+    } else {
291
+      bookedDates.current = [];
322 292
     }
323
-  }
293
+  };
324 294
 
325
-  resetSelection() {
326
-    if (this.canBookEquipment) {
327
-      this.hideBookButton();
328
-    }
329
-    this.canBookEquipment = false;
330
-    this.bookedDates = [];
331
-  }
295
+  const updateMarkedSelection = () => {
296
+    setMarkedDates(generateMarkedDates(true, theme, bookedDates.current));
297
+  };
332 298
 
333
-  updateSelectionRange(start: Date, end: Date) {
334
-    this.bookedDates = getValidRange(start, end, this.item);
335
-  }
299
+  let subHeadingText;
336 300
 
337
-  updateMarkedSelection() {
338
-    const { theme } = this.props;
339
-    this.setState({
340
-      markedDates: generateMarkedDates(true, theme, this.bookedDates),
301
+  if (start == null) {
302
+    subHeadingText = i18n.t('screens.equipment.booking');
303
+  } else if (end != null && start.getTime() !== end.getTime()) {
304
+    subHeadingText = i18n.t('screens.equipment.bookingPeriod', {
305
+      begin: getRelativeDateString(start),
306
+      end: getRelativeDateString(end),
307
+    });
308
+  } else {
309
+    subHeadingText = i18n.t('screens.equipment.bookingDay', {
310
+      date: getRelativeDateString(start),
341 311
     });
342 312
   }
343 313
 
344
-  render() {
345
-    const { item, props, state } = this;
346
-    const start = this.getBookStartDate();
347
-    const end = this.getBookEndDate();
348
-    let subHeadingText;
349
-    if (start == null) {
350
-      subHeadingText = i18n.t('screens.equipment.booking');
351
-    } else if (end != null && start.getTime() !== end.getTime()) {
352
-      subHeadingText = i18n.t('screens.equipment.bookingPeriod', {
353
-        begin: getRelativeDateString(start),
354
-        end: getRelativeDateString(end),
355
-      });
356
-    } else {
357
-      subHeadingText = i18n.t('screens.equipment.bookingDay', {
358
-        date: getRelativeDateString(start),
359
-      });
360
-    }
361
-    if (item != null) {
362
-      const isAvailable = isEquipmentAvailable(item);
363
-      const firstAvailability = getFirstEquipmentAvailability(item);
364
-      return (
365
-        <View style={GENERAL_STYLES.flex}>
366
-          <CollapsibleScrollView>
367
-            <Card style={styles.card}>
368
-              <Card.Content>
369
-                <View style={GENERAL_STYLES.flex}>
370
-