application-amicale/src/screens/Amicale/Equipment/EquipmentRentScreen.tsx
2021-09-12 18:33:18 +02:00

441 lines
13 KiB
TypeScript

/*
* Copyright (c) 2019 - 2020 Arnaud Vergnet.
*
* This file is part of Campus INSAT.
*
* Campus INSAT is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Campus INSAT is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Campus INSAT. If not, see <https://www.gnu.org/licenses/>.
*/
import React, { useCallback, useRef, useState } from 'react';
import {
Button,
Caption,
Card,
Headline,
Subheading,
useTheme,
} from 'react-native-paper';
import { StackNavigationProp, StackScreenProps } from '@react-navigation/stack';
import { BackHandler, StyleSheet, View } from 'react-native';
import * as Animatable from 'react-native-animatable';
import i18n from 'i18n-js';
import { CalendarList, PeriodMarking } from 'react-native-calendars';
import LoadingConfirmDialog from '../../../components/Dialogs/LoadingConfirmDialog';
import ErrorDialog from '../../../components/Dialogs/ErrorDialog';
import {
generateMarkedDates,
getFirstEquipmentAvailability,
getISODate,
getRelativeDateString,
getValidRange,
isEquipmentAvailable,
} from '../../../utils/EquipmentBooking';
import CollapsibleScrollView from '../../../components/Collapsible/CollapsibleScrollView';
import {
MainRoutes,
MainStackParamsList,
} from '../../../navigation/MainNavigator';
import GENERAL_STYLES from '../../../constants/Styles';
import { ApiRejectType } from '../../../utils/WebData';
import { REQUEST_STATUS } from '../../../utils/Requests';
import { useFocusEffect } from '@react-navigation/core';
import { useNavigation } from '@react-navigation/native';
import { useAuthenticatedRequest } from '../../../context/loginContext';
type Props = StackScreenProps<MainStackParamsList, MainRoutes.EquipmentRent>;
export type MarkedDatesObjectType = {
[key: string]: PeriodMarking;
};
const styles = StyleSheet.create({
titleContainer: {
marginLeft: 'auto',
marginRight: 'auto',
flexDirection: 'row',
flexWrap: 'wrap',
},
title: {
textAlign: 'center',
},
caption: {
textAlign: 'center',
lineHeight: 35,
marginLeft: 10,
},
card: {
margin: 5,
},
subtitle: {
textAlign: 'center',
marginBottom: 10,
minHeight: 50,
},
calendar: {
marginBottom: 50,
},
buttonContainer: {
position: 'absolute',
bottom: 0,
left: 0,
width: '100%',
flex: 1,
transform: [{ translateY: 100 }],
},
button: {
width: '80%',
flex: 1,
marginLeft: 'auto',
marginRight: 'auto',
marginBottom: 20,
borderRadius: 10,
},
});
function EquipmentRentScreen(props: Props) {
const theme = useTheme();
const navigation = useNavigation<StackNavigationProp<any>>();
const [currentError, setCurrentError] = useState<ApiRejectType>({
status: REQUEST_STATUS.SUCCESS,
});
const [markedDates, setMarkedDates] = useState<MarkedDatesObjectType>({});
const [dialogVisible, setDialogVisible] = useState(false);
const item = props.route.params.item;
const bookedDates = useRef<Array<string>>([]);
const canBookEquipment = useRef(false);
const bookRef = useRef<Animatable.View & View>(null);
let lockedDates: {
[key: string]: PeriodMarking;
} = {};
if (item) {
item.booked_at.forEach((date: { begin: string; end: string }) => {
const range = getValidRange(
new Date(date.begin),
new Date(date.end),
null
);
lockedDates = {
...lockedDates,
...generateMarkedDates(false, theme, range),
};
});
}
useFocusEffect(
useCallback(() => {
BackHandler.addEventListener(
'hardwareBackPress',
onBackButtonPressAndroid
);
return () => {
BackHandler.removeEventListener(
'hardwareBackPress',
onBackButtonPressAndroid
);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
);
/**
* Overrides default android back button behaviour to deselect date if any is selected.
*
* @return {boolean}
*/
const onBackButtonPressAndroid = (): boolean => {
if (bookedDates.current.length > 0) {
resetSelection();
updateMarkedSelection();
return true;
}
return false;
};
const showDialog = () => setDialogVisible(true);
const onDialogDismiss = () => setDialogVisible(false);
const onErrorDialogDismiss = () =>
setCurrentError({ status: REQUEST_STATUS.SUCCESS });
const getBookStartDate = (): Date | null => {
return bookedDates.current.length > 0
? new Date(bookedDates.current[0])
: null;
};
const getBookEndDate = (): Date | null => {
const { length } = bookedDates.current;
return length > 0 ? new Date(bookedDates.current[length - 1]) : null;
};
const start = getBookStartDate();
const end = getBookEndDate();
const request = useAuthenticatedRequest(
'location/booking',
item && start && end
? {
device: item.id,
begin: getISODate(start),
end: getISODate(end),
}
: undefined
);
/**
* Sends the selected data to the server and waits for a response.
* If the request is a success, navigate to the recap screen.
* If it is an error, display the error to the user.
*
* @returns {Promise<void>}
*/
const onDialogAccept = (): Promise<void> => {
return new Promise((resolve: () => void) => {
if (item != null && start != null && end != null) {
request()
.then(() => {
onDialogDismiss();
navigation.replace('equipment-confirm', {
item: item,
dates: [getISODate(start), getISODate(end)],
});
resolve();
})
.catch((error: ApiRejectType) => {
onDialogDismiss();
setCurrentError(error);
resolve();
});
} else {
onDialogDismiss();
resolve();
}
});
};
/**
* Selects a new date on the calendar.
* If both start and end dates are already selected, unselect all.
*
* @param day The day selected
*/
const selectNewDate = (day: {
dateString: string;
day: number;
month: number;
timestamp: number;
year: number;
}) => {
const selected = new Date(day.dateString);
if (!lockedDates[day.dateString] != null) {
if (start === null) {
updateSelectionRange(selected, selected);
enableBooking();
} else if (start.getTime() === selected.getTime()) {
resetSelection();
} else if (bookedDates.current.length === 1) {
updateSelectionRange(start, selected);
enableBooking();
} else {
resetSelection();
}
updateMarkedSelection();
}
};
const showBookButton = () => {
if (bookRef.current && bookRef.current.fadeInUp) {
bookRef.current.fadeInUp(500);
}
};
const hideBookButton = () => {
if (bookRef.current && bookRef.current.fadeOutDown) {
bookRef.current.fadeOutDown(500);
}
};
const enableBooking = () => {
if (!canBookEquipment.current) {
showBookButton();
canBookEquipment.current = true;
}
};
const resetSelection = () => {
if (canBookEquipment.current) {
hideBookButton();
}
canBookEquipment.current = false;
bookedDates.current = [];
};
const updateSelectionRange = (s: Date, e: Date) => {
if (item) {
bookedDates.current = getValidRange(s, e, item);
} else {
bookedDates.current = [];
}
};
const updateMarkedSelection = () => {
setMarkedDates(generateMarkedDates(true, theme, bookedDates.current));
};
let subHeadingText;
if (start == null) {
subHeadingText = i18n.t('screens.equipment.booking');
} else if (end != null && start.getTime() !== end.getTime()) {
subHeadingText = i18n.t('screens.equipment.bookingPeriod', {
begin: getRelativeDateString(start),
end: getRelativeDateString(end),
});
} else {
subHeadingText = i18n.t('screens.equipment.bookingDay', {
date: getRelativeDateString(start),
});
}
if (item) {
const isAvailable = isEquipmentAvailable(item);
const firstAvailability = getFirstEquipmentAvailability(item);
return (
<View style={GENERAL_STYLES.flex}>
<CollapsibleScrollView>
<Card style={styles.card}>
<Card.Content>
<View style={GENERAL_STYLES.flex}>
<View style={styles.titleContainer}>
<Headline style={styles.title}>{item.name}</Headline>
<Caption style={styles.caption}>
({i18n.t('screens.equipment.bail', { cost: item.caution })})
</Caption>
</View>
</View>
<Button
icon={isAvailable ? 'check-circle-outline' : 'update'}
color={
isAvailable ? theme.colors.success : theme.colors.primary
}
mode="text"
>
{i18n.t('screens.equipment.available', {
date: getRelativeDateString(firstAvailability),
})}
</Button>
<Subheading style={styles.subtitle}>{subHeadingText}</Subheading>
</Card.Content>
</Card>
<CalendarList
// Minimum date that can be selected, dates before minDate will be grayed out. Default = undefined
minDate={new Date()}
// Max amount of months allowed to scroll to the past. Default = 50
pastScrollRange={0}
// Max amount of months allowed to scroll to the future. Default = 50
futureScrollRange={3}
// Enable horizontal scrolling, default = false
horizontal
// Enable paging on horizontal, default = false
pagingEnabled
// Handler which gets executed on day press. Default = undefined
onDayPress={selectNewDate}
// If firstDay=1 week starts from Monday. Note that dayNames and dayNamesShort should still start from Sunday.
firstDay={1}
// Hide month navigation arrows.
hideArrows={false}
// Date marking style [simple/period/multi-dot/custom]. Default = 'simple'
markingType={'period'}
markedDates={{ ...lockedDates, ...markedDates }}
theme={{
'backgroundColor': theme.colors.agendaBackgroundColor,
'calendarBackground': theme.colors.background,
'textSectionTitleColor': theme.colors.agendaDayTextColor,
'selectedDayBackgroundColor': theme.colors.primary,
'selectedDayTextColor': '#ffffff',
'todayTextColor': theme.colors.text,
'dayTextColor': theme.colors.text,
'textDisabledColor': theme.colors.agendaDayTextColor,
'dotColor': theme.colors.primary,
'selectedDotColor': '#ffffff',
'arrowColor': theme.colors.primary,
'monthTextColor': theme.colors.text,
'indicatorColor': theme.colors.primary,
'textDayFontFamily': 'monospace',
'textMonthFontFamily': 'monospace',
'textDayHeaderFontFamily': 'monospace',
'textDayFontWeight': '300',
'textMonthFontWeight': 'bold',
'textDayHeaderFontWeight': '300',
'textDayFontSize': 16,
'textMonthFontSize': 16,
'textDayHeaderFontSize': 16,
'stylesheet.day.period': {
base: {
overflow: 'hidden',
height: 34,
width: 34,
alignItems: 'center',
},
},
}}
style={styles.calendar}
/>
</CollapsibleScrollView>
<LoadingConfirmDialog
visible={dialogVisible}
onDismiss={onDialogDismiss}
onAccept={onDialogAccept}
title={i18n.t('screens.equipment.dialogTitle')}
titleLoading={i18n.t('screens.equipment.dialogTitleLoading')}
message={i18n.t('screens.equipment.dialogMessage')}
/>
<ErrorDialog
visible={
currentError.status !== REQUEST_STATUS.SUCCESS ||
currentError.code !== undefined
}
onDismiss={onErrorDialogDismiss}
status={currentError.status}
code={currentError.code}
/>
<Animatable.View
ref={bookRef}
useNativeDriver
style={styles.buttonContainer}
>
<Button
icon="bookmark-check"
mode="contained"
onPress={showDialog}
style={styles.button}
>
{i18n.t('screens.equipment.bookButton')}
</Button>
</Animatable.View>
</View>
);
}
return null;
}
export default EquipmentRentScreen;