diff --git a/.DS_Store b/.DS_Store index 67fe30e..90eb62a 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.gcloudignore b/.gcloudignore new file mode 100644 index 0000000..481bd26 --- /dev/null +++ b/.gcloudignore @@ -0,0 +1,17 @@ +# This file specifies files that are *not* uploaded to Google Cloud Platform +# using gcloud. It follows the same syntax as .gitignore, with the addition of +# "#!include" directives (which insert the entries of the given .gitignore-style +# file at that point). +# +# For more information, run: +# $ gcloud topic gcloudignore +# +.gcloudignore +# If you would like to upload your .git directory, .gitignore file or files +# from your .gitignore file, remove the corresponding line +# below: +.git +.gitignore + +node_modules +#!include:.gitignore diff --git a/.gitignore b/.gitignore index 2028b50..00cb3c1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ dist/ functions/lib/ js_scripts/ .env - +config/firebase-gcp.yml +config/firebase-gmail.yml diff --git a/README.md b/README.md index c2e024e..893bd2d 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,14 @@ yarn expo-cli start ``` +### Admin App +There are really basic html forms that allow a user to interact and experiment with firestore. +It's a simple static website, with javascript form index.js, and html in index.html. Use your +favorite http server from the root directory. + +``` bash +python -m http.server +``` ### SQL @@ -34,10 +42,13 @@ cloud_sql_proxy -instances==tcp:5432 -credential_file= ``` ### Cloud functions -Updating the environment variables used by cloud functions +Fillout the provided templates in the config directory and rename the files to not include -template. +run the following commands to set up enviroment variables, and deploy the functions to your firebase +cloud instance. ``` bash -cat $path-to-dotenv-file | sed 's/^/pg./' | xargs firebase functions:config:set +cat config/firebase-gcp.yml | sed 's/^/gcp./' | sed 's/: /=/' | xargs firebase functions:config:set +cat config/firebase-gmail.yml | sed 's/^/email./' | sed 's/: /=/' | xargs firebase functions:config:set firebase deploy --only functions ``` diff --git a/config/development.env b/config/development.env new file mode 100644 index 0000000..395648f --- /dev/null +++ b/config/development.env @@ -0,0 +1,5 @@ +db_host=localhost +db_user=postgres +db_pass=postgres +db_name=dataCollect +db_port=5431 \ No newline at end of file diff --git a/config/firebase-gcp-template.yml b/config/firebase-gcp-template.yml new file mode 100644 index 0000000..068dfc5 --- /dev/null +++ b/config/firebase-gcp-template.yml @@ -0,0 +1 @@ +cloud_functions_url: \ No newline at end of file diff --git a/config/firebase-gmail-template.yml b/config/firebase-gmail-template.yml new file mode 100644 index 0000000..097ecae --- /dev/null +++ b/config/firebase-gmail-template.yml @@ -0,0 +1,2 @@ +email: +password: \ No newline at end of file diff --git a/config/firebase-gmail.yml b/config/firebase-gmail.yml new file mode 100644 index 0000000..61ccae7 --- /dev/null +++ b/config/firebase-gmail.yml @@ -0,0 +1,2 @@ +email: thorncliffeparkpubliclifepilot@gmail.com +password: 48tandoor \ No newline at end of file diff --git a/deployment/develop.sh b/deployment/develop.sh index e52a4a5..245f91d 100755 --- a/deployment/develop.sh +++ b/deployment/develop.sh @@ -1,3 +1,3 @@ docker ps -a | grep swl-eng | awk '{ print $1 }' | xargs docker rm packer build postgres.json -docker run -p 5432:5432 swl-eng/gehl-data-collector:v0.0.1 postgres +docker run -p 5431:5431 swl-eng/gehl-data-collector:v0.0.1 postgres -p 5431 diff --git a/deployment/init.sql b/deployment/init.sql index 3f128b8..3aec943 100644 --- a/deployment/init.sql +++ b/deployment/init.sql @@ -30,6 +30,8 @@ CREATE TABLE IF NOT EXISTS study scale studyScale, user_id UUID REFERENCES public.users(user_id) NOT NULL, protocol_version TEXT NOT NULL, + dsl_definition JSON, + tablename VARCHAR(63), notes TEXT ); @@ -37,7 +39,7 @@ CREATE TABLE IF NOT EXISTS study -- survey metadata -- no user tied to this? I guess anonymous surveys are a thing, what about fake data? -- TODO should id have default uuid function call? -CREATE TABLE survey ( +CREATE TABLE IF NOT EXISTS survey ( study_id UUID references study(study_id) NOT NULL, location_id UUID, survey_id UUID PRIMARY KEY, @@ -52,6 +54,11 @@ CREATE TABLE survey ( CREATE TYPE gender AS ENUM ('male', 'female', 'unknown'); +CREATE TABLE IF NOT EXISTS surveyors ( + survey_id UUID references survey(survey_id) NOT NULL, + user_id UUID references public.users(user_id) NOT NULL, + PRIMARY KEY(survey_id, user_id) +) -- the protocol doesn't make use of what diff --git a/deployment/postgres.json b/deployment/postgres.json index 714814d..e787cb7 100644 --- a/deployment/postgres.json +++ b/deployment/postgres.json @@ -13,7 +13,7 @@ "ENV POSTGRES_PASSWORD {{ user `pg_pass` }}", "ENV POSTGRES_USER {{ user `pg_user` }}", "ENV POSTGRES_DB {{ user `db_name` }}", - "EXPOSE 5432 5432" + "EXPOSE 5431 5431" ] } ], diff --git a/expo_project/.DS_Store b/expo_project/.DS_Store new file mode 100644 index 0000000..3b16536 Binary files /dev/null and b/expo_project/.DS_Store differ diff --git a/expo_project/assets/fonts/SpaceMono-Regular.ttf b/expo_project/assets/fonts/SpaceMono-Regular.ttf deleted file mode 100755 index 28d7ff7..0000000 Binary files a/expo_project/assets/fonts/SpaceMono-Regular.ttf and /dev/null differ diff --git a/expo_project/assets/fonts/monaco.ttf b/expo_project/assets/fonts/monaco.ttf new file mode 100644 index 0000000..f33c9a7 Binary files /dev/null and b/expo_project/assets/fonts/monaco.ttf differ diff --git a/expo_project/components/ColoredButton.js b/expo_project/components/ColoredButton.js new file mode 100644 index 0000000..ee46401 --- /dev/null +++ b/expo_project/components/ColoredButton.js @@ -0,0 +1,35 @@ +import PropTypes from "prop-types"; +import React from "react"; + +import { StyleSheet, Text, TouchableOpacity } from "react-native"; + +class ColoredButton extends React.Component { + render() { + const { backgroundColor, color, onPress, label } = this.props; + return ( + + {label} + + ); + } +} + +const styles = StyleSheet.create({ + button: { + backgroundColor: "#5B93D9", + padding: 12, + marginVertical: 20, + justifyContent: "center", + alignItems: "center" + }, + text: { fontWeight: "bold" } +}); + +ColoredButton.propTypes = { + color: PropTypes.string.isRequired +}; + +export default ColoredButton; diff --git a/expo_project/components/MapWithMarkers.js b/expo_project/components/MapWithMarkers.js new file mode 100644 index 0000000..88382e2 --- /dev/null +++ b/expo_project/components/MapWithMarkers.js @@ -0,0 +1,82 @@ +import PropTypes from "prop-types"; +import React from "react"; +import { StyleSheet } from "react-native"; +import { MapView } from "expo"; +import PersonIcon from "./PersonIcon"; + +class MapWithMarkers extends React.Component { + constructor(props) { + super(props); + } + + render() { + const { + markers, + activeMarkerId, + onMarkerDragEnd, + onMarkerPress, + onMapPress, + onMapLongPress + } = this.props; + return ( + + {markers.map(marker => { + const selected = marker.id === activeMarkerId; + // Update the key when selected or delected, so the marker re renders and centers itself based on the new child size + const key = marker.id + (selected ? "-selected" : ""); + return ( + onMarkerPress(marker.id)} + anchor={{ x: 0, y: 0 }} + calloutAnchor={{ x: 0, y: 0 }} + > + + + ); + })} + + ); + } +} + +const styles = StyleSheet.create({ + mapStyle: { flex: 1 } +}); + +MapWithMarkers.propTypes = { + markers: PropTypes.arrayOf( + PropTypes.shape({ + coordinate: PropTypes.any, + color: PropTypes.string, + title: PropTypes.string, + dateLabel: PropTypes.string, + id: PropTypes.string + }) + ).isRequired, + activeMarkerId: PropTypes.string, + onMarkerDragEnd: PropTypes.func.isRequired, + onMarkerPress: PropTypes.func.isRequired, + onMapPress: PropTypes.func.isRequired, + onMapLongPress: PropTypes.func.isRequired +}; + +export default MapWithMarkers; diff --git a/expo_project/components/MarkerCarousel.js b/expo_project/components/MarkerCarousel.js new file mode 100644 index 0000000..9b6ed77 --- /dev/null +++ b/expo_project/components/MarkerCarousel.js @@ -0,0 +1,132 @@ +import PropTypes from "prop-types"; +import React from "react"; + +import PersonIcon from "./PersonIcon"; + +import { FlatList, StyleSheet, TouchableOpacity } from "react-native"; + +import * as _ from "lodash"; + +const CAROUSEL_ICON_SIZE = 50; +const CAROUSEL_ITEM_PADDING = 12; +const CAROUSEL_ITEM_LENGTH = CAROUSEL_ICON_SIZE + 2 * CAROUSEL_ITEM_PADDING; + +const VIEWABILITY_CONFIG = { itemVisiblePercentThreshold: 100 }; + +class MarkerCarousel extends React.Component { + constructor(props) { + super(props); + + this.state = { + viewableIndices: [] + }; + this.onViewableItemsChanged = this.onViewableItemsChanged.bind(this); + } + + onViewableItemsChanged({ viewableItems }) { + const viewableIndices = _.map(viewableItems, "index"); + this.setState({ viewableIndices }); + } + + componentDidUpdate(prevProps, prevState) { + // If user selects a marker, and it's not visible, scroll to it + // Note that Adding / removing markers trigger their own animation (see: onContentSizeChange) + // Therefore we stop if props.markers has changed + if ( + this.props.markers === prevProps.markers && + this.props.activeMarkerId !== prevProps.activeMarkerId + ) { + const index = _.findIndex(this.props.markers, { + id: this.props.activeMarkerId + }); + if (index > -1) { + // Only scroll if the new selection isn't already visible + if (!_.includes(this.state.viewableIndices, index)) { + this.flatList.scrollToIndex({ + index, + viewPosition: 0.5, + animated: true + }); + } + } + } + } + + render() { + const { activeMarkerId, markers, onMarkerPress } = this.props; + return ( + item.id} + extraData={activeMarkerId} + horizontal + removeClippedSubviews + showsHorizontalScrollIndicator={false} + ref={ref => (this.flatList = ref)} + onContentSizeChange={(contentWidth, contentHeight) => { + if (markers.length > 1) { + // This is janky sometimes when there's only one item for some reason ... + this.flatList.scrollToEnd({ animated: true }); + } + }} + getItemLayout={(data, index) => ({ + length: CAROUSEL_ITEM_LENGTH, + offset: CAROUSEL_ITEM_LENGTH * index, + index + })} + onViewableItemsChanged={this.onViewableItemsChanged} + viewabilityConfig={VIEWABILITY_CONFIG} + renderItem={({ item, index }) => { + const selected = item.id === activeMarkerId; + return ( + onMarkerPress(item.id)} + > + + + ); + }} + /> + ); + } +} + +const styles = StyleSheet.create({ + container: { + borderBottomColor: "rgba(0, 0, 0, 0.12)", + borderBottomWidth: 1 + }, + cell: { + padding: CAROUSEL_ITEM_PADDING, + // there's a border on selected cells, so put an inivisble border on all cells to keep cell height consistent + borderBottomColor: "transparent", + borderBottomWidth: 4, + justifyContent: "center", + alignItems: "center" + } +}); + +MarkerCarousel.propTypes = { + markers: PropTypes.arrayOf( + PropTypes.shape({ + coordinate: PropTypes.any, + color: PropTypes.string, + title: PropTypes.string, + dateLabel: PropTypes.string, + id: PropTypes.string + }) + ).isRequired, + activeMarkerId: PropTypes.string, + onMarkerPress: PropTypes.func.isRequired +}; + +export default MarkerCarousel; diff --git a/expo_project/components/PersonIcon.js b/expo_project/components/PersonIcon.js new file mode 100644 index 0000000..e080ee7 --- /dev/null +++ b/expo_project/components/PersonIcon.js @@ -0,0 +1,56 @@ +import { Icon } from "expo"; +import PropTypes from "prop-types"; +import React from "react"; + +import { Platform, View, StyleSheet } from "react-native"; + +class PersonIcon extends React.Component { + render() { + const { size, backgroundColor, shadow } = this.props; + return ( + + + + ); + } +} + +const styles = StyleSheet.create({ + container: { + justifyContent: "center", + alignItems: "center" + }, + shadow: { + ...Platform.select({ + ios: { + shadowColor: "black", + shadowOffset: { height: 3 }, + shadowOpacity: 0.5, + shadowRadius: 3 + }, + android: { + // TODO: verify + elevation: 20 + } + }) + } +}); + +PersonIcon.propTypes = { + size: PropTypes.number.isRequired, + backgroundColor: PropTypes.string.isRequired, + shadow: PropTypes.bool +}; + +export default PersonIcon; diff --git a/expo_project/components/Selectable.js b/expo_project/components/Selectable.js new file mode 100644 index 0000000..cecc1ea --- /dev/null +++ b/expo_project/components/Selectable.js @@ -0,0 +1,100 @@ +import PropTypes from "prop-types"; +import React from "react"; + +import { + View, + ScrollView, + StyleSheet, + Text, + TouchableOpacity +} from "react-native"; +import colors from "../constants/Colors"; + +import * as _ from "lodash"; + +class Selectable extends React.Component { + constructor(props) { + super(props); + + this.state = { + height: 0 + }; + this.onLayout = this.onLayout.bind(this); + } + + // TODO (Ananta): Make this more React-y + // Currently Selectable passes back its height when pressed, since the parent wants to scrol the amount + // But that's a weird API for a child that should function without knowledge of its parents' desires + onLayout(event) { + this.setState({ height: event.nativeEvent.layout.height }); + } + + render() { + const { onSelectablePress, selectedValue, title, options } = this.props; + return ( + + {title} + + {_.map(options, option => { + const { value, label } = option; + const selected = value === selectedValue; + return ( + { + onSelectablePress(value, this.state.height); + }} + > + + {label} + + + ); + })} + + + ); + } +} + +const styles = StyleSheet.create({ + container: { + paddingVertical: 10 + }, + selectable: { + flexDirection: "row" + }, + selectableCell: { + borderWidth: 1, + backgroundColor: "#FAFAFA", + borderRadius: 3, + borderColor: "rgba(0, 0, 0, 0.0980392)", + padding: 5, + marginRight: 5, + marginTop: 10 + }, + selected: { + backgroundColor: colors.colorSecondary + }, + pillText: { + fontFamily: "monaco" + }, + title: { + marginBottom: 5 + } +}); + +Selectable.propTypes = { + onSelectablePress: PropTypes.func.isRequired, + selectedValue: PropTypes.string, + title: PropTypes.string.isRequired, + options: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string, + value: PropTypes.string + }) + ).isRequired +}; + +export default Selectable; diff --git a/expo_project/components/StyledText.js b/expo_project/components/StyledText.js deleted file mode 100644 index 24c7b9b..0000000 --- a/expo_project/components/StyledText.js +++ /dev/null @@ -1,8 +0,0 @@ -import React from 'react'; -import { Text } from 'react-native'; - -export class MonoText extends React.Component { - render() { - return ; - } -} diff --git a/expo_project/components/Survey.js b/expo_project/components/Survey.js new file mode 100644 index 0000000..a4c4597 --- /dev/null +++ b/expo_project/components/Survey.js @@ -0,0 +1,46 @@ +import PropTypes from "prop-types"; +import React from "react"; +import { StyleSheet, Text, View } from "react-native"; +import Selectable from "../components/Selectable"; +import * as _ from "lodash"; + +import QUESTION_CONFIG from "../config/questions"; + +class Survey extends React.Component { + render() { + const { activeMarker, onSelect } = this.props; + return ( + + + {activeMarker.title} + {activeMarker.dateLabel} + + {_.map(QUESTION_CONFIG, question => { + const { questionKey, questionLabel, options } = question; + return ( + + onSelect(activeMarker.id, questionKey, value, selectableHeight) + } + selectedValue={activeMarker[questionKey]} + title={questionLabel} + options={options} + /> + ); + })} + + ); + } +} + +const styles = StyleSheet.create({ + titleContainer: { paddingVertical: 10 }, + title: { fontWeight: "bold" } +}); + +Survey.propTypes = { + onSelect: PropTypes.func.isRequired +}; + +export default Survey; diff --git a/expo_project/components/TabBarIcon.js b/expo_project/components/TabBarIcon.js deleted file mode 100644 index 2f1ebed..0000000 --- a/expo_project/components/TabBarIcon.js +++ /dev/null @@ -1,17 +0,0 @@ -import React from 'react'; -import { Icon } from 'expo'; - -import Colors from '../constants/Colors'; - -export default class TabBarIcon extends React.Component { - render() { - return ( - - ); - } -} \ No newline at end of file diff --git a/expo_project/config/questions.js b/expo_project/config/questions.js new file mode 100644 index 0000000..e3cf049 --- /dev/null +++ b/expo_project/config/questions.js @@ -0,0 +1,113 @@ +export default [ + { + questionKey: "gender", + questionLabel: "Gender", + options: [ + { value: "male", label: "Male" }, + { value: "female", label: "Female" }, + { value: "unknown", label: "Unknown" } + ] + }, + { + questionKey: "age", + questionLabel: "Age", + options: [ + { value: "child", label: "0-14" }, + { value: "young", label: "15-24" }, + { value: "adult", label: "25-64" }, + { value: "elderly", label: "65+" } + ] + }, + { + questionKey: "mode", + questionLabel: "Mode", + options: [ + { value: "pedestrian", label: "Pedestrian" }, + { value: "bicyclist", label: "Bicyclist" } + ] + }, + { + questionKey: "groupSize", + questionLabel: "Group Size", + options: [ + { value: "alone", label: "1" }, + { value: "pair", label: "2" }, + { value: "group", label: "3+" } + ] + }, + { + questionKey: "posture", + questionLabel: "Posture", + options: [ + { value: "leaning", label: "Leaning" }, + { value: "lying", label: "Lying" }, + { value: "sitting", label: "Sitting" }, + { value: "groundSitting", label: "Sitting on the Ground" }, + { value: "standing", label: "Standing" } + ] + }, + { + questionKey: "activity", + questionLabel: "Activity", + options: [ + { value: "commercial", label: "Commercial" }, + { value: "consuming", label: "Consuming" }, + { value: "conversing", label: "Conversing" }, + { value: "electronics", label: "Electronics" }, + { value: "pets", label: "Pets" }, + { value: "idle", label: "Idle" }, + { value: "running", label: "Running" } + ] + }, + { + questionKey: "object", + questionLabel: "Object", + options: [ + { value: "luggage", label: "luggage" }, + { value: "pushcart", label: "Push Cart" }, + { value: "stroller", label: "Stroller" } + ] + }, + { + questionKey: "blahgender", + questionLabel: "Fake Question (for demo purposes)", + options: [ + { value: "male", label: "1" }, + { value: "female", label: "2" }, + { value: "unknown", label: "3" } + ] + }, + { + questionKey: "genderblah", + questionLabel: "Fake Question (for demo purposes)", + options: [ + { value: "male", label: "4" }, + { value: "female", label: "5" }, + { value: "unknown", label: "6" }, + { value: "male1", label: "7" }, + { value: "female2", label: "8" }, + { value: "unknown3", label: "9" }, + { value: "male4", label: "10" }, + { value: "female5", label: "11" }, + { value: "unknown6", label: "12" } + ] + }, + { + questionKey: "adfsasdfgender", + questionLabel: "Fake Question (for demo purposes)", + options: [ + { value: "male", label: "fakefakeffake" }, + { value: "female", label: "reallylongword" }, + { value: "unknown", label: "thisshouldmakeyouscroll" } + ] + }, + { + questionKey: "genafdsafdsder", + questionLabel: "Fake Question (for demo purposes)", + options: [ + { value: "male", label: "100" }, + { value: "female", label: "99" }, + { value: "unknown", label: "98" } + ] + } +]; diff --git a/expo_project/constants/Colors.js b/expo_project/constants/Colors.js index d8bd415..c91111d 100644 --- a/expo_project/constants/Colors.js +++ b/expo_project/constants/Colors.js @@ -1,14 +1,36 @@ -const tintColor = '#2f95dc'; +const tintColor = "#2f95dc"; +const colorPrimary = "#5B93D9"; +const colorSecondary = "#1C4442"; + +const blue = "#1565C0"; +const red = "#F44336"; +const lightGreen = "#4CAF50"; +const indigo = "#1A237E"; +const lightBlue = "#29B6F6"; +const pink = "#D81B60"; +const orange = "#EF6C00"; export default { tintColor, - tabIconDefault: '#ccc', - tabIconSelected: tintColor, - tabBar: '#fefefe', - errorBackground: 'red', - errorText: '#fff', - warningBackground: '#EAEB5E', - warningText: '#666804', - noticeBackground: tintColor, - noticeText: '#fff', + colorPrimary, + colorSecondary, + tabIconDefault: "#ccc", + tabIconSelected: colorPrimary, + tabBar: "#fefefe", + errorBackground: "red", + errorText: "#fff", + warningBackground: "#EAEB5E", + warningText: "#666804", + noticeBackground: colorPrimary, + noticeText: "#fff" +}; + +export const iconColors = { + blue, + red, + lightGreen, + indigo, + lightBlue, + pink, + orange }; diff --git a/expo_project/navigation/AppNavigator.js b/expo_project/navigation/AppNavigator.js index 874ab40..4b52af9 100644 --- a/expo_project/navigation/AppNavigator.js +++ b/expo_project/navigation/AppNavigator.js @@ -1,15 +1,22 @@ import React from "react"; import { createStackNavigator } from "react-navigation"; - +import colors from "../constants/Colors"; import HomeScreen from "../screens/HomeScreen"; -import SurveyScreen from "../screens/SurveyScreen"; export default createStackNavigator( { - Home: HomeScreen, - Survey: SurveyScreen + Home: HomeScreen }, { - initialRouteName: "Home" + initialRouteName: "Home", + navigationOptions: { + headerStyle: { + backgroundColor: colors.colorPrimary + }, + headerTintColor: "#fff", + headerTitleStyle: { + fontWeight: "bold" + } + } } ); diff --git a/expo_project/package.json b/expo_project/package.json index f7050a9..680a0f7 100644 --- a/expo_project/package.json +++ b/expo_project/package.json @@ -11,6 +11,9 @@ "@expo/samples": "2.1.1", "expo": "^28.0.0", "firebase": "^5.3.0", + "lodash": "4.17.4", + "moment": "^2.22.2", + "prop-types": "^15.6.2", "react": "16.3.1", "react-native": "https://github.com/expo/react-native/archive/sdk-28.0.0.tar.gz", "react-navigation": "2.3.1" diff --git a/expo_project/screens/HomeScreen.js b/expo_project/screens/HomeScreen.js index 79f96aa..0ead2f8 100644 --- a/expo_project/screens/HomeScreen.js +++ b/expo_project/screens/HomeScreen.js @@ -1,8 +1,30 @@ import React from "react"; -import { Platform, StyleSheet, Text, View } from "react-native"; -import { KeepAwake, MapView, Constants, Location, Permissions } from "expo"; -import { Button } from "react-native"; +import { + Dimensions, + PanResponder, + Platform, + StyleSheet, + View, + Animated +} from "react-native"; import { withNavigation } from "react-navigation"; +import * as _ from "lodash"; +import Colors, { iconColors } from "../constants/Colors"; +import { ScrollView } from "../node_modules/react-native-gesture-handler"; +const { height } = Dimensions.get("window"); +import moment from "moment"; +import { Header } from "react-navigation"; + +import MapWithMarkers from "../components/MapWithMarkers"; +import MarkerCarousel from "../components/MarkerCarousel"; +import Survey from "../components/Survey"; +import ColoredButton from "../components/ColoredButton"; + +const HEADER_HEIGHT = Header.HEIGHT; +const MIN_DRAWER_OFFSET = 0; // fix this + +const DRAWER_HEIGHT = height - HEADER_HEIGHT; +const INITIAL_DRAWER_OFFSET = DRAWER_HEIGHT; class HomeScreen extends React.Component { static navigationOptions = { @@ -12,49 +34,260 @@ class HomeScreen extends React.Component { constructor(props) { super(props); + this.drawerOffsetY = new Animated.Value(INITIAL_DRAWER_OFFSET); + this.drawerOffsetY.addListener(({ value }) => (this._value = value)); + this.state = { - markerLocation: null, - errorMessage: null + activeMarkerId: null, + markers: [], + formScrollPosition: 0, + drawerHeaderHeight: 0 }; + this.resetDrawer = this.resetDrawer.bind(this); + this.selectMarker = this.selectMarker.bind(this); + this.getRandomIconColor = this.getRandomIconColor.bind(this); + this.createNewMarker = this.createNewMarker.bind(this); this.setMarkerLocation = this.setMarkerLocation.bind(this); + this.setFormResponse = this.setFormResponse.bind(this); + + // TODO (Ananta): Make this easier to understand + // TODO (Ananta): use a "top" value instead of an offset from top, so + / - is a consistent direction between pan responder and scrollview + this._panResponder = PanResponder.create({ + // Ask to be the responder: + onStartShouldSetPanResponder: (evt, gestureState) => false, + onStartShouldSetPanResponderCapture: (evt, gestureState) => false, + onMoveShouldSetPanResponder: (evt, gestureState) => { + // Respond to downward drags if they are a long distance or the scrollview is at the top + // Upward drags are handled in onMoveShouldSetPanResponderCapture because they override all child gestures + + const verticalDistance = Math.abs(gestureState.dy); + const horizontalDistance = Math.abs(gestureState.dx); + const isVerticalPan = verticalDistance > horizontalDistance; + + if (isVerticalPan) { + // only pan if it's a long distance or you can't scroll any more + const directionDown = gestureState.dy > 0; + const scrolledToTop = !this.state.formScrollPosition; + const isLongDistance = gestureState.dy > 50; + return directionDown && (scrolledToTop || isLongDistance); + } + return false; + }, + onMoveShouldSetPanResponderCapture: (evt, gestureState) => { + // Respond and capture (disallow children from responding) if panning upward + // Returning true here will skip onMoveShouldSetPanResponder + + const verticalDistance = Math.abs(gestureState.dy); + const horizontalDistance = Math.abs(gestureState.dx); + const isVerticalPan = verticalDistance > horizontalDistance; + + if (isVerticalPan) { + const directionUp = gestureState.dy < 0; + const hasSpaceToPanUp = this.drawerOffsetY._value > MIN_DRAWER_OFFSET; + return directionUp && hasSpaceToPanUp; + } + return false; + }, + onPanResponderMove: (evt, gestureState) => { + const directionDown = gestureState.dy > 0; + const currentDrawerOffset = this.drawerOffsetY._value; + + // TODO: Make the drawer follow user's gesture + const canMoveDown = + directionDown && + currentDrawerOffset < + INITIAL_DRAWER_OFFSET - this.state.drawerHeaderHeight; + const canMoveUp = + !directionDown && currentDrawerOffset > MIN_DRAWER_OFFSET; + + if (canMoveUp || canMoveDown) { + const toValue = canMoveUp + ? MIN_DRAWER_OFFSET + : INITIAL_DRAWER_OFFSET - this.state.drawerHeaderHeight; + Animated.spring(this.drawerOffsetY, { + toValue, + useNativeDriver: true + }).start(); + if (canMoveDown && this.state.formScrollPosition) { + this.scrollView.scrollTo({ + x: 0, + y: 0, + animated: false + }); + } + } + return true; + } + }); + } + + resetDrawer() { + const isEmpty = this.state.markers.length === 0; + const offsetVal = isEmpty ? INITIAL_DRAWER_OFFSET : DRAWER_HEIGHT - 250; //fix this + if (this.drawerOffsetY._value !== offsetVal) { + Animated.timing(this.drawerOffsetY, { + toValue: offsetVal, + duration: 200, + useNativeDriver: true + }).start(); + } + + if (this.state.formScrollPosition) { + this.scrollView.scrollTo({ x: 0, y: 0, animated: false }); + } + } + + setFormResponse(id, key, value, selectableHeight) { + // TODO: add logic for updating in db + const markersCopy = [...this.state.markers]; + const marker = _.find(markersCopy, { + id + }); + + if (marker) { + marker[key] = value; + this.setState({ + markers: markersCopy + }); + + const currentScrollPosition = this.state.formScrollPosition; + const currentDrawerOffset = this.drawerOffsetY._value; + const newDrawerOffset = currentDrawerOffset - selectableHeight; + + if (newDrawerOffset >= MIN_DRAWER_OFFSET) { + Animated.timing(this.drawerOffsetY, { + toValue: newDrawerOffset, + duration: 200, + useNativeDriver: true + }).start(); + } else if (currentDrawerOffset > MIN_DRAWER_OFFSET) { + // Animate drawer to the top + // then scroll the remaining amount to ensure next question is visible + const remainder = currentDrawerOffset - MIN_DRAWER_OFFSET; + Animated.timing(this.drawerOffsetY, { + toValue: MIN_DRAWER_OFFSET, + duration: 200, + useNativeDriver: true + }).start(); + this.scrollView.scrollTo({ + y: currentScrollPosition + selectableHeight - remainder + }); + } else { + this.scrollView.scrollTo({ + y: currentScrollPosition + selectableHeight + }); + } + } + } + + selectMarker(activeMarkerId) { + this.setState({ activeMarkerId }); + this.resetDrawer(); + } + + createNewMarker(e) { + // TODO: add logic for inserting into db + const markersCopy = [...this.state.markers]; + const date = moment(); + const dateLabel = date.format("HH:mm"); + const timestamp = date.format("x"); + const id = timestamp + ""; // placeholder + const title = "Person " + (markersCopy.length + 1); + + const marker = { + coordinate: e.nativeEvent.coordinate, + color: this.getRandomIconColor(), + gender: null, + position: null, + title, + dateLabel, + id + }; + + markersCopy.push(marker); + this.setState( + { markers: markersCopy, activeMarkerId: id }, + this.resetDrawer + ); } setMarkerLocation(e) { - this.setState({ markerLocation: e.nativeEvent.coordinate }); + // TODO: add logic for updating in db + const { id, coordinate } = e.nativeEvent; + const markersCopy = [...this.state.markers]; + const marker = _.find(markersCopy, { id }); + + if (marker) { + marker.coordinate = coordinate; + this.setState({ + markers: markersCopy + }); + } + } + + getRandomIconColor() { + const iconOptions = Object.values(iconColors); + return iconOptions[Math.floor(Math.random() * iconOptions.length)]; } render() { + const { activeMarkerId, markers } = this.state; + const activeMarker = _.find(markers, { id: activeMarkerId }); return ( - + - {this.state.markerLocation && ( - + this.setState({ + drawerHeaderHeight: e.nativeEvent.layout.height + }) + } + > + - )} - - {this.state.markerLocation && ( - -