<repo_name>paladin952/FlickrClient<file_sep>/src/utils/strings.js
import I18n from 'react-native-i18n';


I18n.translations = {
    en: {
        tab_photos: 'PHOTOS',
        tab_groups: 'GROUPS',
        members: 'members',
        member: 'member',
        empty_data: 'There is no data',
        network_error: 'Check your internet connection and try again!',
        generic_error: 'Something went wrong. Try again!',
    }
};

I18n.fallbacks = true;
export default I18n;<file_sep>/src/ui/pages/photo-details-page.js
import React from 'react';
import {Image, Text, View, StyleSheet} from "react-native";


const PhotoDetailsPage = (props) => {
    let uri = props.navigation.state.params.uri;
    let title = props.navigation.state.params.title;
    return (
        <View style={styles.root}>
            <Image style={styles.image} source={{uri: uri}} resizeMode={'cover'}/>
            <View style={styles.textContainer}>
                <Text style={styles.text}>{title}</Text>
            </View>
        </View>
    )
};

const styles = StyleSheet.create({
    text: {
        fontWeight: 'bold',
        fontSize: 24,
        textAlign: 'center',
        padding: 4,
        color: 'black'
    },
    textContainer: {
        borderRadius: 16,
        backgroundColor: 'rgba(255, 255, 255, 0.5)',
        position: 'absolute',
        bottom: 16,
        alignSelf: 'center'
    },
    image: {
        flex: 1
    },
    root: {
        flex: 1,
    }
});

export default PhotoDetailsPage;<file_sep>/src/redux/middleswares/api.js
import React from 'react';
import * as actions from "../consts/action-types";
import axios from "axios";
import {endNetwork, hideGenericError, hideLoadMore, hideNetworkError, showLoadMore, startNetwork} from "../actions/ui";

const api = ({dispatch, getState}) => next => action => {

    if (action.type !== actions.API) {
        return next(action);
    }

    const {url, success, failure, method, data, page} = action.payload;
    if (page > 1) {
        dispatch(showLoadMore());
    } else {
        dispatch(startNetwork());
    }
    dispatch(hideGenericError());
    dispatch(hideNetworkError());
    axios({
        url: url,
        method: method,
        data: data
    })
        .then(response => response.data)
        .then(data => {
            dispatch(success(data));
            dispatch(endNetwork());
            dispatch(hideLoadMore());
        })
        .catch(err => {
            dispatch(failure(err));
            dispatch(hideLoadMore());
            dispatch(endNetwork());
        })

};

export default api;
<file_sep>/src/redux/reducers/ui.js
import * as actions from "../consts/action-types";

let initialState = {
    loading: false,
    genericError: false,
    networkError: false,
    searchText: '',
    currentTabIndex: 0,
    isLoadingMore: false,
};

const uiReducer = (state = initialState, action) => {
    switch (action.type) {
        case actions.START_NETWORK:
            return {...state, loading: true};

        case actions.END_NETWORK:
            return {...state, loading: false};

        case actions.SHOW_GENERIC_ERROR:
            return {...state, genericError: true};

        case actions.HIDE_GENERIC_ERROR:
            return {...state, genericError: false};

        case actions.SHOW_NETWORK_ERROR:
            return {...state, networkError: true};

        case actions.HIDE_NETWORK_ERROR:
            return {...state, networkError: false};

        case actions.ON_SEARCH:
            return {...state, searchText: action.payload};

        case actions.TAB_NEW_INDEX:
            return {...state, currentTabIndex: action.payload};

        case actions.SHOW_LOAD_MORE:
            return {...state, isLoadingMore: true};

        case actions.HIDE_LOAD_MORE:
            return {...state, isLoadingMore: false};

        default:
            return state;
    }
};

export default uiReducer;<file_sep>/src/redux/reducers/group.js
import * as actions from "../consts/action-types";

