From 5067fd47d6fb30747a49c6d79cdd762d4dcb4fdf Mon Sep 17 00:00:00 2001 From: Arnaud Vergnet Date: Thu, 9 Jul 2020 14:40:01 +0200 Subject: [PATCH] Added basic equipment booking functionality --- package.json | 3 +- .../Lists/Equipment/EquipmentListItem.js | 86 +++ src/navigation/MainNavigator.js | 4 + .../Amicale/Equipment/EquipmentListScreen.js | 145 +++++ .../Amicale/Equipment/EquipmentRentScreen.js | 569 ++++++++++++++++++ src/screens/Services/ServicesScreen.js | 7 + translations/en.json | 22 +- translations/fr.json | 22 +- 8 files changed, 853 insertions(+), 5 deletions(-) create mode 100644 src/components/Lists/Equipment/EquipmentListItem.js create mode 100644 src/screens/Amicale/Equipment/EquipmentListScreen.js create mode 100644 src/screens/Amicale/Equipment/EquipmentRentScreen.js diff --git a/package.json b/package.json index 08babf2..2cdf674 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "dependencies": { "@nartc/react-native-barcode-mask": "^1.2.0", "@react-native-community/async-storage": "^1.11.0", + "@react-native-community/datetimepicker": "^2.6.0", "@react-native-community/masked-view": "^0.1.10", "@react-native-community/push-notification-ios": "^1.2.2", "@react-native-community/slider": "^3.0.0", @@ -34,7 +35,7 @@ "react-native-app-intro-slider": "^4.0.0", "react-native-appearance": "^0.3.3", "react-native-autolink": "^3.0.0", - "react-native-calendars": "^1.299.0", + "react-native-calendars": "^1.300.0", "react-native-camera": "^3.30.0", "react-native-collapsible": "^1.5.2", "react-native-gesture-handler": "^1.6.1", diff --git a/src/components/Lists/Equipment/EquipmentListItem.js b/src/components/Lists/Equipment/EquipmentListItem.js new file mode 100644 index 0000000..78dafa8 --- /dev/null +++ b/src/components/Lists/Equipment/EquipmentListItem.js @@ -0,0 +1,86 @@ +// @flow + +import * as React from 'react'; +import {Avatar, List, withTheme} from 'react-native-paper'; +import type {CustomTheme} from "../../../managers/ThemeManager"; +import type {Device} from "../../../screens/Amicale/Equipment/EquipmentListScreen"; +import i18n from "i18n-js"; +import {getTimeOnlyString, stringToDate} from "../../../utils/Planning"; + +type Props = { + onPress: () => void, + item: Device, + height: number, + theme: CustomTheme, +} + +class EquipmentListItem extends React.Component { + + shouldComponentUpdate() { + return false; + } + + isAvailable() { + const availableDate = stringToDate(this.props.item.available_at); + return availableDate != null && availableDate < new Date(); + } + + /** + * Gets the string representation of the given date. + * + * If the given date is the same day as today, only return the tile. + * Otherwise, return the full date. + * + * @param dateString The string representation of the wanted date + * @returns {string} + */ + getDateString(dateString: string): string { + const today = new Date(); + const date = stringToDate(dateString); + if (date != null && today.getDate() === date.getDate()) { + const str = getTimeOnlyString(dateString); + return str != null ? str : ""; + } else + return dateString; + } + + + render() { + const colors = this.props.theme.colors; + const item = this.props.item; + const isAvailable = this.isAvailable(); + return ( + } + right={(props) => } + style={{ + height: this.props.height, + justifyContent: 'center', + }} + /> + ); + } +} + +export default withTheme(EquipmentListItem); diff --git a/src/navigation/MainNavigator.js b/src/navigation/MainNavigator.js index 99e5422..5b4adda 100644 --- a/src/navigation/MainNavigator.js +++ b/src/navigation/MainNavigator.js @@ -23,6 +23,8 @@ import ClubDisplayScreen from "../screens/Amicale/Clubs/ClubDisplayScreen"; import {createScreenCollapsibleStack, getWebsiteStack} from "../utils/CollapsibleUtils"; import BugReportScreen from "../screens/Other/FeedbackScreen"; import WebsiteScreen from "../screens/Services/WebsiteScreen"; +import EquipmentScreen from "../screens/Amicale/Equipment/EquipmentListScreen"; +import EquipmentLendScreen from "../screens/Amicale/Equipment/EquipmentRentScreen"; const modalTransition = Platform.OS === 'ios' ? TransitionPresets.ModalPresentationIOS : TransitionPresets.ModalSlideFromBottomIOS; @@ -119,6 +121,8 @@ function MainStackComponent(props: { createTabNavigator: () => React.Node }) { {createScreenCollapsibleStack("profile", MainStack, ProfileScreen, i18n.t('screens.profile'))} {createScreenCollapsibleStack("club-list", MainStack, ClubListScreen, i18n.t('clubs.clubList'))} + {createScreenCollapsibleStack("equipment-list", MainStack, EquipmentScreen, i18n.t('screens.equipmentList'))} + {createScreenCollapsibleStack("equipment-lend", MainStack, EquipmentLendScreen, i18n.t('screens.equipmentLend'))} { + + data: Array; + + getRenderItem = ({item}: { item: Device }) => { + return ( + this.props.navigation.navigate('equipment-lend', {item: item})} + item={item} + height={LIST_ITEM_HEIGHT}/> + ); + }; + + /** + * Gets the list header, with explains this screen's purpose + * + * @returns {*} + */ + getListHeader() { + return + } + /> + + + {i18n.t('equipmentScreen.message')} + + + ; + } + + keyExtractor = (item: club) => item.id.toString(); + + /** + * Gets the main screen component with the fetched data + * + * @param data The data fetched from the server + * @returns {*} + */ + getScreen = (data: Array<{ [key: string]: any } | null>) => { + if (data[0] != null) { + const fetchedData = data[0]; + if (fetchedData != null) + this.data = fetchedData["devices"]; + + this.data = TEST_DATASET; // TODO remove in prod + } + const {containerPaddingTop, scrollIndicatorInsetTop, onScroll} = this.props.collapsibleStack; + return ( + + ) + }; + + render() { + return ( + + ); + } +} + +export default withCollapsible(withTheme(EquipmentListScreen)); diff --git a/src/screens/Amicale/Equipment/EquipmentRentScreen.js b/src/screens/Amicale/Equipment/EquipmentRentScreen.js new file mode 100644 index 0000000..08a9b2f --- /dev/null +++ b/src/screens/Amicale/Equipment/EquipmentRentScreen.js @@ -0,0 +1,569 @@ +// @flow + +import * as React from 'react'; +import {Button, Caption, Card, Headline, Subheading, Text, withTheme} from 'react-native-paper'; +import {Collapsible} from "react-navigation-collapsible"; +import {withCollapsible} from "../../../utils/withCollapsible"; +import {StackNavigationProp} from "@react-navigation/stack"; +import type {CustomTheme} from "../../../managers/ThemeManager"; +import type {Device} from "./EquipmentListScreen"; +import {Animated, BackHandler} from "react-native"; +import * as Animatable from "react-native-animatable"; +import {View} from "react-native-animatable"; +import i18n from "i18n-js"; +import {dateToString, getTimeOnlyString, stringToDate} from "../../../utils/Planning"; +import {CalendarList} from "react-native-calendars"; +import DateTimePicker from '@react-native-community/datetimepicker'; +import LoadingConfirmDialog from "../../../components/Dialogs/LoadingConfirmDialog"; +import ConnectionManager from "../../../managers/ConnectionManager"; +import ErrorDialog from "../../../components/Dialogs/ErrorDialog"; + +type Props = { + navigation: StackNavigationProp, + route: { + params?: { + item?: Device, + }, + }, + theme: CustomTheme, + collapsibleStack: Collapsible, +} + +type State = { + dialogVisible: boolean, + errorDialogVisible: boolean, + markedDates: { [key: string]: { startingDay: boolean, endingDay: boolean, color: string } }, + timePickerVisible: boolean, + currentError: number, +} + +class EquipmentRentScreen extends React.Component { + + state = { + dialogVisible: false, + errorDialogVisible: false, + markedDates: {}, + timePickerVisible: false, + currentError: 0, + } + + item: Device | null; + selectedDates: { + start: Date | null, + end: Date | null, + }; + + currentlySelectedDate: Date | null; + + bookRef: { current: null | Animatable.View } + canBookEquipment: boolean; + + constructor(props: Props) { + super(props); + this.resetSelection(); + this.bookRef = React.createRef(); + this.canBookEquipment = false; + if (this.props.route.params != null) { + if (this.props.route.params.item != null) + this.item = this.props.route.params.item; + else + this.item = null; + } + } + + /** + * Captures focus and blur events to hook on android back button + */ + componentDidMount() { + this.props.navigation.addListener( + 'focus', + () => + BackHandler.addEventListener( + 'hardwareBackPress', + this.onBackButtonPressAndroid + ) + ); + this.props.navigation.addListener( + 'blur', + () => + BackHandler.removeEventListener( + 'hardwareBackPress', + this.onBackButtonPressAndroid + ) + ); + } + + /** + * Overrides default android back button behaviour to deselect date if any is selected. + * + * @return {boolean} + */ + onBackButtonPressAndroid = () => { + if (this.currentlySelectedDate != null) { + this.resetSelection(); + this.setState({ + markedDates: this.generateMarkedDates(), + }); + return true; + } else + return false; + }; + + isAvailable(item: Device) { + const availableDate = stringToDate(item.available_at); + return availableDate != null && availableDate < new Date(); + } + + /** + * Gets the string representation of the given date. + * + * If the given date is the same day as today, only return the tile. + * Otherwise, return the full date. + * + * @param dateString The string representation of the wanted date + * @returns {string} + */ + getDateString(dateString: string): string { + const today = new Date(); + const date = stringToDate(dateString); + if (date != null && today.getDate() === date.getDate()) { + const str = getTimeOnlyString(dateString); + return str != null ? str : ""; + } else + return dateString; + } + + /** + * Gets the minimum date for renting equipment + * + * @param item The item to rent + * @param isAvailable True is it is available right now + * @returns {Date} + */ + getMinDate(item: Device, isAvailable: boolean) { + let date = new Date(); + if (isAvailable) + return date; + else { + const limit = stringToDate(item.available_at) + return limit != null ? limit : date; + } + } + + /** + * Selects a new date on the calendar. + * If both start and end dates are already selected, unselect all. + * + * @param day The day selected + */ + selectNewDate = (day: { dateString: string, day: number, month: number, timestamp: number, year: number }) => { + this.currentlySelectedDate = new Date(day.dateString); + + if (!this.canBookEquipment) { + const start = this.selectedDates.start; + if (start == null) + this.selectedDates.start = this.currentlySelectedDate; + else if (this.currentlySelectedDate < start) { + this.selectedDates.end = start; + this.selectedDates.start = this.currentlySelectedDate; + } else + this.selectedDates.end = this.currentlySelectedDate; + } else + this.resetSelection(); + + if (this.selectedDates.start != null) { + this.setState({ + markedDates: this.generateMarkedDates(), + timePickerVisible: true, + }); + } else { + this.setState({ + markedDates: this.generateMarkedDates(), + }); + } + } + + resetSelection() { + if (this.canBookEquipment) + this.hideBookButton(); + this.canBookEquipment = false; + this.selectedDates = {start: null, end: null}; + this.currentlySelectedDate = null; + } + + /** + * Deselect the currently selected date + */ + deselectCurrentDate() { + let currentlySelectedDate = this.currentlySelectedDate; + const start = this.selectedDates.start; + const end = this.selectedDates.end; + if (currentlySelectedDate != null && start != null) { + if (currentlySelectedDate === start && end === null) + this.resetSelection(); + else if (end != null && currentlySelectedDate === end) { + this.currentlySelectedDate = start; + this.selectedDates.end = null; + } else if (currentlySelectedDate === start) { + this.currentlySelectedDate = end; + this.selectedDates.start = this.selectedDates.end; + this.selectedDates.end = null; + } + } + } + + /** + * Saves the selected time to the currently selected date. + * If no the time selection was canceled, cancels the current selecction + * + * @param event The click event + * @param date The date selected + */ + onTimeChange = (event: { nativeEvent: { timestamp: number }, type: string }, date: Date) => { + let currentDate = this.currentlySelectedDate; + const item = this.item; + if (item != null && event.type === "set" && currentDate != null) { + currentDate.setHours(date.getHours()); + currentDate.setMinutes(date.getMinutes()); + + const isAvailable = this.isAvailable(item); + let limit = this.getMinDate(item, isAvailable); + // Prevent selecting a date before now + if (this.getISODate(currentDate) === this.getISODate(limit) && currentDate < limit) { + currentDate.setHours(limit.getHours()); + currentDate.setMinutes(limit.getMinutes()); + } + + if (this.selectedDates.start != null && this.selectedDates.end != null) { + if (this.selectedDates.start > this.selectedDates.end) { + const temp = this.selectedDates.start; + this.selectedDates.start = this.selectedDates.end; + this.selectedDates.end = temp; + } + this.canBookEquipment = true; + this.showBookButton(); + } + } else + this.deselectCurrentDate(); + + this.setState({ + timePickerVisible: false, + markedDates: this.generateMarkedDates(), + }); + } + + /** + * Returns the ISO date format (without the time) + * + * @param date The date to recover the ISO format from + * @returns {*} + */ + getISODate(date: Date) { + return date.toISOString().split("T")[0]; + } + + /** + * Generates the object containing all marked dates between the start and end dates selected + * + * @returns {{}} + */ + generateMarkedDates() { + let markedDates = {} + const start = this.selectedDates.start; + const end = this.selectedDates.end; + if (start != null) { + const startISODate = this.getISODate(start); + if (end != null && this.getISODate(end) !== startISODate) { + markedDates[startISODate] = { + startingDay: true, + endingDay: false, + color: this.props.theme.colors.primary + }; + markedDates[this.getISODate(end)] = { + startingDay: false, + endingDay: true, + color: this.props.theme.colors.primary + }; + let date = new Date(start); + date.setDate(date.getDate() + 1); + while (date < end && this.getISODate(date) !== this.getISODate(end)) { + markedDates[this.getISODate(date)] = + {startingDay: false, endingDay: false, color: this.props.theme.colors.danger}; + date.setDate(date.getDate() + 1); + } + } else { + markedDates[startISODate] = { + startingDay: true, + endingDay: true, + color: this.props.theme.colors.primary + }; + } + } + return markedDates; + } + + /** + * Shows the book button by plying a fade animation + */ + showBookButton() { + if (this.bookRef.current != null) { + this.bookRef.current.fadeInUp(500); + } + } + + /** + * Hides the book button by plying a fade animation + */ + hideBookButton() { + if (this.bookRef.current != null) { + this.bookRef.current.fadeOutDown(500); + } + } + + showDialog = () => { + this.setState({dialogVisible: true}); + } + + showErrorDialog = (error: number) => { + this.setState({ + errorDialogVisible: true, + currentError: error, + }); + } + + onDialogDismiss = () => { + this.setState({dialogVisible: false}); + } + + onErrorDialogDismiss = () => { + this.setState({errorDialogVisible: false}); + } + + /** + * 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} + */ + onDialogAccept = () => { + return new Promise((resolve) => { + const item = this.item; + const start = this.selectedDates.start; + const end = this.selectedDates.end; + if (item != null && start != null && end != null) { + ConnectionManager.getInstance().authenticatedRequest( + "", // TODO set path + { + "id": item.id, + "start": dateToString(start, false), + "end": dateToString(end, false), + }) + .then(() => { + console.log("Success, replace screen"); + resolve(); + }) + .catch((error: number) => { + this.onDialogDismiss(); + this.showErrorDialog(error); + resolve(); + }); + } else + resolve(); + }); + } + + render() { + const {containerPaddingTop, scrollIndicatorInsetTop, onScroll} = this.props.collapsibleStack; + let startString = {i18n.t('equipmentScreen.notSet')}; + let endString = {i18n.t('equipmentScreen.notSet')}; + const start = this.selectedDates.start; + const end = this.selectedDates.end; + if (start != null) + startString = dateToString(start, false); + if (end != null) + endString = dateToString(end, false); + + const item = this.item; + if (item != null) { + const isAvailable = this.isAvailable(item); + return ( + + + + + + + + {item.name} + + + ({i18n.t('equipmentScreen.bail', {cost: item.caution})}) + + + + + + + {i18n.t('equipmentScreen.booking')} + + + {i18n.t('equipmentScreen.startDate')} + {startString} + + + {i18n.t('equipmentScreen.endDate')} + {endString} + + + + {this.state.timePickerVisible + ? + : null} + + + + + + + + + + + + ) + } else + return ; + } + +} + +export default withCollapsible(withTheme(EquipmentRentScreen)); diff --git a/src/screens/Services/ServicesScreen.js b/src/screens/Services/ServicesScreen.js index 653050c..898a6ea 100644 --- a/src/screens/Services/ServicesScreen.js +++ b/src/screens/Services/ServicesScreen.js @@ -33,6 +33,7 @@ const AMICALE_LOGO = require("../../../assets/amicale.png"); const CLUBS_IMAGE = "https://etud.insa-toulouse.fr/~amicale_app/images/Clubs.png"; const PROFILE_IMAGE = "https://etud.insa-toulouse.fr/~amicale_app/images/ProfilAmicaliste.png"; +const EQUIPMENT_IMAGE = "https://etud.insa-toulouse.fr/~amicale_app/images/Materiel.png"; const VOTE_IMAGE = "https://etud.insa-toulouse.fr/~amicale_app/images/Vote.png"; const AMICALE_IMAGE = "https://etud.insa-toulouse.fr/~amicale_app/images/WebsiteAmicale.png"; @@ -72,6 +73,12 @@ class ServicesScreen extends React.Component { image: PROFILE_IMAGE, onPress: () => this.onAmicaleServicePress("profile"), }, + { + title: i18n.t('screens.equipmentList'), + subtitle: i18n.t('servicesScreen.descriptions.equipment'), + image: EQUIPMENT_IMAGE, + onPress: () => this.onAmicaleServicePress("equipment-list"), + }, { title: i18n.t('screens.amicaleWebsite'), subtitle: i18n.t('servicesScreen.descriptions.amicaleWebsite'), diff --git a/translations/en.json b/translations/en.json index d896a60..ea969e1 100644 --- a/translations/en.json +++ b/translations/en.json @@ -26,7 +26,9 @@ "vote": "Elections", "scanner": "Scanotron 3000", "feedback": "Feedback", - "insaAccount": "INSA Account" + "insaAccount": "INSA Account", + "equipmentList": "Equipment Booking", + "equipmentLend": "Book" }, "intro": { "slideMain": { @@ -428,7 +430,8 @@ "bib": "Book a Bib'Box for project work", "mails": "Check your INSA mails", "ent": "See your grades", - "insaAccount": "See your information and change your password" + "insaAccount": "See your information and change your password", + "equipment": "Book a BBQ or other equipment" } }, "planningScreen": { @@ -444,5 +447,20 @@ "contactMeans": "Using Gitea is recommended, to use it simply login with your INSA account.", "homeButtonTitle": "Feedback/Bug report", "homeButtonSubtitle": "Contact the devs" + }, + "equipmentScreen": { + "title": "Equipment booking", + "message": "Thanks to the Amicale, students have access to some equipment like BBQs and others. To book one of those items, click the equipment of your choice in the list bellow, enter your lend dates, then come around the Amicale to claim it and give your bail.", + "bail": "Bail: %{cost}€", + "availableAt": "Available at: %{date}", + "available": "Available!", + "booking": "Click on the calendar to set the start and end dates", + "startDate": "Start: ", + "endDate": "End: ", + "notSet": "Not set", + "bookButton": "Book selected dates", + "dialogTitle": "Confirm booking?", + "dialogTitleLoading": "Sending your booking...", + "dialogMessage": "Are you sure you want to confirm your booking?\n\nYou will then be able to claim the selected equipment at the Amicale for the duration of your booking in exchange of a bail." } } diff --git a/translations/fr.json b/translations/fr.json index 1b0a55c..ac792bd 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -26,7 +26,9 @@ "vote": "Élections", "scanner": "Scanotron 3000", "feedback": "Votre avis", - "insaAccount": "Compte INSA" + "insaAccount": "Compte INSA", + "equipmentList": "Réservation Matériel", + "equipmentLend": "Réserver" }, "intro": { "slideMain": { @@ -430,7 +432,8 @@ "bib": "Réservez une Bib'Box pour les travaux de groupe", "mails": "Vérifiez vos mails INSA", "ent": "Retrouvez vos notes", - "insaAccount": "Accédez à vos informations et modifiez votre mot de passe" + "insaAccount": "Accédez à vos informations et modifiez votre mot de passe", + "equipment": "Réservez un BBQ ou d'autre matériel" } }, "planningScreen": { @@ -446,5 +449,20 @@ "contactMeans": "L'utilisation de Gitea est recommandée, pour l'utiliser, connectez vous avec vos identifiants INSA.", "homeButtonTitle": "Feedback/Bugs", "homeButtonSubtitle": "Contacter le développeur" + }, + "equipmentScreen": { + "title": "Réservation de Matériel", + "message": "L'Amicale mets à disposition des étudiants du matériel comme des BBQ, des appareils à raclette et autres. Pour réserver l'un de ces formidables appareils, cliquez sur celui de votre choix dans la liste, indiquez les dates du prêt, puis passez à l'Amicale pour le récupérer et donner votre caution.", + "bail": "Caution : %{cost}€", + "availableAt": "Disponible à : %{date}", + "available": "Disponible !", + "booking": "Cliquez sur le calendrier pour choisir les dates de début et de fin de la réservation", + "startDate": "Début: ", + "endDate": "Fin: ", + "notSet": "Non défini", + "bookButton": "Choisir ces dates", + "dialogTitle": "Confirmer la réservation ?", + "dialogTitleLoading": "Envoi de la réservation...", + "dialogMessage": "Êtes vous sûr de vouloir confirmer cette réservation?\n\nVous pourrez ensuite récupérer le matériel à l'Amicale pour la durée de votre reservation en échange d'une caution." } }