Commit dfac65c5 authored by Wietse Kuipers's avatar Wietse Kuipers
Browse files

Merge branch 'feature/localisation' into 'master'

Localisation using i18next

Closes #22

See merge request !87
parents 06582c29 af963183
image: node:6
image: node:8
variables:
GRADLE_USER_HOME: $CI_PROJECT_DIR/.gradle
......
......@@ -6,6 +6,8 @@ import {
global.fetch = jest.fn().mockReturnValue(
Promise.resolve({ status: 200, json: () => 'responseJson' }));
jest.mock('react-native-locale-detector', () => 'en');
describe('url helper', () => {
beforeEach(() => {
});
......@@ -23,27 +25,38 @@ describe('url helper', () => {
it('should do a fetch request', () => {
expect.assertions(2);
return apiRequest('route', 'fetchOpts', null)
return apiRequest('route', {}, null)
.then((response) => {
expect(global.fetch).toBeCalledWith(`${apiUrl}/route/`, 'fetchOpts');
expect(global.fetch).toBeCalledWith(`${apiUrl}/route/`,
{ headers: { 'Accept-Language': 'en' } });
expect(response).toEqual('responseJson');
});
});
it('should do a fetch request with params', () => {
expect.assertions(1);
return apiRequest('route', 'fetchOpts', {
return apiRequest('route', {}, {
params: 'value',
}).then(() => {
expect(global.fetch).toBeCalledWith(`${apiUrl}/route/?params=value`, 'fetchOpts');
expect(global.fetch).toBeCalledWith(`${apiUrl}/route/?params=value`,
{ headers: { 'Accept-Language': 'en' } });
});
});
it('should do a fetch request with headers', () => {
expect.assertions(1);
return apiRequest('route', { headers: { Authorization: 'Token abc' } }, null).then(() => {
expect(global.fetch).toBeCalledWith(`${apiUrl}/route/`,
{ headers: { 'Accept-Language': 'en', Authorization: 'Token abc' } });
});
});
it('should generate the url parameters', () => {
expect.assertions(2);
return apiRequest('route', 'fetchOpts', null)
return apiRequest('route', {}, null)
.then((response) => {
expect(global.fetch).toBeCalledWith(`${apiUrl}/route/`, 'fetchOpts');
expect(global.fetch).toBeCalledWith(`${apiUrl}/route/`,
{ headers: { 'Accept-Language': 'en' } });
expect(response).toEqual('responseJson');
});
});
......@@ -52,7 +65,7 @@ describe('url helper', () => {
expect.assertions(1);
const response = { status: 404, json: () => 'responseJson' };
global.fetch.mockReturnValue(Promise.resolve(response));
return apiRequest('route', 'fetchOpts', null)
return apiRequest('route', {}, null)
.catch(e => expect(e).toEqual(new ServerError('Invalid status code: 404', response)));
});
......@@ -60,7 +73,7 @@ describe('url helper', () => {
expect.assertions(1);
const response = { status: 204, json: () => 'responseJson' };
global.fetch.mockReturnValue(Promise.resolve(response));
return apiRequest('route', 'fetchOpts', null)
return apiRequest('route', {}, null)
.then(res => expect(res).toEqual({}));
});
});
......@@ -145,6 +145,7 @@ android {
}
dependencies {
compile project(':react-native-locale-detector')
compile project(':react-native-snackbar')
compile project(':react-native-fcm')
compile project(':react-native-linear-gradient')
......
......@@ -4,6 +4,7 @@ import android.app.Application;
import com.facebook.react.ReactApplication;
import com.azendoo.reactnativesnackbar.SnackbarPackage;
import com.i18n.reactnativei18n.ReactNativeI18n;
import com.evollu.react.fcm.FIRMessagingPackage;
import com.BV.LinearGradient.LinearGradientPackage;
import com.oblador.vectoricons.VectorIconsPackage;
......@@ -26,8 +27,9 @@ public class MainApplication extends Application implements ReactApplication {
@Override
protected List<ReactPackage> getPackages() {
return Arrays.<ReactPackage>asList(
new MainReactPackage(),
new MainReactPackage(),
new SnackbarPackage(),
new ReactNativeI18n(),
new FIRMessagingPackage(),
new LinearGradientPackage(),
new VectorIconsPackage()
......
rootProject.name = 'ThaliApp'
include ':react-native-snackbar'
project(':react-native-snackbar').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-snackbar/android')
include ':react-native-locale-detector'
project(':react-native-locale-detector').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-locale-detector/android')
include ':react-native-fcm'
project(':react-native-fcm').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-fcm/android')
include ':react-native-linear-gradient'
......
......@@ -2,12 +2,16 @@ import React, { Component } from 'react';
import { AsyncStorage, Linking, Platform } from 'react-native';
import { applyMiddleware, combineReducers, createStore } from 'redux';
import { Provider } from 'react-redux';
import { I18nextProvider } from 'react-i18next';
import createSagaMiddleware from 'redux-saga';
import FCM, { FCMEvent } from 'react-native-fcm';
import locale from 'react-native-locale-detector';
import Moment from 'moment';
import 'moment/locale/nl';
import * as reducers from './reducers';
import i18n from './i18n';
import sagas from './sagas';
import ReduxNavigator from './components/navigator';
import * as loginActions from './actions/login';
......@@ -49,8 +53,16 @@ FCM.on(FCMEvent.RefreshToken, async () => {
});
class Main extends Component {
constructor() {
super();
if (locale.startsWith('nl')) {
Moment.locale(locale);
} else {
Moment.locale('en');
}
}
componentDidMount() {
Moment.locale('nl');
AsyncStorage.multiGet([USERNAMEKEY, TOKENKEY, DISPLAYNAMEKEY, PHOTOKEY])
.then(
(result) => {
......@@ -91,9 +103,11 @@ class Main extends Component {
render() {
return (
<Provider store={store}>
<ReduxNavigator />
</Provider>
<I18nextProvider i18n={i18n}>
<Provider store={store}>
<ReduxNavigator />
</Provider>
</I18nextProvider>
);
}
}
......
......@@ -2,8 +2,9 @@ import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { Text, View, SectionList, ScrollView, RefreshControl } from 'react-native';
import { connect } from 'react-redux';
import { translate } from 'react-i18next';
import Moment from 'moment';
import 'moment/locale/nl';
import locale from 'react-native-locale-detector';
import * as calendarActions from '../actions/calendar';
import EventCard from './EventCard';
......@@ -29,7 +30,7 @@ const addEventToSection = (sections, date, event) => {
if (!(day in sections[sectionKey].data)) {
sections[sectionKey].data[day] = {
dayNumber: day,
dayOfWeek: date.format('dd'),
dayOfWeek: locale.startsWith('nl') ? date.format('dd') : date.format('ddd'),
events: [],
};
}
......@@ -42,7 +43,7 @@ const addEventToSection = (sections, date, event) => {
* Any event that spans multiple days will be split into separate events.
* The list of sections is sorted at the end.
*/
const eventListToSections = (eventList) => {
const eventListToSections = (eventList, t) => {
const sections = {};
for (let i = 0; i < eventList.length; i += 1) {
const start = Moment(eventList[i].start);
......@@ -58,7 +59,7 @@ const eventListToSections = (eventList) => {
// Add start day
addEventToSection(sections, start, {
...eventList[i],
title: `${eventList[i].title} (dag 1/${daySpan})`,
title: `${eventList[i].title} (${t('day')} 1/${daySpan})`,
end: null,
});
......@@ -68,13 +69,13 @@ const eventListToSections = (eventList) => {
...eventList[i],
start: null,
end: null,
title: `${eventList[i].title} (dag ${j}/${daySpan})`,
title: `${eventList[i].title} (${t('day')} ${j}/${daySpan})`,
});
}
// Add end day
addEventToSection(sections, end, {
...eventList[i],
title: `${eventList[i].title} (dag ${daySpan}/${daySpan})`,
title: `${eventList[i].title} (${t('day')} ${daySpan}/${daySpan})`,
start: null,
});
}
......@@ -118,10 +119,6 @@ const renderItem = (item) => {
};
class Calendar extends Component {
componentDidMount() {
Moment.locale('nl');
}
handleRefresh = () => {
this.props.refresh();
};
......@@ -140,7 +137,7 @@ class Calendar extends Component {
/>
)}
>
<ErrorScreen message="Sorry! We couldn't load any data." />
<ErrorScreen message={this.props.t('Sorry, we couldn\'t load any data.')} />
</ScrollView>
);
} else if (this.props.eventList.length === 0) {
......@@ -154,7 +151,7 @@ class Calendar extends Component {
/>
)}
>
<ErrorScreen message="No events found!" />
<ErrorScreen message={this.props.t('No events found!')} />
</ScrollView>
);
}
......@@ -166,7 +163,7 @@ class Calendar extends Component {
renderSectionHeader={
itemHeader => <Text style={styles.sectionHeader}>{itemHeader.section.key}</Text>
}
sections={eventListToSections(this.props.eventList)}
sections={eventListToSections(this.props.eventList, this.props.t)}
keyExtractor={item => item.dayNumber}
stickySectionHeadersEnabled
onRefresh={this.handleRefresh}
......@@ -194,6 +191,7 @@ Calendar.propTypes = {
loading: PropTypes.bool.isRequired,
status: PropTypes.string.isRequired,
refresh: PropTypes.func.isRequired,
t: PropTypes.func.isRequired,
};
const mapStateToProps = state => ({
......@@ -206,4 +204,4 @@ const mapDispatchToProps = dispatch => ({
refresh: () => dispatch(calendarActions.refresh()),
});
export default connect(mapStateToProps, mapDispatchToProps)(Calendar);
export default connect(mapStateToProps, mapDispatchToProps)(translate('calendar')(Calendar));
import React from 'react';
import { Image, Text, View } from 'react-native';
import { translate } from 'react-i18next';
import PropTypes from 'prop-types';
import styles from './style/errorScreen';
......@@ -15,12 +16,13 @@ const ErrorScreen = props => (
style={styles.image}
/>
<Text style={styles.text}>{props.message}</Text>
<Text style={styles.text}>Try again later.</Text>
<Text style={styles.text}>{props.t('Try again later.')}</Text>
</View>
);
ErrorScreen.propTypes = {
message: PropTypes.string.isRequired,
t: PropTypes.func.isRequired,
};
export default ErrorScreen;
export default translate('errorScreen')(ErrorScreen);
......@@ -2,8 +2,8 @@ import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { FlatList, Alert, Image, ScrollView, Text, View, RefreshControl, Button, TouchableHighlight, Platform, Linking } from 'react-native';
import { connect } from 'react-redux';
import { translate } from 'react-i18next';
import Moment from 'moment';
import 'moment/locale/nl';
import styles, { memberSize } from './style/event';
import MemberView from './MemberView';
......@@ -18,17 +18,16 @@ import * as pizzaActions from '../actions/pizza';
class Event extends Component {
cancelPrompt = (pk) => {
const cancelDeadlineDate = new Date(this.props.data.cancel_deadline);
let message = 'Are you sure you want to cancel your registration?';
let message = this.props.t('Are you sure you want to cancel your registration?');
if (this.props.data.cancel_deadline !== null && cancelDeadlineDate <= new Date()) {
message = 'The deadline has passed, are you sure you want to cancel your registration and'
+ ` pay the full costs of €${this.props.data.fine}? You will not be able to undo this!`;
message = this.props.t('The deadline has passed, are you sure you want to cancel your registration and pay the full costs of €{{ fine }}? You will not be able to undo this!', { fine: this.props.data.fine });
}
return Alert.alert(
'Cancel registration?',
this.props.t('Cancel registration?'),
message,
[
{ text: 'Dismiss' },
{ text: 'Cancel registration', onPress: () => this.props.cancel(pk) },
{ text: this.props.t('No') },
{ text: this.props.t('Yes'), onPress: () => this.props.cancel(pk) },
],
);
};
......@@ -41,25 +40,25 @@ class Event extends Component {
infoTexts.push(
<View key="start-holder" style={styles.infoHolder}>
<Text style={styles.infoText} key="start-title">Van:</Text>
<Text style={styles.infoText} key="start-title">{this.props.t('From')}:</Text>
<Text style={styles.infoValueText} key="start-value">{startDate}</Text>
</View>,
);
infoTexts.push(
<View key="end-holder" style={styles.infoHolder}>
<Text style={styles.infoText} key="end-title">Tot:</Text>
<Text style={styles.infoText} key="end-title">{this.props.t('Until')}:</Text>
<Text style={styles.infoValueText} key="end-value">{endDate}</Text>
</View>,
);
infoTexts.push(
<View key="loc-holder" style={styles.infoHolder}>
<Text style={styles.infoText} key="loc-title">Locatie:</Text>
<Text style={styles.infoText} key="loc-title">{this.props.t('Location')}:</Text>
<Text style={styles.infoValueText} key="loc-value">{data.location}</Text>
</View>,
);
infoTexts.push(
<View key="price-holder" style={styles.infoHolder}>
<Text style={styles.infoText} key="price-title">Prijs:</Text>
<Text style={styles.infoText} key="price-title">{this.props.t('Price')}:</Text>
<Text style={styles.infoValueText} key="price-value">{data.price}</Text>
</View>,
);
......@@ -70,25 +69,25 @@ class Event extends Component {
infoTexts.push(
<View key="registrationend-holder" style={styles.infoHolder}>
<Text style={styles.infoText} key="registrationend-title">Aanmelddeadline:</Text>
<Text style={styles.infoText} key="registrationend-title">{this.props.t('Registration deadline')}:</Text>
<Text style={styles.infoValueText} key="registrationend-value">{registrationDeadline}</Text>
</View>,
);
infoTexts.push(
<View key="canceldeadline-holder" style={styles.infoHolder}>
<Text style={styles.infoText} key="canceldeadline-title">Afmelddeadline:</Text>
<Text style={styles.infoText} key="canceldeadline-title">{this.props.t('Cancellation deadline')}:</Text>
<Text style={styles.infoValueText} key="canceldeadline-value">{cancelDeadline}</Text>
</View>,
);
let participantsText = `${data.num_participants} aanmeldingen`;
let participantsText = `${data.num_participants} ${this.props.t('registrations')}`;
if (data.max_participants) {
participantsText += ` (${data.max_participants} max)`;
participantsText += ` (${data.max_participants} ${this.props.t('max')})`;
}
infoTexts.push(
<View key="participants-holder" style={styles.infoHolder}>
<Text style={styles.infoText} key="participants-title">Aantal aanmeldingen:</Text>
<Text style={styles.infoText} key="participants-title">{this.props.t('Number of registrations')}:</Text>
<Text style={styles.infoValueText} key="participants-value">{participantsText}</Text>
</View>,
);
......@@ -96,20 +95,20 @@ class Event extends Component {
if (data.user_registration) {
let registrationState;
if (data.user_registration.is_late_cancellation) {
registrationState = 'Je bent afgemeld na de afmelddeadline';
registrationState = this.props.t('Your registration is cancelled after the cancellation deadline');
} else if (data.user_registration.is_cancelled) {
registrationState = 'Je bent afgemeld';
registrationState = this.props.t('Your registration is cancelled');
} else if (data.user_registration.queue_position === null) {
registrationState = 'Je bent aangemeld';
registrationState = this.props.t('You are registered');
} else if (data.user_registration.queue_position > 0) {
registrationState = `Wachtlijst positie ${data.user_registration.queue_position}`;
registrationState = this.props.t('Queue position {{pos}}', { pos: data.user_registration.queue_position });
} else {
registrationState = 'Je bent afgemeld';
registrationState = this.props.t('Your registration is cancelled');
}
infoTexts.push(
<View key="status-holder" style={styles.infoHolder}>
<Text style={styles.infoText} key="status-title">Aanmeldstatus:</Text>
<Text style={styles.infoText} key="status-title">{this.props.t('Registration status')}:</Text>
<Text style={styles.infoValueText} key="status-value">{registrationState}</Text>
</View>,
);
......@@ -150,23 +149,26 @@ class Event extends Component {
const afterCancelDeadline = event.cancel_deadline !== null && cancelDeadlineDate <= nowDate;
if (!regRequired) {
text = 'Geen aanmelding vereist.';
text = this.props.t('No registration required.');
if (event.no_registration_message) {
text = event.no_registration_message;
}
} else if (!regStarted) {
const registrationStart = Moment(event.registration_start).format('D MMM YYYY, HH:mm');
text = `Aanmelden opent ${registrationStart}.`;
const registrationStart = Moment(event.registration_start).format('D MMM YYYY, HH:m');
text = this.props.t('Registration will open {{start}}', { start: registrationStart });
} else if (!regAllowed) {
text = 'Aanmelden is niet meer mogelijk.';
text = this.props.t('Registration is not possible anymore.');
}
if (afterCancelDeadline) {
if (text.length > 0) {
text += ' ';
}
text += `Afmelden is niet meer mogelijk zonder de volledige kosten van €${event.fine} te ` +
'betalen. Let op: je kunt je hierna niet meer aanmelden.';
text += this.props.t(
'Cancellation isn\'t possible anymore without having to pay the full ' +
'costs of €{{fine}}. Also note that you will be unable to re-register.',
{ fine: event.fine },
);
}
if (text.length > 0) {
......@@ -189,7 +191,7 @@ class Event extends Component {
if (regAllowed) {
if (event.user_registration === null || event.user_registration.is_cancelled) {
const text = event.max_participants && event.max_participants <= event.num_participants ?
'Zet me op de wachtlijst' : 'Aanmelden';
this.props.t('Put me on the waiting list') : this.props.t('Register');
return (
<View style={styles.registrationActions}>
<Button
......@@ -207,13 +209,13 @@ class Event extends Component {
<View style={styles.registrationActions}>
<Button
color={colors.magenta}
title="Aanmelding bijwerken"
title={this.props.t('Update registration')}
onPress={() => this.props.fields(event.user_registration.pk)}
/>
<View style={styles.secondButtonMargin}>
<Button
color={colors.magenta}
title="Afmelden"
title={this.props.t('Cancel registration')}
onPress={() => this.cancelPrompt(event.user_registration.pk)}
/>
</View>
......@@ -222,7 +224,7 @@ class Event extends Component {
}
return (
<View style={styles.registrationActions}>
<Button color={colors.magenta} title="Afmelden" onPress={() => this.cancelPrompt(event.user_registration.pk)} />
<Button color={colors.magenta} title={this.props.t('Cancel registration')} onPress={() => this.cancelPrompt(event.user_registration.pk)} />
</View>
);
}
......@@ -236,7 +238,7 @@ class Event extends Component {
return (
<View>
<View style={styles.divider} />
<Text style={styles.registrationsTitle}>Aanmeldingen</Text>
<Text style={styles.registrationsTitle}>{this.props.t('Registrations')}</Text>
<FlatList
numColumns={3}
data={this.props.registrations}
......@@ -296,7 +298,7 @@ class Event extends Component {
{this.eventInfo(this.props.data)}
<View style={styles.divider} />
<Text style={styles.descText}>{this.props.data.description}</Text>
{this.registrationsGrid(this.props.registrations)}
{this.registrationsGrid(this.props.registrations, this.props.t)}
</ScrollView>
);
}
......@@ -311,7 +313,7 @@ class Event extends Component {
/>
)}
>
<ErrorScreen message="Could not load the event..." />
<ErrorScreen message={this.props.t('Could not load the event...')} />
</ScrollView>
);
}
......@@ -365,6 +367,7 @@ Event.propTypes = {
fields: PropTypes.func.isRequired,
openMaps: PropTypes.func.isRequired,
retrievePizzaInfo: PropTypes.func.isRequired,
t: PropTypes.func.isRequired,
};
const mapStateToProps = state => ({
......@@ -383,4 +386,4 @@ const mapDispatchToProps = dispatch => ({
retrievePizzaInfo: () => dispatch(pizzaActions.retrievePizzaInfo()),
});
export default connect(mapStateToProps, mapDispatchToProps)(Event);
export default connect(mapStateToProps, mapDispatchToProps)(translate('event')(Event));
......@@ -2,21 +2,20 @@ import React from 'react';
import PropTypes from 'prop-types';
import { View, Text, TouchableHighlight, Linking } from 'react-native';
import { connect } from 'react-redux';
import { translate } from 'react-i18next';
import Moment from 'moment';
import 'moment/locale/nl';
import * as eventActions from '../actions/event';
import styles from './style/eventCard';
const getEventInfo = (event) => {
Moment.locale('nl');
const getEventInfo = (event, t) => {
if (event.start === null && event.end === null) {
return event.location;
} else if (event.start === null) {
return `Tot ${Moment(event.end).format('HH:mm')} | ${event.location}`;
return `${t('Until')} ${Moment(event.end).format('HH:mm')} | ${event.location}`;
} else if (event.end === null) {
return `Vanaf ${Moment(event.start).format('HH:mm')} | ${event.location}`;
return `${t('From')} ${Moment(event.start).format('HH:mm')} | ${event.location}`;
}
return `${Moment(event.start).format('HH:mm')} - ${Moment(event.end).format('HH:mm')} | ${event.location}`;
};
......@@ -37,7 +36,7 @@ const EventCard = props => (
{props.event.title}
</Text>
<Text style={[styles.eventInfo, props.event.partner ? styles.partnerEventInfo : null]}>
{getEventInfo(props.event)}
{getEventInfo(props.event, props.t)}
</Text>
</View>
</TouchableHighlight>
......@@ -58,6 +57,7 @@ EventCard.propTypes = {
url: PropTypes.string,
}).isRequired,
loadEvent: PropTypes.func.isRequired,
t: PropTypes.func.isRequired,
};
const mapDispatchToProps = dispatch => ({
......@@ -70,4 +70,4 @@ const mapDispatchToProps = dispatch => ({
},
});
export default connect(() => ({}), mapDispatchToProps)(EventCard);
export default connect(() => ({}), mapDispatchToProps)(translate('eventCard')(EventCard));
......@@ -2,8 +2,8 @@ import React from 'react';
import PropTypes from 'prop-types';
import { View, Text, TouchableOpacity, TouchableHighlight } from 'react-native';
import { connect } from 'react-redux';
import { translate } from 'react-i18next';
import Moment from 'moment';
import 'moment/locale/nl';
import * as actions from '../actions/event';
import { retrievePizzaInfo } from '../actions/pizza';
......@@ -12,7 +12,6 @@ import styles from './style/eventDetailCard';
import { colors } from '../style';
const getInfo = (event) => {
Moment.locale('nl');
const start = Moment(event.start);
const end = Moment(event.end);
......@@ -47,13 +46,13 @@ const EventDetailCard = props => (
style={styles.description}
>{props.event.description}</Text>
<View style={styles.buttonList}>
<Text style={[styles.moreInfo, styles.button]}>MEER INFO</Text>
<Text style={[