places detail view and tile hours

This commit is contained in:
Bart Akeley 2017-07-23 19:58:10 -05:00
parent e23f279b65
commit 33303d7339
14 changed files with 251 additions and 90 deletions

View file

@ -1,8 +1,9 @@
// @flow // @flow
const apiKey = 'AIzaSyBfMm1y6JayCbXrQmgAG1R3ka4ZOJno_5E'; const apiKey = 'AIzaSyBfMm1y6JayCbXrQmgAG1R3ka4ZOJno_5E';
const placesUrl = `https://maps.googleapis.com/maps/api/place/details/json?key=${apiKey}`; const placesUrl = `https://maps.googleapis.com/maps/api/place/details/json?key=${apiKey}`;
const photosUrl = `https://maps.googleapis.com/maps/api/place/photo?key=${apiKey}`;
export const getPlaceDetails = async (placeid: string) => { export const getPlaceDetails = async (placeid: ?string) => {
if (!placeid || typeof placeid !== 'string') { if (!placeid || typeof placeid !== 'string') {
throw new Error('placeid looks wrong'); throw new Error('placeid looks wrong');
} }
@ -16,3 +17,19 @@ export const getPlaceDetails = async (placeid: string) => {
} }
return result; return result;
}; };
export const getURLForPhotoReference = (opts?: { maxheight?: number, maxwidth?: number } = {}) => (
photoReference: string
) => {
if (!photoReference) {
return '';
}
const { maxheight, maxwidth } = opts;
const maxHeight = `&maxheight=${maxheight || 600}`;
const maxWidth = maxwidth ? `&maxwidth=${maxwidth}` : '';
const photoref = `&photoreference=${photoReference}`;
return `${photosUrl}${photoref}${maxHeight}${maxWidth}`;
};

View file

@ -0,0 +1,37 @@
// @flow
import React from 'react';
import { View } from 'react-native';
import { pure } from 'recompose';
import FoodItemRecord from '../records/FoodItemRecord';
import typeof PlaceRecord from '../records/PlaceRecord';
import theme from '../ui-theme';
import { Link } from 'react-router-native';
import { routeWithTitle } from '../helpers/RouteHelpers';
import { TileBox, StrongText, SubText, Thumbnail } from './ItemTile';
export default pure(({ foodItem, place = {} }: { foodItem: FoodItemRecord, place: PlaceRecord }) => {
if (!foodItem) {
return <View />;
}
return (
<Link
to={routeWithTitle(`/foodItem/${foodItem.id || ''}`, foodItem.name)}
underlayColor={theme.itemTile.pressHighlightColor}
>
<View>
<TileBox>
<Thumbnail thumb={foodItem.thumbImage} />
<View style={{ paddingTop: 15 }}>
<StrongText>
{foodItem.name || ''}
</StrongText>
<SubText>
{`${place.name || ''} - ${foodItem.distance} mi`}
</SubText>
</View>
</TileBox>
</View>
</Link>
);
});

View file

@ -4,13 +4,15 @@ import { Text, View, TouchableOpacity } from 'react-native';
import { pure } from 'recompose'; import { pure } from 'recompose';
import { Icon } from 'react-native-material-ui'; import { Icon } from 'react-native-material-ui';
type Props = { glyph: string, text: string, route: string, onPress: Function, underlayColor?: string }; type Props = { glyph: string, text: string, route: string, onPress: Function, color?: string };
export default pure(({ glyph, text, underlayColor = 'grey', ...others }: Props) => ( export default pure(({ glyph, text, color = 'grey', ...others }: Props) =>
<TouchableOpacity {...others}> <TouchableOpacity {...others}>
<View style={{ flexDirection: 'row', marginTop: 5 }}> <View style={{ flexDirection: 'row', marginTop: 5 }}>
<Icon name={glyph} size={35} color={underlayColor} style={{ marginRight: 10 }} /> <Icon name={glyph} size={35} color={color} style={{ marginRight: 10 }} />
<Text style={{ lineHeight: 26 }}>{text}</Text> <Text style={{ lineHeight: 26 }}>
{text}
</Text>
</View> </View>
</TouchableOpacity> </TouchableOpacity>
)); );

