Commit 8e5841ff authored by Sébastiaan Versteeg's avatar Sébastiaan Versteeg

Merge branch 'feature/photos-3' into 'master'

In-app photo gallery

Closes #100

See merge request !299
parents 798b52c1 e13eda68
......@@ -5,7 +5,7 @@ exports[`LoadingScreen component renders correctly 1`] = `
style={
Object {
"alignItems": "center",
"backgroundColor": "#FFFFFF",
"backgroundColor": "#FAFAFA",
"flex": 1,
"justifyContent": "center",
}
......
......@@ -402,6 +402,97 @@ exports[`Sidebar component renders correctly 1`] = `
</Text>
</View>
</View>
<View
accessible={true}
isTVSelectable={true}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
Array [
Object {
"overflow": "hidden",
},
Object {
"backgroundColor": "#FFFFFF",
"borderRadius": 0,
},
]
}
>
<View
style={
Array [
Object {
"alignItems": "center",
"flexDirection": "row",
"justifyContent": "flex-start",
"padding": 8,
},
Object {
"backgroundColor": "#FFFFFF",
"borderRadius": 0,
},
Array [
Object {
"padding": 16,
},
Object {},
],
]
}
>
<Text
allowFontScaling={false}
style={
Array [
Object {
"color": "#313131",
"fontSize": 24,
},
Array [
Object {
"marginRight": 10,
},
Object {
"marginRight": 30,
"textAlign": "center",
"width": 28,
},
],
Object {
"fontFamily": "Material Icons",
"fontStyle": "normal",
"fontWeight": "normal",
},
Object {},
]
}
>
</Text>
<Text
style={
Array [
Object {
"color": "#313131",
"fontFamily": "System",
"fontSize": 14,
"fontWeight": "600",
},
Object {
"color": "#313131",
},
]
}
>
Photos
</Text>
</View>
</View>
<View
accessible={true}
isTVSelectable={true}
......
......@@ -36,6 +36,7 @@
<data android:host="thalia.nu" />
<data android:path="/pizzas/" />
<data android:pathPattern="/events/.*/" />
<data android:pathPattern="/photos/.*/" />
</intent-filter>
</activity>
<activity android:name="com.facebook.react.devsupport.DevSettingsActivity"/>
......
export const PHOTOS_ALBUMS_OPEN = 'PHOTOS_ALBUMS_OPEN';
export const PHOTOS_ALBUMS_SUCCESS = 'PHOTOS_ALBUMS_SUCCESS';
export const PHOTOS_ALBUMS_FAILURE = 'PHOTOS_ALBUMS_FAILURE';
export const PHOTOS_ALBUMS_FETCHING = 'PHOTOS_ALBUMS_FETCHING';
export const PHOTOS_ALBUM_OPEN = 'PHOTOS_ALBUM_OPEN';
export const PHOTOS_ALBUM_SUCCESS = 'PHOTOS_ALBUM_SUCCESS';
export const PHOTOS_ALBUM_FAILURE = 'PHOTOS_ALBUM_FAILURE';
export const PHOTOS_ALBUM_FETCHING = 'PHOTOS_ALBUM_FETCHING';
export const PHOTOS_PHOTO_SHOW = 'PHOTOS_PHOTO_SHOW';
export const PHOTOS_GALLERY_OPEN = 'PHOTOS_GALLERY_OPEN';
export function openAlbums() {
return {
type: PHOTOS_ALBUMS_OPEN,
};
}
export function successAlbums(albums, more) {
return {
type: PHOTOS_ALBUMS_SUCCESS,
payload: { albums, more },
};
}
export function failureAlbums() {
return { type: PHOTOS_ALBUMS_FAILURE };
}
export function fetchingAlbums() {
return {
type: PHOTOS_ALBUMS_FETCHING,
};
}
export function openGallery(selection) {
return {
type: PHOTOS_GALLERY_OPEN,
payload: { selection },
};
}
export function openAlbum(pk) {
return {
type: PHOTOS_ALBUM_OPEN,
payload: { pk },
};
}
export function openAlbumWithSlug(slug) {
return {
type: PHOTOS_ALBUM_OPEN,
payload: { slug },
};
}
export function successAlbum(album) {
return {
type: PHOTOS_ALBUM_SUCCESS,
payload: album,
};
}
export function failureAlbum() {
return { type: PHOTOS_ALBUM_FAILURE };
}
export function fetchingAlbum() {
return {
type: PHOTOS_ALBUM_FETCHING,
};
}
export function showPhoto(photo) {
return {
type: PHOTOS_PHOTO_SHOW,
payload: photo,
};
}
import React, { Component } from 'react';
import {
Linking, Platform, NativeModules, Alert,
Alert, Linking, NativeModules, Platform,
} from 'react-native';
import { applyMiddleware, createStore } from 'redux';
import { Provider } from 'react-redux';
import { I18nextProvider, withTranslation } from 'react-i18next';
import { I18nextProvider } from 'react-i18next';
import createSagaMiddleware from 'redux-saga';
import firebase from 'react-native-firebase';
import DeviceInfo from 'react-native-device-info';
import Moment from 'moment';
import 'moment/locale/nl';
import PropTypes from 'prop-types';
import { i18n as i18next } from 'i18next';
import reducers from './reducers';
import i18n from './utils/i18n';
......@@ -74,12 +74,13 @@ class Main extends Component {
};
showNotification = (notification) => {
const t = i18next.getFixedT(undefined, 'app');
let buttons;
if (notification.data.url) {
buttons = [
{ text: this.props.t('Dismiss') },
{ text: t('Dismiss') },
{
text: this.props.t('Open'),
text: t('Open'),
onPress: () => store.dispatch(
deepLinkingActions.deepLink(notification.data.url, false),
),
......@@ -128,8 +129,4 @@ class Main extends Component {
}
}
Main.propTypes = {
t: PropTypes.func.isRequired,
};
export default withTranslation('app')(Main);
export default Main;
......@@ -15,6 +15,9 @@ import Profile from './ui/screens/profile/ProfileScreenConnector';
import Pizza from './ui/screens/pizza/PizzaScreenConnector';
import Registration from './ui/screens/events/RegistrationScreenConnector';
import MemberList from './ui/screens/memberList/MemberListScreenConnector';
import Photos from './ui/screens/photos/AlbumsOverviewScreenConnector';
import PhotoAlbum from './ui/screens/photos/AlbumDetailScreenConnector';
import PhotoGallery from './ui/screens/photos/AlbumGalleryScreenConnector';
import SplashScreen from './ui/screens/splash/SplashScreen';
import Settings from './ui/screens/settings/SettingsScreenConnector';
import EventAdmin from './ui/screens/events/EventAdminScreenConnector';
......@@ -24,6 +27,7 @@ const MainNavigator = createDrawerNavigator({
Welcome,
Calendar,
MemberList,
Photos,
Settings,
}, {
contentComponent: Sidebar,
......@@ -34,6 +38,8 @@ const SignedInNavigator = createStackNavigator({
Event,
Profile,
Pizza,
PhotoAlbum,
PhotoGallery,
Registration,
EventAdmin,
}, {
......
......@@ -8,6 +8,7 @@ import pizza from './pizza';
import registration from './registration';
import members from './members';
import settings from './settings';
import photos from './photos';
export default combineReducers({
session,
......@@ -19,4 +20,5 @@ export default combineReducers({
registration,
members,
settings,
photos,
});
import * as photosActions from '../actions/photos';
export const STATUS_INITIAL = 'initial';
export const STATUS_SUCCESS = 'success';
export const STATUS_FAILURE = 'failure';
const initialState = {
albums: {
status: STATUS_INITIAL,
fetching: true,
data: [],
},
album: {
status: STATUS_INITIAL,
fetching: true,
data: {},
selection: undefined,
},
};
export default function photos(state = initialState, action = {}) {
switch (action.type) {
case photosActions.PHOTOS_ALBUMS_FETCHING:
return {
...state,
albums: initialState.albums,
};
case photosActions.PHOTOS_ALBUMS_SUCCESS:
return {
...state,
albums: {
status: STATUS_SUCCESS,
fetching: false,
data: action.payload.albums,
more: action.payload.more,
},
};
case photosActions.PHOTOS_ALBUMS_FAILURE:
return {
...state,
albums: {
status: STATUS_FAILURE,
fetching: false,
data: [],
},
};
case photosActions.PHOTOS_ALBUM_FETCHING:
return {
...state,
album: initialState.album,
};
case photosActions.PHOTOS_ALBUM_SUCCESS:
return {
...state,
album: {
status: STATUS_SUCCESS,
fetching: false,
data: action.payload,
selection: undefined,
},
};
case photosActions.PHOTOS_ALBUM_FAILURE:
return {
...state,
album: {
status: STATUS_FAILURE,
fetching: false,
data: {},
selection: undefined,
},
};
case photosActions.PHOTOS_GALLERY_OPEN:
return {
...state,
album: {
...state.album,
selection: action.payload.selection,
},
};
default:
return state;
}
}
......@@ -10,6 +10,7 @@ import * as pizzaActions from '../actions/pizza';
import * as loginActions from '../actions/session';
import * as eventActions from '../actions/event';
import * as calendarActions from '../actions/calendar';
import * as photosActions from '../actions/photos';
export const parseURL = (url) => {
const matches = new RegExp(`^${siteURL}(/[^?]+)(?:\\?(.+))?`).exec(url);
......@@ -65,6 +66,16 @@ const deepLink = function* deepLink(action) {
action: calendarActions.open,
args: [],
},
{
regexp: new RegExp('^/photos/([-\\w]+)/$'),
action: photosActions.openAlbumWithSlug,
args: [],
},
{
regexp: new RegExp('^/photos/$'),
action: photosActions.openAlbums,
args: [],
},
];
for (let i = 0; i < patterns.length; i += 1) {
......
......@@ -12,6 +12,7 @@ import registrationSaga from './registration';
import deepLinkingSaga from './deepLinking';
import membersSaga from './members';
import settingsSaga from './settings';
import photosSaga from './photos';
export default function* () {
yield all([
......@@ -23,6 +24,7 @@ export default function* () {
fork(calendarSaga),
fork(pushNotificationsSaga),
fork(pizzaSaga),
fork(photosSaga),
fork(registrationSaga),
fork(deepLinkingSaga),
fork(membersSaga),
......
......@@ -11,6 +11,7 @@ import * as membersActions from '../actions/members';
import * as welcomeActions from '../actions/welcome';
import { settingsActions } from '../actions/settings';
import NavigationService from '../navigation';
import * as photosActions from '../actions/photos';
function* navigate(routeName) {
yield call(NavigationService.navigate, routeName);
......@@ -42,6 +43,9 @@ export default function* () {
yield takeEvery(registrationActions.FIELDS, navigate, 'Registration');
yield takeEvery(registrationActions.SUCCESS, back);
yield takeEvery(pizzaActions.PIZZA, navigate, 'Pizza');
yield takeEvery(photosActions.PHOTOS_ALBUMS_OPEN, navigate, 'Photos');
yield takeEvery(photosActions.PHOTOS_ALBUM_OPEN, navigate, 'PhotoAlbum');
yield takeEvery(photosActions.PHOTOS_GALLERY_OPEN, navigate, 'PhotoGallery');
yield takeEvery(sessionActions.SIGNED_IN, navigate, 'SignedIn');
yield takeEvery([sessionActions.TOKEN_INVALID, sessionActions.SIGN_OUT], navigate, 'Auth');
}
import {
call, put, select, takeEvery,
} from 'redux-saga/effects';
import { apiRequest, tokenSelector } from '../utils/url';
import * as photosActions from '../actions/photos';
function* loadAlbums() {
const token = yield select(tokenSelector);
yield put(photosActions.fetchingAlbums());
const data = {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Token ${token}`,
},
};
const params = {
limit: 12,
};
try {
const response = yield call(apiRequest, 'photos/albums', data, params);
yield put(photosActions.successAlbums(
response.results.filter(item => item.accessible && !item.hidden && item.cover != null),
response.next,
));
} catch (error) {
yield put(photosActions.failureAlbums());
}
}
function* loadAlbum(action) {
const { pk, slug } = action.payload;
const token = yield select(tokenSelector);
yield put(photosActions.fetchingAlbum());
const data = {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Token ${token}`,
},
};
try {
let album;
if (pk !== undefined) {
album = yield call(apiRequest, `photos/albums/${pk}`, data);
} else {
const albums = yield call(apiRequest, 'photos/albums', data, {
search: slug,
});
album = yield call(apiRequest, `photos/albums/${albums[0].pk}`, data);
}
if (album !== undefined) {
yield put(photosActions.successAlbum(album));
} else {
yield put(photosActions.failureAlbum());
}
} catch (error) {
yield put(photosActions.failureAlbum());
}
}
export default function* photosSaga() {
yield takeEvery(photosActions.PHOTOS_ALBUMS_OPEN, loadAlbums);
yield takeEvery(photosActions.PHOTOS_ALBUM_OPEN, loadAlbum);
}
export const photosStore = state => state.photos;
export const albumsData = state => photosStore(state).albums.data;
export const albumsStatus = state => photosStore(state).albums.status;
export const isFetchingAlbums = state => photosStore(state).albums.fetching;
export const albumData = state => photosStore(state).album.data;
export const albumSelection = state => photosStore(state).album.selection;
export const albumStatus = state => photosStore(state).album.status;
export const isFetchingAlbum = state => photosStore(state).album.fetching;
import { StyleSheet } from 'react-native';
import Colors from '../../../style/Colors';
import StyleSheet from '../../../style/StyleSheet';
const styles = StyleSheet.create({
indicatorView: {
justifyContent: 'center',
alignItems: 'center',
flex: 1,
backgroundColor: Colors.white,
backgroundColor: Colors.background,
},
});
......
......@@ -42,6 +42,13 @@ const Sidebar = (props) => {
style: {},
routeName: 'MemberList',
},
{
onPress: () => props.openPhotos(),
iconName: 'photo',
text: props.t('Photos'),
style: {},
routeName: 'Photos',
},
{
onPress: () => props.openSettings(),
iconName: 'settings',
......@@ -123,6 +130,7 @@ Sidebar.propTypes = {
openWelcome: PropTypes.func.isRequired,
openSettings: PropTypes.func.isRequired,
openMemberList: PropTypes.func.isRequired,
openPhotos: PropTypes.func.isRequired,
};
export default withTranslation('ui/components/sidebar/Sidebar')(Sidebar);
......@@ -4,6 +4,7 @@ import * as profileActions from '../../../actions/profile';
import * as welcomeActions from '../../../actions/welcome';
import * as calendarActions from '../../../actions/calendar';
import * as loginActions from '../../../actions/session';
import * as photosActions from '../../../actions/photos';
import * as membersActions from '../../../actions/members';
import Sidebar from './Sidebar';
......@@ -12,13 +13,14 @@ const mapStateToProps = state => ({
photo: state.session.photo,
});
const mapDispatchToProps = {
loadProfile: profileActions.profile,
openCalendar: calendarActions.open,
openMemberList: membersActions.members,
openWelcome: welcomeActions.open,
openSettings: settingsActions.open,
signOut: loginActions.signOut,
};
const mapDispatchToProps = dispatch => ({
loadProfile: () => dispatch(profileActions.profile()),
openCalendar: () => dispatch(calendarActions.open()),
openMemberList: () => dispatch(membersActions.members()),
openWelcome: () => dispatch(welcomeActions.open()),
openSettings: () => dispatch(settingsActions.open()),
openPhotos: () => dispatch(photosActions.openAlbums()),
signOut: () => dispatch(loginActions.signOut()),
});
export default connect(mapStateToProps, mapDispatchToProps)(Sidebar);
......@@ -25,6 +25,10 @@ const sceneToTitle = (routeName, t) => {
return t('Profile');
case 'Registration':
return t('Registration');
case 'Photos':
return t('Photos');
case 'PhotoAlbum':
return t('Album');
case 'Settings':
return t('Settings');
case 'EventAdmin':
......
import React from 'react';
import PropTypes from 'prop-types';
import { Dimensions, FlatList, View } from 'react-native';
import { STATUS_FAILURE, STATUS_INITIAL } from '../../../reducers/photos';
import LoadingScreen from '../../components/loadingScreen/LoadingScreen';
import ErrorScreen from '../../components/errorScreen/ErrorScreen';
import styles from './style/AlbumDetailScreen';
import PhotoListItem from './PhotoListItem';
import StandardHeader from '../../components/standardHeader/StandardHeader';
const windowWidth = Dimensions.get('window').width;
export const itemSize = (windowWidth - 48) / 3;
const AlbumDetailScreen = ({
t, fetching, status, photos, openGallery,
}) => {
let content = (
<View style={styles.wrapper}>
<FlatList
style={styles.flatList}
contentContainerStyle={styles.listContainer}
data={photos.filter(p => !p.hidden)}
renderItem={
data => (
<PhotoListItem
photo={data.item}
size={itemSize}
style={styles.listItem}
onPress={() => openGallery(data.index)}
/>
)
}
keyExtractor={item => item.pk}
numColumns={3}
/>
</View>
);