let initialData = {groups: [], groupsCurrentPage: 1};
const groupReducer = (state = initialData, action) => {
    switch (action.type) {

        case actions.SET_GROUPS:
            return {...state, groups: action.payload.groups.group};

        case actions.ON_CLEAR_SEARCH:
            return {...state, groups: [], groupsCurrentPage: 1};

        case actions.INCREMENT_GROUPS_PAGE:
            return {...state, groupsCurrentPage: state.groupsCurrentPage + 1};

        case actions.SET_MORE_GROUPS:
            return {...state, groups: state.groups.concat(action.payload.groups.group)};

        case actions.RESET_GROUPS_PAGE:
            return {...state, groupsCurrentPage: 1};


        default:
            return state;
    }
};

export default groupReducer;<file_sep>/src/redux/middleswares/ui.js
import * as actions from "../consts/action-types";
import * as uiActions from "../actions/ui";
import * as photosActions from "../actions/photo";
import * as groupsActions from "../actions/group";

const uiMiddleware = ({dispatch, getState}) => next => action => {
    if (action.type !== actions.ON_SEARCH && action.type !== actions.LOAD_MORE_PHOTOS && action.type !== actions.LOAD_MORE_GROUPS
        && action.type !== actions.TAB_NEW_INDEX) {
        return next(action);
    }

    let {type, payload} = action;
    if (type === actions.ON_SEARCH) {
        if (payload && payload.length > 0) {
            dispatch(photosActions.fetchPhotos(payload));
            dispatch(groupsActions.fetchGroups(payload));
        } else {
            dispatch(uiActions.onClearSearch())
        }
        next(action);
    }

    if (type === actions.LOAD_MORE_PHOTOS) {
        //skip if multiple times or empty data
        if (!getState().ui.isLoadingMore && getState().photo.photos.length > 0) {
            dispatch(photosActions.fetchPhotos(getState().ui.searchText, getState().photo.photosCurrentPage + 1));
            dispatch(photosActions.incrementPhotosPage())
        }
    }

    if (type === actions.LOAD_MORE_GROUPS) {
        //skip if multiple times or empty data
        if (!getState().ui.isLoadingMore && getState().group.groups.length > 0) {
            dispatch(groupsActions.fetchGroups(getState().ui.searchText, getState().group.groupsCurrentPage + 1));
            dispatch(groupsActions.incrementGroupsPage())
        }
    }
};

export default uiMiddleware;<file_sep>/README.md
# FlickrClient

Simple React Native + Redux app using flickr.com API to retrive some photos and group info

# Run on device/simulator
`npm install`

`react-native run-android`

`react-native run-ios`
# Screenshots
    #Android
![alt text](public/a1.png)
![alt text](public/a2.png)
![alt text](public/a3.png)

    # iOS
![alt text](public/i1.png)
![alt text](public/i2.png)
![alt text](public/i3.png)

# Libraries

react-redux: used for storing data and decoupling ui from business logic(side effect)

react-navigation: used for creating the stack of screens and navigation between them

native-base: ui library used mostly for cards

react-native-searchbar: ui library for search bar

react-native-i18n: internationalization

axios: as HTTP client

react-native-tab-view: ui library for material design tabs
  

<file_sep>/src/redux/middleswares/logger.js
const logger = ({dispatch}) => next => action => {
    console.log('logger', JSON.stringify({type: action.type, payload: action.payload}))
    return next(action);
};

export default logger;<file_sep>/src/redux/store.js
import {applyMiddleware, createStore, compose, combineReducers} from "redux";
import api from "./middleswares/api";
import ui from "./reducers/ui";
import photo from "./reducers/photo";
import group from "./reducers/group";
import multi from "./middleswares/multi";
import uiMiddleware from "./middleswares/ui";
import logger from "./middleswares/logger";
import takeLast from "./middleswares/take-last";