View file

@ -1,12 +1,7 @@
//@flow //@flow
import React from 'react'; import React from 'react';
import { Text, View, Image } from 'react-native'; import { Text, View, Image } from 'react-native';
import { pure } from 'recompose';
import FoodItemRecord from '../records/FoodItemRecord';
import typeof PlaceRecord from '../records/PlaceRecord';
import theme from '../ui-theme'; import theme from '../ui-theme';
import { Link } from 'react-router-native';
import { routeWithTitle } from '../helpers/RouteHelpers';
export const Thumbnail = ({ thumb }: { thumb: ?string }) => export const Thumbnail = ({ thumb }: { thumb: ?string }) =>
<View <View
@ -49,24 +44,3 @@ export const TileBox = ({ children, style = {} }: { children?: any, style?: Obje
<View style={{ height: 70, flexDirection: 'row', backgroundColor: 'white', ...style }}> <View style={{ height: 70, flexDirection: 'row', backgroundColor: 'white', ...style }}>
{children} {children}
</View>; </View>;
export const FoodItemTile = pure(({ foodItem = {}, place = {} }: { foodItem: FoodItemRecord, place: PlaceRecord }) =>
<Link
to={routeWithTitle(`/foodItem/${foodItem.id || ''}`, foodItem.name)}
underlayColor={theme.itemTile.pressHighlightColor}
>
<View>
<TileBox>
<Thumbnail thumb={foodItem.thumbImage} />
<View style={{ paddingTop: 15 }}>
<StrongText>
{foodItem.name || ''}
</StrongText>
<SubText>
{`${place.name || ''} - ${foodItem.distance} mi`}
</SubText>
</View>
</TileBox>
</View>
</Link>
);

View file

@ -8,10 +8,23 @@ import { Link } from 'react-router-native';
import { Thumbnail, StrongText, SubText } from './ItemTile'; import { Thumbnail, StrongText, SubText } from './ItemTile';
import { routeWithTitle } from '../helpers/RouteHelpers'; import { routeWithTitle } from '../helpers/RouteHelpers';
const getHoursText = (hours: List<string>) => {
if (!hours) {
return 'not listed';
}
// Note: js Date return 0-Sun/6-Sat where google return 0-Mon/6-Sun
// Immutable.List is smart enough to know that a negative index is counted from the last index
return hours.get(new Date().getDay() - 1);
};
import theme from '../ui-theme'; import theme from '../ui-theme';
type PlaceTileProps = { place: PlaceRecord, distance: number, categories: List<string> }; type PlaceTileProps = { place: PlaceRecord, distance: number, categories: List<string> };
export default pure(({ place = {}, distance = 999, categories = new List() }: PlaceTileProps) => { export default pure(({ place, distance = 999, categories = new List() }: PlaceTileProps) => {
if (!place) {
return <View />;
}
return ( return (
<Link <Link
to={routeWithTitle(`/place/${place.id || ''}`, place.name)} to={routeWithTitle(`/place/${place.id || ''}`, place.name)}
@ -19,7 +32,7 @@ export default pure(({ place = {}, distance = 999, categories = new List() }: Pl
> >
<View style={{ height: 70, flexDirection: 'row', backgroundColor: 'white' }}> <View style={{ height: 70, flexDirection: 'row', backgroundColor: 'white' }}>
<Thumbnail thumb={place.thumb} /> <Thumbnail thumb={place.thumb} />
<View style={{ paddingTop: 15 }}> <View style={{ paddingTop: 5 }}>
<StrongText> <StrongText>
{`${place.name} - ${distance} mi`} {`${place.name} - ${distance} mi`}
</StrongText> </StrongText>
@ -27,7 +40,7 @@ export default pure(({ place = {}, distance = 999, categories = new List() }: Pl
{`${categories.interpose(', ').reduce((str, token) => str + token, '')}`} {`${categories.interpose(', ').reduce((str, token) => str + token, '')}`}
</SubText> </SubText>
<SubText> <SubText>
{'hours'} {getHoursText(place.hours)}
</SubText> </SubText>
</View> </View>
</View> </View>

View file

@ -3,6 +3,7 @@ import withProps from 'recompose/withProps';
import mapPropsStream from 'recompose/mapPropsStream'; import mapPropsStream from 'recompose/mapPropsStream';
import compose from 'recompose/compose'; import compose from 'recompose/compose';
import Places$ from '../streams/PlacesStream'; import Places$ from '../streams/PlacesStream';
import FoodItems$ from '../streams/FoodItemsStream';
import { path } from 'ramda'; import { path } from 'ramda';
export const withPlaces = mapPropsStream(props$ => export const withPlaces = mapPropsStream(props$ =>
@ -40,3 +41,13 @@ export const withPlaceForFoodItem = mapPropsStream(props$ =>
}; };
}) })
); );
export const withFoodItemsForPlace = mapPropsStream(props$ =>
props$.combineLatest(FoodItems$, (props, foodItems) => {
const placeId = props.placeId;
return {
...props,
foodItems: foodItems.toList().filter(foodItem => placeId === foodItem.placeId),
};
})
);

View file

@ -2,7 +2,7 @@
import React, { Component, PropTypes } from 'react'; import React, { Component, PropTypes } from 'react';
import { View, ScrollView } from 'react-native'; import { View, ScrollView } from 'react-native';
import theme from '../ui-theme'; import theme from '../ui-theme';
import { FoodItemTile } from '../components/ItemTile'; import FoodItemTile from '../components/FoodItemTile';
import { ActionButton } from 'react-native-material-ui'; import { ActionButton } from 'react-native-material-ui';
import { routeWithTitle } from '../helpers/RouteHelpers'; import { routeWithTitle } from '../helpers/RouteHelpers';
import FoodItemList from '../components/FoodItemList'; import FoodItemList from '../components/FoodItemList';

View file

@ -57,6 +57,11 @@ export class FoodItemDetail extends Component {
render() { render() {
const { foodItem, place } = this.props; const { foodItem, place } = this.props;
if (!foodItem || !place) {
return <View />;
}
const { images, quantity } = foodItem; const { images, quantity } = foodItem;
const viewableImages = images.filter(image => !!image); const viewableImages = images.filter(image => !!image);

View file

@ -1,31 +1,112 @@
// @flow // @flow
import React, { Component } from 'react'; import React, { Component } from 'react';
import { Text, View } from 'react-native'; import { View, Image, ScrollView } from 'react-native';
import theme from '../ui-theme'; import theme from '../ui-theme';
import { compose, pure } from 'recompose'; import { compose, pure } from 'recompose';
import typeof PlaceRecord from '../records/PlaceRecord';
import { withPlace, withPlaceIdFromRoute, withFoodItemsForPlace } from '../enhancers/placeEnhancers';
import Carousel from 'react-native-looped-carousel';
import CountBadge from '../components/CountBadge';
import { StrongText } from '../components/ItemTile';
import IconButton from '../components/IconButton';
import { type List } from 'immutable';
import typeof FoodItemRecord from '../records/FoodItemRecord';
import FoodItemTile from '../components/FoodItemTile';
const { placeDetails: style } = theme;
const stretchedStyle = { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 };
const contentTileStyle = {
backgroundColor: 'white',
paddingLeft: 20,
paddingRight: 20,
};
export class PlaceDetail extends Component { export class PlaceDetail extends Component {
static displayName = 'PlaceDetail'; static displayName = 'PlaceDetail';
props: { props: {
match: { place: ?PlaceRecord,
params: { foodItems: ?List<FoodItemRecord>,
id: string,
},
},
}; };
state: {
currentImage: number,
};
state = {
currentImage: 0,
};
changeCurrentImage = (currentImage: number) => this.setState({ currentImage });
// TODO placeholder implementation until we get a backend
addToFaves = () =>
this.setState(prevState => ({
isFavorite: !prevState.isFavorite,
}));
// todo - build a map view and open it here
viewOnMap = () => {};
// todo: need to get the item from the stream by this id // todo: need to get the item from the stream by this id
render() { render() {
const { match: { params: { id } } } = this.props; const { place, foodItems } = this.props;
if (!place) {
return <View />;
}
const { photos } = place;
const { currentImage } = this.state;
return ( return (
<View style={theme.page.container}> <ScrollView style={theme.page.container}>
<Text>{id}</Text> <View style={{ height: 200 }}>
</View> {photos.size === 1 && <Image style={stretchedStyle} source={{ uri: photos.first() }} />}
{photos.size > 1 &&
<Carousel autoplay={false} onAnimateNextPage={this.changeCurrentImage} style={stretchedStyle}>
{photos.map(uri =>
<Image key={uri} style={{ flex: 1, resizeMode: 'stretch' }} source={{ uri }} />
)}
</Carousel>}
<CountBadge currentImage={currentImage + 1} totalCount={photos.size} />
</View>
<View style={{ height: 50, marginBottom: 10, ...contentTileStyle }}>
<View style={{ marginTop: 15 }}>
<StrongText>
{place.address}
</StrongText>
</View>
</View>
<View
style={{ flexBasis: 75, ...contentTileStyle, marginBottom: 10, paddingTop: 5, paddingBottom: 10 }}
>
<IconButton
glyph="stars"
text="Add to Faves"
onPress={this.addToFaves}
color={style.actionIconColor}
/>
<IconButton
glyph="map"
text="View on a Map"
onPress={this.viewOnMap}
color={style.actionIconColor}
/>
</View>
<View style={{ flex: 2, ...contentTileStyle, paddingTop: 10 }}>
<StrongText>Products</StrongText>
{!!foodItems &&
foodItems.size &&
foodItems.map(foodItem => <FoodItemTile key={foodItem.id} foodItem={foodItem} place={place} />)}
</View>
</ScrollView>
); );
} }
} }
const enhance = compose(pure); const enhance = compose(pure, withPlaceIdFromRoute, withPlace, withFoodItemsForPlace);
export default enhance(PlaceDetail); export default enhance(PlaceDetail);

View file

@ -17,8 +17,8 @@ const PlacesList = withFoodItemsGroupedByPlace(
<View> <View>
{foodItemsByPlace {foodItemsByPlace
.map((foodItems: Map<string, FoodItemRecord>, placeId: string) => { .map((foodItems: Map<string, FoodItemRecord>, placeId: string) => {
const Comp = withPlaceProps(PlaceTile); const EnhancedPlaceTile = withPlaceProps(PlaceTile);
return <Comp key={placeId} placeId={placeId} foodItems={foodItems} />; return <EnhancedPlaceTile key={placeId} placeId={placeId} foodItems={foodItems} />;
}) })
.toList()} .toList()}
</View> </View>

View file

@ -1,5 +1,7 @@
// @flow // @flow
import { List, Record, fromJS } from 'immutable'; import { List, Map, Record, fromJS } from 'immutable';
import { pipe, pathOr, map, head, memoizeWith } from 'ramda';
import { getURLForPhotoReference } from '../apis/PlaceDetailsApi';
export type GooglePlaceObj = { export type GooglePlaceObj = {
place_id: string, place_id: string,
@ -24,7 +26,6 @@ export type GooglePlaceObj = {
formatted_phone_number: string, formatted_phone_number: string,
url: string, url: string,
photos: Array<string>, photos: Array<string>,
icon: string,
opening_hours: { opening_hours: {
open_now: boolean, open_now: boolean,
periods: Array<{ periods: Array<{
@ -41,6 +42,8 @@ export type GooglePlaceObj = {
}, },
}; };
export type Period = Map<string, any>;
export type Place = { export type Place = {
id: string, id: string,
name: string, name: string,
@ -50,8 +53,9 @@ export type Place = {
phoneNumber: string, phoneNumber: string,
googleMapsUrl: string, googleMapsUrl: string,
photos: List<string>, photos: List<string>,
icon: string, thumb: string,
hours: List<string>, hours: List<string>,
periods: List<Map<string, Period>>,
openNow: boolean, openNow: boolean,
}; };
@ -64,39 +68,54 @@ const FoodRecordDefaults: Place = {
phoneNumber: '', phoneNumber: '',
googleMapsUrl: '', googleMapsUrl: '',
photos: new List(), photos: new List(),
icon: '', thumb: '',
hours: new List(), hours: new List(),
periods: new List(),
openNow: false, openNow: false,
}; };
const PlaceRecord = Record(FoodRecordDefaults, 'PlaceRecord'); const PlaceRecord = Record(FoodRecordDefaults, 'PlaceRecord');
export const buildPlaceRecord = (place: GooglePlaceObj) => { const getPhotos = pathOr([], ['photos']);
const {
place_id,
name,
formatted_address,
geometry: { location = {} } = {},
formatted_phone_number,
url,
photos,
icon,
opening_hours = {},
} = place;
return new PlaceRecord({ const getPhotoRef = photo => photo.photo_reference || '';
id: place_id,
name: name, const getThumb = pipe(getPhotos, head, getPhotoRef, getURLForPhotoReference({ maxheight: 200 }));
address: formatted_address,
latitude: location.lat, const getPhotoUrls = pipe(getPhotos, map(getPhotoRef), map(getURLForPhotoReference({ maxheight: 600 })));
longitude: location.lng,
phoneNumber: formatted_phone_number, export const buildPlaceRecord = memoizeWith(
googleMapsUrl: url, (place: GooglePlaceObj) => place.place_id,
photos: fromJS(photos), (place: GooglePlaceObj) => {
icon: icon, const {
hours: fromJS(opening_hours.weekday_text), place_id,
openNow: opening_hours.open_now, name,
}); formatted_address,
}; geometry: { location = {} } = {},
formatted_phone_number,
url,
opening_hours = {},
} = place;
const photos = fromJS(getPhotoUrls(place));
const thumb = getThumb(place);
return new PlaceRecord({
id: place_id,
name: name,
address: formatted_address,
latitude: location.lat,
longitude: location.lng,
phoneNumber: formatted_phone_number,
googleMapsUrl: url,
photos,
thumb,
hours: fromJS(opening_hours.weekday_text),
periods: fromJS(opening_hours.periods),
open: !!opening_hours.open_now,
openNow: opening_hours.open_now,
});
}
);
export default PlaceRecord; export default PlaceRecord;

View file

@ -1,7 +1,7 @@
// @flow // @flow
import { buildPlaceRecord } from '../records/PlaceRecord'; import { buildPlaceRecord } from '../records/PlaceRecord';
import { Map } from 'immutable'; import { Map } from 'immutable';
import { foodItemsRaw$ } from './FoodItemsStream'; import FoodItems$ from './FoodItemsStream';
import { getPlaceDetails } from '../apis/PlaceDetailsApi'; import { getPlaceDetails } from '../apis/PlaceDetailsApi';
import { memoize } from 'ramda'; import { memoize } from 'ramda';
@ -17,7 +17,9 @@ const safeGetPlaceDetails = memoize(placeId => {
}); });
}); });
const uniquePlaceIds$ = foodItemsRaw$.map(({ placeId }) => placeId).distinct(); const uniquePlaceIds$ = FoodItems$.mergeMap(foodItemMap => {
return foodItemMap.map(foodItem => foodItem.placeId).toArray();
}).distinct();
const placeRecords$ = uniquePlaceIds$.mergeMap(safeGetPlaceDetails).map(buildPlaceRecord); const placeRecords$ = uniquePlaceIds$.mergeMap(safeGetPlaceDetails).map(buildPlaceRecord);

View file

@ -60,6 +60,9 @@ export default {
color: '#0E6E9E', color: '#0E6E9E',
}, },
foodItemDetails: { foodItemDetails: {
actionIconColor: COLOR.blue500, actionIconColor: '#0E6E9E',
},
placeDetails: {
actionIconColor: '#0E6E9E',
}, },
}; };

View file

@ -1,9 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"allowJs": true, "allowSyntheticDefaultImports": true
"allowSyntheticDefaultImports": true },
}, "exclude": ["node_modules"]
"exclude": [ }
"node_modules"
]
}