Created
June 11, 2020 07:15
-
-
Save jennmueng/92059201cd427140b8f4e649435143c2 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// @flow | |
import React, { Component } from 'react'; | |
import { StyleSheet } from 'react-native'; | |
import * as Animatable from 'react-native-animatable'; | |
import MaskedView from '@react-native-community/masked-view'; | |
import Svg, { Path } from 'react-native-svg'; | |
import moment from 'moment-timezone'; | |
import styled from 'styled-components'; | |
import { BlurView } from '@react-native-community/blur'; | |
import { connect } from 'react-redux'; | |
import { createSelector } from 'reselect'; | |
import { withNavigation, type NavigationScreenProp } from 'react-navigation'; | |
import type { PlannerEntity, Hotel } from 'tour-flowtypes'; | |
import CalloutNeighborCard from './CalloutNeighborCard'; | |
import DummyAction from '../planner/dummyEntities/DummyAction'; | |
import DummyActivity from '../planner/dummyEntities/DummyActivity'; | |
import DummyFreeTime from '../planner/dummyEntities/DummyFreeTime'; | |
import DurationSection from '../planner/durationSection'; | |
import FlightCard from '../planner/entities/FlightCard'; | |
import ScaleFeedback from '../../../../components/feedback/scaleFeedback'; | |
import TimeSeparator from '../planner/timeSeparator'; | |
import openCalloutAction from '../../actions/map/set.openCallout'; | |
import closeCalloutAction from '../../actions/map/set.closeCallout'; | |
import selectEntityNeighbors from '../../selectors/selectEntityNeighbors'; | |
import getDurationString from '../../../../utilities/strings/getDurationString'; | |
import getEntityTime from '../../functions/helpers/getEntityTime'; | |
import getNameFromEntity from '../../functions/helpers/getNameFromEntity'; | |
import getTextColorFromEntity from '../../functions/helpers/getTextColorFromEntity'; | |
import DeviceProps from '../../../../config/DeviceProps'; | |
import ENTITY_TYPES from '../../constants/ENTITY_TYPES'; | |
import MEASUREMENTS from '../../constants/measurements'; | |
import TRAVEL_TYPES from '../../constants/TRAVEL_TYPES'; | |
import TourStyles from '../../../../config/TourStyles'; | |
const TRIANGLE_HEIGHT = 18; | |
class MapCalloutDisplay extends Component< | |
{ | |
calloutActivated: boolean, | |
entity: ?PlannerEntity, | |
openCallout: Function, | |
closeCallout: Function, | |
/* eslint-disable react/no-unused-prop-types */ | |
// These props are copied over to state in componentWillReceiveProps and not used directly. | |
hotel: ?Hotel, | |
previousPreviousEntity: ?PlannerEntity, | |
previousEntity: ?PlannerEntity, | |
nextEntity: ?PlannerEntity, | |
nextNextEntity: ?PlannerEntity, | |
dayDate: string, | |
zoomLevel: number, | |
/* eslint-enable react/no-unused-prop-types */ | |
navigation: NavigationScreenProp | |
}, | |
{ | |
showCallout: boolean, | |
entity: ?PlannerEntity, | |
hotel: ?Hotel, | |
dayDate: string, | |
previousPreviousEntity: ?PlannerEntity, | |
previousEntity: ?PlannerEntity, | |
nextEntity: ?PlannerEntity, | |
nextNextEntity: ?PlannerEntity, | |
zoomLevel: number, | |
height: number | |
} | |
> { | |
state = { | |
showCallout: false, | |
entity: null, | |
hotel: null, | |
dayDate: '', | |
previousPreviousEntity: null, | |
previousEntity: null, | |
nextEntity: null, | |
nextNextEntity: null, | |
zoomLevel: 9, | |
height: 0 | |
}; | |
componentWillReceiveProps(newProps) { | |
if (!this.props.calloutActivated && newProps.calloutActivated) { | |
// Store the props used in rendering the callout so it still displays while animating out. | |
this.setState({ | |
showCallout: true, | |
entity: newProps.entity, | |
dayDate: newProps.dayDate, | |
hotel: newProps.hotel, | |
previousPreviousEntity: newProps.previousPreviousEntity, | |
previousEntity: newProps.previousEntity, | |
nextEntity: newProps.nextEntity, | |
nextNextEntity: newProps.nextNextEntity, | |
zoomLevel: newProps.zoomLevel | |
}); | |
} | |
if ( | |
this.props.calloutActivated && | |
newProps.calloutActivated && | |
this.props.entity && | |
newProps.entity && | |
this.props.entity.id !== newProps.entity.id | |
) { | |
this.animateOut(); | |
setTimeout(() => { | |
if (this) { | |
this.setState( | |
{ | |
entity: newProps.entity, | |
dayDate: newProps.dayDate, | |
hotel: newProps.hotel, | |
previousPreviousEntity: newProps.previousPreviousEntity, | |
previousEntity: newProps.previousEntity, | |
nextEntity: newProps.nextEntity, | |
nextNextEntity: newProps.nextNextEntity, | |
zoomLevel: newProps.zoomLevel | |
}, | |
() => { | |
this.animateIn(); | |
} | |
); | |
} | |
}, 180); | |
} | |
} | |
componentDidUpdate(prevProps) { | |
if (!prevProps.calloutActivated && this.props.calloutActivated) { | |
this.animateIn(); | |
} else if (prevProps.calloutActivated && !this.props.calloutActivated) { | |
this.closeCallout(); | |
} | |
} | |
openEntity = () => { | |
const { entity } = this.state; | |
if (entity && entity.entityType === ENTITY_TYPES.ACTIVITY) { | |
this.props.closeCallout(); | |
// this.props.navigation.navigate(() => { | |
// }) | |
} | |
}; | |
openHotel = () => { | |
const { hotel } = this.state; | |
if (hotel) { | |
this.props.closeCallout(); | |
// this.props.navigation.navigate(() => { | |
// }) | |
} | |
}; | |
closeCallout = () => { | |
this.animateOut(); | |
setTimeout(() => { | |
if (this) { | |
this.setState({ | |
showCallout: false, | |
entity: null, | |
hotel: null, | |
previousPreviousEntity: null, | |
previousEntity: null, | |
nextEntity: null, | |
nextNextEntity: null, | |
zoomLevel: 9, | |
dayDate: '' | |
}); | |
} | |
}, 180); | |
}; | |
setCardHeight = (height: number) => { | |
this.setState(prevState => { | |
if (height !== prevState.height) { | |
return { height }; | |
} | |
return null; | |
}); | |
}; | |
animateIn = () => { | |
if (this.calloutContainer) { | |
const halfHeight = (this.state.height + TRIANGLE_HEIGHT) / 1.5; | |
this.calloutContainer.transition( | |
{ | |
opacity: 0, | |
scale: 0, | |
translateY: halfHeight | |
}, | |
{ | |
opacity: 1, | |
scale: 1, | |
translateY: 0 | |
}, | |
180, | |
'ease-out-cubic' | |
); | |
} | |
}; | |
animateOut = () => { | |
if (this.calloutContainer) { | |
const halfHeight = (this.state.height + TRIANGLE_HEIGHT) / 1.5; | |
this.calloutContainer.transition( | |
{ | |
opacity: 1, | |
scale: 1, | |
translateY: 0 | |
}, | |
{ | |
opacity: 0, | |
scale: 0, | |
translateY: halfHeight | |
}, | |
180, | |
'ease-in-cubic' | |
); | |
} | |
}; | |
onPressPreviousNeighbor = () => { | |
const { navigation, openCallout } = this.props; | |
const { previousPreviousEntity, previousEntity, zoomLevel } = this.state; | |
if (previousEntity && previousPreviousEntity && previousEntity.entityType === ENTITY_TYPES.TRAVEL) { | |
// open flight card if the previous entity is a flight. | |
if (previousEntity.entity.travelType === TRAVEL_TYPES.FLIGHT) { | |
return navigation.navigate({ | |
routeName: 'EditFlight', | |
params: { | |
packId: previousEntity.entity.travelData.packId, | |
flightId: previousEntity.entity.travelData.flightId | |
} | |
}); | |
} | |
openCallout({ | |
entityId: previousPreviousEntity.id, | |
zoomLevel, | |
focusMap: true | |
}); | |
} else if (previousEntity) { | |
openCallout({ | |
entityId: previousEntity.id, | |
zoomLevel, | |
focusMap: true | |
}); | |
} | |
}; | |
onPressNextNeighbor = () => { | |
const { navigation, openCallout } = this.props; | |
const { nextEntity, nextNextEntity, zoomLevel } = this.state; | |
if (nextEntity && nextNextEntity && nextEntity.entityType === ENTITY_TYPES.TRAVEL) { | |
if (nextEntity.entity.travelType === TRAVEL_TYPES.FLIGHT) { | |
// open flight card if the previous entity is a flight. | |
return navigation.navigate({ | |
routeName: 'EditFlight', | |
params: { | |
packId: nextEntity.entity.travelData.packId, | |
flightId: nextEntity.entity.travelData.flightId | |
} | |
}); | |
} | |
openCallout({ | |
entityId: nextNextEntity.id, | |
zoomLevel, | |
focusMap: true | |
}); | |
} else if (nextEntity) { | |
openCallout({ | |
entityId: nextEntity.id, | |
zoomLevel, | |
focusMap: true | |
}); | |
} | |
}; | |
calloutContainer: Animatable.AnimatableComponent; | |
renderPreviousNeighbor = () => { | |
const { previousPreviousEntity, previousEntity, entity, dayDate } = this.state; | |
if (previousEntity && previousPreviousEntity && previousEntity.entityType === ENTITY_TYPES.TRAVEL) { | |
if (previousEntity.entity.travelType === TRAVEL_TYPES.FLIGHT) { | |
const { nextEntity, nextNextEntity } = this.state; | |
if ( | |
!( | |
nextEntity && | |
nextNextEntity && | |
nextEntity.entityType === ENTITY_TYPES.TRAVEL && | |
nextEntity.entity.travelType === TRAVEL_TYPES.FLIGHT | |
) | |
) { | |
// We only render a flight card if the next entity is not a flight. | |
// In the case that both are flights they would only have the small entity card. | |
return ( | |
<UpperNeighborCardContainer> | |
<FlightCardContainer> | |
<FlightCard | |
packId={previousEntity.entity.travelData.packId} | |
flightId={previousEntity.entity.travelData.flightId} | |
dayDate={dayDate} | |
/> | |
</FlightCardContainer> | |
</UpperNeighborCardContainer> | |
); | |
} | |
} | |
return ( | |
<UpperNeighborCardContainer> | |
<CalloutNeighborCard | |
travelType={previousEntity.entity.travelType} | |
onPress={this.onPressPreviousNeighbor} | |
text={ | |
<> | |
<TimeBold> | |
{getDurationString( | |
// $FlowFixMe | |
getEntityTime(previousEntity, 'from'), | |
// $FlowFixMe | |
getEntityTime(previousEntity, 'to') | |
)} | |
</TimeBold> | |
{' from '} | |
<EntityTextBold color={getTextColorFromEntity(previousPreviousEntity)}> | |
{getNameFromEntity(previousPreviousEntity)} | |
</EntityTextBold> | |
</> | |
} | |
/> | |
</UpperNeighborCardContainer> | |
); | |
} | |
if (previousEntity && entity) { | |
const previousEntityTo = getEntityTime(previousEntity, 'to'); | |
const thisFrom = getEntityTime(entity, 'from'); | |
/* We only render neighboring entities if they're on the same day, or within 3 hours of eachother. */ | |
if ( | |
previousEntityTo.format('YYYY-MM-DD') === dayDate || | |
moment.duration(thisFrom.diff(previousEntityTo)).asHours() <= 3 | |
) | |
return ( | |
<UpperNeighborCardContainer> | |
<CalloutNeighborCard | |
onPress={this.onPressPreviousNeighbor} | |
text={ | |
<> | |
{'Coming from '} | |
<EntityTextBold color={getTextColorFromEntity(previousEntity)}> | |
{getNameFromEntity(previousEntity)} | |
</EntityTextBold> | |
</> | |
} | |
/> | |
</UpperNeighborCardContainer> | |
); | |
} | |
return null; | |
}; | |
renderNextNeighbor = () => { | |
const { nextEntity, nextNextEntity, entity, dayDate } = this.state; | |
if (nextEntity && nextNextEntity && nextEntity.entityType === ENTITY_TYPES.TRAVEL) { | |
if (nextEntity.entity.travelType === TRAVEL_TYPES.FLIGHT) { | |
const { previousEntity, previousPreviousEntity } = this.state; | |
if ( | |
!( | |
previousEntity && | |
previousPreviousEntity && | |
previousEntity.entityType === ENTITY_TYPES.TRAVEL && | |
previousEntity.entity.travelType === TRAVEL_TYPES.FLIGHT | |
) | |
) { | |
// We only render a flight card if the previous entity is not a flight. | |
// In the case that both are flights they would only have the small entity card. | |
return ( | |
<LowerNeighborCardContainer> | |
<FlightCardContainer> | |
<FlightCard | |
packId={nextEntity.entity.travelData.packId} | |
flightId={nextEntity.entity.travelData.flightId} | |
dayDate={dayDate} | |
/> | |
</FlightCardContainer> | |
</LowerNeighborCardContainer> | |
); | |
} | |
} | |
return ( | |
<LowerNeighborCardContainer> | |
<CalloutNeighborCard | |
travelType={nextEntity.entity.travelType} | |
onPress={this.onPressNextNeighbor} | |
text={ | |
<> | |
<TimeBold> | |
{getDurationString( | |
// $FlowFixMe | |
getEntityTime(nextEntity, 'from'), | |
// $FlowFixMe | |
getEntityTime(nextEntity, 'to') | |
)} | |
</TimeBold> | |
{' to '} | |
<EntityTextBold color={getTextColorFromEntity(nextNextEntity)}> | |
{getNameFromEntity(nextNextEntity)} | |
</EntityTextBold> | |
</> | |
} | |
/> | |
</LowerNeighborCardContainer> | |
); | |
} | |
/* We only render neighboring entities if they're on the same day, or within 3 hours of eachother. */ | |
if (nextEntity && entity) { | |
const nextEntityFrom = getEntityTime(nextEntity, 'from'); | |
const thisTo = getEntityTime(entity, 'to'); | |
/* We only render neighboring entities if they're on the same day, or within 3 hours of eachother. */ | |
if ( | |
nextEntityFrom.format('YYYY-MM-DD') === dayDate || | |
moment.duration(nextEntityFrom.diff(thisTo)).asHours() <= 3 | |
) { | |
return ( | |
<LowerNeighborCardContainer> | |
<CalloutNeighborCard | |
onPress={this.onPressNextNeighbor} | |
text={ | |
<> | |
{'Next is '} | |
<EntityTextBold color={getTextColorFromEntity(nextEntity)}> | |
{getNameFromEntity(nextEntity)} | |
</EntityTextBold> | |
</> | |
} | |
/> | |
</LowerNeighborCardContainer> | |
); | |
} | |
} | |
return null; | |
}; | |
render() { | |
const { showCallout, entity, hotel, height, dayDate, zoomLevel } = this.state; | |
const canOpenEntity = !!(entity && entity.entityType !== ENTITY_TYPES.FREE_TIME); | |
return showCallout ? ( | |
<Container pointerEvents="box-none"> | |
<Positioner | |
style={{ | |
transform: [ | |
{ | |
translateY: MEASUREMENTS.CALLOUT_CENTER_OFFSET | |
} | |
] | |
}} | |
> | |
<CalloutContainer ref={c => (this.calloutContainer = c)} useNativeDriver> | |
<MaskedView | |
style={{ | |
width: '100%', | |
height: height + TRIANGLE_HEIGHT | |
}} | |
maskElement={ | |
<DisplayContainer> | |
<MaskCard height={height} /> | |
{zoomLevel > 2.5 && ( | |
<Svg width={40} height={TRIANGLE_HEIGHT}> | |
<Path | |
d="M22.7799 16.8615C21.2295 18.3795 18.7705 18.3795 17.2201 16.8615L0 0L40 4.28009e-06L22.7799 16.8615Z" | |
fill="#000" | |
/> | |
</Svg> | |
)} | |
</DisplayContainer> | |
} | |
> | |
<BackgroundBlurView blurAmount={9} blurType="light" /> | |
<BackgroundFill /> | |
<ContentContainer | |
onLayout={({ | |
nativeEvent: { | |
layout: { height: layoutHeight } | |
} | |
}) => { | |
this.setCardHeight(layoutHeight); | |
}} | |
> | |
{this.renderPreviousNeighbor()} | |
{entity && ( | |
<EntityContainer> | |
<TimeSeparator | |
time={getEntityTime(entity, 'from')} | |
dayDate={dayDate} | |
adjustable={false} | |
separatorHasPopup={false} | |
reduceLineWidth | |
loc="from" | |
/> | |
<RowContainer> | |
<DurationSection | |
from={getEntityTime(entity, 'from')} | |
to={getEntityTime(entity, 'to')} | |
reduceWidth | |
isFixed={false} | |
/> | |
{entity.entityType === ENTITY_TYPES.ACTIVITY && ( | |
<ScaleFeedback | |
style={styles.scaleFeedback} | |
onPress={this.openEntity} | |
enabled={canOpenEntity} | |
useOpacity | |
> | |
<DummyActivity activity={entity.entity} hasShadow={false} /> | |
</ScaleFeedback> | |
)} | |
{entity.entityType === ENTITY_TYPES.FREE_TIME && ( | |
<DummyFreeTime text="Oh! Free time on the map? Niiiice." transparent /> | |
)} | |
{entity.entityType === ENTITY_TYPES.ACTION && hotel && ( | |
<ScaleFeedback | |
style={styles.scaleFeedback} | |
onPress={this.openHotel} | |
enabled={canOpenEntity} | |
useOpacity | |
> | |
<DummyAction | |
hotel={hotel} | |
actionType={entity.entity.actionType} | |
hasShadow={false} | |
/> | |
</ScaleFeedback> | |
)} | |
</RowContainer> | |
<TimeSeparator | |
time={getEntityTime(entity, 'to')} | |
dayDate={dayDate} | |
adjustable={false} | |
separatorHasPopup={false} | |
reduceLineWidth | |
loc="to" | |
/> | |
</EntityContainer> | |
)} | |
{!entity && hotel && ( | |
<RowContainer> | |
<ScaleFeedback style={styles.scaleFeedback} onPress={this.openHotel} useOpacity> | |
<DummyAction hotel={hotel} actionType="hotel-stay" hasShadow={false} /> | |
</ScaleFeedback> | |
</RowContainer> | |
)} | |
{this.renderNextNeighbor()} | |
</ContentContainer> | |
</MaskedView> | |
</CalloutContainer> | |
</Positioner> | |
</Container> | |
) : null; | |
} | |
} | |
const selectCalloutEntityNeighbors = createSelector( | |
[state => state, state => state.mapCallouts.entity], | |
(state, entity) => { | |
if (entity) { | |
return selectEntityNeighbors(state, { | |
id: entity.id, | |
_getPreviousPreviousEntity: true, | |
_getNextNextEntity: true | |
}); | |
} | |
return { | |
previousPreviousEntity: null, | |
previousEntity: null, | |
nextEntity: null, | |
nextNextEntity: null | |
}; | |
} | |
); | |
const selectDayDate = createSelector( | |
[state => state.trip.dayPlans, state => state.trip.selectedDayIndex], | |
(dayPlans, selectedDayIndex) => { | |
if (!dayPlans) { | |
return ''; | |
} | |
const dayPlan = dayPlans[selectedDayIndex]; | |
if (!dayPlan) { | |
return ''; | |
} | |
return dayPlan.day.dayDate; | |
} | |
); | |
const selectHotel = createSelector( | |
[state => state.mapCallouts.hotelId, state => state.trip.hotels], | |
(hotelId: string, hotels: { [hotelId: string]: Hotel }) => { | |
if (hotelId) { | |
return hotels[hotelId]; | |
} | |
return null; | |
} | |
); | |
const mapStateToProps = state => ({ | |
...selectCalloutEntityNeighbors(state), | |
calloutActivated: state.mapCallouts.calloutActivated, | |
entity: state.mapCallouts.entity, | |
hotel: selectHotel(state), | |
dayDate: selectDayDate(state), | |
zoomLevel: state.mapCallouts.zoomLevel | |
}); | |
const actionCreators = { | |
openCallout: openCalloutAction, | |
closeCallout: closeCalloutAction | |
}; | |
export default connect( | |
mapStateToProps, | |
actionCreators | |
)(withNavigation(MapCalloutDisplay)); | |
const Container = styled.View` | |
position: absolute; | |
top: 0; | |
left: 0; | |
width: 100%; | |
height: 100%; | |
align-items: center; | |
justify-content: center; | |
padding-bottom: ${MEASUREMENTS.CONTENT_LOWER_ANCHOR_HEIGHT}; | |
`; | |
const Positioner = styled.View` | |
width: 100%; | |
align-items: center; | |
justify-content: flex-end; | |
`; | |
const CalloutContainer = Animatable.createAnimatableComponent(styled.View` | |
width: ${DeviceProps.width - 40}; | |
height: auto; | |
position: absolute; | |
shadow-radius: 14; | |
shadow-color: #000; | |
shadow-offset: 0px 14px; | |
shadow-opacity: 0.07; | |
`); | |
const DisplayContainer = styled.View` | |
width: 100%; | |
height: auto; | |
align-items: center; | |
background-color: transparent; | |
`; | |
const MaskCard = styled.View` | |
width: 100%; | |
height: ${({ height }) => height}; | |
border-radius: ${TourStyles.values.screenViewBorderRadius}; | |
background-color: #000; | |
`; | |
const BackgroundBlurView = styled(BlurView)` | |
position: absolute; | |
width: 100%; | |
height: 100%; | |
top: 0; | |
left: 0; | |
`; | |
const BackgroundFill = styled.View` | |
position: absolute; | |
top: 0; | |
left: 0; | |
width: 100%; | |
height: 100%; | |
background-color: ${TourStyles.colors.background1}; | |
opacity: 0.55; | |
`; | |
const ContentContainer = styled.View` | |
width: 100%; | |
height: auto; | |
flex-direction: column; | |
padding: ${TourStyles.values.cardInnerPadding}px; | |
`; | |
const EntityContainer = styled.View` | |
width: 100%; | |
height: auto; | |
`; | |
const RowContainer = styled.View` | |
width: 100%; | |
height: ${MEASUREMENTS.ACTIVITY_CARD_HEIGHT_PER_HOUR}; | |
flex-direction: row; | |
`; | |
const TimeBold = styled.Text` | |
color: ${TourStyles.colors.text1}; | |
font-weight: 700; | |
`; | |
const EntityTextBold = styled.Text` | |
color: ${({ color }) => color}; | |
font-weight: 700; | |
`; | |
const UpperNeighborCardContainer = styled.View` | |
width: 100%; | |
margin-bottom: 6px; | |
`; | |
const LowerNeighborCardContainer = styled.View` | |
width: 100%; | |
margin-top: 6px; | |
`; | |
const FlightCardContainer = styled.View` | |
height: auto; | |
width: 100%; | |
`; | |
const styles = StyleSheet.create({ | |
scaleFeedback: { | |
height: 'auto', | |
flex: 1 | |
} | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment