Application Android et IOS pour l'amicale des élèves
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

EquipmentRentScreen.tsx 14KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468
  1. /*
  2. * Copyright (c) 2019 - 2020 Arnaud Vergnet.
  3. *
  4. * This file is part of Campus INSAT.
  5. *
  6. * Campus INSAT is free software: you can redistribute it and/or modify
  7. * it under the terms of the GNU General Public License as published by
  8. * the Free Software Foundation, either version 3 of the License, or
  9. * (at your option) any later version.
  10. *
  11. * Campus INSAT is distributed in the hope that it will be useful,
  12. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. * GNU General Public License for more details.
  15. *
  16. * You should have received a copy of the GNU General Public License
  17. * along with Campus INSAT. If not, see <https://www.gnu.org/licenses/>.
  18. */
  19. import * as React from 'react';
  20. import {
  21. Button,
  22. Caption,
  23. Card,
  24. Headline,
  25. Subheading,
  26. withTheme,
  27. } from 'react-native-paper';
  28. import {StackNavigationProp, StackScreenProps} from '@react-navigation/stack';
  29. import {BackHandler, View} from 'react-native';
  30. import * as Animatable from 'react-native-animatable';
  31. import i18n from 'i18n-js';
  32. import {CalendarList, PeriodMarking} from 'react-native-calendars';
  33. import type {DeviceType} from './EquipmentListScreen';
  34. import LoadingConfirmDialog from '../../../components/Dialogs/LoadingConfirmDialog';
  35. import ErrorDialog from '../../../components/Dialogs/ErrorDialog';
  36. import {
  37. generateMarkedDates,
  38. getFirstEquipmentAvailability,
  39. getISODate,
  40. getRelativeDateString,
  41. getValidRange,
  42. isEquipmentAvailable,
  43. } from '../../../utils/EquipmentBooking';
  44. import ConnectionManager from '../../../managers/ConnectionManager';
  45. import CollapsibleScrollView from '../../../components/Collapsible/CollapsibleScrollView';
  46. import {MainStackParamsList} from '../../../navigation/MainNavigator';
  47. type EquipmentRentScreenNavigationProp = StackScreenProps<
  48. MainStackParamsList,
  49. 'equipment-rent'
  50. >;
  51. type Props = EquipmentRentScreenNavigationProp & {
  52. navigation: StackNavigationProp<any>;
  53. theme: ReactNativePaper.Theme;
  54. };
  55. export type MarkedDatesObjectType = {
  56. [key: string]: PeriodMarking;
  57. };
  58. type StateType = {
  59. dialogVisible: boolean;
  60. errorDialogVisible: boolean;
  61. markedDates: MarkedDatesObjectType;
  62. currentError: number;
  63. };
  64. class EquipmentRentScreen extends React.Component<Props, StateType> {
  65. item: DeviceType | null;
  66. bookedDates: Array<string>;
  67. bookRef: {current: null | (Animatable.View & View)};
  68. canBookEquipment: boolean;
  69. lockedDates: {
  70. [key: string]: PeriodMarking;
  71. };
  72. constructor(props: Props) {
  73. super(props);
  74. this.item = null;
  75. this.lockedDates = {};
  76. this.state = {
  77. dialogVisible: false,
  78. errorDialogVisible: false,
  79. markedDates: {},
  80. currentError: 0,
  81. };
  82. this.resetSelection();
  83. this.bookRef = React.createRef();
  84. this.canBookEquipment = false;
  85. this.bookedDates = [];
  86. if (props.route.params != null) {
  87. if (props.route.params.item != null) {
  88. this.item = props.route.params.item;
  89. } else {
  90. this.item = null;
  91. }
  92. }
  93. const {item} = this;
  94. if (item != null) {
  95. this.lockedDates = {};
  96. item.booked_at.forEach((date: {begin: string; end: string}) => {
  97. const range = getValidRange(
  98. new Date(date.begin),
  99. new Date(date.end),
  100. null,
  101. );
  102. this.lockedDates = {
  103. ...this.lockedDates,
  104. ...generateMarkedDates(false, props.theme, range),
  105. };
  106. });
  107. }
  108. }
  109. /**
  110. * Captures focus and blur events to hook on android back button
  111. */
  112. componentDidMount() {
  113. const {navigation} = this.props;
  114. navigation.addListener('focus', () => {
  115. BackHandler.addEventListener(
  116. 'hardwareBackPress',
  117. this.onBackButtonPressAndroid,
  118. );
  119. });
  120. navigation.addListener('blur', () => {
  121. BackHandler.removeEventListener(
  122. 'hardwareBackPress',
  123. this.onBackButtonPressAndroid,
  124. );
  125. });
  126. }
  127. /**
  128. * Overrides default android back button behaviour to deselect date if any is selected.
  129. *
  130. * @return {boolean}
  131. */
  132. onBackButtonPressAndroid = (): boolean => {
  133. if (this.bookedDates.length > 0) {
  134. this.resetSelection();
  135. this.updateMarkedSelection();
  136. return true;
  137. }
  138. return false;
  139. };
  140. onDialogDismiss = () => {
  141. this.setState({dialogVisible: false});
  142. };
  143. onErrorDialogDismiss = () => {
  144. this.setState({errorDialogVisible: false});
  145. };
  146. /**
  147. * Sends the selected data to the server and waits for a response.
  148. * If the request is a success, navigate to the recap screen.
  149. * If it is an error, display the error to the user.
  150. *
  151. * @returns {Promise<void>}
  152. */
  153. onDialogAccept = (): Promise<void> => {
  154. return new Promise((resolve: () => void) => {
  155. const {item, props} = this;
  156. const start = this.getBookStartDate();
  157. const end = this.getBookEndDate();
  158. if (item != null && start != null && end != null) {
  159. ConnectionManager.getInstance()
  160. .authenticatedRequest('location/booking', {
  161. device: item.id,
  162. begin: getISODate(start),
  163. end: getISODate(end),
  164. })
  165. .then(() => {
  166. this.onDialogDismiss();
  167. props.navigation.replace('equipment-confirm', {
  168. item: this.item,
  169. dates: [getISODate(start), getISODate(end)],
  170. });
  171. resolve();
  172. })
  173. .catch((error: number) => {
  174. this.onDialogDismiss();
  175. this.showErrorDialog(error);
  176. resolve();
  177. });
  178. } else {
  179. this.onDialogDismiss();
  180. resolve();
  181. }
  182. });
  183. };
  184. getBookStartDate(): Date | null {
  185. return this.bookedDates.length > 0 ? new Date(this.bookedDates[0]) : null;
  186. }
  187. getBookEndDate(): Date | null {
  188. const {length} = this.bookedDates;
  189. return length > 0 ? new Date(this.bookedDates[length - 1]) : null;
  190. }
  191. /**
  192. * Selects a new date on the calendar.
  193. * If both start and end dates are already selected, unselect all.
  194. *
  195. * @param day The day selected
  196. */
  197. selectNewDate = (day: {
  198. dateString: string;
  199. day: number;
  200. month: number;
  201. timestamp: number;
  202. year: number;
  203. }) => {
  204. const selected = new Date(day.dateString);
  205. const start = this.getBookStartDate();
  206. if (!this.lockedDates[day.dateString] != null) {
  207. if (start === null) {
  208. this.updateSelectionRange(selected, selected);
  209. this.enableBooking();
  210. } else if (start.getTime() === selected.getTime()) {
  211. this.resetSelection();
  212. } else if (this.bookedDates.length === 1) {
  213. this.updateSelectionRange(start, selected);
  214. this.enableBooking();
  215. } else {
  216. this.resetSelection();
  217. }
  218. this.updateMarkedSelection();
  219. }
  220. };
  221. showErrorDialog = (error: number) => {
  222. this.setState({
  223. errorDialogVisible: true,
  224. currentError: error,
  225. });
  226. };
  227. showDialog = () => {
  228. this.setState({dialogVisible: true});
  229. };
  230. /**
  231. * Shows the book button by plying a fade animation
  232. */
  233. showBookButton() {
  234. if (this.bookRef.current && this.bookRef.current.fadeInUp) {
  235. this.bookRef.current.fadeInUp(500);
  236. }
  237. }
  238. /**
  239. * Hides the book button by plying a fade animation
  240. */
  241. hideBookButton() {
  242. if (this.bookRef.current && this.bookRef.current.fadeOutDown) {
  243. this.bookRef.current.fadeOutDown(500);
  244. }
  245. }
  246. enableBooking() {
  247. if (!this.canBookEquipment) {
  248. this.showBookButton();
  249. this.canBookEquipment = true;
  250. }
  251. }
  252. resetSelection() {
  253. if (this.canBookEquipment) {
  254. this.hideBookButton();
  255. }
  256. this.canBookEquipment = false;
  257. this.bookedDates = [];
  258. }
  259. updateSelectionRange(start: Date, end: Date) {
  260. this.bookedDates = getValidRange(start, end, this.item);
  261. }
  262. updateMarkedSelection() {
  263. const {theme} = this.props;
  264. this.setState({
  265. markedDates: generateMarkedDates(true, theme, this.bookedDates),
  266. });
  267. }
  268. render() {
  269. const {item, props, state} = this;
  270. const start = this.getBookStartDate();
  271. const end = this.getBookEndDate();
  272. let subHeadingText;
  273. if (start == null) {
  274. subHeadingText = i18n.t('screens.equipment.booking');
  275. } else if (end != null && start.getTime() !== end.getTime()) {
  276. subHeadingText = i18n.t('screens.equipment.bookingPeriod', {
  277. begin: getRelativeDateString(start),
  278. end: getRelativeDateString(end),
  279. });
  280. } else {
  281. subHeadingText = i18n.t('screens.equipment.bookingDay', {
  282. date: getRelativeDateString(start),
  283. });
  284. }
  285. if (item != null) {
  286. const isAvailable = isEquipmentAvailable(item);
  287. const firstAvailability = getFirstEquipmentAvailability(item);
  288. return (
  289. <View style={{flex: 1}}>
  290. <CollapsibleScrollView>
  291. <Card style={{margin: 5}}>
  292. <Card.Content>
  293. <View style={{flex: 1}}>
  294. <View
  295. style={{
  296. marginLeft: 'auto',
  297. marginRight: 'auto',
  298. flexDirection: 'row',
  299. flexWrap: 'wrap',
  300. }}>
  301. <Headline style={{textAlign: 'center'}}>
  302. {item.name}
  303. </Headline>
  304. <Caption
  305. style={{
  306. textAlign: 'center',
  307. lineHeight: 35,
  308. marginLeft: 10,
  309. }}>
  310. ({i18n.t('screens.equipment.bail', {cost: item.caution})})
  311. </Caption>
  312. </View>
  313. </View>
  314. <Button
  315. icon={isAvailable ? 'check-circle-outline' : 'update'}
  316. color={
  317. isAvailable
  318. ? props.theme.colors.success
  319. : props.theme.colors.primary
  320. }
  321. mode="text">
  322. {i18n.t('screens.equipment.available', {
  323. date: getRelativeDateString(firstAvailability),
  324. })}
  325. </Button>
  326. <Subheading
  327. style={{
  328. textAlign: 'center',
  329. marginBottom: 10,
  330. minHeight: 50,
  331. }}>
  332. {subHeadingText}
  333. </Subheading>
  334. </Card.Content>
  335. </Card>
  336. <CalendarList
  337. // Minimum date that can be selected, dates before minDate will be grayed out. Default = undefined
  338. minDate={new Date()}
  339. // Max amount of months allowed to scroll to the past. Default = 50
  340. pastScrollRange={0}
  341. // Max amount of months allowed to scroll to the future. Default = 50
  342. futureScrollRange={3}
  343. // Enable horizontal scrolling, default = false
  344. horizontal
  345. // Enable paging on horizontal, default = false
  346. pagingEnabled
  347. // Handler which gets executed on day press. Default = undefined
  348. onDayPress={this.selectNewDate}
  349. // If firstDay=1 week starts from Monday. Note that dayNames and dayNamesShort should still start from Sunday.
  350. firstDay={1}
  351. // Hide month navigation arrows.
  352. hideArrows={false}
  353. // Date marking style [simple/period/multi-dot/custom]. Default = 'simple'
  354. markingType={'period'}
  355. markedDates={{...this.lockedDates, ...state.markedDates}}
  356. theme={{
  357. backgroundColor: props.theme.colors.agendaBackgroundColor,
  358. calendarBackground: props.theme.colors.background,
  359. textSectionTitleColor: props.theme.colors.agendaDayTextColor,
  360. selectedDayBackgroundColor: props.theme.colors.primary,
  361. selectedDayTextColor: '#ffffff',
  362. todayTextColor: props.theme.colors.text,
  363. dayTextColor: props.theme.colors.text,
  364. textDisabledColor: props.theme.colors.agendaDayTextColor,
  365. dotColor: props.theme.colors.primary,
  366. selectedDotColor: '#ffffff',
  367. arrowColor: props.theme.colors.primary,
  368. monthTextColor: props.theme.colors.text,
  369. indicatorColor: props.theme.colors.primary,
  370. textDayFontFamily: 'monospace',
  371. textMonthFontFamily: 'monospace',
  372. textDayHeaderFontFamily: 'monospace',
  373. textDayFontWeight: '300',
  374. textMonthFontWeight: 'bold',
  375. textDayHeaderFontWeight: '300',
  376. textDayFontSize: 16,
  377. textMonthFontSize: 16,
  378. textDayHeaderFontSize: 16,
  379. 'stylesheet.day.period': {
  380. base: {
  381. overflow: 'hidden',
  382. height: 34,
  383. width: 34,
  384. alignItems: 'center',
  385. },
  386. },
  387. }}
  388. style={{marginBottom: 50}}
  389. />
  390. </CollapsibleScrollView>
  391. <LoadingConfirmDialog
  392. visible={state.dialogVisible}
  393. onDismiss={this.onDialogDismiss}
  394. onAccept={this.onDialogAccept}
  395. title={i18n.t('screens.equipment.dialogTitle')}
  396. titleLoading={i18n.t('screens.equipment.dialogTitleLoading')}
  397. message={i18n.t('screens.equipment.dialogMessage')}
  398. />
  399. <ErrorDialog
  400. visible={state.errorDialogVisible}
  401. onDismiss={this.onErrorDialogDismiss}
  402. errorCode={state.currentError}
  403. />
  404. <Animatable.View
  405. ref={this.bookRef}
  406. useNativeDriver
  407. style={{
  408. position: 'absolute',
  409. bottom: 0,
  410. left: 0,
  411. width: '100%',
  412. flex: 1,
  413. transform: [{translateY: 100}],
  414. }}>
  415. <Button
  416. icon="bookmark-check"
  417. mode="contained"
  418. onPress={this.showDialog}
  419. style={{
  420. width: '80%',
  421. flex: 1,
  422. marginLeft: 'auto',
  423. marginRight: 'auto',
  424. marginBottom: 20,
  425. borderRadius: 10,
  426. }}>
  427. {i18n.t('screens.equipment.bookButton')}
  428. </Button>
  429. </Animatable.View>
  430. </View>
  431. );
  432. }
  433. return null;
  434. }
  435. }
  436. export default withTheme(EquipmentRentScreen);