Commit cfee4808 authored by Sébastiaan Versteeg's avatar Sébastiaan Versteeg

Merge branch 'feature/settings-screen' into 'master'

Added settings screen for push notification categories

Closes #45

See merge request !179
parents fe2d8c7d 1ebb16b8
......@@ -8,6 +8,17 @@ Object {
exports[`push notifications actions should create an action to register the push token 1`] = `
Object {
"categories": null,
"type": "PUSH_NOTIFICATIONS_REGISTER",
}
`;
exports[`push notifications actions should create an action to register the push tokens and update push categories 1`] = `
Object {
"categories": Array [
"category1",
"category2",
],
"type": "PUSH_NOTIFICATIONS_REGISTER",
}
`;
......@@ -10,6 +10,10 @@ describe('push notifications actions', () => {
expect(actions.register()).toMatchSnapshot();
});
it('should create an action to register the push tokens and update push categories', () => {
expect(actions.register(['category1', 'category2'])).toMatchSnapshot();
});
it('should create an action to invalidate the push token', () => {
expect(actions.invalidate()).toMatchSnapshot();
});
......
......@@ -383,6 +383,95 @@ 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}
onPress={[Function]}
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",
},
]
}
>
</Text>
<Text
style={
Array [
Object {
"backgroundColor": "transparent",
"fontWeight": "600",
},
Object {
"color": "#313131",
},
]
}
>
Settings
</Text>
</View>
</View>
<View
accessible={true}
isTVSelectable={true}
......
export const REGISTER = 'PUSH_NOTIFICATIONS_REGISTER';
export const INVALIDATE = 'PUSH_NOTIFICATIONS_INVALIDATE';
export function register() {
return { type: REGISTER };
export function register(categories = null) {
return { type: REGISTER, categories };
}
export function invalidate() {
......
// Actions are grouped based on the settings sections
export const settingsActions = {
INIT_START: 'SETTINGS_INIT_START',
INIT_COMPLETE: 'SETTINGS_INIT_COMPLETE',
initStart: () => ({
type: settingsActions.INIT_START,
}),
initComplete: () => ({
type: settingsActions.INIT_COMPLETE,
}),
};
export const notificationsSettingsActions = {
SUCCESS: 'SETTINGS_PUSH_NOTIFICATIONS_SUCCESS',
FAILURE: 'SETTINGS_PUSH_NOTIFICATIONS_FAILURE',
SAVE_CATEGORIES: 'SETTINGS_PUSH_NOTIFICATIONS_SAVE_CATEGORIES',
success: categoryList => ({
type: notificationsSettingsActions.SUCCESS,
categoryList,
}),
failure: () => ({
type: notificationsSettingsActions.FAILURE,
}),
saveCategories: categories => ({
type: notificationsSettingsActions.SAVE_CATEGORIES,
categories,
}),
};
......@@ -26,6 +26,7 @@ const USERNAMEKEY = '@MyStore:username';
const TOKENKEY = '@MyStore:token';
const DISPLAYNAMEKEY = '@MyStore:displayName';
const PHOTOKEY = '@MyStore:photo';
const PUSHCATEGORYKEY = '@MyStore:pushCategories';
const pairsToObject = (obj, pair) => {
const obj2 = { ...obj };
......@@ -62,7 +63,7 @@ class Main extends Component {
}
componentDidMount() {
AsyncStorage.multiGet([USERNAMEKEY, TOKENKEY, DISPLAYNAMEKEY, PHOTOKEY])
AsyncStorage.multiGet([USERNAMEKEY, TOKENKEY, DISPLAYNAMEKEY, PHOTOKEY, PUSHCATEGORYKEY])
.then(
(result) => {
const values = result.reduce(pairsToObject, {});
......@@ -70,12 +71,13 @@ class Main extends Component {
const token = values[TOKENKEY];
const displayName = values[DISPLAYNAMEKEY];
const photo = values[PHOTOKEY];
const pushCategories = JSON.parse(values[PUSHCATEGORYKEY]);
if (username !== null && token !== null) {
store.dispatch(loginActions.success(username, token));
store.dispatch(loginActions.profileSuccess(displayName, photo));
store.dispatch(loginActions.profile(token));
store.dispatch(register());
store.dispatch(register(pushCategories));
}
},
);
......
const files = {};
files['app/ui/screens/settings/NotificationsSectionNL'] = require('./nl/app/ui/screens/settings/NotificationsSection.json');
files['app/ui/screens/user/ProfileNL'] = require('./nl/app/ui/screens/user/Profile.json');
files['app/ui/screens/user/LoginNL'] = require('./nl/app/ui/screens/user/Login.json');
files['app/ui/screens/welcome/WelcomeNL'] = require('./nl/app/ui/screens/welcome/Welcome.json');
......@@ -15,6 +16,7 @@ files['app/ui/components/errorScreen/ErrorScreenNL'] = require('./nl/app/ui/comp
export default {
nl: {
'screens/settings/NotificationsSection': files['app/ui/screens/settings/NotificationsSectionNL'],
'screens/user/Profile': files['app/ui/screens/user/ProfileNL'],
'screens/user/Login': files['app/ui/screens/user/LoginNL'],
'screens/welcome/Welcome': files['app/ui/screens/welcome/WelcomeNL'],
......
......@@ -6,5 +6,6 @@
"Welcome": "Welkom",
"Calendar": "Agenda",
"Logout": "Uitloggen",
"Member List": "Ledenlijst"
"Member List": "Ledenlijst",
"Settings": "Instellingen"
}
......@@ -4,5 +4,7 @@
"Calendar": "Agenda",
"Pizza": "Pizza",
"Profile": "Profiel",
"Registration": "Registratie"
"Registration": "Registratie",
"Settings": "Instellingen",
"Notifications": "Meldingen"
}
{
"Notifications settings could not be loaded.": "De instellingen voor notificaties konden niet worden geladen.",
"(required)": "(verplicht)",
"Notifications": "Notificaties"
}
......@@ -8,6 +8,7 @@ import profile from './profile';
import pizza from './pizza';
import registration from './registration';
import members from './members';
import settings from './settings';
export default combineReducers({
session,
......@@ -19,4 +20,5 @@ export default combineReducers({
pizza,
registration,
members,
settings,
});
import pushNotifications, { initialState as initialPushNotificationsState } from './settings/pushNotifications';
import { settingsActions } from '../actions/settings';
const initialState = {
pushNotifications: initialPushNotificationsState,
loading: true,
};
export default function calendar(state = initialState, action = {}) {
switch (action.type) {
case settingsActions.INIT_START:
return {
...state,
loading: true,
};
case settingsActions.INIT_COMPLETE:
return {
...state,
loading: false,
};
default:
return {
...state,
pushNotifications: pushNotifications(state.pushNotifications, action),
};
}
}
import { notificationsSettingsActions as actions } from '../../actions/settings';
export const initialState = {
categoryList: [],
status: 'loading',
};
export default function pushNotifications(state = initialState, action = {}) {
switch (action.type) {
case actions.SUCCESS:
return {
categoryList: action.categoryList,
status: 'success',
};
case actions.FAILURE:
return {
...state,
status: 'failure',
};
default:
return state;
}
}
......@@ -10,6 +10,7 @@ import pizzaSaga from './pizza';
import registrationSaga from './registration';
import deepLinkingSaga from './deepLinking';
import membersSaga from './members';
import settingsSaga from './settings';
const sagas = function* sagas() {
yield all([
......@@ -23,6 +24,7 @@ const sagas = function* sagas() {
fork(registrationSaga),
fork(deepLinkingSaga),
fork(membersSaga),
fork(settingsSaga),
]);
};
......
......@@ -6,8 +6,9 @@ import { Sentry } from 'react-native-sentry';
import { apiRequest, tokenSelector } from '../utils/url';
import * as pushNotificationsActions from '../actions/pushNotifications';
const register = function* register() {
const register = function* register(action) {
const token = yield select(tokenSelector);
const { categories } = action;
if (token === undefined) {
// There is no token, thus do nothing
......@@ -28,6 +29,15 @@ const register = function* register() {
pushToken = yield call(FCM.getFCMToken);
}
const body = {
registration_id: pushToken,
type: Platform.OS,
};
if (categories !== null) {
body.receive_category = categories;
}
const data = {
method: 'POST',
headers: {
......@@ -35,10 +45,7 @@ const register = function* register() {
'Content-Type': 'application/json',
Authorization: `Token ${token}`,
},
body: JSON.stringify({
registration_id: pushToken,
type: Platform.OS,
}),
body: JSON.stringify(body),
};
try {
......
import { AsyncStorage } from 'react-native';
import { Sentry } from 'react-native-sentry';
import {
all, call, put, select, takeEvery,
} from 'redux-saga/effects';
import { notificationsSettingsActions, settingsActions } from '../actions/settings';
import { apiRequest, tokenSelector } from '../utils/url';
import * as pushNotifactionsActions from '../actions/pushNotifications';
const PUSHCATEGORYKEY = '@MyStore:pushCategories';
function* pushNotifications() {
const token = yield select(tokenSelector);
const data = {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Token ${token}`,
},
};
try {
const categoryList = yield call(apiRequest, 'devices/categories', data);
const preferencesJson = yield call(AsyncStorage.getItem, PUSHCATEGORYKEY);
if (preferencesJson === null) {
for (let i = 0; i < categoryList.length; i += 1) {
categoryList[i].enabled = true;
}
} else {
const categoryPreferences = JSON.parse(preferencesJson);
for (let i = 0; i < categoryList.length; i += 1) {
categoryList[i].enabled = categoryPreferences.includes(categoryList[i].key);
}
}
yield put(notificationsSettingsActions.success(categoryList));
} catch (error) {
Sentry.captureException(error);
yield put(notificationsSettingsActions.failure());
}
}
function* saveCategories(action) {
const { categories } = action;
try {
yield call(AsyncStorage.setItem, PUSHCATEGORYKEY, JSON.stringify(categories));
yield put(pushNotifactionsActions.register(categories));
} catch (error) {
Sentry.captureException(error);
}
}
function* init() {
yield all([
pushNotifications(),
]);
yield put(settingsActions.initComplete());
}
function* settingsSaga() {
yield takeEvery(settingsActions.INIT_START, init);
yield takeEvery(notificationsSettingsActions.SAVE_CATEGORIES, saveCategories);
}
export default settingsSaga;
import React from 'react';
import PropTypes from 'prop-types';
import {
StyleSheet, Text, View, ViewPropTypes,
} from 'react-native';
import styles from './style/CardSection';
const CardSection = props => (
<View style={[styles.section, props.style]}>
<Text style={styles.sectionHeader}>
{props.sectionHeader}
</Text>
<View style={[styles.card, props.contentStyle]}>
{props.children}
</View>
</View>
);
CardSection.propTypes = {
children: PropTypes.node.isRequired,
sectionHeader: PropTypes.string,
style: ViewPropTypes.style,
contentStyle: ViewPropTypes.style,
};
const defaultStyles = StyleSheet.create({
});
CardSection.defaultProps = {
sectionHeader: null,
style: defaultStyles,
contentStyle: defaultStyles,
};
export default CardSection;
import StyleSheet from '../../../style/StyleSheet';
import Colors from '../../../style/Colors';
const styles = StyleSheet.create({
section: {
marginTop: 8,
marginBottom: 8,
},
sectionHeader: {
android: {
fontFamily: 'sans-serif-medium',
},
ios: {
fontFamily: 'System',
fontWeight: '600',
},
color: Colors.textColour,
fontSize: 14,
paddingLeft: 10,
paddingTop: 6,
paddingBottom: 6,
},
card: {
backgroundColor: Colors.white,
elevation: 2,
android: {
borderRadius: 2,
},
ios: {
borderRadius: 4,
borderColor: Colors.lightGray,
borderStyle: 'solid',
borderWidth: 0.5,
},
},
});
export default styles;
......@@ -15,6 +15,7 @@ import Pizza from '../../screens/pizza/Pizza';
import StandardHeader from '../standardHeader/StandardHeader';
import Registration from '../../screens/events/Registration';
import MemberList from '../../screens/memberList/MemberList';
import Settings from '../../screens/settings/Settings';
import * as actions from '../../../actions/navigation';
import styles from './style/ReduxNavigator';
......@@ -36,6 +37,8 @@ const sceneToComponent = (scene) => {
return <Registration />;
case 'members':
return <MemberList />;
case 'settings':
return <Settings />;
default:
return <Welcome />;
}
......
......@@ -47,6 +47,13 @@ const Sidebar = (props) => {
style: {},
scene: 'members',
},
{
onPress: () => props.navigate('settings', true),
iconName: 'settings',
text: props.t('Settings'),
style: {},
scene: 'settings',
},
{
onPress: logoutPrompt(props),
iconName: 'lock',
......
......@@ -26,6 +26,10 @@ const sceneToTitle = (scene, t) => {
return t('Profile');
case 'registration':
return t('Registration');
case 'settings':
return t('Settings');
case 'pushNotificationsSettings':
return t('Notifications');
default:
return 'ThaliApp';
}
......
import React, { Component } from 'react';
import { Switch, Text, View } from 'react-native';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { translate } from 'react-i18next';
import styles from './style/NotificationsSection';
import Colors from '../../style/Colors';
import { notificationsSettingsActions } from '../../../actions/settings';
import CardSection from '../../components/cardSection/CardSection';
const GENERAL_KEY = 'general';
class NotificationsSection extends Component {
constructor(props) {
super(props);
this.state = {};
}
static getDerivedStateFromProps = (props) => {
if (props.status !== 'success') {
return null;
}
const newState = {};
for (let i = 0; i < props.categoryList.length; i += 1) {
if (props.categoryList[i].key === GENERAL_KEY) {
newState[props.categoryList[i].key] = true;
} else {
newState[props.categoryList[i].key] = props.categoryList[i].enabled;
}
}
return newState;
};
updateField = (key, value) => {
const update = {};
update[key] = value;
this.setState(update, () => {
const categories = Object.keys(this.state).filter(k => this.state[k]);
this.props.saveCategories(categories);
});
};
render() {
const { status, categoryList, t } = this.props;
let content = (
<Text style={styles.emptyText}>
{t('Notifications settings could not be loaded.')}
</Text>
);
if (status === 'success') {
content = categoryList.map((category, i) => (
<View
style={[styles.categoryContainer, i !== 0 && styles.borderTop]}
key={category.key}
>
<Text
style={styles.label}
>
{category.name}
{' '}
{category.key === GENERAL_KEY && this.props.t('(required)')}
</Text>
<Switch
value={this.state[category.key]}
onValueChange={value => this.updateField(category.key, value)}
onTintColor={Colors.magenta}
thumbTintColor={this.state[category.key]
? Colors.darkMagenta : Colors.gray}
disabled={category.key === GENERAL_KEY}
/>
</View>
));
}