const store = createStore(
    combineReducers({
        ui,
        photo,
        group
    }), compose(
        applyMiddleware(
            logger,
            takeLast,
            api,
            multi,
            uiMiddleware,
        )
    )
);

window.store = store;
export default store;<file_sep>/src/redux/actions/data.js
import {fetchPhotos} from "./photo";
import {fetchGroups} from "./group";
import {TAB_INDEX_GROUPS, TAB_INDEX_PHOTOS} from "../../utils/constants";

export const fetchData = (currentTabIndex, input) => {
    if (currentTabIndex === TAB_INDEX_PHOTOS) {
        return fetchPhotos(input)
    } else if (currentTabIndex === TAB_INDEX_GROUPS) {
        return fetchGroups(input)
    }
};<file_sep>/src/utils/constants.js
const key = '<KEY>';
export const SERVER_API = `https://api.flickr.com/services/rest/?api_key=${key}&format=rest&format=json&nojsoncallback=1`;


export const getPhotoUrl = (image) => {
    return `https://farm${image.farm}.staticflickr.com/${image.server}/${image.id}_${image.secret}.jpg`;
};

export const getSeachPhotoUrl = (searchText, page) => {
    return `${SERVER_API}&method=flickr.photos.search&text=${searchText}&page=${page}`;
};

export const getSearchGroupUrl = (group, page) => {
    return `${SERVER_API}&method=flickr.groups.search&text=${group}&page=${page}`
};


export const TAB_INDEX_PHOTOS = 0;
export const TAB_INDEX_GROUPS = 1;<file_sep>/App.js
/**
 * Sample React Native App
 * https://github.com/facebook/react-native
 * @flow
 */

'use strict';
import React, {Component} from 'react';
import MainPage from "./src/ui/pages/main-page";
import {StackNavigator} from "react-navigation";
import {Provider} from "react-redux";
import store from "./src/redux/store";
import PhotoDetailsPage from "./src/ui/pages/photo-details-page";
import NavigatorService from "./src/utils/navigation-service";


console.disableYellowBox = true;

const StackNav = StackNavigator({
    MainPage: {
        screen: MainPage,
        navigationOptions: {
            header: null
        }
    },
    PhotoDetailsPage: {
        screen: PhotoDetailsPage
    }
});


const App = () => (
    <Provider store={store}>
        <StackNav
            ref={navigatorRef => {
                NavigatorService.setContainer(navigatorRef);
            }}/>
    </Provider>
);

export default App;<file_sep>/src/redux/actions/group.js
import * as actions from "../consts/action-types";
import * as Constants from "../../utils/constants";
import {showGenericError, showNetworkError} from "./ui";

export const fetchGroups = (input, page = 1) => ({
    type: actions.API,
    payload: {
        url: Constants.getSearchGroupUrl(input, page),
        method: 'GET',
        page: page,
        success: (data) => {
            if (page > 1) {
                //TODO check if any data otherwise show message
                return setMoreGroups(data)
            } else {
                return [setGroups(data), resetGroupsPage()]
            }
        },
        failure: (err) => {
            if (!err.status) {
                return showNetworkError();
            } else {
                return showGenericError()
            }
        }
    },
});

export const setMoreGroups = (groups) => ({
    type: actions.SET_MORE_GROUPS,
    payload: groups
});

export const setGroups = (groups) => ({
    type: actions.SET_GROUPS,
    payload: groups
});

export const incrementGroupsPage = () => ({
    type: actions.INCREMENT_GROUPS_PAGE,
});

export const loadMoreGroups = () => ({
    type: actions.LOAD_MORE_GROUPS,
});

export const resetGroupsPage = () => ({
    type: actions.RESET_GROUPS_PAGE,
});<file_sep>/src/redux/actions/photo.js
import * as actions from "../consts/action-types";
import * as Constants from "../../utils/constants";
import {showGenericError, showNetworkError} from "./ui";

