Merge branch '33-faves' into 'master'

Resolve "Fave food items list"

Closes #33

See merge request aretherecookies/ui-mobile!26
This commit is contained in:
Bart Akeley 2020-03-29 19:47:33 +00:00
commit 221703c4f0
24 changed files with 502 additions and 269 deletions

View file

@ -1,26 +1,27 @@
//@flow
import React, { Component } from "react";
import { StatusBar } from "react-native";
import { ThemeContext, getTheme } from "react-native-material-ui";
import rxjsconfig from "recompose/rxjsObservableConfig";
import setObservableConfig from "recompose/setObservableConfig";
import { NativeRouter, Route, Redirect, AndroidBackButton, Switch } from "react-router-native";
import Nav from "./pages/Nav";
import FoodItemDetail from "./pages/FoodItemDetail";
import PlaceDetail from "./pages/PlaceDetail";
import CreateFoodItem from "./pages/CreateFoodItem";
import AuthManager from "./AuthManager";
import LoginPage from "./pages/LoginPage";
import LandingPage from "./pages/LandingPage";
import ZipcodePage from "./pages/ZipcodePage";
import { AppContainer } from "./components/AppContainer";
import theme from "./ui-theme";
import rxjsconfig from 'recompose/rxjsObservableConfig';
import setObservableConfig from 'recompose/setObservableConfig';
setObservableConfig(rxjsconfig);
import React, { Component } from 'react';
import { StatusBar } from 'react-native';
import { ThemeContext, getTheme } from 'react-native-material-ui';
import { NativeRouter, Route, Redirect, AndroidBackButton, Switch } from 'react-router-native';
import Nav from './pages/Nav';
import FoodItemDetail from './pages/FoodItemDetail';
import PlaceDetail from './pages/PlaceDetail';
import CreateFoodItem from './pages/CreateFoodItem';
import LoginPage from './pages/LoginPage';
import LandingPage from './pages/LandingPage';
import ZipcodePage from './pages/ZipcodePage';
import { AppContainer } from './components/AppContainer';
import theme from './ui-theme';
console.disableYellowBox = true;
export default class App extends Component {
static displayName = "App";
static displayName = 'App';
render() {
return (
@ -29,7 +30,7 @@ export default class App extends Component {
<ThemeContext.Provider value={getTheme(theme)}>
<AppContainer>
<StatusBar backgroundColor={theme.statusBarColor} />
<Redirect from="/" to="/landing" />
<Redirect from="/" to="/landing" replace />
<Switch>
<Route path="/landing" component={LandingPage} />
<Route path="/list/:type" component={Nav} />
@ -38,16 +39,7 @@ export default class App extends Component {
<Route path="/login" component={LoginPage} />
<Route path="/logout" component={LoginPage} />
<Route path="/zipcode" component={ZipcodePage} />
<Route
path="/createFoodItem"
render={props => {
if (!AuthManager.isLoggedIn()) {
const search = props.location.search + "&" || "?";
return <Redirect to={`/login${search}`} push />;
}
return <CreateFoodItem />;
}}
/>
<Route path="/createFoodItem" component={CreateFoodItem} />
</Switch>
</AppContainer>
</ThemeContext.Provider>

View file

@ -13,6 +13,7 @@ export type AUTH_PROVIDER =
| typeof AUTH_PROVIDER_AUTH0;
export type User = {
id: string,
name: string,
token: string,
tokenExpires: number,
@ -21,6 +22,11 @@ export type User = {
provider: AUTH_PROVIDER,
};
export type EmailAndPassword = {
email: string,
password: string,
};
class AuthManager {
user: ?User;
@ -28,15 +34,17 @@ class AuthManager {
return this.user && this.user.token;
};
getAuthHeader = () => {
const { token, provider } = this.user || {};
getEmail = () => {
return this.user && this.user.email;
};
switch (provider) {
case AUTH_PROVIDER_FACEBOOK:
return { authorization: `facebook-token ${token}` };
default:
return {};
}
getId = () => {
return this.user && this.user.id;
};
getAuthHeader = () => {
const { token } = this.user || {};
return { authorization: `auth0-token ${token || ''}` };
};
isLoggedIn = () => {
@ -54,17 +62,19 @@ class AuthManager {
}
this.setUser({
email: auth0User.email,
token: auth0User.accessToken,
tokenExpires: +moment().add(auth0User.expiresIn, 'seconds'),
id: auth0User.idToken,
});
};
setUser = ({ token, tokenExpires }) => {
if (!token || +moment(tokenExpires) <= +moment()) {
setUser = user => {
if (!user.token || +moment(user.tokenExpires) <= +moment()) {
this.user = null;
AsyncStorage.removeItem('user');
} else {
this.user = { token, tokenExpires };
this.user = user;
AsyncStorage.setItem('user', JSON.stringify(this.user));
}
};
@ -73,8 +83,8 @@ class AuthManager {
const user = JSON.parse(await AsyncStorage.getItem('user'));
if (user) {
this.setUser(user);
return this.user;
}
return this.isLoggedIn();
};
authenticate = async ({ email, password }) => {

View file

@ -1,11 +1,14 @@
// @flow
import Auth0 from 'react-native-auth0';
import { type EmailAndPassword } from '../AuthManager';
let auth0Instance = null;
export type Auth0User = {
email: string,
accessToken: string,
expiresIn: number,
idToken: string,
};
const getAuth0Instance = () => {
@ -18,13 +21,16 @@ const getAuth0Instance = () => {
);
};
export const loginAuth0 = async ({ email: username, password }): Promise<?Auth0User> => {
export const loginAuth0 = async ({ email, password }: EmailAndPassword): Promise<?Auth0User> => {
const creds = await getAuth0Instance().auth.passwordRealm({
username,
username: email,
password,
realm: 'Username-Password-Authentication',
});
return creds;
return {
email,
...creds,
};
};
export const createAuth0User = async ({ email, username, password }) => {

44
js/apis/FavesApi.js Normal file
View file

@ -0,0 +1,44 @@
import { fetchRequest } from './FetchApi';
import AuthManager from '../AuthManager';
const withLoggedInEmail = fn => async (...args) => {
await AuthManager.checkIsAuthed();
const email = AuthManager.getEmail();
if (!email) {
throw new Error('you must be logged in to view favorites');
}
return fn(email, ...args);
};
export const fetchFaves = withLoggedInEmail(async email => {
return fetchRequest({
endpoint: `/faves`,
method: 'POST',
body: { email },
});
});
export const putFaves = withLoggedInEmail(async (email, foodItemIds) => {
return fetchRequest({
endpoint: '/fave',
method: 'PUT',
body: {
email,
foodItemIds,
},
});
});
export const deleteFaves = withLoggedInEmail(async (email, foodItemIds) => {
return fetchRequest({
endpoint: '/fave',
method: 'DELETE',
body: {
email,
foodItemIds,
},
});
});

View file

@ -1,26 +1,26 @@
// @flow
import { BASE_URL } from "../constants/AppConstants";
import AuthManager from "../AuthManager";
import { BASE_URL } from '../constants/AppConstants';
import AuthManager from '../AuthManager';
export const fetchRequest = async ({
endpoint,
method,
headers = {},
body
body,
}: {
endpoint: string,
method: string,
headers?: Object,
body?: Object
body?: Object,
}) => {
const res = await fetch(`https://${BASE_URL}${endpoint}`, {
const res = await fetch(`http://${BASE_URL}${endpoint}`, {
method,
headers: {
"Content-Type": "application/json",
'Content-Type': 'application/json',
...AuthManager.getAuthHeader(),
...headers
...headers,
},
...(body ? { body: JSON.stringify(body) } : {})
...(body ? { body: JSON.stringify(body) } : {}),
});
if (res.status === 401) {
@ -36,10 +36,10 @@ export const fetchRequest = async ({
export const fetchRequestBinary = async ({ endpoint, body }: { endpoint: string, body: any }) => {
return fetch(`http://${BASE_URL}${endpoint}`, {
method: "POST",
method: 'POST',
headers: {
...AuthManager.getAuthHeader()
...AuthManager.getAuthHeader(),
},
body
body,
});
};

View file

@ -20,9 +20,22 @@ const PlaceNameAndDistance = withPlace(
}
);
const EmptyFoodItemTile = () => {
return (
<View>
<TileBox>
<Thumbnail thumb={null} />
<View>
<StrongText>Loading...</StrongText>
</View>
</TileBox>
</View>
);
};
export default pure(({ foodItem }: { foodItem: FoodItemRecord }) => {
if (!foodItem) {
return <View />;
return <EmptyFoodItemTile />;
}
return (

View file

@ -2,7 +2,7 @@
import React, { Component } from 'react';
import { View, TextInput, TouchableOpacity, SafeAreaView } from 'react-native';
import { Toolbar, Icon } from 'react-native-material-ui';
import { path, pipe, not, cond, isNil, compose, always } from 'ramda';
import { path, cond, test } from 'ramda';
import queryString from 'query-string';
import { getSearch, getViewMode } from '../helpers/RouteHelpers';
import FoodItemSaveBtn from '../components/FoodItemSaveBtn';
@ -31,19 +31,11 @@ class TopToolbar extends Component {
const { router } = this.props;
const { history } = router;
const notNil = compose(
not,
isNil
);
const goBackTo = pipe(
path(['route', 'location', 'search']),
queryString.parse,
path(['backto']),
cond([[notNil, history.replace], [always(true), history.goBack]])
);
goBackTo(router);
if (history.length > 1) {
history.goBack();
} else {
history.replace('/list/food');
}
};
toggleMapList = () => {
@ -86,10 +78,15 @@ class TopToolbar extends Component {
const showSearch = /^\/list/.test(route);
const showSearchClear = filter.search.length > 0;
const searchPlaceholder = /\/food/.test(route) ? 'Search Food...' : 'Search Places...';
const searchPlaceholder = cond([
[test(/\/food/), () => 'Search Food...'],
[test(/\/places/), () => 'Search Places...'],
[test(/\/faves/), () => 'Search favorites'],
])(route);
return (
<View style={{backgroundColor: palette.primaryColor}}>
<View style={{ backgroundColor: palette.primaryColor }}>
<SafeAreaView>
{!showSearch && (
<Toolbar
@ -100,7 +97,11 @@ class TopToolbar extends Component {
/>
)}
{showSearch && (
<View style={{ flexDirection: 'row', padding: 10 }}>
<View
style={{
flexDirection: 'row',
padding: 10,
}}>
<View
style={{
flex: 1,

View file

@ -1,12 +1,12 @@
// @flow
// @public
export const BASE_URL = 'aretherecookies.herokuapp.com';
// export const BASE_URL = 'aretherecookies.herokuapp.com';
// @home
// export const BASE_URL = '192.168.1.8:3000';
// @stouthaus
// export const BASE_URL = '192.168.1.191:3000';
export const BASE_URL = '192.168.1.181:3000';
export const GoogleAPIKey = 'AIzaSyBfMm1y6JayCbXrQmgAG1R3ka4ZOJno_5E';

View file

@ -0,0 +1,27 @@
import mapPropsStream from 'recompose/mapPropsStream';
import { compose, lifecycle } from 'recompose';
import AuthManager from '../AuthManager';
import { withRouterContext } from './routeEnhancers';
import { getPathname, loginWithBackto } from '../helpers/RouteHelpers';
export const withAuthed = mapPropsStream(props$ =>
props$.combineLatest(AuthManager.checkIsAuthed(), (props, isAuthed) => {
return {
...props,
isAuthed,
};
})
);
export const withAuthRedirect = compose(
withRouterContext,
withAuthed,
lifecycle({
componentDidMount() {
if (!this.props.isAuthed) {
const pathname = getPathname(this.props);
this.props.router.history.replace(loginWithBackto(pathname));
}
},
})
);

View file

@ -0,0 +1,52 @@
//@flow
import mapPropsStream from 'recompose/mapPropsStream';
import Faves$, { emit as emitFaves, emitOne as emitFave } from '../streams/FavesStream';
import { fetchFaves, putFaves, deleteFaves } from '../apis/FavesApi';
import { fromJS, get } from 'immutable';
const fetchAndEmitFaves = async () => {
const faves = fromJS(await fetchFaves());
emitFaves(faves);
};
const buildTempFave = foodItemId =>
fromJS({
food_item_id: foodItemId,
date: Date.now(),
});
export const withFaves = mapPropsStream(props$ =>
props$.combineLatest(Faves$, (props, faves) => {
return {
...props,
faves,
getFaves: async () => {
try {
await fetchAndEmitFaves();
} catch (err) {
console.log(err); // eslint-disable-line no-console
}
},
addFave: async foodItemId => {
try {
await putFaves([foodItemId]);
emitFave(buildTempFave(foodItemId));
} catch (err) {
console.log(err); // eslint-disable-line no-console
}
},
deleteFave: async foodItemId => {
try {
await deleteFaves([foodItemId]);
const idx = faves.findIndex(fave => get(fave, 'id') === foodItemId);
if (idx >= 0) {
emitFaves(faves.delete(idx));
}
} catch (err) {
console.log(err); // eslint-disable-line no-console
}
},
};
})
);

View file

@ -9,10 +9,7 @@ import { pipe, path, toLower, equals } from 'ramda';
type FindPredicate<A> = (left: A) => (right: A) => Boolean;
const getName: (f: FoodItemRecord) => String = pipe(
path(['name']),
toLower
);
const getName: (f: FoodItemRecord) => String = pipe(path(['name']), toLower);
const matchesName: FindPredicate<FoodItemRecord> = left => right => {
return equals(getName(left), getName(right));
@ -33,7 +30,7 @@ export const withFoodItems = mapPropsStream(props$ =>
props$.combineLatest(FoodItems$, (props, foodItems) => {
return {
...props,
foodItemsMap: foodItems && foodItems,
foodItemsMap: foodItems,
};
})
);

View file

@ -1,7 +1,6 @@
// @flow
import { getContext, withProps, compose } from 'recompose';
import { shape, func, string } from 'prop-types';
import { path } from 'ramda';
import { path, pathOr } from 'ramda';
import { getViewMode } from '../helpers/RouteHelpers';
export type routeMatch = {
@ -66,3 +65,14 @@ export const withReplaceRoute = compose(
};
})
);
export const withCurrentPath = compose(
withRouterContext,
withProps(props => {
const { pathname, search } = pathOr({}, ['router', 'route', 'location'], props);
return {
currentPath: `${pathname}${search || '?'}`,
};
})
);

View file

@ -1,7 +1,9 @@
// @flow
import { curry, pipe, pathOr } from 'ramda';
import { curry, pipe, pathOr, path } from 'ramda';
import queryString from 'query-string';
const PARAM_BACK_TO = 'backto';
type RouteTo = {
pathname: string,
search?: string,
@ -18,8 +20,8 @@ export const routeWithQuery = curry(
}
);
export const routeWithTitle = curry(
(pathname: string, routeTitle: string): RouteTo => routeWithQuery(pathname, { routeTitle })
export const routeWithTitle = curry((pathname: string, routeTitle: string): RouteTo =>
routeWithQuery(pathname, { routeTitle })
);
export const getSearch = pipe(
@ -27,7 +29,10 @@ export const getSearch = pipe(
queryString.parse
);
export const getViewMode = pipe(
getSearch,
pathOr('list', ['viewMode'])
);
export const getViewMode = pipe(getSearch, pathOr('list', ['viewMode']));
export const getBackTo = pipe(getSearch, path([PARAM_BACK_TO]));
export const loginWithBackto = (backto: string) => `/login?${PARAM_BACK_TO}=${backto}`;
export const getPathname = pathOr('/list/food', ['router', 'route', 'location', 'pathname']);

View file

@ -1,20 +1,24 @@
// @flow
export default <T>(fn: Function, delay?: number = 0): (() => Promise<T>) => {
let timeoutId;
export default <T>(fn: Function, delay?: number = 100): (() => Promise<T>) => {
let cacheTimeout;
let cachedResult;
return (...args) => {
if (timeoutId) {
clearTimeout(timeoutId);
if (cacheTimeout) {
clearTimeout(cacheTimeout);
}
return new Promise((resolve, reject) => {
timeoutId = setTimeout(() => {
if (!cachedResult) {
cachedResult = new Promise((resolve, reject) => {
try {
resolve(fn(...args));
} catch (err) {
reject(err);
} finally {
timeoutId = null;
}
}, delay);
});
}
cacheTimeout = setTimeout(() => (cachedResult = null), delay);
return cachedResult;
};
};

View file

@ -29,6 +29,7 @@ import { getCategoryText } from '../helpers/CategoryHelpers';
import { getQuantityDropdownText } from '../helpers/QuantityHelpers';
import QuantityModal from '../modals/QuantityModal';
import { wrapModalComponent } from '../modals/FullScreenModal';
import { withAuthRedirect } from '../enhancers/authEnhancers';
type Props = {
foodItem: typeof FoodItemRecord,
@ -171,6 +172,7 @@ const updatePlace = ({ foodItem, setFoodItem, emitPlace }: Props) => placeDetail
};
export default compose(
withAuthRedirect,
withCreateFoodItemState,
withPlaceId,
withPlaceForFoodItem,

View file

@ -1,23 +0,0 @@
// @flow
import React, { Component } from 'react';
import { Text, View } from 'react-native';
import theme from '../ui-theme';
type Props = {
route: Object,
navigator: Object,
};
export default class Faves extends Component {
static displayName = 'Faves';
props: Props;
render() {
return (
<View style={theme.page.container}>
<Text>Faves!</Text>
</View>
);
}
}

58
js/pages/FavesPage.js Normal file
View file

@ -0,0 +1,58 @@
// @flow
import React from 'react';
import { View, ScrollView, RefreshControl } from 'react-native';
import { compose, pure, withState, withHandlers, lifecycle } from 'recompose';
import { withFoodItems } from '../enhancers/foodItemEnhancers';
import { Map, get } from 'immutable';
import typeof FoodItemRecord from '../records/FoodItemRecord';
import { withFaves } from '../enhancers/favesEnhancer';
import type { Faves, Fave } from '../streams/FavesStream';
import FoodItemTile from '../components/FoodItemTile';
import { withAuthRedirect } from '../enhancers/authEnhancers';
type Props = {
faves: Faves,
onRefresh: () => Promise<any>,
onPulldown: () => {},
isRefreshing: boolean,
viewMode: string,
};
const FavesList = ({ faves, isRefreshing, onPulldown }: Props) => {
const refreshing = isRefreshing || !faves;
return (
<View>
<ScrollView
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onPulldown} />}>
{faves &&
faves.map((fave: Faves, index: number) => {
return <FoodItemTile key={fave.id || index} foodItem={fave} />;
})}
</ScrollView>
</View>
);
};
export default compose(
pure,
withAuthRedirect,
withFaves,
withState('isRefreshing', 'setRefreshing', true),
withHandlers({
onPulldown: ({ setRefreshing, getFaves }) => async () => {
try {
setRefreshing(true);
await getFaves();
} catch (error) {
console.error(error); // eslint-disable-line no-console
} finally {
setRefreshing(false);
}
},
}),
lifecycle({
componentDidMount() {
this.props.onPulldown();
},
})
)(FavesList);

View file

@ -19,21 +19,24 @@ import { withFoodItem } from '../enhancers/foodItemEnhancers';
import { withPlaceForFoodItem } from '../enhancers/placeEnhancers';
import Carousel from 'react-native-looped-carousel';
import CountBadge from '../components/CountBadge';
import { routeWithTitle } from '../helpers/RouteHelpers';
import { routeWithTitle, loginWithBackto } from '../helpers/RouteHelpers';
import { Link } from 'react-router-native';
import moment from 'moment';
import { withUpdateQuantity } from '../enhancers/quantityEnhancers';
import { getQuantityLabelText } from '../helpers/QuantityHelpers';
import AuthManager from '../AuthManager';
import RouterButton from 'react-router-native-button';
import QuantityModal from '../modals/QuantityModal';
import { withImages } from '../enhancers/imagesEnhancers';
import { openImagePicker } from '../helpers/ImagePickerHelpers';
import { identity } from 'ramda';
import { identity, pathOr } from 'ramda';
import Spinner from 'react-native-loading-spinner-overlay';
import ImageRecord from '../records/ImageRecord';
import { List } from 'immutable';
import { List, get } from 'immutable';
import Snackbar from 'react-native-snackbar';
import { withAuthed } from '../enhancers/authEnhancers';
import { withFaves } from '../enhancers/favesEnhancer';
import debounce from '../helpers/debounce';
import { withCurrentPath, withReplaceRoute } from '../enhancers/routeEnhancers';
const { foodItemDetails: style } = theme;
@ -94,7 +97,7 @@ type Props = {
place: PlaceRecord,
currentImage: number,
quantityModalOpen: boolean,
isLoggedIn: boolean,
isAuthed: boolean,
changeCurrentImage: (index: number) => void,
updateAmount: (quantity: Quantity) => void,
updateQuantity: (arg: { foodItemId: string, quantity: Quantity }) => void,
@ -103,6 +106,9 @@ type Props = {
addImage: (arg: { foodItemId: string, imageUri: string }) => Promise<void>,
imagesLoading: boolean,
setImagesLoading: (loading: boolean) => void,
addToFaves: () => void,
isFave: boolean,
deleteFromFaves: () => void,
};
export const FoodItemDetail = (props: Props) => {
@ -111,12 +117,15 @@ export const FoodItemDetail = (props: Props) => {
place,
currentImage,
changeCurrentImage,
isLoggedIn,
isAuthed,
addPhoto,
toggleQuantityModal,
quantityModalOpen,
updateAmount,
imagesLoading,
addToFaves,
isFave,
deleteFromFaves,
} = props;
if (!foodItem || !place) {
@ -155,34 +164,48 @@ export const FoodItemDetail = (props: Props) => {
<SubText>
Last updated at {moment(foodItem.lastupdated).format('h:mm A on MMM D, YYYY')}
</SubText>
{isLoggedIn && (
{isAuthed && (
<Button
title="Update quantity"
onPress={toggleQuantityModal}
color={theme.palette.primaryColor}
/>
)}
{!isLoggedIn && (
{!isAuthed && (
<RouterButton
title="Log in to update quantity"
to={'/login'}
to={loginWithBackto(`/foodItem/${foodItem.id}?loginAction=open-quantity-modal`)}
color={theme.palette.primaryColor}
/>
)}
</View>
<View style={{ flex: 1, ...contentTileStyle }}>
{/* <IconButton
glyph="stars"
{isAuthed && isFave ? (
<IconButton
glyph="favorite"
text="Favorited"
onPress={deleteFromFaves}
color="#990000"
style={{ marginBottom: 10 }}
textStyle={{ fontSize: 20, color: '#555555' }}
/>
) : (
<IconButton
glyph="favorite"
text="Add to Faves"
onPress={this.addToFaves}
onPress={addToFaves}
color={style.actionIconColor}
/> */}
{isLoggedIn && (
style={{ marginBottom: 10 }}
textStyle={{ fontSize: 20, color: '#555555' }}
/>
)}
{isAuthed && (
<IconButton
glyph="insert-photo"
text="Add Photo"
onPress={addPhoto}
color={style.actionIconColor}
textStyle={{ fontSize: 20, color: '#555555' }}
/>
)}
</View>
@ -198,9 +221,10 @@ export default compose(
withPlaceForFoodItem,
withUpdateQuantity,
withImages,
withProps(() => ({
isLoggedIn: AuthManager.isLoggedIn(),
})),
withAuthed,
withFaves,
withCurrentPath,
withReplaceRoute,
withState('currentImage', 'changeCurrentImage', 0),
withState('quantityModalOpen', 'setQuantityModalOpen', false),
withState('imagesLoading', 'setImagesLoading', true),
@ -230,21 +254,51 @@ export default compose(
await updateQuantity({ foodItemId: foodItem.id, quantity });
Snackbar.show({ title: 'Food updated.', backgroundColor: 'black', color: 'white' });
},
toggleQuantityModal: ({ quantityModalOpen, setQuantityModalOpen }) => () => {
toggleQuantityModal: ({ quantityModalOpen, setQuantityModalOpen }) =>
debounce(() => {
setQuantityModalOpen(!quantityModalOpen);
},
}, 500),
addToFaves: ({ addFave, foodItemId, isAuthed, replaceRoute }) =>
debounce(() => {
if (!isAuthed) {
replaceRoute(loginWithBackto(`/foodItem/${foodItemId}?loginAction=add-to-faves`));
} else {
addFave(foodItemId);
}
}, 500),
deleteFromFaves: ({ deleteFave, foodItemId }) => () => deleteFave(foodItemId),
}),
withProps(props => ({
isFave: props.faves && !!props.faves.find(fave => get(fave, 'id') === props.foodItemId),
loginAction: pathOr('', [1], /loginAction=(.+)(&?.*$)/.exec(props.currentPath)),
})),
onlyUpdateForKeys([
'foodItem',
'place',
'currentImage',
'quantityModalOpen',
'imagesLoading',
'isLoggedIn',
'isAuthed',
'faves',
]),
lifecycle({
componentDidMount() {
const { getImages, foodItemId, setImagesLoading } = this.props;
const {
getImages,
foodItemId,
setImagesLoading,
loginAction,
setQuantityModalOpen,
addToFaves,
} = this.props;
if (loginAction === 'open-quantity-modal') {
setQuantityModalOpen(true);
}
if (loginAction === 'add-to-faves') {
addToFaves();
}
getImages({
foodItemId,

View file

@ -1,22 +1,23 @@
// @flow
import React from "react";
import { View, Text, ScrollView, RefreshControl } from "react-native";
import FoodItemTile from "../components/FoodItemTile";
import FoodItemList from "../components/FoodItemList";
import typeof FoodItemRecord from "../records/FoodItemRecord";
import { withFoodItemsAsSeq } from "../enhancers/foodItemEnhancers";
import { type SetSeq } from "immutable";
import { compose, pure, withState, withHandlers, lifecycle } from "recompose";
import { withFilter } from "../enhancers/filterEnhancers";
import { withRouterContext } from "../enhancers/routeEnhancers";
import FullScreenMessage from "../components/FullScreenMessage";
import React from 'react';
import { View, Text, ScrollView, RefreshControl } from 'react-native';
import FoodItemTile from '../components/FoodItemTile';
import FoodItemList from '../components/FoodItemList';
import typeof FoodItemRecord from '../records/FoodItemRecord';
import { withFoodItemsAsSeq } from '../enhancers/foodItemEnhancers';
import { type SetSeq } from 'immutable';
import { compose, pure, withState, withHandlers, lifecycle } from 'recompose';
import { withFilter } from '../enhancers/filterEnhancers';
import { withRouterContext } from '../enhancers/routeEnhancers';
import FullScreenMessage from '../components/FullScreenMessage';
import { withFaves } from '../enhancers/favesEnhancer';
type Props = {
foodItemsSeq: SetSeq<FoodItemRecord>,
isRefreshing: boolean,
onRefresh: () => Promise<any>,
onPulldown: () => {},
isFilterDirty: boolean
isFilterDirty: boolean,
};
const FoodList = (props: Props) => {
@ -36,8 +37,7 @@ const FoodList = (props: Props) => {
{!showNoResults && (
<ScrollView
style={{ flex: 1 }}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onPulldown} />}
>
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onPulldown} />}>
<FoodItemList foodItemsSeq={foodItemsSeq}>
{(foodItem: FoodItemRecord) => <FoodItemTile key={foodItem.id} foodItem={foodItem} />}
</FoodItemList>
@ -52,23 +52,24 @@ export default compose(
withRouterContext,
withFoodItemsAsSeq,
withFilter,
withState("isRefreshing", "setRefreshing", false),
withFaves,
withState('isRefreshing', 'setRefreshing', false),
withHandlers({
onPulldown: ({ setRefreshing, onRefresh, router }) => async () => {
onPulldown: ({ setRefreshing, onRefresh, getFaves, router }) => async () => {
try {
setRefreshing(true);
await onRefresh();
await Promise.all([onRefresh(), getFaves()]);
} catch (error) {
console.error(error); // eslint-disable-line no-console
router.history.push("/zipcode");
router.history.push('/zipcode');
} finally {
setTimeout(() => setRefreshing(false), 1000);
}
}
},
}),
lifecycle({
componentDidMount() {
this.props.onPulldown();
}
},
})
)(FoodList);

View file

@ -6,6 +6,7 @@ import { withRouterContext } from '../enhancers/routeEnhancers';
import AuthManager from '../AuthManager';
import theme from '../ui-theme';
import { Divider, Icon } from 'react-native-material-ui';
import { getBackTo } from '../helpers/RouteHelpers';
const auth0ErrToStr = message => {
if (typeof message === 'string') {
@ -78,7 +79,11 @@ const LoginPageComponent = ({
alignItems: 'center',
justifyContent: 'flex-start',
}}>
{error && <Text style={{ fontSize: 16, color: '#721c24', backgroundColor: '#f8d7da', padding: 16,}}>{error}</Text>}
{error && (
<Text style={{ fontSize: 16, color: '#721c24', backgroundColor: '#f8d7da', padding: 16 }}>
{error}
</Text>
)}
<Text style={{ fontSize: 42, fontWeight: '300', margin: 16 }}>Sign In</Text>
@ -91,7 +96,7 @@ const LoginPageComponent = ({
onChangeText={setEmail}
style={{
fontSize: 16,
paddingLeft: 0
paddingLeft: 0,
}}
/>
<Divider />
@ -108,7 +113,7 @@ const LoginPageComponent = ({
onChangeText={setUsername}
style={{
fontSize: 16,
paddingLeft: 0
paddingLeft: 0,
}}
/>
<Divider />
@ -126,7 +131,7 @@ const LoginPageComponent = ({
onChangeText={setPassword}
style={{
fontSize: 16,
paddingLeft: 0
paddingLeft: 0,
}}
/>
<Divider />
@ -134,7 +139,7 @@ const LoginPageComponent = ({
</View>
<View style={{ flexDirection: 'row', padding: 16, paddingBottom: 0, paddingTop: 32 }}>
<View style={{ flex: 1, flexDirection: 'column', }}>
<View style={{ flex: 1, flexDirection: 'column' }}>
<Button title={submitTitle} onPress={submitAction} color={theme.palette.primaryColor} />
</View>
</View>
@ -160,7 +165,7 @@ const LoginPageComponent = ({
style={{
flexDirection: 'row',
marginTop: 0,
padding:16,
padding: 16,
alignItems: 'center',
justifyContent: 'center',
}}>
@ -188,10 +193,11 @@ export default compose(
withState('username', 'setUsername', ''),
withState('isNewUser', 'setIsNewUser', false),
withHandlers({
checkAuth: ({ router }) => async () => {
checkAuth: props => async () => {
const isLoggedIn = await AuthManager.checkIsAuthed();
if (isLoggedIn) {
router.history.goBack();
const backTo = getBackTo(props);
return backTo ? props.router.history.replace(backTo) : props.router.history.goBack();
}
},
}),

View file

@ -11,11 +11,15 @@ import { pathOr } from 'ramda';
import uiTheme from '../ui-theme';
import ProfilePage from './ProfilePage';
import AuthManager from '../AuthManager';
import FavesPage from './FavesPage';
type Props = {
activeTab: string,
router: routerContext,
setRoute: (route: string) => () => void,
location: {
pathname: string,
},
};
const Nav = (props: Props) => {
@ -25,7 +29,8 @@ const Nav = (props: Props) => {
<View style={{ flex: 1 }}>
{activeTab === 'food' && <Food onRefresh={getLocation} />}
{activeTab === 'places' && <Places onRefresh={getLocation} />}
{activeTab === 'profile' && <ProfilePage isLoggedIn={AuthManager.isLoggedIn()} />}
{activeTab === 'faves' && <FavesPage />}
{activeTab === 'profile' && <ProfilePage />}
</View>
<SafeAreaView>
<BottomNavigation active={activeTab}>
@ -63,10 +68,10 @@ const Nav = (props: Props) => {
}}
/>
<BottomNavigation.Action
key="fave"
key="faves"
icon="favorite"
label="Faves"
onPress={setRoute('/list/food')}
onPress={setRoute('/list/faves')}
style={{
container: {
minWidth: 40,

View file

@ -8,6 +8,7 @@ import { withRouterContext } from '../enhancers/routeEnhancers';
import { getLocation } from '../apis/PositionApi';
import Snackbar from 'react-native-snackbar';
import AsyncStorage from '@react-native-community/async-storage';
import { withAuthed } from '../enhancers/authEnhancers';
type Props = {
zipcode: ?string,
@ -21,9 +22,10 @@ type Props = {
router: {
history: {
replace: (route: string) => void,
push: (route: string) => void,
},
},
isLoggedIn: boolean,
isAuthed: boolean,
};
export const ProfilePage = (props: Props) => {
@ -35,13 +37,21 @@ export const ProfilePage = (props: Props) => {
error,
toggleGPS,
router: { history },
isLoggedIn,
isAuthed,
} = props;
const usingGPS = zipcode === 'usegps';
return (
<View style={{ flex: 1, flexDirection: 'column', }}>
<Text style={{ fontSize: 42, marginBottom: 8, color: '000', padding: 16 }}>Profile</Text>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', borderColor: '#ccc', borderTopWidth: 1, borderBottomWidth: 1, padding: 16 }}>
<View style={{ flex: 1, flexDirection: 'column' }}>
<Text style={{ fontSize: 42, marginBottom: 8, color: '#000', padding: 16 }}>Profile</Text>
<View
style={{
flexDirection: 'row',
justifyContent: 'space-between',
borderColor: '#ccc',
borderTopWidth: 1,
borderBottomWidth: 1,
padding: 16,
}}>
<Text style={{ fontSize: 16 }}>Use My Location</Text>
<Switch value={usingGPS} onValueChange={toggleGPS} />
</View>
@ -70,7 +80,7 @@ export const ProfilePage = (props: Props) => {
)}
{!!error && <Text style={{ fontSize: 16, color: 'red' }}>{error}</Text>}
<View style={{ marginTop: 16, padding: 8 }}>
{isLoggedIn && (
{isAuthed && (
<IconButton
glyph="exit-to-app"
text="Logout"
@ -79,7 +89,7 @@ export const ProfilePage = (props: Props) => {
textStyle={{ color: palette.accentColor }}
/>
)}
{!isLoggedIn && (
{!isAuthed && (
<IconButton
glyph="exit-to-app"
text="Login"
@ -140,5 +150,6 @@ export default compose(
componentDidMount() {
this.props.getZipcode();
},
})
}),
withAuthed
)(ProfilePage);

View file

@ -1,83 +0,0 @@
// @flow
import React from 'react';
import { shape, func, string, arrayOf } from 'prop-types';
import Food from './pages/Food';
import Places from './pages/Places';
import Faves from './pages/Faves';
import FoodItemDetail from './pages/FoodItemDetail';
import PlaceDetail from './pages/PlaceDetail';
export type Route = {
title: string,
component: React.Component<any, any, any>,
icon: string,
};
export default {
food: {
title: 'Food',
component: Food,
icon: 'local-pizza',
},
places: {
title: 'Places',
component: Places,
icon: 'restaurant',
},
faves: {
title: 'Faves',
component: Faves,
icon: 'stars',
},
foodDetail: {
title: 'Food Details',
component: FoodItemDetail,
icon: 'local-pizza',
},
placeDetail: {
title: 'Place Details',
component: PlaceDetail,
icon: 'domain',
},
};
export const routingContextPropTypes = {
router: shape({
history: shape({
push: func.isRequired,
replace: func.isRequired,
goBack: func.isRequired,
entries: arrayOf(
shape({
pathname: string.isRequired,
search: string.isRequired,
})
),
}).isRequired,
}).isRequired,
};
export type RoutingContextFlowTypes = {
router: {
history: {
push: Function,
replace: Function,
goBack: Function,
entries: Array<{
pathname: string,
search: string,
}>,
},
route: {
location: {
pathname: string,
state: ?Object,
},
},
},
match: {
params: {
type?: string,
},
},
};

41
js/streams/FavesStream.js Normal file
View file

@ -0,0 +1,41 @@
//@flow
import { BehaviorSubject } from 'rxjs';
import { Record, List, get } from 'immutable';
import filter$ from './FilterStream';
import foodItems$ from './FoodItemsStream';
import { test } from 'ramda';
export type Fave = {
food_item_id: string,
date: number,
};
export type Faves = List<Record>;
const observable: BehaviorSubject<Faves> = new BehaviorSubject(null);
export function emit(fave: ?Faves) {
observable.next(fave);
}
export function emitOne(fave: ?Fave) {
observable.first().subscribe(currentFaves => observable.next(currentFaves.push(fave)));
}
export default observable
.combineLatest(foodItems$)
.map(([faves, foodItems]) => {
return faves && faves.map(fave => get(foodItems, get(fave, 'food_item_id')));
})
.combineLatest(filter$)
.map(([faveFoodItems, filter]) => {
const filterTest = filter.search && test(new RegExp(filter.search, 'i'));
return (
faveFoodItems &&
faveFoodItems.filter(faveFoodItem => {
if (!faveFoodItem) {
return false;
}
return !filterTest || filterTest(faveFoodItem.name);
})
);
});