Verified Commit c51c51a2 authored by Sébastiaan Versteeg's avatar Sébastiaan Versteeg
Browse files

Refactor avatar modal to component

parent f5f87ec0
...@@ -6,7 +6,7 @@ import AsyncStorage from '@react-native-community/async-storage'; ...@@ -6,7 +6,7 @@ import AsyncStorage from '@react-native-community/async-storage';
import { Sentry } from 'react-native-sentry'; import { Sentry } from 'react-native-sentry';
import sessionSaga, { import sessionSaga, {
DISPLAYNAMEKEY, PHOTOKEY, TOKENKEY, USERNAMEKEY, DISPLAYNAMEKEY, IDENTIFIERKEY, PHOTOKEY, TOKENKEY, USERNAMEKEY,
} from '../../app/sagas/session'; } from '../../app/sagas/session';
import { apiRequest } from '../../app/utils/url'; import { apiRequest } from '../../app/utils/url';
import * as sessionActions from '../../app/actions/session'; import * as sessionActions from '../../app/actions/session';
...@@ -141,19 +141,21 @@ describe('session saga', () => { ...@@ -141,19 +141,21 @@ describe('session saga', () => {
it('should put the result data when the request succeeds', () => expectSaga(sessionSaga) it('should put the result data when the request succeeds', () => expectSaga(sessionSaga)
.provide([ .provide([
[matchers.call.like({ fn: apiRequest, args: ['members/me'] }), { [matchers.call.like({ fn: apiRequest, args: ['members/me'] }), {
pk: 12,
display_name: 'Johnny Test', display_name: 'Johnny Test',
avatar: { avatar: {
medium: 'http://example.org/photo.png', medium: 'http://example.org/photo.png',
}, },
}], }],
]) ])
.put(sessionActions.setUserInfo('Johnny Test', 'http://example.org/photo.png')) .put(sessionActions.setUserInfo(12, 'Johnny Test', 'http://example.org/photo.png'))
.dispatch(sessionActions.fetchUserInfo()) .dispatch(sessionActions.fetchUserInfo())
.silentRun()); .silentRun());
it('should save the token in the AsyncStorage when the request succeeds', () => expectSaga(sessionSaga) it('should save the token in the AsyncStorage when the request succeeds', () => expectSaga(sessionSaga)
.provide([ .provide([
[matchers.call.like({ fn: apiRequest, args: ['members/me'] }), { [matchers.call.like({ fn: apiRequest, args: ['members/me'] }), {
pk: 12,
display_name: 'Johnny Test', display_name: 'Johnny Test',
avatar: { avatar: {
medium: 'http://example.org/photo.png', medium: 'http://example.org/photo.png',
...@@ -164,6 +166,7 @@ describe('session saga', () => { ...@@ -164,6 +166,7 @@ describe('session saga', () => {
.silentRun() .silentRun()
.then(() => { .then(() => {
expect(AsyncStorage.multiSet).toBeCalledWith([ expect(AsyncStorage.multiSet).toBeCalledWith([
[IDENTIFIERKEY, 12],
[DISPLAYNAMEKEY, 'Johnny Test'], [DISPLAYNAMEKEY, 'Johnny Test'],
[PHOTOKEY, 'http://example.org/photo.png'], [PHOTOKEY, 'http://example.org/photo.png'],
]); ]);
......
...@@ -2,9 +2,11 @@ export const PROFILE = 'PROFILE_PROFILE'; ...@@ -2,9 +2,11 @@ export const PROFILE = 'PROFILE_PROFILE';
export const FETCHING = 'PROFILE_FETCHING'; export const FETCHING = 'PROFILE_FETCHING';
export const SUCCESS = 'PROFILE_SUCCESS'; export const SUCCESS = 'PROFILE_SUCCESS';
export const FAILURE = 'PROFILE_FAILURE'; export const FAILURE = 'PROFILE_FAILURE';
export const UPDATE = 'PROFILE_UPDATE';
export const UPDATING = 'PROFILE_UPDATING'; export const UPDATING = 'PROFILE_UPDATING';
export const UPDATE_SUCCESS = 'PROFILE_UPDATE_SUCCESS'; export const UPDATE_SUCCESS = 'PROFILE_UPDATE_SUCCESS';
export const UPDATE_FAIL = 'PROFILE_UPDATE_FAIL'; export const UPDATE_FAIL = 'PROFILE_UPDATE_FAIL';
export const CHANGE_AVATAR = 'PROFILE_CHANGE_AVATAR';
export function profile(member = 'me') { export function profile(member = 'me') {
return { return {
...@@ -19,6 +21,18 @@ export function fetching() { ...@@ -19,6 +21,18 @@ export function fetching() {
}; };
} }
export function changeAvatar() {
return {
type: CHANGE_AVATAR,
};
}
export function update() {
return {
type: UPDATE,
};
}
export function updating() { export function updating() {
return { return {
type: UPDATING, type: UPDATING,
......
...@@ -30,6 +30,6 @@ export function fetchUserInfo() { ...@@ -30,6 +30,6 @@ export function fetchUserInfo() {
return { type: FETCH_USER_INFO }; return { type: FETCH_USER_INFO };
} }
export function setUserInfo(displayName, photo, pk) { export function setUserInfo(pk, displayName, photo) {
return { type: SET_USER_INFO, payload: { displayName, photo, pk } }; return { type: SET_USER_INFO, payload: { pk, displayName, photo } };
} }
...@@ -6,7 +6,6 @@ const initialState = { ...@@ -6,7 +6,6 @@ const initialState = {
profile: { profile: {
pk: -1, pk: -1,
display_name: '', display_name: '',
photo: defaultProfileImage,
avatar: { avatar: {
full: defaultProfileImage, full: defaultProfileImage,
large: defaultProfileImage, large: defaultProfileImage,
...@@ -52,17 +51,11 @@ export default function profile(state = initialState, action = {}) { ...@@ -52,17 +51,11 @@ export default function profile(state = initialState, action = {}) {
...state, ...state,
updating: true, updating: true,
}; };
case profileActions.UPDATE_SUCCESS:
return {
...state,
updating: false,
success: true,
};
case profileActions.UPDATE_FAIL: case profileActions.UPDATE_FAIL:
case profileActions.UPDATE_SUCCESS:
return { return {
...state, ...state,
updating: false, updating: false,
success: true,
}; };
default: default:
return state; return state;
......
...@@ -32,9 +32,9 @@ export default function session(state = initialState, action = {}) { ...@@ -32,9 +32,9 @@ export default function session(state = initialState, action = {}) {
case sessionActions.SET_USER_INFO: case sessionActions.SET_USER_INFO:
return { return {
...state, ...state,
pk: action.payload.pk,
displayName: action.payload.displayName, displayName: action.payload.displayName,
photo: action.payload.photo, photo: action.payload.photo,
pk: action.payload.pk,
}; };
case sessionActions.TOKEN_INVALID: case sessionActions.TOKEN_INVALID:
case sessionActions.SIGN_OUT: case sessionActions.SIGN_OUT:
......
import ImagePicker from 'react-native-image-picker';
import { import {
call, put, select, takeEvery, call, put, select, takeEvery, cps
} from 'redux-saga/effects'; } from 'redux-saga/effects';
import { Sentry } from 'react-native-sentry'; import { Sentry } from 'react-native-sentry';
import i18next from '../utils/i18n';
import { apiRequest } from '../utils/url'; import { apiRequest } from '../utils/url';
import * as profileActions from '../actions/profile'; import * as profileActions from '../actions/profile';
import { tokenSelector } from '../selectors/session'; import { tokenSelector } from '../selectors/session';
const t = i18next.getFixedT(undefined, 'sagas/profile');
const openImageLibrary = options => new Promise((resolve, reject) => {
ImagePicker.launchImageLibrary(options, (response) => {
if (response.error || response.didCancel) {
reject(response);
}
resolve(response);
});
});
function* profile(action) { function* profile(action) {
const { member } = action.payload; const { member } = action.payload;
const token = yield select(tokenSelector); const token = yield select(tokenSelector);
...@@ -31,30 +44,49 @@ function* profile(action) { ...@@ -31,30 +44,49 @@ function* profile(action) {
} }
} }
function* uploadProfilePicture(action) { function* updateAvatar() {
const { member } = action.payload; const options = {
const token = yield select(tokenSelector); title: t('Change profile picture'),
yield put(profileActions.updating()); storageOptions: {
skipBackup: true,
path: 'images',
},
};
const data = {
method: 'PUT',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Token ${token}`,
};
}
try { try {
const profileData = yield call(apiRequest, `members/${member}`, data); const response = yield call(openImageLibrary, options);
yield put(profileActions.updateSuccess(profileData)); const source = response.uri;
} catch (error) { console.log(source);
Sentry.captureException(error); } catch (e) {
yield put(profileActions.updateFail()); // eat error, om nom nom
// error from the picker that we cannot do anything about
} }
// const { member } = action.payload;
// const token = yield select(tokenSelector);
// yield put(profileActions.updating());
//
// const data = {
// method: 'PUT',
// headers: {
// Accept: 'application/json',
// 'Content-Type': 'application/json',
// Authorization: `Token ${token}`,
// },
// };
//
// try {
// const profileData = yield call(apiRequest, `members/${member}`, data);
// yield put(profileActions.updateSuccess(profileData));
// } catch (error) {
// Sentry.captureException(error);
// yield put(profileActions.updateFail());
// }
} }
function* eventSaga() { function* profileSaga() {
yield takeEvery(profileActions.PROFILE, profile); yield takeEvery(profileActions.PROFILE, profile);
yield takeEvery(profileActions.CHANGE_AVATAR, updateAvatar);
} }
export default profileSaga; export default profileSaga;
...@@ -12,6 +12,7 @@ import * as sessionActions from '../actions/session'; ...@@ -12,6 +12,7 @@ import * as sessionActions from '../actions/session';
import * as pushNotificationsActions from '../actions/pushNotifications'; import * as pushNotificationsActions from '../actions/pushNotifications';
import { tokenSelector } from '../selectors/session'; import { tokenSelector } from '../selectors/session';
export const IDENTIFIERKEY = '@MyStore:identifier';
export const USERNAMEKEY = '@MyStore:username'; export const USERNAMEKEY = '@MyStore:username';
export const TOKENKEY = '@MyStore:token'; export const TOKENKEY = '@MyStore:token';
export const DISPLAYNAMEKEY = '@MyStore:displayName'; export const DISPLAYNAMEKEY = '@MyStore:displayName';
...@@ -29,10 +30,11 @@ const t = i18next.getFixedT(undefined, 'sagas/session'); ...@@ -29,10 +30,11 @@ const t = i18next.getFixedT(undefined, 'sagas/session');
function* init() { function* init() {
try { try {
const result = yield call([AsyncStorage, 'multiGet'], [ const result = yield call([AsyncStorage, 'multiGet'], [
USERNAMEKEY, TOKENKEY, DISPLAYNAMEKEY, PHOTOKEY, PUSHCATEGORYKEY, IDENTIFIERKEY, USERNAMEKEY, TOKENKEY, DISPLAYNAMEKEY, PHOTOKEY, PUSHCATEGORYKEY,
]); ]);
const values = result.reduce(pairsToObject, {}); const values = result.reduce(pairsToObject, {});
const id = values[IDENTIFIERKEY];
const username = values[USERNAMEKEY]; const username = values[USERNAMEKEY];
const token = values[TOKENKEY]; const token = values[TOKENKEY];
const displayName = values[DISPLAYNAMEKEY]; const displayName = values[DISPLAYNAMEKEY];
...@@ -41,7 +43,7 @@ function* init() { ...@@ -41,7 +43,7 @@ function* init() {
if (username !== null && token !== null) { if (username !== null && token !== null) {
yield put(sessionActions.signedIn(username, token)); yield put(sessionActions.signedIn(username, token));
yield put(sessionActions.setUserInfo(displayName, photo)); yield put(sessionActions.setUserInfo(id, displayName, photo));
yield put(sessionActions.fetchUserInfo()); yield put(sessionActions.fetchUserInfo());
yield put(pushNotificationsActions.register(pushCategories)); yield put(pushNotificationsActions.register(pushCategories));
} else { } else {
...@@ -125,10 +127,13 @@ function* userInfo() { ...@@ -125,10 +127,13 @@ function* userInfo() {
const userProfile = yield call(apiRequest, 'members/me', data); const userProfile = yield call(apiRequest, 'members/me', data);
yield call(AsyncStorage.multiSet, [ yield call(AsyncStorage.multiSet, [
[IDENTIFIERKEY, userProfile.pk],
[DISPLAYNAMEKEY, userProfile.display_name], [DISPLAYNAMEKEY, userProfile.display_name],
[PHOTOKEY, userProfile.avatar.medium], [PHOTOKEY, userProfile.avatar.medium],
]); ]);
yield put(sessionActions.setUserInfo(userProfile.display_name, userProfile.avatar.medium, userProfile.pk)); yield put(sessionActions.setUserInfo(
userProfile.pk, userProfile.display_name, userProfile.avatar.medium,
));
} catch (error) { } catch (error) {
Sentry.captureException(error); Sentry.captureException(error);
} }
......
import React from 'react';
import { TouchableOpacity, ViewPropTypes, } from 'react-native';
import Icon from 'react-native-vector-icons/MaterialIcons';
import PropTypes from 'prop-types';
import Colors from '../../style/Colors';
const IconButton = props => (
<TouchableOpacity
disabled={props.disabled}
onPress={props.disabled ? null : props.onPress}
>
<Icon
name={props.name}
style={props.style}
color={props.color}
size={props.size}
/>
</TouchableOpacity>
);
IconButton.propTypes = {
name: PropTypes.string.isRequired,
onPress: PropTypes.func.isRequired,
color: PropTypes.string,
disabled: PropTypes.bool,
size: PropTypes.number,
style: Icon.propTypes.style,
};
IconButton.defaultProps = {
color: Colors.white,
disabled: false,
size: 24,
style: Icon.defaultProps.style,
};
export default IconButton;
import React, { Component } from 'react'; import React, { Component } from 'react';
import { import {
Animated, Animated, BackHandler, Easing, Platform, SafeAreaView, Text, TextInput, View,
BackHandler,
Easing,
Platform,
Text,
TextInput,
TouchableOpacity,
View,
SafeAreaView,
} from 'react-native'; } from 'react-native';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import StatusBar from '@react-native-community/status-bar'; import StatusBar from '@react-native-community/status-bar';
import Icon from 'react-native-vector-icons/MaterialIcons';
import styles from './style/SearchHeader'; import styles from './style/SearchHeader';
import Colors from '../../style/Colors'; import Colors from '../../style/Colors';
import IconButton from '../button/IconButton';
class SearchHeader extends Component { class SearchHeader extends Component {
constructor(props) { constructor(props) {
...@@ -43,15 +34,11 @@ class SearchHeader extends Component { ...@@ -43,15 +34,11 @@ class SearchHeader extends Component {
const { leftIcon, leftIconAction } = this.props; const { leftIcon, leftIconAction } = this.props;
const { isSearching } = this.state; const { isSearching } = this.state;
return ( return (
<TouchableOpacity <IconButton
onPress={() => (isSearching ? this.updateSearch(false) : leftIconAction())} onPress={() => (isSearching ? this.updateSearch(false) : leftIconAction())}
> name={isSearching ? 'arrow-back' : leftIcon}
<Icon style={[styles.leftIcon, isSearching ? styles.magenta : styles.white]}
name={isSearching ? 'arrow-back' : leftIcon} />
style={[styles.leftIcon, isSearching ? styles.magenta : styles.white]}
size={24}
/>
</TouchableOpacity>
); );
}; };
...@@ -80,27 +67,19 @@ class SearchHeader extends Component { ...@@ -80,27 +67,19 @@ class SearchHeader extends Component {
const { searchKey, isSearching } = this.state; const { searchKey, isSearching } = this.state;
if (!isSearching) { if (!isSearching) {
return ( return (
<TouchableOpacity <IconButton
onPress={() => this.updateSearch(true)} onPress={() => this.updateSearch(true)}
> name="search"
<Icon style={[styles.rightIcon, styles.white]}
name="search" />
style={[styles.rightIcon, styles.white]}
size={24}
/>
</TouchableOpacity>
); );
} if (searchKey) { } if (searchKey) {
return ( return (
<TouchableOpacity <IconButton
onPress={() => this.updateSearchKey('')} onPress={() => this.updateSearchKey('')}
> name="close"
<Icon style={[styles.rightIcon, styles.gray]}
name="close" />
style={[styles.rightIcon, styles.gray]}
size={24}
/>
</TouchableOpacity>
); );
} }
return null; return null;
......
...@@ -4,7 +4,7 @@ import { withTranslation } from 'react-i18next'; ...@@ -4,7 +4,7 @@ import { withTranslation } from 'react-i18next';
import { Text, View } from 'react-native'; import { Text, View } from 'react-native';
import Moment from 'moment'; import Moment from 'moment';
import CardSection from '../../components/cardSection/CardSection'; import CardSection from '../../components/cardSection/CardSection';
import styles from './style/Profile'; import styles from './style/ProfileScreen';
const AchievementSection = ({ profile, t, type }) => { const AchievementSection = ({ profile, t, type }) => {
const memberships = profile[type]; const memberships = profile[type];
......
import PropTypes from 'prop-types';
import React from 'react';
import { Image, Modal, View } from 'react-native';
import styles from './style/AvatarModal';
import IconButton from '../../components/button/IconButton';
const AvatarModal = props => (
<Modal
visible={props.visible}
transparent
animationType="fade"
>
<View
style={styles.background}
>
<Image
source={{ uri: props.image }}
style={styles.image}
/>
<View
style={styles.buttonLayout}
>
{props.canEdit && (
<IconButton
name="edit"
onPress={props.changeAvatar}
/>
)}
<IconButton
name="cancel"
onPress={props.close}
/>
</View>
</View>
</Modal>
);
AvatarModal.defaultProps = {
visible: false,
canEdit: false,
};
AvatarModal.propTypes = {
visible: PropTypes.bool,
canEdit: PropTypes.bool,
image: PropTypes.string.isRequired,
close: PropTypes.func.isRequired,
changeAvatar: PropTypes.func.isRequired,
};
export default AvatarModal;
...@@ -3,7 +3,7 @@ import React from 'react'; ...@@ -3,7 +3,7 @@ import React from 'react';
import { withTranslation } from 'react-i18next'; import { withTranslation } from 'react-i18next';
import { Text } from 'react-native'; import { Text } from 'react-native';
import CardSection from '../../components/cardSection/CardSection'; import CardSection from '../../components/cardSection/CardSection';
import styles from './style/Profile'; import styles from './style/ProfileScreen';
const DescriptionSection = ( const DescriptionSection = (
{ profile: { display_name: name, profile_description: description }, t }, { profile: { display_name: name, profile_description: description }, t },
......
...@@ -4,7 +4,7 @@ import { withTranslation } from 'react-i18next'; ...@@ -4,7 +4,7 @@ import { withTranslation } from 'react-i18next';
import { Text, View } from 'react-native'; import { Text, View } from 'react-native';
import Moment from 'moment'; import Moment from 'moment';
import CardSection from '../../components/cardSection/CardSection'; import CardSection from '../../components/cardSection/CardSection';
import styles from './style/Profile'; import styles from './style/ProfileScreen';
const PersonalInfoSection = ({ profile, t, openUrl }) => { const PersonalInfoSection = ({ profile, t, openUrl }) => {
const profileInfo = { const profileInfo = {
......
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';