export const fetchPhotos = (input, page = 1) => ({
    type: actions.API,
    payload: {
        url: Constants.getSeachPhotoUrl(input, page),
        method: 'GET',
        page: page,
        success: (data) => {
            if (page > 1) {
                //TODO check if any data otherwise show message
                return setMorePhotos(data);
            } else {
                return [setPhotos(data), resetPhotosPage()]
            }
        },
        failure: (err) => {
            if (!err.status) {
                return showNetworkError();
            } else {
                return showGenericError()
            }
        }
    },
});

export const setPhotos = (photos) => ({
    type: actions.SET_PHOTOS,
    payload: photos
});

export const setMorePhotos = (photos) => ({
    type: actions.SET_MORE_PHOTOS,
    payload: photos
});

export const incrementPhotosPage = () => ({
    type: actions.INCREMENT_PHOTOS_PAGE,
});

export const loadMorePhotos = () => ({
    type: actions.LOAD_MORE_PHOTOS,
});

export const resetPhotosPage = () => ({
    type: actions.RESET_PHOTOS_PAGE,
});<file_sep>/src/ui/components/photos-tab.js
import {ActivityIndicator, FlatList, ImageBackground, Text, TouchableOpacity, View, StyleSheet} from "react-native";
import * as Constants from "../../utils/constants";
import {connect} from "react-redux";
import React from 'react';
import {Card} from "native-base";
import NavigatorService from "../../utils/navigation-service";
import Strings from '../../utils/strings';
import * as photosActions from "../../redux/actions/photo";

class PhotosTabRoute extends React.Component {

    render() {
        if (this.props.loading) {
            return <ActivityIndicator size={'large'} style={{alignSelf: 'center'}}/>
        }
        if (this.props.networkError) {
            return <View style={styles.errorContainer}>
                <Text>{Strings.t('network_error')}</Text>
            </View>
        }

        if (this.props.genericError) {
            return <View style={styles.errorContainer}>
                <Text>{Strings.t('generic_error')}</Text>
            </View>
        }

        if (this.props.photos.length === 0) {
            return <View style={styles.errorContainer}>
                <Text>{Strings.t('empty_data')}</Text>
            </View>
        }


        return <View style={styles.root}>
            <FlatList
                style={styles.list}
                data={this.props.photos}
                renderItem={(item) => {
                    return <Card style={styles.cardContainer}>
                        <TouchableOpacity
                            style={styles.touchContainer}
                            onPress={() => {
                                NavigatorService.navigate('PhotoDetailsPage', {uri: Constants.getPhotoUrl(item.item), title: item.item.title});
                            }}
                        >
                            <ImageBackground
                                style={styles.imageBackground}
                                source={{uri: Constants.getPhotoUrl(item.item)}}
                            />
                        </TouchableOpacity>
                    </Card>
                }}
                keyExtractor={(item, index) => {
                    return item.id;
                }}
                ListFooterComponent={() => {
                    return (
                        this.props.isLoadingMore
                            ? <View key={'indicator'} style={styles.loadingContainer}>
                                <ActivityIndicator size="small"/>
                            </View>
                            : null
                    );
                }}
                onEndReached={() => {
                    this.props.loadMorePhotos();
                }}
            />
        </View>
    }
}

const styles = StyleSheet.create({
    errorContainer: {
        flex: 1,
        justifyContent: 'center',
        alignItems: 'center'
    },
    root: {
        flex: 1
    },
    list: {
        flex: 1,
    },
    loadingContainer: {
        flex: 1,
        padding: 10
    },
    imageBackground: {
        flex: 1,
    },
    touchContainer: {
        flex: 1
    },
    cardContainer: {
        borderBottomColor: 'black',
        borderBottomWidth: 1,
        height: 250,
        marginLeft: 16,
        marginBottom: 8,
        marginTop: 8,
        marginRight: 16
    }
});

