Improve websectionlist and update proximo api

This commit is contained in:
Arnaud Vergnet 2021-05-10 14:55:21 +02:00
parent aed58f8749
commit a94006d18a
24 changed files with 902 additions and 842 deletions

View file

@ -92,6 +92,9 @@
"prettier"
],
"rules": {
"no-undef": 0,
"no-shadow": "off",
"@typescript-eslint/no-shadow": ["error"],
"prettier/prettier": [
"error",
{

View file

@ -23,6 +23,7 @@ import ConnectionManager from '../../managers/ConnectionManager';
import { ERROR_TYPE } from '../../utils/WebData';
import ErrorView from '../Screens/ErrorView';
import BasicLoadingScreen from '../Screens/BasicLoadingScreen';
import i18n from 'i18n-js';
type PropsType<T> = {
navigation: StackNavigationProp<any>;
@ -151,11 +152,28 @@ class AuthenticatedScreen<T> extends React.Component<PropsType<T>, StateType> {
<ErrorView
icon={override.icon}
message={override.message}
showRetryButton={override.showRetryButton}
button={
override.showRetryButton
? {
icon: 'refresh',
text: i18n.t('general.retry'),
onPress: this.fetchData,
}
: undefined
}
/>
);
}
return <ErrorView errorCode={errorCode} onRefresh={this.fetchData} />;
return (
<ErrorView
status={errorCode}
button={{
icon: 'refresh',
text: i18n.t('general.retry'),
onPress: this.fetchData,
}}
/>
);
}
/**

View file

@ -22,6 +22,7 @@ import { Avatar, List, Text } from 'react-native-paper';
import i18n from 'i18n-js';
import type { ProximoArticleType } from '../../../screens/Services/Proximo/ProximoMainScreen';
import { StyleSheet } from 'react-native';
import Urls from '../../../constants/Urls';
type PropsType = {
onPress: () => void;
@ -43,6 +44,8 @@ const styles = StyleSheet.create({
});
function ProximoListItem(props: PropsType) {
// console.log(Urls.proximo.images + props.item.image);
return (
<List.Item
title={props.item.name}
@ -55,7 +58,7 @@ function ProximoListItem(props: PropsType) {
<Avatar.Image
style={styles.avatar}
size={64}
source={{ uri: props.item.image }}
source={{ uri: Urls.proximo.images + props.item.image }}
/>
)}
right={() => <Text style={styles.text}>{props.item.price}</Text>}

View file

@ -18,28 +18,29 @@
*/
import * as React from 'react';
import { Button, Subheading, withTheme } from 'react-native-paper';
import { Button, Subheading, useTheme } from 'react-native-paper';
import { StyleSheet, View } from 'react-native';
import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons';
import i18n from 'i18n-js';
import * as Animatable from 'react-native-animatable';
import { StackNavigationProp } from '@react-navigation/stack';
import { ERROR_TYPE } from '../../utils/WebData';
import { REQUEST_CODES, REQUEST_STATUS } from '../../utils/Requests';
type PropsType = {
navigation?: StackNavigationProp<any>;
theme: ReactNativePaper.Theme;
route?: { name: string };
onRefresh?: () => void;
errorCode?: number;
type Props = {
status?: Exclude<REQUEST_STATUS, REQUEST_STATUS.SUCCESS>;
code?: Exclude<REQUEST_CODES, REQUEST_CODES.SUCCESS>;
icon?: string;
message?: string;
showRetryButton?: boolean;
loading?: boolean;
button?: {
text: string;
icon: string;
onPress: () => void;
};
};
const styles = StyleSheet.create({
outer: {
height: '100%',
flex: 1,
},
inner: {
marginTop: 'auto',
@ -61,134 +62,96 @@ const styles = StyleSheet.create({
},
});
class ErrorView extends React.PureComponent<PropsType> {
static defaultProps = {
onRefresh: () => {},
errorCode: 0,
icon: '',
function getMessage(props: Props) {
let fullMessage = {
message: '',
showRetryButton: true,
icon: '',
};
message: string;
icon: string;
showLoginButton: boolean;
constructor(props: PropsType) {
super(props);
this.icon = '';
this.showLoginButton = false;
this.message = '';
}
getRetryButton() {
const { props } = this;
return (
<Button
mode="contained"
icon="refresh"
onPress={props.onRefresh}
style={styles.button}
>
{i18n.t('general.retry')}
</Button>
);
}
getLoginButton() {
return (
<Button
mode="contained"
icon="login"
onPress={this.goToLogin}
style={styles.button}
>
{i18n.t('screens.login.title')}
</Button>
);
}
goToLogin = () => {
const { props } = this;
if (props.navigation) {
props.navigation.navigate('login', {
screen: 'login',
params: { nextScreen: props.route ? props.route.name : undefined },
});
if (props.code === undefined) {
switch (props.status) {
case REQUEST_STATUS.BAD_INPUT:
fullMessage.message = i18n.t('errors.badInput');
fullMessage.icon = 'alert-circle-outline';
break;
case REQUEST_STATUS.FORBIDDEN:
fullMessage.message = i18n.t('errors.forbidden');
fullMessage.icon = 'lock';
break;
case REQUEST_STATUS.CONNECTION_ERROR:
fullMessage.message = i18n.t('errors.connectionError');
fullMessage.icon = 'access-point-network-off';
break;
case REQUEST_STATUS.SERVER_ERROR:
fullMessage.message = i18n.t('errors.serverError');
fullMessage.icon = 'server-network-off';
break;
default:
fullMessage.message = i18n.t('errors.unknown');
fullMessage.icon = 'alert-circle-outline';
break;
}
};
generateMessage() {
const { props } = this;
this.showLoginButton = false;
if (props.errorCode !== 0) {
switch (props.errorCode) {
case ERROR_TYPE.BAD_CREDENTIALS:
this.message = i18n.t('errors.badCredentials');
this.icon = 'account-alert-outline';
break;
case ERROR_TYPE.BAD_TOKEN:
this.message = i18n.t('errors.badToken');
this.icon = 'account-alert-outline';
this.showLoginButton = true;
break;
case ERROR_TYPE.NO_CONSENT:
this.message = i18n.t('errors.noConsent');
this.icon = 'account-remove-outline';
break;
case ERROR_TYPE.TOKEN_SAVE:
this.message = i18n.t('errors.tokenSave');
this.icon = 'alert-circle-outline';
break;
case ERROR_TYPE.BAD_INPUT:
this.message = i18n.t('errors.badInput');
this.icon = 'alert-circle-outline';
break;
case ERROR_TYPE.FORBIDDEN:
this.message = i18n.t('errors.forbidden');
this.icon = 'lock';
break;
case ERROR_TYPE.CONNECTION_ERROR:
this.message = i18n.t('errors.connectionError');
this.icon = 'access-point-network-off';
break;
case ERROR_TYPE.SERVER_ERROR:
this.message = i18n.t('errors.serverError');
this.icon = 'server-network-off';
break;
default:
this.message = i18n.t('errors.unknown');
this.icon = 'alert-circle-outline';
break;
}
this.message += `\n\nCode ${
props.errorCode != null ? props.errorCode : -1
}`;
} else {
this.message = props.message != null ? props.message : '';
this.icon = props.icon != null ? props.icon : '';
} else {
switch (props.code) {
case REQUEST_CODES.BAD_CREDENTIALS:
fullMessage.message = i18n.t('errors.badCredentials');
fullMessage.icon = 'account-alert-outline';
break;
case REQUEST_CODES.BAD_TOKEN:
fullMessage.message = i18n.t('errors.badToken');
fullMessage.icon = 'account-alert-outline';
break;
case REQUEST_CODES.NO_CONSENT:
fullMessage.message = i18n.t('errors.noConsent');
fullMessage.icon = 'account-remove-outline';
break;
case REQUEST_CODES.TOKEN_SAVE:
fullMessage.message = i18n.t('errors.tokenSave');
fullMessage.icon = 'alert-circle-outline';
break;
case REQUEST_CODES.BAD_INPUT:
fullMessage.message = i18n.t('errors.badInput');
fullMessage.icon = 'alert-circle-outline';
break;
case REQUEST_CODES.FORBIDDEN:
fullMessage.message = i18n.t('errors.forbidden');
fullMessage.icon = 'lock';
break;
case REQUEST_CODES.CONNECTION_ERROR:
fullMessage.message = i18n.t('errors.connectionError');
fullMessage.icon = 'access-point-network-off';
break;
case REQUEST_CODES.SERVER_ERROR:
fullMessage.message = i18n.t('errors.serverError');
fullMessage.icon = 'server-network-off';
break;
default:
fullMessage.message = i18n.t('errors.unknown');
fullMessage.icon = 'alert-circle-outline';
break;
}
}
render() {
const { props } = this;
this.generateMessage();
let button;
if (this.showLoginButton) {
button = this.getLoginButton();
} else if (props.showRetryButton) {
button = this.getRetryButton();
} else {
button = null;
}
fullMessage.message += `\n\nCode {${props.status}:${props.code}}`;
if (props.message != null) {
fullMessage.message = props.message;
}
if (props.icon != null) {
fullMessage.icon = props.icon;
}
return fullMessage;
}
return (
function ErrorView(props: Props) {
const theme = useTheme();
const fullMessage = getMessage(props);
const { button } = props;
return (
<View style={styles.outer}>
<Animatable.View
style={{
...styles.outer,
backgroundColor: props.theme.colors.background,
backgroundColor: theme.colors.background,
}}
animation="zoomIn"
duration={200}
@ -197,25 +160,33 @@ class ErrorView extends React.PureComponent<PropsType> {
<View style={styles.inner}>
<View style={styles.iconContainer}>
<MaterialCommunityIcons
// $FlowFixMe
name={this.icon}
name={fullMessage.icon}
size={150}
color={props.theme.colors.textDisabled}
color={theme.colors.disabled}
/>
</View>
<Subheading
style={{
...styles.subheading,
color: props.theme.colors.textDisabled,
color: theme.colors.disabled,
}}
>
{this.message}
{fullMessage.message}
</Subheading>
{button}
{button ? (
<Button
mode={'contained'}
icon={button.icon}
onPress={button.onPress}
style={styles.button}
>
{button.text}
</Button>
) : null}
</View>
</Animatable.View>
);
}
</View>
);
}
export default withTheme(ErrorView);
export default ErrorView;

View file

@ -0,0 +1,115 @@
import React, { useEffect, useRef } from 'react';
import ErrorView from './ErrorView';
import { useRequestLogic } from '../../utils/customHooks';
import { useFocusEffect } from '@react-navigation/native';
import BasicLoadingScreen from './BasicLoadingScreen';
import i18n from 'i18n-js';
import { REQUEST_STATUS } from '../../utils/Requests';
export type RequestScreenProps<T> = {
request: () => Promise<T>;
render: (
data: T | undefined,
loading: boolean,
refreshData: (newRequest?: () => Promise<T>) => void,
status: REQUEST_STATUS,
code: number | undefined
) => React.ReactElement;
cache?: T;
onCacheUpdate?: (newCache: T) => void;
onMajorError?: (status: number, code?: number) => void;
showLoading?: boolean;
showError?: boolean;
refreshOnFocus?: boolean;
autoRefreshTime?: number;
refresh?: boolean;
onFinish?: () => void;
};
export type RequestProps = {
refreshData: () => void;
loading: boolean;
};
type Props<T> = RequestScreenProps<T>;
const MIN_REFRESH_TIME = 5 * 1000;
export default function RequestScreen<T>(props: Props<T>) {
const refreshInterval = useRef<number>();
const [loading, status, code, data, refreshData] = useRequestLogic<T>(
() => props.request(),
props.cache,
props.onCacheUpdate,
props.refreshOnFocus,
MIN_REFRESH_TIME
);
// Store last refresh prop value
const lastRefresh = useRef<boolean>(false);
useEffect(() => {
// Refresh data if refresh prop changed and we are not loading
if (props.refresh && !lastRefresh.current && !loading) {
refreshData();
// Call finish callback if refresh prop was set and we finished loading
} else if (lastRefresh.current && !loading && props.onFinish) {
props.onFinish();
}
// Update stored refresh prop value
if (props.refresh !== lastRefresh.current) {
lastRefresh.current = props.refresh === true;
}
}, [props, loading, refreshData]);
useFocusEffect(
React.useCallback(() => {
if (!props.cache && props.refreshOnFocus !== false) {
refreshData();
}
if (props.autoRefreshTime && props.autoRefreshTime > 0) {
refreshInterval.current = setInterval(
refreshData,
props.autoRefreshTime
);
}
return () => {
if (refreshInterval.current) {
clearInterval(refreshInterval.current);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.cache, props.refreshOnFocus])
);
// useEffect(() => {
// if (status === REQUEST_STATUS.BAD_TOKEN && props.onMajorError) {
// props.onMajorError(status, code);
// }
// // eslint-disable-next-line react-hooks/exhaustive-deps
// }, [status, code]);
// if (status === REQUEST_STATUS.BAD_TOKEN && props.onMajorError) {
// return <View />;
// } else
if (data === undefined && loading && props.showLoading !== false) {
return <BasicLoadingScreen />;
} else if (
data === undefined &&
status !== REQUEST_STATUS.SUCCESS &&
props.showError !== false
) {
return (
<ErrorView
status={status}
loading={loading}
button={{
icon: 'refresh',
text: i18n.t('general.retry'),
onPress: () => refreshData(),
}}
/>
);
} else {
return props.render(data, loading, refreshData, status, code);
}
}

View file

@ -17,25 +17,26 @@
* along with Campus INSAT. If not, see <https://www.gnu.org/licenses/>.
*/
import * as React from 'react';
import React, { useState } from 'react';
import i18n from 'i18n-js';
import { Snackbar } from 'react-native-paper';
import {
NativeScrollEvent,
NativeSyntheticEvent,
RefreshControl,
SectionListData,
SectionListRenderItemInfo,
StyleSheet,
View,
} from 'react-native';
import * as Animatable from 'react-native-animatable';
import { Collapsible } from 'react-navigation-collapsible';
import { StackNavigationProp } from '@react-navigation/stack';
import ErrorView from './ErrorView';
import BasicLoadingScreen from './BasicLoadingScreen';
import { TAB_BAR_HEIGHT } from '../Tabbar/CustomTabBar';
import { ERROR_TYPE, readData } from '../../utils/WebData';
import { ERROR_TYPE } from '../../utils/WebData';
import CollapsibleSectionList from '../Collapsible/CollapsibleSectionList';
import GENERAL_STYLES from '../../constants/Styles';
import RequestScreen from './RequestScreen';
export type SectionListDataType<ItemT> = Array<{
title: string;
@ -44,39 +45,30 @@ export type SectionListDataType<ItemT> = Array<{
keyExtractor?: (data: ItemT) => string;
}>;
type PropsType<ItemT, RawData> = {
navigation: StackNavigationProp<any>;
fetchUrl: string;
autoRefreshTime: number;
type Props<ItemT, RawData> = {
request: () => Promise<RawData>;
refreshOnFocus: boolean;
renderItem: (data: { item: ItemT }) => React.ReactNode;
renderItem: (data: SectionListRenderItemInfo<ItemT>) => React.ReactNode;
createDataset: (
data: RawData | null,
isLoading?: boolean
data: RawData | undefined,
isLoading: boolean
) => SectionListDataType<ItemT>;
onScroll?: (event: NativeSyntheticEvent<EventTarget>) => void;
onScroll?: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
showError?: boolean;
itemHeight?: number | null;
updateData?: number;
autoRefreshTime?: number;
updateData?: number | string;
renderListHeaderComponent?: (
data: RawData | null
data?: RawData
) => React.ComponentType<any> | React.ReactElement | null;
renderSectionHeader?: (
data: { section: SectionListData<ItemT> },
isLoading?: boolean
isLoading: boolean
) => React.ReactElement | null;
stickyHeader?: boolean;
};
type StateType<RawData> = {
refreshing: boolean;
fetchedData: RawData | null;
snackbarVisible: boolean;
};
const MIN_REFRESH_TIME = 5 * 1000;
const styles = StyleSheet.create({
container: {
minHeight: '100%',
@ -85,131 +77,18 @@ const styles = StyleSheet.create({
/**
* Component used to render a SectionList with data fetched from the web
*
* This is a pure component, meaning it will only update if a shallow comparison of state and props is different.
* To force the component to update, change the value of updateData.
*/
class WebSectionList<ItemT, RawData> extends React.PureComponent<
PropsType<ItemT, RawData>,
StateType<RawData>
> {
static defaultProps = {
showError: true,
itemHeight: null,
updateData: 0,
renderListHeaderComponent: () => null,
renderSectionHeader: () => null,
stickyHeader: false,
};
function WebSectionList<ItemT, RawData>(props: Props<ItemT, RawData>) {
const [snackbarVisible, setSnackbarVisible] = useState(false);
refreshInterval: NodeJS.Timeout | undefined;
const showSnackBar = () => setSnackbarVisible(true);
lastRefresh: Date | undefined;
const hideSnackBar = () => setSnackbarVisible(false);
constructor(props: PropsType<ItemT, RawData>) {
super(props);
this.state = {
refreshing: false,
fetchedData: null,
snackbarVisible: false,
};
}
/**
* Registers react navigation events on first screen load.
* Allows to detect when the screen is focused
*/
componentDidMount() {
const { navigation } = this.props;
navigation.addListener('focus', this.onScreenFocus);
navigation.addListener('blur', this.onScreenBlur);
this.lastRefresh = undefined;
this.onRefresh();
}
/**
* Refreshes data when focusing the screen and setup a refresh interval if asked to
*/
onScreenFocus = () => {
const { props } = this;
if (props.refreshOnFocus && this.lastRefresh) {
setTimeout(this.onRefresh, 200);
}
if (props.autoRefreshTime > 0) {
this.refreshInterval = setInterval(this.onRefresh, props.autoRefreshTime);
}
};
/**
* Removes any interval on un-focus
*/
onScreenBlur = () => {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
};
/**
* Callback used when fetch is successful.
* It will update the displayed data and stop the refresh animation
*
* @param fetchedData The newly fetched data
*/
onFetchSuccess = (fetchedData: RawData) => {
this.setState({
fetchedData,
refreshing: false,
});
this.lastRefresh = new Date();
};
/**
* Callback used when fetch encountered an error.
* It will reset the displayed data and show an error.
*/
onFetchError = () => {
this.setState({
fetchedData: null,
refreshing: false,
});
this.showSnackBar();
};
/**
* Refreshes data and shows an animations while doing it
*/
onRefresh = () => {
const { fetchUrl } = this.props;
let canRefresh;
if (this.lastRefresh != null) {
const last = this.lastRefresh;
canRefresh = new Date().getTime() - last.getTime() > MIN_REFRESH_TIME;
} else {
canRefresh = true;
}
if (canRefresh) {
this.setState({ refreshing: true });
readData(fetchUrl).then(this.onFetchSuccess).catch(this.onFetchError);
}
};
/**
* Shows the error popup
*/
showSnackBar = () => {
this.setState({ snackbarVisible: true });
};
/**
* Hides the error popup
*/
hideSnackBar = () => {
this.setState({ snackbarVisible: false });
};
getItemLayout = (
const getItemLayout = (
height: number,
data: Array<SectionListData<ItemT>> | null,
_data: Array<SectionListData<ItemT>> | null,
index: number
): { length: number; offset: number; index: number } => {
return {
@ -219,105 +98,125 @@ class WebSectionList<ItemT, RawData> extends React.PureComponent<
};
};
getRenderSectionHeader = (data: { section: SectionListData<ItemT> }) => {
const { renderSectionHeader } = this.props;
const { refreshing } = this.state;
if (renderSectionHeader != null) {
const getRenderSectionHeader = (
data: { section: SectionListData<ItemT> },
loading: boolean
) => {
const { renderSectionHeader } = props;
if (renderSectionHeader) {
return (
<Animatable.View animation="fadeInUp" duration={500} useNativeDriver>
{renderSectionHeader(data, refreshing)}
<Animatable.View
animation={'fadeInUp'}
duration={500}
useNativeDriver={true}
>
{renderSectionHeader(data, loading)}
</Animatable.View>
);
}
return null;
};
getRenderItem = (data: { item: ItemT }) => {
const { renderItem } = this.props;
const getRenderItem = (data: SectionListRenderItemInfo<ItemT>) => {
const { renderItem } = props;
return (
<Animatable.View animation="fadeInUp" duration={500} useNativeDriver>
<Animatable.View
animation={'fadeInUp'}
duration={500}
useNativeDriver={true}
>
{renderItem(data)}
</Animatable.View>
);
};
onScroll = (event: NativeSyntheticEvent<EventTarget>) => {
const { onScroll } = this.props;
if (onScroll != null) {
onScroll(event);
const onScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
if (props.onScroll) {
props.onScroll(event);
}
};
render() {
const { props, state } = this;
const render = (
data: RawData | undefined,
loading: boolean,
refreshData: (newRequest?: () => Promise<RawData>) => void
) => {
const { itemHeight } = props;
let dataset: SectionListDataType<ItemT> = [];
if (
state.fetchedData != null ||
(state.fetchedData == null && !props.showError)
) {
dataset = props.createDataset(state.fetchedData, state.refreshing);
const dataset = props.createDataset(data, loading);
if (!data && !loading) {
showSnackBar();
}
return (
<View style={GENERAL_STYLES.flex}>
<CollapsibleSectionList
sections={dataset}
extraData={props.updateData}
paddedProps={(paddingTop) => ({
refreshControl: (
<RefreshControl
progressViewOffset={paddingTop}
refreshing={state.refreshing}
onRefresh={this.onRefresh}
/>
),
})}
renderSectionHeader={this.getRenderSectionHeader}
renderItem={this.getRenderItem}
stickySectionHeadersEnabled={props.stickyHeader}
style={styles.container}
ListHeaderComponent={
props.renderListHeaderComponent != null
? props.renderListHeaderComponent(state.fetchedData)
: null
}
ListEmptyComponent={
state.refreshing ? (
<BasicLoadingScreen />
) : (
<ErrorView
navigation={props.navigation}
errorCode={ERROR_TYPE.CONNECTION_ERROR}
onRefresh={this.onRefresh}
/>
)
}
getItemLayout={
itemHeight
? (data, index) => this.getItemLayout(itemHeight, data, index)
: undefined
}
onScroll={this.onScroll}
hasTab={true}
/>
<Snackbar
visible={state.snackbarVisible}
onDismiss={this.hideSnackBar}
action={{
label: 'OK',
onPress: () => {},
}}
duration={4000}
style={{
bottom: TAB_BAR_HEIGHT,
}}
>
{i18n.t('general.listUpdateFail')}
</Snackbar>
</View>
<CollapsibleSectionList
sections={dataset}
extraData={props.updateData}
paddedProps={(paddingTop) => ({
refreshControl: (
<RefreshControl
progressViewOffset={paddingTop}
refreshing={loading}
onRefresh={refreshData}
/>
),
})}
renderSectionHeader={(info) => getRenderSectionHeader(info, loading)}
renderItem={getRenderItem}
stickySectionHeadersEnabled={props.stickyHeader}
style={styles.container}
ListHeaderComponent={
props.renderListHeaderComponent != null
? props.renderListHeaderComponent(data)
: null
}
ListEmptyComponent={
loading ? (
<BasicLoadingScreen />
) : (
<ErrorView
status={ERROR_TYPE.CONNECTION_ERROR}
button={{
icon: 'refresh',
text: i18n.t('general.retry'),
onPress: refreshData,
}}
/>
)
}
getItemLayout={
itemHeight ? (d, i) => getItemLayout(itemHeight, d, i) : undefined
}
onScroll={onScroll}
hasTab={true}
/>
);
}
};
return (
<View style={GENERAL_STYLES.flex}>
<RequestScreen<RawData>
request={props.request}
render={render}
showError={false}
showLoading={false}
autoRefreshTime={props.autoRefreshTime}
refreshOnFocus={props.refreshOnFocus}
/>
<Snackbar
visible={snackbarVisible}
onDismiss={hideSnackBar}
action={{
label: 'OK',
onPress: hideSnackBar,
}}
duration={4000}
style={{
bottom: TAB_BAR_HEIGHT,
}}
>
{i18n.t('general.listUpdateFail')}
</Snackbar>
</View>
);
}
export default WebSectionList;

View file

@ -251,8 +251,12 @@ function WebViewScreen(props: Props) {
renderLoading={getRenderLoading}
renderError={() => (
<ErrorView
errorCode={ERROR_TYPE.CONNECTION_ERROR}
onRefresh={onRefreshClicked}
status={ERROR_TYPE.CONNECTION_ERROR}
button={{
icon: 'refresh',
text: i18n.t('general.retry'),
onPress: onRefreshClicked,
}}
/>
)}
onNavigationStateChange={setNavState}

View file

@ -21,11 +21,14 @@ const STUDENT_SERVER = 'https://etud.insa-toulouse.fr/';
const AMICALE_SERVER = 'https://www.amicale-insat.fr/';
const GIT_SERVER =
'https://git.etud.insa-toulouse.fr/vergnet/application-amicale/';
const PLANEX_SERVER = 'http://planex.insa-toulouse.fr/';
const AMICALE_ENDPOINT = AMICALE_SERVER + 'api/';
const APP_ENDPOINT = STUDENT_SERVER + '~amicale_app/v2/';
const PROXIMO_ENDPOINT = STUDENT_SERVER + '~proximo/data/stock-v2.json';
const PROXIMO_ENDPOINT = STUDENT_SERVER + '~proximo/v2/api/';
const PROXIMO_IMAGES_ENDPOINT =
STUDENT_SERVER + '~proximo/v2/api-proximo/public/storage/app/';
const APP_IMAGES_ENDPOINT = STUDENT_SERVER + '~amicale_app/images/';
export default {
@ -39,7 +42,16 @@ export default {
dashboard: APP_ENDPOINT + 'dashboard/dashboard_data.json',
menu: APP_ENDPOINT + 'menu/menu_data.json',
},
proximo: PROXIMO_ENDPOINT,
proximo: {
articles: PROXIMO_ENDPOINT + 'articles',
categories: PROXIMO_ENDPOINT + 'categories',
images: PROXIMO_IMAGES_ENDPOINT + 'img/',
icons: PROXIMO_IMAGES_ENDPOINT + 'icon/',
},
planex: {
planning: PLANEX_SERVER,
groups: PLANEX_SERVER + 'wsAdeGrp.php?projectId=1',
},
images: {
proxiwash: APP_IMAGES_ENDPOINT + 'Proxiwash.png',
washer: APP_IMAGES_ENDPOINT + 'ProxiwashLaveLinge.png',

View file

@ -84,6 +84,10 @@ export type FullParamsList = DefaultParams & {
};
'equipment-rent': { item?: DeviceType };
'gallery': { images: Array<{ url: string }> };
[MainRoutes.ProximoList]: {
shouldFocusSearchBar: boolean;
category: number;
};
};
// Don't know why but TS is complaining without this

View file

@ -22,6 +22,7 @@ import {
FlatList,
NativeScrollEvent,
NativeSyntheticEvent,
SectionListData,
StyleSheet,
} from 'react-native';
import i18n from 'i18n-js';
@ -52,6 +53,7 @@ import { getDisplayEvent, getFutureEvents } from '../../utils/Home';
import type { PlanningEventType } from '../../utils/Planning';
import GENERAL_STYLES from '../../constants/Styles';
import Urls from '../../constants/Urls';
import { readData } from '../../utils/WebData';
const FEED_ITEM_HEIGHT = 500;
@ -314,12 +316,7 @@ class HomeScreen extends React.Component<PropsType, StateType> {
getRenderItem = ({ item }: { item: FeedItemType }) => this.getFeedItem(item);
getRenderSectionHeader = (
data: {
section: {
data: Array<object>;
title: string;
};
},
data: { section: SectionListData<FeedItemType> },
isLoading: boolean
) => {
const { props } = this;
@ -352,7 +349,7 @@ class HomeScreen extends React.Component<PropsType, StateType> {
);
};
getListHeader = (fetchedData: RawDashboardType) => {
getListHeader = (fetchedData: RawDashboardType | undefined) => {
let dashboard = null;
if (fetchedData != null) {
dashboard = fetchedData.dashboard;
@ -404,21 +401,20 @@ class HomeScreen extends React.Component<PropsType, StateType> {
* @return {*}
*/
createDataset = (
fetchedData: RawDashboardType | null,
fetchedData: RawDashboardType | undefined,
isLoading: boolean
): Array<{
title: string;
data: [] | Array<FeedItemType>;
id: string;
}> => {
// fetchedData = DATA;
if (fetchedData != null) {
if (fetchedData.news_feed != null) {
if (fetchedData) {
if (fetchedData.news_feed) {
this.currentNewFeed = HomeScreen.generateNewsFeed(
fetchedData.news_feed
);
}
if (fetchedData.dashboard != null) {
if (fetchedData.dashboard) {
this.currentDashboard = fetchedData.dashboard;
}
}
@ -470,11 +466,10 @@ class HomeScreen extends React.Component<PropsType, StateType> {
<View style={GENERAL_STYLES.flex}>
<View style={styles.content}>
<WebSectionList
navigation={props.navigation}
request={() => readData<RawDashboardType>(Urls.app.dashboard)}
createDataset={this.createDataset}
autoRefreshTime={REFRESH_TIME}
refreshOnFocus
fetchUrl={Urls.app.dashboard}
refreshOnFocus={true}
renderItem={this.getRenderItem}
itemHeight={FEED_ITEM_HEIGHT}
onScroll={this.onScroll}

View file

@ -26,6 +26,8 @@ import { stringMatchQuery } from '../../utils/Search';
import WebSectionList from '../../components/Screens/WebSectionList';
import GroupListAccordion from '../../components/Lists/PlanexGroups/GroupListAccordion';
import AsyncStorageManager from '../../managers/AsyncStorageManager';
import Urls from '../../constants/Urls';
import { readData } from '../../utils/WebData';
export type PlanexGroupType = {
name: string;
@ -60,8 +62,6 @@ function sortName(
return 0;
}
const GROUPS_URL = 'http://planex.insa-toulouse.fr/wsAdeGrp.php?projectId=1';
/**
* Class defining planex group selection screen.
*/
@ -137,9 +137,13 @@ class GroupSelectionScreen extends React.Component<PropsType, StateType> {
* @param fetchedData
* @return {*}
* */
createDataset = (fetchedData: {
[key: string]: PlanexGroupCategoryType;
}): Array<{ title: string; data: Array<PlanexGroupCategoryType> }> => {
createDataset = (
fetchedData:
| {
[key: string]: PlanexGroupCategoryType;
}
| undefined
): Array<{ title: string; data: Array<PlanexGroupCategoryType> }> => {
return [
{
title: '',
@ -236,20 +240,28 @@ class GroupSelectionScreen extends React.Component<PropsType, StateType> {
* @param fetchedData The raw data fetched from the server
* @returns {[]}
*/
generateData(fetchedData: {
[key: string]: PlanexGroupCategoryType;
}): Array<PlanexGroupCategoryType> {
generateData(
fetchedData:
| {
[key: string]: PlanexGroupCategoryType;
}
| undefined
): Array<PlanexGroupCategoryType> {
const { favoriteGroups } = this.state;
const data: Array<PlanexGroupCategoryType> = [];
Object.values(fetchedData).forEach((category: PlanexGroupCategoryType) => {
data.push(category);
});
data.sort(sortName);
data.unshift({
name: i18n.t('screens.planex.favorites'),
id: 0,
content: favoriteGroups,
});
if (fetchedData) {
Object.values(fetchedData).forEach(
(category: PlanexGroupCategoryType) => {
data.push(category);
}
);
data.sort(sortName);
data.unshift({
name: i18n.t('screens.planex.favorites'),
id: 0,
content: favoriteGroups,
});
}
return data;
}
@ -298,14 +310,16 @@ class GroupSelectionScreen extends React.Component<PropsType, StateType> {
}
render() {
const { props, state } = this;
const { state } = this;
return (
<WebSectionList
navigation={props.navigation}
request={() =>
readData<{ [key: string]: PlanexGroupCategoryType }>(
Urls.planex.groups
)
}
createDataset={this.createDataset}
autoRefreshTime={0}
refreshOnFocus={false}
fetchUrl={GROUPS_URL}
refreshOnFocus={true}
renderItem={this.getRenderItem}
updateData={state.currentSearchString + state.favoriteGroups.length}
/>

View file

@ -42,6 +42,7 @@ import { MASCOT_STYLE } from '../../components/Mascot/Mascot';
import MascotPopup from '../../components/Mascot/MascotPopup';
import { getPrettierPlanexGroupName } from '../../utils/Utils';
import GENERAL_STYLES from '../../constants/Styles';
import Urls from '../../constants/Urls';
type PropsType = {
navigation: StackNavigationProp<any>;
@ -57,8 +58,6 @@ type StateType = {
injectJS: string;
};
const PLANEX_URL = 'http://planex.insa-toulouse.fr/';
// // JS + JQuery functions used to remove alpha from events. Copy paste in browser console for quick testing
// // Remove alpha from given Jquery node
// function removeAlpha(node) {
@ -197,22 +196,19 @@ class PlanexScreen extends React.Component<PropsType, StateType> {
* @returns {*}
*/
getWebView() {
const { props, state } = this;
const { state } = this;
const showWebview = state.currentGroup.id !== -1;
console.log(state.injectJS);
return (
<View style={GENERAL_STYLES.flex}>
{!showWebview ? (
<ErrorView
navigation={props.navigation}
icon="account-clock"
icon={'account-clock'}
message={i18n.t('screens.planex.noGroupSelected')}
showRetryButton={false}
/>
) : null}
<WebViewScreen
url={PLANEX_URL}
url={Urls.planex.planning}
initialJS={this.generateInjectedJS(this.state.currentGroup.id)}
injectJS={this.state.injectJS}
onMessage={this.onMessage}

View file

@ -28,9 +28,7 @@ import BasicLoadingScreen from '../../components/Screens/BasicLoadingScreen';
import { apiRequest, ERROR_TYPE } from '../../utils/WebData';
import ErrorView from '../../components/Screens/ErrorView';
import CustomHTML from '../../components/Overrides/CustomHTML';
import CustomTabBar, {
TAB_BAR_HEIGHT,
} from '../../components/Tabbar/CustomTabBar';
import { TAB_BAR_HEIGHT } from '../../components/Tabbar/CustomTabBar';
import CollapsibleScrollView from '../../components/Collapsible/CollapsibleScrollView';
import type { PlanningEventType } from '../../utils/Planning';
import ImageGalleryButton from '../../components/Media/ImageGalleryButton';
@ -163,12 +161,9 @@ class PlanningDisplayScreen extends React.Component<PropsType, StateType> {
* @returns {*}
*/
getErrorView() {
const { navigation } = this.props;
if (this.errorCode === ERROR_TYPE.BAD_INPUT) {
return (
<ErrorView
navigation={navigation}
showRetryButton={false}
message={i18n.t('screens.planning.invalidEvent')}
icon="calendar-remove"
/>
@ -176,9 +171,12 @@ class PlanningDisplayScreen extends React.Component<PropsType, StateType> {
}
return (
<ErrorView
navigation={navigation}
errorCode={this.errorCode}
onRefresh={this.fetchData}
status={this.errorCode}
button={{
icon: 'refresh',
text: i18n.t('general.retry'),
onPress: this.fetchData,
}}
/>
);
}

View file

@ -18,7 +18,13 @@
*/
import * as React from 'react';
import { Alert, StyleSheet, View } from 'react-native';
import {
Alert,
SectionListData,
SectionListRenderItemInfo,
StyleSheet,
View,
} from 'react-native';
import i18n from 'i18n-js';
import { Avatar, Button, Card, Text, withTheme } from 'react-native-paper';
import { StackNavigationProp } from '@react-navigation/stack';
@ -46,6 +52,7 @@ import MascotPopup from '../../components/Mascot/MascotPopup';
import type { SectionListDataType } from '../../components/Screens/WebSectionList';
import type { LaundromatType } from './ProxiwashAboutScreen';
import GENERAL_STYLES from '../../constants/Styles';
import { readData } from '../../utils/WebData';
const REFRESH_TIME = 1000 * 10; // Refresh every 10 seconds
const LIST_ITEM_HEIGHT = 64;
@ -72,6 +79,11 @@ type StateType = {
selectedWash: string;
};
type FetchedDataType = {
dryers: Array<ProxiwashMachineType>;
washers: Array<ProxiwashMachineType>;
};
const styles = StyleSheet.create({
modalContainer: {
flex: 1,
@ -277,7 +289,11 @@ class ProxiwashScreen extends React.Component<PropsType, StateType> {
* @param section The section to render
* @return {*}
*/
getRenderSectionHeader = ({ section }: { section: { title: string } }) => {
getRenderSectionHeader = ({
section,
}: {
section: SectionListData<ProxiwashMachineType>;
}) => {
const isDryer = section.title === i18n.t('screens.proxiwash.dryers');
const nbAvailable = this.getMachineAvailableNumber(isDryer);
return (
@ -296,20 +312,14 @@ class ProxiwashScreen extends React.Component<PropsType, StateType> {
* @param section The object describing the current SectionList section
* @returns {React.Node}
*/
getRenderItem = ({
item,
section,
}: {
item: ProxiwashMachineType;
section: { title: string };
}) => {
getRenderItem = (data: SectionListRenderItemInfo<ProxiwashMachineType>) => {
const { machinesWatched } = this.state;
const isDryer = section.title === i18n.t('screens.proxiwash.dryers');
const isDryer = data.section.title === i18n.t('screens.proxiwash.dryers');
return (
<ProxiwashListItem
item={item}
item={data.item}
onPress={this.showModal}
isWatched={isMachineWatched(item, machinesWatched)}
isWatched={isMachineWatched(data.item, machinesWatched)}
isDryer={isDryer}
height={LIST_ITEM_HEIGHT}
/>
@ -382,37 +392,40 @@ class ProxiwashScreen extends React.Component<PropsType, StateType> {
* @param fetchedData
* @return {*}
*/
createDataset = (fetchedData: {
dryers: Array<ProxiwashMachineType>;
washers: Array<ProxiwashMachineType>;
}): SectionListDataType<ProxiwashMachineType> => {
createDataset = (
fetchedData: FetchedDataType | undefined
): SectionListDataType<ProxiwashMachineType> => {
const { state } = this;
let data = fetchedData;
if (AprilFoolsManager.getInstance().isAprilFoolsEnabled()) {
data = JSON.parse(JSON.stringify(fetchedData)); // Deep copy
AprilFoolsManager.getNewProxiwashDryerOrderedList(data.dryers);
AprilFoolsManager.getNewProxiwashWasherOrderedList(data.washers);
if (fetchedData) {
let data = fetchedData;
if (AprilFoolsManager.getInstance().isAprilFoolsEnabled()) {
data = JSON.parse(JSON.stringify(fetchedData)); // Deep copy
AprilFoolsManager.getNewProxiwashDryerOrderedList(data.dryers);
AprilFoolsManager.getNewProxiwashWasherOrderedList(data.washers);
}
this.fetchedData = data;
// TODO dirty, should be refactored
this.state.machinesWatched = getCleanedMachineWatched(
state.machinesWatched,
[...data.dryers, ...data.washers]
);
return [
{
title: i18n.t('screens.proxiwash.dryers'),
icon: 'tumble-dryer',
data: data.dryers === undefined ? [] : data.dryers,
keyExtractor: this.getKeyExtractor,
},
{
title: i18n.t('screens.proxiwash.washers'),
icon: 'washing-machine',
data: data.washers === undefined ? [] : data.washers,
keyExtractor: this.getKeyExtractor,
},
];
} else {
return [];
}
this.fetchedData = data;
// TODO dirty, should be refactored
this.state.machinesWatched = getCleanedMachineWatched(
state.machinesWatched,
[...data.dryers, ...data.washers]
);
return [
{
title: i18n.t('screens.proxiwash.dryers'),
icon: 'tumble-dryer',
data: data.dryers === undefined ? [] : data.dryers,
keyExtractor: this.getKeyExtractor,
},
{
title: i18n.t('screens.proxiwash.washers'),
icon: 'washing-machine',
data: data.washers === undefined ? [] : data.washers,
keyExtractor: this.getKeyExtractor,
},
];
};
/**
@ -481,7 +494,6 @@ class ProxiwashScreen extends React.Component<PropsType, StateType> {
render() {
const { state } = this;
const { navigation } = this.props;
let data: LaundromatType;
switch (state.selectedWash) {
case 'tripodeB':
@ -494,13 +506,12 @@ class ProxiwashScreen extends React.Component<PropsType, StateType> {
<View style={GENERAL_STYLES.flex}>
<View style={styles.container}>
<WebSectionList
request={() => readData<FetchedDataType>(data.url)}
createDataset={this.createDataset}
navigation={navigation}
fetchUrl={data.url}
renderItem={this.getRenderItem}
renderSectionHeader={this.getRenderSectionHeader}
autoRefreshTime={REFRESH_TIME}
refreshOnFocus
refreshOnFocus={true}
updateData={state.machinesWatched.length}
/>
</View>

View file

@ -17,7 +17,7 @@
* along with Campus INSAT. If not, see <https://www.gnu.org/licenses/>.
*/
import * as React from 'react';
import React, { useLayoutEffect, useRef, useState } from 'react';
import { Image, Platform, ScrollView, StyleSheet, View } from 'react-native';
import i18n from 'i18n-js';
import {
@ -26,9 +26,8 @@ import {
Subheading,
Text,
Title,
withTheme,
useTheme,
} from 'react-native-paper';
import { StackNavigationProp } from '@react-navigation/stack';
import { Modalize } from 'react-native-modalize';
import CustomModal from '../../../components/Overrides/CustomModal';
import { stringMatchQuery } from '../../../utils/Search';
@ -36,19 +35,29 @@ import ProximoListItem from '../../../components/Lists/Proximo/ProximoListItem';
import MaterialHeaderButtons, {
Item,
} from '../../../components/Overrides/CustomHeaderButton';
import CollapsibleFlatList from '../../../components/Collapsible/CollapsibleFlatList';
import type { ProximoArticleType } from './ProximoMainScreen';
import GENERAL_STYLES from '../../../constants/Styles';
import { useNavigation } from '@react-navigation/core';
import Urls from '../../../constants/Urls';
import WebSectionList, {
SectionListDataType,
} from '../../../components/Screens/WebSectionList';
import { readData } from '../../../utils/WebData';
import { StackScreenProps } from '@react-navigation/stack';
import {
MainRoutes,
MainStackParamsList,
} from '../../../navigation/MainNavigator';
function sortPrice(a: ProximoArticleType, b: ProximoArticleType): number {
return parseInt(a.price, 10) - parseInt(b.price, 10);
return a.price - b.price;
}
function sortPriceReverse(
a: ProximoArticleType,
b: ProximoArticleType
): number {
return parseInt(b.price, 10) - parseInt(a.price, 10);
return b.price - a.price;
}
function sortName(a: ProximoArticleType, b: ProximoArticleType): number {
@ -73,23 +82,6 @@ function sortNameReverse(a: ProximoArticleType, b: ProximoArticleType): number {
const LIST_ITEM_HEIGHT = 84;
type PropsType = {
navigation: StackNavigationProp<any>;
route: {
params: {
data: { data: Array<ProximoArticleType> };
shouldFocusSearchBar: boolean;
};
};
theme: ReactNativePaper.Theme;
};
type StateType = {
currentSortMode: number;
modalCurrentDisplayItem: React.ReactNode;
currentSearchString: string;
};
const styles = StyleSheet.create({
modalContainer: {
flex: 1,
@ -118,113 +110,72 @@ const styles = StyleSheet.create({
},
});
/**
* Class defining Proximo article list of a certain category.
*/
class ProximoListScreen extends React.Component<PropsType, StateType> {
modalRef: Modalize | null;
type ArticlesType = Array<ProximoArticleType>;
listData: Array<ProximoArticleType>;
type Props = StackScreenProps<MainStackParamsList, MainRoutes.ProximoList>;
shouldFocusSearchBar: boolean;
function ProximoListScreen(props: Props) {
const navigation = useNavigation();
const theme = useTheme();
const modalRef = useRef<Modalize>();
constructor(props: PropsType) {
super(props);
this.modalRef = null;
this.listData = props.route.params.data.data.sort(sortName);
this.shouldFocusSearchBar = props.route.params.shouldFocusSearchBar;
this.state = {
currentSearchString: '',
currentSortMode: 3,
modalCurrentDisplayItem: null,
};
}
const [currentSearchString, setCurrentSearchString] = useState('');
const [currentSortMode, setCurrentSortMode] = useState(2);
const [modalCurrentDisplayItem, setModalCurrentDisplayItem] = useState<
React.ReactNode | undefined
>();
/**
* Creates the header content
*/
componentDidMount() {
const { navigation } = this.props;
const sortModes = [sortPrice, sortPriceReverse, sortName, sortNameReverse];
useLayoutEffect(() => {
navigation.setOptions({
headerRight: this.getSortMenuButton,
headerTitle: this.getSearchBar,
headerRight: getSortMenuButton,
headerTitle: getSearchBar,
headerBackTitleVisible: false,
headerTitleContainerStyle:
Platform.OS === 'ios'
? { marginHorizontal: 0, width: '70%' }
: { marginHorizontal: 0, right: 50, left: 50 },
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [navigation, currentSortMode]);
/**
* Callback used when clicking on the sort menu button.
* It will open the modal to show a sort selection
*/
onSortMenuPress = () => {
this.setState({
modalCurrentDisplayItem: this.getModalSortMenu(),
});
if (this.modalRef) {
this.modalRef.open();
const onSortMenuPress = () => {
setModalCurrentDisplayItem(getModalSortMenu());
if (modalRef.current) {
modalRef.current.open();
}
};
/**
* Callback used when the search changes
*
* @param str The new search string
*/
onSearchStringChange = (str: string) => {
this.setState({ currentSearchString: str });
};
/**
* Callback used when clicking an article in the list.
* It opens the modal to show detailed information about the article
*
* @param item The article pressed
*/
onListItemPress(item: ProximoArticleType) {
this.setState({
modalCurrentDisplayItem: this.getModalItemContent(item),
});
if (this.modalRef) {
this.modalRef.open();
const onListItemPress = (item: ProximoArticleType) => {
setModalCurrentDisplayItem(getModalItemContent(item));
if (modalRef.current) {
modalRef.current.open();
}
}
};
/**
* Sets the current sort mode.
*
* @param mode The number representing the mode
*/
setSortMode(mode: string) {
const { currentSortMode } = this.state;
const setSortMode = (mode: string) => {
const currentMode = parseInt(mode, 10);
this.setState({
currentSortMode: currentMode,
});
switch (currentMode) {
case 1:
this.listData.sort(sortPrice);
break;
case 2:
this.listData.sort(sortPriceReverse);
break;
case 3:
this.listData.sort(sortName);
break;
case 4:
this.listData.sort(sortNameReverse);
break;
default:
this.listData.sort(sortName);
break;
setCurrentSortMode(currentMode);
if (modalRef.current && currentMode !== currentSortMode) {
modalRef.current.close();
}
if (this.modalRef && currentMode !== currentSortMode) {
this.modalRef.close();
}
}
};
/**
* Gets a color depending on the quantity available
@ -232,8 +183,7 @@ class ProximoListScreen extends React.Component<PropsType, StateType> {
* @param availableStock The quantity available
* @return
*/
getStockColor(availableStock: number): string {
const { theme } = this.props;
const getStockColor = (availableStock: number): string => {
let color: string;
if (availableStock > 3) {
color = theme.colors.success;
@ -243,17 +193,17 @@ class ProximoListScreen extends React.Component<PropsType, StateType> {
color = theme.colors.danger;
}
return color;
}
};
/**
* Gets the sort menu header button
*
* @return {*}
*/
getSortMenuButton = () => {
const getSortMenuButton = () => {
return (
<MaterialHeaderButtons>
<Item title="main" iconName="sort" onPress={this.onSortMenuPress} />
<Item title="main" iconName="sort" onPress={onSortMenuPress} />
</MaterialHeaderButtons>
);
};
@ -263,12 +213,13 @@ class ProximoListScreen extends React.Component<PropsType, StateType> {
*
* @return {*}
*/
getSearchBar = () => {
const getSearchBar = () => {
return (
// @ts-ignore
<Searchbar
placeholder={i18n.t('screens.proximo.search')}
onChangeText={this.onSearchStringChange}
onChangeText={setCurrentSearchString}
autoFocus={props.route.params.shouldFocusSearchBar}
/>
);
};
@ -279,14 +230,14 @@ class ProximoListScreen extends React.Component<PropsType, StateType> {
* @param item The article to display
* @return {*}
*/
getModalItemContent(item: ProximoArticleType) {
const getModalItemContent = (item: ProximoArticleType) => {
return (
<View style={styles.modalContainer}>
<Title>{item.name}</Title>
<View style={styles.modalTitleContainer}>
<Subheading
style={{
color: this.getStockColor(parseInt(item.quantity, 10)),
color: getStockColor(item.quantity),
}}
>
{`${item.quantity} ${i18n.t('screens.proximo.inStock')}`}
@ -302,46 +253,43 @@ class ProximoListScreen extends React.Component<PropsType, StateType> {
</ScrollView>
</View>
);
}
};
/**
* Gets the modal content to display a sort menu
*
* @return {*}
*/
getModalSortMenu() {
const { currentSortMode } = this.state;
const getModalSortMenu = () => {
return (
<View style={styles.modalContainer}>
<Title style={styles.sortTitle}>
{i18n.t('screens.proximo.sortOrder')}
</Title>
<RadioButton.Group
onValueChange={(value: string) => {
this.setSortMode(value);
}}
onValueChange={setSortMode}
value={currentSortMode.toString()}
>
<RadioButton.Item
label={i18n.t('screens.proximo.sortPrice')}
value={'1'}
value={'0'}
/>
<RadioButton.Item
label={i18n.t('screens.proximo.sortPriceReverse')}
value={'2'}
value={'1'}
/>
<RadioButton.Item
label={i18n.t('screens.proximo.sortName')}
value={'3'}
value={'2'}
/>
<RadioButton.Item
label={i18n.t('screens.proximo.sortNameReverse')}
value={'4'}
value={'3'}
/>
</RadioButton.Group>
</View>
);
}
};
/**
* Gets a render item for the given article
@ -349,13 +297,12 @@ class ProximoListScreen extends React.Component<PropsType, StateType> {
* @param item The article to render
* @return {*}
*/
getRenderItem = ({ item }: { item: ProximoArticleType }) => {
const { currentSearchString } = this.state;
const getRenderItem = ({ item }: { item: ProximoArticleType }) => {
if (stringMatchQuery(item.name, currentSearchString)) {
const onPress = () => {
this.onListItemPress(item);
onListItemPress(item);
};
const color = this.getStockColor(parseInt(item.quantity, 10));
const color = getStockColor(item.quantity);
return (
<ProximoListItem
item={item}
@ -374,46 +321,55 @@ class ProximoListScreen extends React.Component<PropsType, StateType> {
* @param item The article to extract the key from
* @return {string} The extracted key
*/
keyExtractor = (item: ProximoArticleType): string => item.name + item.code;
const keyExtractor = (item: ProximoArticleType): string =>
item.name + item.code;
/**
* Callback used when receiving the modal ref
*
* @param ref
*/
onModalRef = (ref: Modalize) => {
this.modalRef = ref;
const createDataset = (
data: ArticlesType | undefined
): SectionListDataType<ProximoArticleType> => {
if (data) {
console.log(data);
console.log(props.route.params.category);
return [
{
title: '',
data: data
.filter(
(d) =>
props.route.params.category === -1 ||
props.route.params.category === d.category_id
)
.sort(sortModes[currentSortMode]),
keyExtractor: keyExtractor,
},
];
} else {
return [
{
title: '',
data: [],
keyExtractor: keyExtractor,
},
];
}
};
itemLayout = (
data: Array<ProximoArticleType> | null | undefined,
index: number
): { length: number; offset: number; index: number } => ({
length: LIST_ITEM_HEIGHT,
offset: LIST_ITEM_HEIGHT * index,
index,
});
render() {
const { state } = this;
return (
<View style={GENERAL_STYLES.flex}>
<CustomModal onRef={this.onModalRef}>
{state.modalCurrentDisplayItem}
</CustomModal>
<CollapsibleFlatList
data={this.listData}
extraData={state.currentSearchString + state.currentSortMode}
keyExtractor={this.keyExtractor}
renderItem={this.getRenderItem}
// Performance props, see https://reactnative.dev/docs/optimizing-flatlist-configuration
removeClippedSubviews
getItemLayout={this.itemLayout}
initialNumToRender={10}
/>
</View>
);
}
return (
<View style={GENERAL_STYLES.flex}>
<CustomModal onRef={(ref) => (modalRef.current = ref)}>
{modalCurrentDisplayItem}
</CustomModal>
<WebSectionList
request={() => readData<ArticlesType>(Urls.proximo.articles)}
createDataset={createDataset}
refreshOnFocus={true}
renderItem={getRenderItem}
updateData={currentSearchString + currentSortMode}
itemHeight={LIST_ITEM_HEIGHT}
/>
</View>
);
}
export default withTheme(ProximoListScreen);
export default ProximoListScreen;

View file

@ -19,8 +19,7 @@
import * as React from 'react';
import i18n from 'i18n-js';
import { List, withTheme } from 'react-native-paper';
import { StackNavigationProp } from '@react-navigation/stack';
import { Avatar, List, useTheme, withTheme } from 'react-native-paper';
import WebSectionList from '../../../components/Screens/WebSectionList';
import MaterialHeaderButtons, {
Item,
@ -28,40 +27,35 @@ import MaterialHeaderButtons, {
import type { SectionListDataType } from '../../../components/Screens/WebSectionList';
import { StyleSheet } from 'react-native';
import Urls from '../../../constants/Urls';
import { readData } from '../../../utils/WebData';
import { useNavigation } from '@react-navigation/core';
import { useLayoutEffect } from 'react';
const LIST_ITEM_HEIGHT = 84;
export type ProximoCategoryType = {
id: number;
name: string;
icon: string;
id: string;
created_at: string;
updated_at: string;
};
export type ProximoArticleType = {
id: number;
name: string;
description: string;
quantity: string;
price: string;
quantity: number;
price: number;
code: string;
id: string;
type: Array<string>;
image: string;
category_id: number;
created_at: string;
updated_at: string;
category: ProximoCategoryType;
};
export type ProximoMainListItemType = {
type: ProximoCategoryType;
data: Array<ProximoArticleType>;
};
export type ProximoDataType = {
types: Array<ProximoCategoryType>;
articles: Array<ProximoArticleType>;
};
type PropsType = {
navigation: StackNavigationProp<any>;
theme: ReactNativePaper.Theme;
};
type CategoriesType = Array<ProximoCategoryType>;
const styles = StyleSheet.create({
item: {
@ -69,138 +63,69 @@ const styles = StyleSheet.create({
},
});
function sortFinalData(a: ProximoCategoryType, b: ProximoCategoryType): number {
const str1 = a.name.toLowerCase();
const str2 = b.name.toLowerCase();
// Make 'All' category with id -1 stick to the top
if (a.id === -1) {
return -1;
}
if (b.id === -1) {
return 1;
}
// Sort others by name ascending
if (str1 < str2) {
return -1;
}
if (str1 > str2) {
return 1;
}
return 0;
}
/**
* Class defining the main proximo screen.
* This screen shows the different categories of articles offered by proximo.
*/
class ProximoMainScreen extends React.Component<PropsType> {
/**
* Function used to sort items in the list.
* Makes the All category sticks to the top and sorts the others by name ascending
*
* @param a
* @param b
* @return {number}
*/
static sortFinalData(
a: ProximoMainListItemType,
b: ProximoMainListItemType
): number {
const str1 = a.type.name.toLowerCase();
const str2 = b.type.name.toLowerCase();
function ProximoMainScreen() {
const navigation = useNavigation();
const theme = useTheme();
// Make 'All' category with id -1 stick to the top
if (a.type.id === '-1') {
return -1;
}
if (b.type.id === '-1') {
return 1;
}
// Sort others by name ascending
if (str1 < str2) {
return -1;
}
if (str1 > str2) {
return 1;
}
return 0;
}
/**
* Get an array of available articles (in stock) of the given type
*
* @param articles The list of all articles
* @param type The type of articles to find (undefined for any type)
* @return {Array} The array of available articles
*/
static getAvailableArticles(
articles: Array<ProximoArticleType> | null,
type?: ProximoCategoryType
): Array<ProximoArticleType> {
const availableArticles: Array<ProximoArticleType> = [];
if (articles != null) {
articles.forEach((article: ProximoArticleType) => {
if (
((type != null && article.type.includes(type.id)) || type == null) &&
parseInt(article.quantity, 10) > 0
) {
availableArticles.push(article);
}
});
}
return availableArticles;
}
articles: Array<ProximoArticleType> | null;
constructor(props: PropsType) {
super(props);
this.articles = null;
}
/**
* Creates header button
*/
componentDidMount() {
const { navigation } = this.props;
useLayoutEffect(() => {
navigation.setOptions({
headerRight: () => this.getHeaderButtons(),
headerRight: () => getHeaderButtons(),
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [navigation]);
/**
* Callback used when the search button is pressed.
* This will open a new ProximoListScreen with all items displayed
*/
onPressSearchBtn = () => {
const { navigation } = this.props;
const onPressSearchBtn = () => {
const searchScreenData = {
shouldFocusSearchBar: true,
data: {
type: {
id: '0',
name: i18n.t('screens.proximo.all'),
icon: 'star',
},
data:
this.articles != null
? ProximoMainScreen.getAvailableArticles(this.articles)
: [],
},
category: -1,
};
navigation.navigate('proximo-list', searchScreenData);
};
/**
* Callback used when the about button is pressed.
* This will open the ProximoAboutScreen
*/
onPressAboutBtn = () => {
const { navigation } = this.props;
navigation.navigate('proximo-about');
};
const onPressAboutBtn = () => navigation.navigate('proximo-about');
/**
* Gets the header buttons
* @return {*}
*/
getHeaderButtons() {
const getHeaderButtons = () => {
return (
<MaterialHeaderButtons>
<Item
title="magnify"
iconName="magnify"
onPress={this.onPressSearchBtn}
/>
<Item title="magnify" iconName="magnify" onPress={onPressSearchBtn} />
<Item
title="information"
iconName="information"
onPress={this.onPressAboutBtn}
onPress={onPressAboutBtn}
/>
</MaterialHeaderButtons>
);
}
};
/**
* Extracts a key for the given category
@ -208,7 +133,8 @@ class ProximoMainScreen extends React.Component<PropsType> {
* @param item The category to extract the key from
* @return {*} The extracted key
*/
getKeyExtractor = (item: ProximoMainListItemType): string => item.type.id;
const getKeyExtractor = (item: ProximoCategoryType): string =>
item.id.toString();
/**
* Gets the given category render item
@ -216,33 +142,36 @@ class ProximoMainScreen extends React.Component<PropsType> {
* @param item The category to render
* @return {*}
*/
getRenderItem = ({ item }: { item: ProximoMainListItemType }) => {
const { navigation, theme } = this.props;
const getRenderItem = ({ item }: { item: ProximoCategoryType }) => {
const dataToSend = {
shouldFocusSearchBar: false,
data: item,
category: item.id,
};
const subtitle = `${item.data.length} ${
item.data.length > 1
// TODO get article number
const article_number = 1;
const subtitle = `${article_number} ${
article_number > 1
? i18n.t('screens.proximo.articles')
: i18n.t('screens.proximo.article')
}`;
const onPress = () => {
navigation.navigate('proximo-list', dataToSend);
};
if (item.data.length > 0) {
const onPress = () => navigation.navigate('proximo-list', dataToSend);
if (article_number > 0) {
return (
<List.Item
title={item.type.name}
title={item.name}
description={subtitle}
onPress={onPress}
left={(props) => (
<List.Icon
style={props.style}
icon={item.type.icon}
color={theme.colors.primary}
/>
)}
left={(props) =>
item.icon.endsWith('.png') ? (
<Avatar.Image style={props.style} source={{ uri: item.icon }} />
) : (
<List.Icon
style={props.style}
icon={item.icon}
color={theme.colors.primary}
/>
)
}
right={(props) => (
<List.Icon
color={props.color}
@ -266,65 +195,46 @@ class ProximoMainScreen extends React.Component<PropsType> {
* @param fetchedData
* @return {*}
* */
createDataset = (
fetchedData: ProximoDataType | null
): SectionListDataType<ProximoMainListItemType> => {
return [
{
title: '',
data: this.generateData(fetchedData),
keyExtractor: this.getKeyExtractor,
},
];
};
/**
* Generate the data using types and FetchedData.
* This will group items under the same type.
*
* @param fetchedData The array of articles represented by objects
* @returns {Array} The formatted dataset
*/
generateData(
fetchedData: ProximoDataType | null
): Array<ProximoMainListItemType> {
const finalData: Array<ProximoMainListItemType> = [];
this.articles = null;
if (fetchedData != null) {
const { types } = fetchedData;
this.articles = fetchedData.articles;
finalData.push({
type: {
id: '-1',
const createDataset = (
data: CategoriesType | undefined
): SectionListDataType<ProximoCategoryType> => {
if (data) {
const finalData: CategoriesType = [
{
id: -1,
name: i18n.t('screens.proximo.all'),
icon: 'star',
created_at: '',
updated_at: '',
},
data: ProximoMainScreen.getAvailableArticles(this.articles),
});
types.forEach((type: ProximoCategoryType) => {
finalData.push({
type,
data: ProximoMainScreen.getAvailableArticles(this.articles, type),
});
});
...data,
];
return [
{
title: '',
data: finalData.sort(sortFinalData),
keyExtractor: getKeyExtractor,
},
];
} else {
return [
{
title: '',
data: [],
keyExtractor: getKeyExtractor,
},
];
}
finalData.sort(ProximoMainScreen.sortFinalData);
return finalData;
}
};
render() {
const { navigation } = this.props;
return (
<WebSectionList
createDataset={this.createDataset}
navigation={navigation}
autoRefreshTime={0}
refreshOnFocus={false}
fetchUrl={Urls.proximo}
renderItem={this.getRenderItem}
/>
);
}
return (
<WebSectionList
request={() => readData<CategoriesType>(Urls.proximo.categories)}
createDataset={createDataset}
refreshOnFocus={true}
renderItem={getRenderItem}
/>
);
}
export default withTheme(ProximoMainScreen);

View file

@ -18,7 +18,7 @@
*/
import * as React from 'react';
import { StyleSheet, View } from 'react-native';
import { SectionListData, StyleSheet, View } from 'react-native';
import { Card, Text, withTheme } from 'react-native-paper';
import { StackNavigationProp } from '@react-navigation/stack';
import i18n from 'i18n-js';
@ -26,6 +26,7 @@ import DateManager from '../../managers/DateManager';
import WebSectionList from '../../components/Screens/WebSectionList';
import type { SectionListDataType } from '../../components/Screens/WebSectionList';
import Urls from '../../constants/Urls';
import { readData } from '../../utils/WebData';
type PropsType = {
navigation: StackNavigationProp<any>;
@ -108,7 +109,7 @@ class SelfMenuScreen extends React.Component<PropsType> {
* @return {[]}
*/
createDataset = (
fetchedData: Array<RawRuMenuType>
fetchedData: Array<RawRuMenuType> | undefined
): SectionListDataType<RuFoodCategoryType> => {
let result: SectionListDataType<RuFoodCategoryType> = [];
if (fetchedData == null || fetchedData.length === 0) {
@ -137,7 +138,11 @@ class SelfMenuScreen extends React.Component<PropsType> {
* @param section The section to render the header from
* @return {*}
*/
getRenderSectionHeader = ({ section }: { section: { title: string } }) => {
getRenderSectionHeader = ({
section,
}: {
section: SectionListData<RuFoodCategoryType>;
}) => {
return (
<Card style={styles.headerCard}>
<Card.Title
@ -189,17 +194,14 @@ class SelfMenuScreen extends React.Component<PropsType> {
getKeyExtractor = (item: RuFoodCategoryType): string => item.name;
render() {
const { navigation } = this.props;
return (
<WebSectionList
request={() => readData<Array<RawRuMenuType>>(Urls.app.menu)}
createDataset={this.createDataset}
navigation={navigation}
autoRefreshTime={0}
refreshOnFocus={false}
fetchUrl={Urls.app.menu}
refreshOnFocus={true}
renderItem={this.getRenderItem}
renderSectionHeader={this.getRenderSectionHeader}
stickyHeader
stickyHeader={true}
/>
);
}

View file

@ -105,7 +105,6 @@ class WebsiteScreen extends React.Component<Props, State> {
const { route, navigation } = this.props;
if (route.params != null) {
console.log(route.params);
this.host = route.params.host;
let { path } = route.params;
const { title } = route.params;

View file

@ -141,7 +141,6 @@ class Test extends React.Component<Props> {
// );
return (
<WebSectionList
navigation={props.navigation}
createDataset={this.createDataset}
autoRefreshTime={REFRESH_TIME}
refreshOnFocus

22
src/utils/Requests.tsx Normal file
View file

@ -0,0 +1,22 @@
export enum REQUEST_STATUS {
SUCCESS = 200,
BAD_INPUT = 400,
FORBIDDEN = 403,
CONNECTION_ERROR = 404,
SERVER_ERROR = 500,
UNKNOWN = 999,
}
export enum REQUEST_CODES {
SUCCESS = 0,
BAD_CREDENTIALS = 1,
BAD_TOKEN = 2,
NO_CONSENT = 3,
TOKEN_SAVE = 4,
TOKEN_RETRIEVE = 5,
BAD_INPUT = 400,
FORBIDDEN = 403,
CONNECTION_ERROR = 404,
SERVER_ERROR = 500,
UNKNOWN = 999,
}

View file

@ -120,11 +120,11 @@ export async function apiRequest<T>(
* @param url The urls to fetch data from
* @return Promise<any>
*/
export async function readData(url: string): Promise<any> {
return new Promise((resolve: (response: any) => void, reject: () => void) => {
export async function readData<T>(url: string): Promise<T> {
return new Promise((resolve: (response: T) => void, reject: () => void) => {
fetch(url)
.then(async (response: Response): Promise<any> => response.json())
.then((data: any): void => resolve(data))
.catch((): void => reject());
.then((data: T) => resolve(data))
.catch(() => reject());
});
}

21
src/utils/cacheContext.ts Normal file
View file

@ -0,0 +1,21 @@
import React, { useContext } from 'react';
export type CacheContextType<T> = {
cache: T | undefined;
setCache: (newCache: T) => void;
resetCache: () => void;
};
export const CacheContext = React.createContext<CacheContextType<any>>({
cache: undefined,
setCache: () => undefined,
resetCache: () => undefined,
});
function getCacheContext<T>() {
return CacheContext as React.Context<CacheContextType<T>>;
}
export function useCache<T>() {
return useContext(getCacheContext<T>());
}

106
src/utils/customHooks.tsx Normal file
View file

@ -0,0 +1,106 @@
import { DependencyList, useEffect, useRef, useState } from 'react';
import { REQUEST_STATUS } from './Requests';
export function useMountEffect(func: () => void) {
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(func, []);
}
/**
* Effect that does not run on first render
*
* @param effect
* @param deps
*/
export function useSubsequentEffect(effect: () => void, deps?: DependencyList) {
const didMountRef = useRef(false);
useEffect(
() => {
if (didMountRef.current) {
effect();
} else {
didMountRef.current = true;
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
deps ? deps : []
);
}
export function useRequestLogic<T>(
request: () => Promise<T>,
cache?: T,
onCacheUpdate?: (newCache: T) => void,
startLoading?: boolean,
minRefreshTime?: number
) {
const [response, setResponse] = useState<{
loading: boolean;
status: REQUEST_STATUS;
code?: number;
data: T | undefined;
}>({
loading: startLoading !== false && cache === undefined,
status: REQUEST_STATUS.SUCCESS,
code: undefined,
data: undefined,
});
const [lastRefreshDate, setLastRefreshDate] = useState<Date | undefined>(
undefined
);
const refreshData = (newRequest?: () => Promise<T>) => {
let canRefresh;
if (lastRefreshDate && minRefreshTime) {
const last = lastRefreshDate;
canRefresh = new Date().getTime() - last.getTime() > minRefreshTime;
} else {
canRefresh = true;
}
if (canRefresh) {
if (!response.loading) {
setResponse((prevState) => ({
...prevState,
loading: true,
}));
}
setLastRefreshDate(new Date());
const r = newRequest ? newRequest : request;
r()
.then((requestResponse: T) => {
setResponse({
loading: false,
status: REQUEST_STATUS.SUCCESS,
code: undefined,
data: requestResponse,
});
if (onCacheUpdate) {
onCacheUpdate(requestResponse);
}
})
.catch(() => {
setResponse((prevState) => ({
loading: false,
status: REQUEST_STATUS.CONNECTION_ERROR,
code: 0,
data: prevState.data,
}));
});
}
};
const value: [
boolean,
REQUEST_STATUS,
number | undefined,
T | undefined,
(newRequest?: () => Promise<T>) => void
] = [
response.loading,
response.status,
response.code,
cache ? cache : response.data,
refreshData,
];
return value;
}

View file

@ -30,10 +30,10 @@
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
/* Additional Checks */
// "noUnusedLocals": true, /* Report errors on unused locals. */
// "noUnusedParameters": true, /* Report errors on unused parameters. */
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
"noUnusedLocals": true, /* Report errors on unused locals. */
"noUnusedParameters": true, /* Report errors on unused parameters. */
"noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
"noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
/* Module Resolution Options */
"moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
@ -45,13 +45,15 @@
"allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
"resolveJsonModule": true /* Allow import of JSON files */
"resolveJsonModule": true, /* Allow import of JSON files */
/* Source Map Options */
// "sourceRoot": "./", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
// "mapRoot": "./", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
"skipLibCheck": true,
"allowUnreachableCode": false,
"allowUnusedLabels": false
/* Experimental Options */
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */