forked from vergnet/application-amicale
		
	Added planex group search and selection with native UI
This commit is contained in:
		
							parent
							
								
									c7527d8c6b
								
							
						
					
					
						commit
						e693636464
					
				
					 7 changed files with 366 additions and 25 deletions
				
			
		|  | @ -2,12 +2,14 @@ | |||
| 
 | ||||
| import * as React from 'react'; | ||||
| import {StyleSheet, View} from "react-native"; | ||||
| import {IconButton, Surface, withTheme} from "react-native-paper"; | ||||
| import {Button, IconButton, Surface, withTheme} from "react-native-paper"; | ||||
| import AutoHideComponent from "./AutoHideComponent"; | ||||
| 
 | ||||
| type Props = { | ||||
|     navigation: Object, | ||||
|     theme: Object, | ||||
|     onPress: Function, | ||||
|     currentGroup: string, | ||||
| } | ||||
| 
 | ||||
| type State = { | ||||
|  | @ -39,6 +41,10 @@ class AnimatedBottomBar extends React.Component<Props, State> { | |||
|         this.displayModeIcons[DISPLAY_MODES.MONTH] = "calendar-range"; | ||||
|     } | ||||
| 
 | ||||
|     shouldComponentUpdate(nextProps: Props) { | ||||
|         return (nextProps.currentGroup !== this.props.currentGroup); | ||||
|     } | ||||
| 
 | ||||
|     onScroll = (event: Object) => { | ||||
|         this.ref.current.onScroll(event); | ||||
|     }; | ||||
|  | @ -79,6 +85,13 @@ class AnimatedBottomBar extends React.Component<Props, State> { | |||
|                             style={{marginLeft: 5}} | ||||
|                             onPress={() => this.props.onPress('today', undefined)}/> | ||||
|                     </View> | ||||
|                         <Button | ||||
|                             icon="book-variant" | ||||
|                             onPress={() => this.props.navigation.navigate('group-select')} | ||||
|                             style={{maxWidth: '40%'}} | ||||
|                         > | ||||
|                             {this.props.currentGroup.replace(/_/g, " ")} | ||||
|                         </Button> | ||||
|                     <View style={{flexDirection: 'row'}}> | ||||
|                         <IconButton | ||||
|                             icon="chevron-left" | ||||
|  |  | |||
							
								
								
									
										94
									
								
								src/components/Lists/GroupListAccordion.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										94
									
								
								src/components/Lists/GroupListAccordion.js
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,94 @@ | |||
| // @flow
 | ||||
| 
 | ||||
| import * as React from 'react'; | ||||
| import {List} from 'react-native-paper'; | ||||
| import {FlatList} from "react-native"; | ||||
| import {stringMatchQuery} from "../../utils/Search"; | ||||
| 
 | ||||
| type Props = { | ||||
|     item: Object, | ||||
|     onGroupPress: Function, | ||||
|     currentSearchString: string, | ||||
|     height: number, | ||||
| } | ||||
| 
 | ||||
| type State = { | ||||
|     expanded: boolean, | ||||
| } | ||||
| 
 | ||||
| const LIST_ITEM_HEIGHT = 64; | ||||
| 
 | ||||
| const REPLACE_REGEX = /_/g; | ||||
| 
 | ||||
| export default class GroupListAccordion extends React.Component<Props, State> { | ||||
| 
 | ||||
|     state = { | ||||
|         expanded: false, | ||||
|     } | ||||
| 
 | ||||
|     shouldComponentUpdate(nextProps: Props, nextSate: State) { | ||||
|         if (nextProps.currentSearchString !== this.props.currentSearchString) | ||||
|             this.state.expanded = nextProps.currentSearchString.length > 0; | ||||
| 
 | ||||
|         return (nextProps.currentSearchString !== this.props.currentSearchString) | ||||
|             || (nextSate.expanded !== this.state.expanded); | ||||
|     } | ||||
| 
 | ||||
|     onPress = () => this.setState({expanded: !this.state.expanded}); | ||||
| 
 | ||||
|     keyExtractor = (item: Object) => item.id.toString(); | ||||
| 
 | ||||
|     renderItem = ({item}: Object) => { | ||||
|         if (stringMatchQuery(item.name, this.props.currentSearchString)) { | ||||
|             const onPress = () => this.props.onGroupPress(item); | ||||
|             return ( | ||||
|                 <List.Item | ||||
|                     title={item.name.replace(REPLACE_REGEX, " ")} | ||||
|                     onPress={onPress} | ||||
|                     left={props => | ||||
|                         <List.Icon | ||||
|                             {...props} | ||||
|                             icon={"chevron-right"}/>} | ||||
|                     right={props => | ||||
|                         <List.Icon | ||||
|                             {...props} | ||||
|                             icon={"star"}/>} | ||||
|                     style={{ | ||||
|                         height: LIST_ITEM_HEIGHT, | ||||
|                         justifyContent: 'center', | ||||
|                     }} | ||||
|                 /> | ||||
|             ); | ||||
|         } else | ||||
|             return null; | ||||
|     } | ||||
| 
 | ||||
|     itemLayout = (data: Object, index: number) => ({length: LIST_ITEM_HEIGHT, offset: LIST_ITEM_HEIGHT * index, index}); | ||||
| 
 | ||||
|     render() { | ||||
|         const item = this.props.item; | ||||
|         return ( | ||||
|             <List.Accordion | ||||
|                 title={item.name} | ||||
|                 expanded={this.state.expanded} | ||||
|                 onPress={this.onPress} | ||||
|                 style={{ | ||||
|                     height: this.props.height, | ||||
|                     justifyContent: 'center', | ||||
|                 }} | ||||
|             > | ||||
|                 {/*$FlowFixMe*/} | ||||
|                 <FlatList | ||||
|                     data={item.content} | ||||
|                     extraData={this.props.currentSearchString} | ||||
|                     renderItem={this.renderItem} | ||||
|                     keyExtractor={this.keyExtractor} | ||||
|                     listKey={item.id} | ||||
|                     // Performance props, see https://reactnative.dev/docs/optimizing-flatlist-configuration
 | ||||
|                     getItemLayout={this.itemLayout} | ||||
|                     removeClippedSubviews={true} | ||||
|                 /> | ||||
|             </List.Accordion> | ||||
|         ); | ||||
|     } | ||||
| } | ||||
|  | @ -79,6 +79,11 @@ export default class AsyncStorageManager { | |||
|             default: '1', | ||||
|             current: '', | ||||
|         }, | ||||
|         planexCurrentGroup: { | ||||
|             key: 'planexCurrentGroup', | ||||
|             default: '', | ||||
|             current: '', | ||||
|         }, | ||||
|     }; | ||||
| 
 | ||||
|     /** | ||||
|  |  | |||
|  | @ -20,6 +20,7 @@ import ScannerScreen from "../screens/ScannerScreen"; | |||
| import MaterialHeaderButtons, {Item} from "../components/Custom/HeaderButton"; | ||||
| import FeedItemScreen from "../screens/FeedItemScreen"; | ||||
| import {createCollapsibleStack} from "react-navigation-collapsible"; | ||||
| import GroupSelectionScreen from "../screens/GroupSelectionScreen"; | ||||
| 
 | ||||
| 
 | ||||
| const TAB_ICONS = { | ||||
|  | @ -283,6 +284,22 @@ function PlanexStackComponent() { | |||
|                     useNativeDriver: false, // native driver does not work with webview
 | ||||
|                 } | ||||
|             )} | ||||
|             {createCollapsibleStack( | ||||
|                 <PlanexStack.Screen | ||||
|                     name="group-select" | ||||
|                     component={GroupSelectionScreen} | ||||
|                     options={({navigation}) => { | ||||
|                         return { | ||||
|                             title: 'GroupSelectionScreen', | ||||
|                             ...TransitionPresets.ModalSlideFromBottomIOS, | ||||
|                         }; | ||||
|                     }} | ||||
|                 />, | ||||
|                 { | ||||
|                     collapsedColor: 'transparent', | ||||
|                     useNativeDriver: true, | ||||
|                 } | ||||
|             )} | ||||
|         </PlanexStack.Navigator> | ||||
|     ); | ||||
| } | ||||
|  |  | |||
							
								
								
									
										170
									
								
								src/screens/GroupSelectionScreen.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										170
									
								
								src/screens/GroupSelectionScreen.js
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,170 @@ | |||
| // @flow
 | ||||
| 
 | ||||
| import * as React from 'react'; | ||||
| import {Platform, View} from "react-native"; | ||||
| import i18n from "i18n-js"; | ||||
| import {Searchbar, withTheme} from "react-native-paper"; | ||||
| import {stringMatchQuery} from "../utils/Search"; | ||||
| import WebSectionList from "../components/Lists/WebSectionList"; | ||||
| import GroupListAccordion from "../components/Lists/GroupListAccordion"; | ||||
| 
 | ||||
| const LIST_ITEM_HEIGHT = 70; | ||||
| 
 | ||||
| type Props = { | ||||
|     navigation: Object, | ||||
|     route: Object, | ||||
|     theme: Object, | ||||
|     collapsibleStack: Object, | ||||
| } | ||||
| 
 | ||||
| type State = { | ||||
|     currentSearchString: string, | ||||
| }; | ||||
| 
 | ||||
| function sortName(a, b) { | ||||
|     if (a.name.toLowerCase() < b.name.toLowerCase()) | ||||
|         return -1; | ||||
|     if (a.name.toLowerCase() > b.name.toLowerCase()) | ||||
|         return 1; | ||||
|     return 0; | ||||
| } | ||||
| 
 | ||||
| const GROUPS_URL = 'http://planex.insa-toulouse.fr/wsAdeGrp.php?projectId=1'; | ||||
| 
 | ||||
| /** | ||||
|  * Class defining proximo's article list of a certain category. | ||||
|  */ | ||||
| class GroupSelectionScreen extends React.Component<Props, State> { | ||||
| 
 | ||||
|     constructor(props) { | ||||
|         super(props); | ||||
|         this.state = { | ||||
|             currentSearchString: '', | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Creates the header content | ||||
|      */ | ||||
|     componentDidMount() { | ||||
|         this.props.navigation.setOptions({ | ||||
|             headerTitle: this.getSearchBar, | ||||
|             headerBackTitleVisible: false, | ||||
|             headerTitleContainerStyle: Platform.OS === 'ios' ? | ||||
|                 {marginHorizontal: 0, width: '70%'} : | ||||
|                 {marginHorizontal: 0, right: 50, left: 50}, | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Gets the header search bar | ||||
|      * | ||||
|      * @return {*} | ||||
|      */ | ||||
|     getSearchBar = () => { | ||||
|         return ( | ||||
|             <Searchbar | ||||
|                 placeholder={i18n.t('proximoScreen.search')} | ||||
|                 onChangeText={this.onSearchStringChange} | ||||
|             /> | ||||
|         ); | ||||
|     }; | ||||
| 
 | ||||
|     /** | ||||
|      * 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: Object) => { | ||||
|         this.props.navigation.navigate("planex", { | ||||
|             screen: "index", | ||||
|             params: {group: item} | ||||
|         }); | ||||
|     }; | ||||
| 
 | ||||
|     shouldDisplayAccordion(item: Object) { | ||||
|         let shouldDisplay = false; | ||||
|         for (let i = 0; i < item.content.length; i++) { | ||||
|             if (stringMatchQuery(item.content[i].name, this.state.currentSearchString)) { | ||||
|                 shouldDisplay = true; | ||||
|                 break; | ||||
|             } | ||||
|         } | ||||
|         return shouldDisplay; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Gets a render item for the given article | ||||
|      * | ||||
|      * @param item The article to render | ||||
|      * @return {*} | ||||
|      */ | ||||
|     renderItem = ({item}: Object) => { | ||||
|         if (this.shouldDisplayAccordion(item)) { | ||||
|             return ( | ||||
|                 <GroupListAccordion | ||||
|                     item={item} | ||||
|                     onGroupPress={this.onListItemPress} | ||||
|                     currentSearchString={this.state.currentSearchString} | ||||
|                     height={LIST_ITEM_HEIGHT} | ||||
|                 /> | ||||
|             ); | ||||
|         } else | ||||
|             return null; | ||||
|     }; | ||||
| 
 | ||||
|     generateData(fetchedData: Object) { | ||||
|         let data = []; | ||||
|         for (let key in fetchedData) { | ||||
|             data.push(fetchedData[key]); | ||||
|         } | ||||
|         data.sort(sortName); | ||||
|         return data; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Creates the dataset to be used in the FlatList | ||||
|      * | ||||
|      * @param fetchedData | ||||
|      * @return {*} | ||||
|      * */ | ||||
|     createDataset = (fetchedData: Object) => { | ||||
|         return [ | ||||
|             { | ||||
|                 title: '', | ||||
|                 data: this.generateData(fetchedData) | ||||
|             } | ||||
|         ]; | ||||
|     } | ||||
| 
 | ||||
|     render() { | ||||
|         return ( | ||||
|             <View style={{ | ||||
|                 height: '100%' | ||||
|             }}> | ||||
|                 <WebSectionList | ||||
|                     {...this.props} | ||||
|                     createDataset={this.createDataset} | ||||
|                     autoRefreshTime={0} | ||||
|                     refreshOnFocus={false} | ||||
|                     fetchUrl={GROUPS_URL} | ||||
|                     renderItem={this.renderItem} | ||||
|                     updateData={this.state.currentSearchString} | ||||
|                     itemHeight={LIST_ITEM_HEIGHT} | ||||
|                 /> | ||||
|             </View> | ||||
|         ); | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| export default withTheme(GroupSelectionScreen); | ||||
|  | @ -12,9 +12,11 @@ import {withCollapsible} from "../../utils/withCollapsible"; | |||
| import {dateToString, getTimeOnlyString} from "../../utils/Planning"; | ||||
| import DateManager from "../../managers/DateManager"; | ||||
| import AnimatedBottomBar from "../../components/Custom/AnimatedBottomBar"; | ||||
| import {CommonActions} from "@react-navigation/native"; | ||||
| 
 | ||||
| type Props = { | ||||
|     navigation: Object, | ||||
|     route: Object, | ||||
|     theme: Object, | ||||
|     collapsibleStack: Object, | ||||
| } | ||||
|  | @ -24,6 +26,7 @@ type State = { | |||
|     dialogVisible: boolean, | ||||
|     dialogTitle: string, | ||||
|     dialogMessage: string, | ||||
|     currentGroup: Object, | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
|  | @ -105,10 +108,13 @@ const LISTEN_TO_MESSAGES = ` | |||
| document.addEventListener("message", function(event) { | ||||
|     //alert(event.data);
 | ||||
|     var data = JSON.parse(event.data); | ||||
|     $('#calendar').fullCalendar(data.action, data.data); | ||||
|     if (data.action === "setGroup") | ||||
|         displayAde(data.data); | ||||
|     else | ||||
|         $('#calendar').fullCalendar(data.action, data.data); | ||||
| }, false);` | ||||
| 
 | ||||
| const CUSTOM_CSS = "body>.container{padding-top:20px; padding-bottom: 50px}header{display:none}.fc-toolbar .fc-center{width:100%}.fc-toolbar .fc-center>*{float:none;width:100%;margin:0}#entite{margin-bottom:5px!important}#entite,#groupe{width:calc(100% - 20px);margin:0 10px}#calendar .fc-left,#calendar .fc-right{display:none}#groupe_visibility{width:100%}#calendar .fc-agendaWeek-view .fc-content-skeleton .fc-title{font-size:.6rem}#calendar .fc-agendaWeek-view .fc-content-skeleton .fc-time{font-size:.5rem}#calendar .fc-month-view .fc-content-skeleton .fc-title{font-size:.6rem}#calendar .fc-month-view .fc-content-skeleton .fc-time{font-size:.7rem}.fc-axis{font-size:.8rem;width:15px!important}.fc-day-header{font-size:.8rem}.fc-unthemed td.fc-today{background:#be1522; opacity:0.4}"; | ||||
| const CUSTOM_CSS = "body>.container{padding-top:20px; padding-bottom: 50px}header,#entite,#groupe_visibility,#calendar .fc-left,#calendar .fc-right{display:none}.fc-toolbar .fc-center{width:100%}.fc-toolbar .fc-center>*{float:none;width:100%;margin:0}#entite{margin-bottom:5px!important}#entite,#groupe{width:calc(100% - 20px);margin:0 10px}#groupe_visibility{width:100%}#calendar .fc-agendaWeek-view .fc-content-skeleton .fc-title{font-size:.6rem}#calendar .fc-agendaWeek-view .fc-content-skeleton .fc-time{font-size:.5rem}#calendar .fc-month-view .fc-content-skeleton .fc-title{font-size:.6rem}#calendar .fc-month-view .fc-content-skeleton .fc-time{font-size:.7rem}.fc-axis{font-size:.8rem;width:15px!important}.fc-day-header{font-size:.8rem}.fc-unthemed td.fc-today{background:#be1522; opacity:0.4}"; | ||||
| const CUSTOM_CSS_DARK = "body{background-color:#121212}.fc-unthemed .fc-content,.fc-unthemed .fc-divider,.fc-unthemed .fc-list-heading td,.fc-unthemed .fc-list-view,.fc-unthemed .fc-popover,.fc-unthemed .fc-row,.fc-unthemed tbody,.fc-unthemed td,.fc-unthemed th,.fc-unthemed thead{border-color:#222}.fc-toolbar .fc-center>*,h2,table{color:#fff}.fc-event-container{color:#121212}.fc-event-container .fc-bg{opacity:0.2;background-color:#000}.fc-unthemed td.fc-today{background:#be1522; opacity:0.4}"; | ||||
| 
 | ||||
| const INJECT_STYLE = ` | ||||
|  | @ -127,15 +133,6 @@ class PlanexScreen extends React.Component<Props, State> { | |||
| 
 | ||||
|     customInjectedJS: string; | ||||
| 
 | ||||
|     state = { | ||||
|         bannerVisible: | ||||
|             AsyncStorageManager.getInstance().preferences.planexShowBanner.current === '1' && | ||||
|             AsyncStorageManager.getInstance().preferences.defaultStartScreen.current !== 'Planex', | ||||
|         dialogVisible: false, | ||||
|         dialogTitle: "", | ||||
|         dialogMessage: "", | ||||
|     }; | ||||
| 
 | ||||
|     /** | ||||
|      * Defines custom injected JavaScript to improve the page display on mobile | ||||
|      */ | ||||
|  | @ -143,16 +140,58 @@ class PlanexScreen extends React.Component<Props, State> { | |||
|         super(); | ||||
|         this.webScreenRef = React.createRef(); | ||||
|         this.barRef = React.createRef(); | ||||
|         this.generateInjectedCSS(); | ||||
| 
 | ||||
|         let currentGroup = AsyncStorageManager.getInstance().preferences.planexCurrentGroup.current; | ||||
|         if (currentGroup === '') | ||||
|             currentGroup = {name: "SELECT GROUP", id: 0}; | ||||
|         else | ||||
|             currentGroup = JSON.parse(currentGroup); | ||||
|         this.state = { | ||||
|             bannerVisible: | ||||
|                 AsyncStorageManager.getInstance().preferences.planexShowBanner.current === '1' && | ||||
|                 AsyncStorageManager.getInstance().preferences.defaultStartScreen.current !== 'Planex', | ||||
|             dialogVisible: false, | ||||
|             dialogTitle: "", | ||||
|             dialogMessage: "", | ||||
|             currentGroup: currentGroup, | ||||
|         }; | ||||
|         this.generateInjectedJS(currentGroup.id); | ||||
|     } | ||||
| 
 | ||||
|     generateInjectedCSS() { | ||||
|         this.customInjectedJS = | ||||
|             "$(document).ready(function() {" + | ||||
|             OBSERVE_MUTATIONS_INJECTED + | ||||
|             FULL_CALENDAR_SETTINGS + | ||||
|             LISTEN_TO_MESSAGES + | ||||
|             INJECT_STYLE; | ||||
|     componentDidMount() { | ||||
|         this.props.navigation.addListener('focus', this.onScreenFocus); | ||||
|     } | ||||
| 
 | ||||
|     onScreenFocus = () => { | ||||
|         this.handleNavigationParams(); | ||||
|     }; | ||||
| 
 | ||||
|     handleNavigationParams = () => { | ||||
|         if (this.props.route.params !== undefined) { | ||||
|             if (this.props.route.params.group !== undefined && this.props.route.params.group !== null) { | ||||
|                 // reset params to prevent infinite loop
 | ||||
|                 this.selectNewGroup(this.props.route.params.group); | ||||
|                 this.props.navigation.dispatch(CommonActions.setParams({group: null})); | ||||
|             } | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|     selectNewGroup(group: Object) { | ||||
|         this.sendMessage('setGroup', group.id); | ||||
|         this.setState({currentGroup: group}); | ||||
|         AsyncStorageManager.getInstance().savePref( | ||||
|             AsyncStorageManager.getInstance().preferences.planexCurrentGroup.key, | ||||
|             JSON.stringify(group)); | ||||
|         this.generateInjectedJS(group.id); | ||||
|     } | ||||
| 
 | ||||
|     generateInjectedJS(groupID: number) { | ||||
|         this.customInjectedJS = "$(document).ready(function() {" | ||||
|             + OBSERVE_MUTATIONS_INJECTED | ||||
|             + FULL_CALENDAR_SETTINGS | ||||
|             + "displayAde(" + groupID + ");" // Reset Ade
 | ||||
|             + LISTEN_TO_MESSAGES | ||||
|             + INJECT_STYLE; | ||||
| 
 | ||||
|         if (ThemeManager.getNightMode()) | ||||
|             this.customInjectedJS += "$('head').append('<style>" + CUSTOM_CSS_DARK + "</style>');"; | ||||
|  | @ -162,10 +201,10 @@ class PlanexScreen extends React.Component<Props, State> { | |||
|             '});true;'; // Prevents crash on ios
 | ||||
|     } | ||||
| 
 | ||||
|     componentWillUpdate(prevProps: Props) { | ||||
|         if (prevProps.theme.dark !== this.props.theme.dark) | ||||
|             this.generateInjectedCSS(); | ||||
|     } | ||||
|     // componentWillUpdate(prevProps: Props) {
 | ||||
|     //     if (prevProps.theme.dark !== this.props.theme.dark)
 | ||||
|     //         this.generateInjectedCSS();
 | ||||
|     // }
 | ||||
| 
 | ||||
|     /** | ||||
|      * Callback used when closing the banner. | ||||
|  | @ -269,8 +308,10 @@ class PlanexScreen extends React.Component<Props, State> { | |||
|                     ? this.getWebView() | ||||
|                     : <View style={{height: '100%'}}>{this.getWebView()}</View>} | ||||
|                 <AnimatedBottomBar | ||||
|                     {...this.props} | ||||
|                     ref={this.barRef} | ||||
|                     onPress={this.sendMessage} | ||||
|                     currentGroup={this.state.currentGroup.name} | ||||
|                 /> | ||||
|             </View> | ||||
|         ); | ||||
|  |  | |||
|  | @ -11,7 +11,8 @@ export function sanitizeString(str: string): string { | |||
|     return str.toLowerCase() | ||||
|         .normalize("NFD") | ||||
|         .replace(/[\u0300-\u036f]/g, "") | ||||
|         .replace(" ", ""); | ||||
|         .replace(/ /g, "") | ||||
|         .replace(/_/g, ""); | ||||
| } | ||||
| 
 | ||||
| export function stringMatchQuery(str: string, query: string) { | ||||
|  |  | |||
		Loading…
	
		Reference in a new issue