const mapStateToProps = state => {
    return {
        loading: state.ui.loading,
        photos: state.photo.photos,
        isLoadingMore: state.ui.isLoadingMore,
        networkError: state.ui.networkError,
        genericError: state.ui.genericError
    }
};
const mapDispatchToProps = dispatch => {
    return {
        loadMorePhotos: () => dispatch(photosActions.loadMorePhotos())
    }
};
export default connect(mapStateToProps, mapDispatchToProps)(PhotosTabRoute);<file_sep>/src/ui/components/group-tab.js
import {ActivityIndicator, FlatList, StyleSheet, Text, TouchableOpacity, View} from "react-native";
import {connect} from "react-redux";
import React from 'react';
import {Card} from "native-base";
import Strings from "../../utils/strings";
import colors from "../../utils/colors";
import * as groupsActions from "../../redux/actions/group";

class GroupTab extends React.Component {

    render() {
        if (this.props.loading) {
            return <ActivityIndicator size={'large'} style={{flex: 1, alignSelf: 'center'}}/>
        }

        if (this.props.networkError) {
            return <View style={styles.errorContainer}>
                <Text>{Strings.t('network_error')}</Text>
            </View>
        }

        if (this.props.genericError) {
            return <View style={styles.errorContainer}>
                <Text>{Strings.t('generic_error')}</Text>
            </View>
        }

        if (this.props.groups.length === 0) {
            return <View style={styles.errorContainer}>
                <Text>{Strings.t('empty_data')}</Text>
            </View>
        }

        return <View style={{flex: 1}}>
            <FlatList
                style={{flex: 1}}
                data={this.props.groups}
                renderItem={(item) => {
                    return (
                        <Card style={styles.cardContainer}
                        >
                            <TouchableOpacity
                                style={styles.touchContainer}
                                onPress={() => {
                                }}
                            >
                                <Text
                                    style={styles.title}
                                >
                                    {item.item.name}
                                </Text>

                                <Text
                                    style={styles.subtitle}
                                >
                                    {item.item.members} {item.item.members > 1 ? Strings.t('members') : Strings.t('member')}
                                </Text>
                            </TouchableOpacity>
                        </Card>
                    )
                }}
                keyExtractor={(item, index) => {
                    return item.nsid;
                }}
                ListFooterComponent={() => {
                    return (
                        this.props.isLoadingMore
                            ? <View key={'indicator'}
                                    style={styles.loadingContainer}
                            >
                                <ActivityIndicator size="small"/>
                            </View>
                            : null
                    );
                }}
                onEndReached={() => {
                    this.props.loadMore();
                    console.log('luci', "ON END REACHED GROUP");
                }}
            />
        </View>
    }
}

const styles = StyleSheet.create({
    title: {
        textAlign: 'center',
        fontWeight: 'bold',
        fontSize: 18,
        color: colors.textGray
    },
    subtitle: {
        textAlign: 'center',
        fontWeight: 'bold',
        fontSize: 12,
        color: colors.textGray
    },
    errorContainer: {
        flex: 1,
        justifyContent: 'center',
        alignItems: 'center'
    },
    root: {
        flex: 1
    },
    list: {
        flex: 1,
    },
    loadingContainer: {
        flex: 1,
        padding: 10
    },
    imageBackground: {
        flex: 1,
    },
    touchContainer: {
        flex: 1
    },
    cardContainer: {
        elevation: 1,
        minHeight: 75,
        paddingLeft: 16,
        paddingTop: 8,
        paddingBottom: 8,
        paddingRight: 16,
    }
});

const mapStateToProps = state => {
    return {
        loading: state.ui.loading,
        groups: state.group.groups,
        isLoadingMore: state.ui.isLoadingMore,
        networkError: state.ui.networkError,
        genericError: state.ui.genericError
    }
};
const mapDispatchToProps = dispatch => {
    return {
        loadMore: () => dispatch(groupsActions.loadMoreGroups())
    }
};
export default connect(mapStateToProps, mapDispatchToProps)(GroupTab);