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';
import { Sentry } from 'react-native-sentry';
import sessionSaga, {
DISPLAYNAMEKEY, PHOTOKEY, TOKENKEY, USERNAMEKEY,
DISPLAYNAMEKEY, IDENTIFIERKEY, PHOTOKEY, TOKENKEY, USERNAMEKEY,
} from '../../app/sagas/session';
import { apiRequest } from '../../app/utils/url';
import * as sessionActions from '../../app/actions/session';
......@@ -141,19 +141,21 @@ describe('session saga', () => {
it('should put the result data when the request succeeds', () => expectSaga(sessionSaga)
.provide([
[matchers.call.like({ fn: apiRequest, args: ['members/me'] }), {
pk: 12,
display_name: 'Johnny Test',
avatar: {
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())
.silentRun());
it('should save the token in the AsyncStorage when the request succeeds', () => expectSaga(sessionSaga)
.provide([
[matchers.call.like({ fn: apiRequest, args: ['members/me'] }), {
pk: 12,
display_name: 'Johnny Test',
avatar: {
medium: 'http://example.org/photo.png',
......@@ -164,6 +166,7 @@ describe('session saga', () => {
.silentRun()
.then(() => {
expect(AsyncStorage.multiSet).toBeCalledWith([
[IDENTIFIERKEY, 12],
[DISPLAYNAMEKEY, 'Johnny Test'],
[PHOTOKEY, 'http://example.org/photo.png'],
]);
......
......@@ -2,9 +2,11 @@ export const PROFILE = 'PROFILE_PROFILE';
export const FETCHING = 'PROFILE_FETCHING';
export const SUCCESS = 'PROFILE_SUCCESS';
export const FAILURE = 'PROFILE_FAILURE';
export const UPDATE = 'PROFILE_UPDATE';
export const UPDATING = 'PROFILE_UPDATING';
export const UPDATE_SUCCESS = 'PROFILE_UPDATE_SUCCESS';
export const UPDATE_FAIL = 'PROFILE_UPDATE_FAIL';
export const CHANGE_AVATAR = 'PROFILE_CHANGE_AVATAR';
export function profile(member = 'me') {
return {
......@@ -19,6 +21,18 @@ export function fetching() {
};
}
export function changeAvatar() {
return {
type: CHANGE_AVATAR,
};
}
export function update() {
return {
type: UPDATE,
};
}
export function updating() {
return {
type: UPDATING,
......
......@@ -30,6 +30,6 @@ export function fetchUserInfo() {
return { type: FETCH_USER_INFO };
}
export function setUserInfo(displayName, photo, pk) {
return { type: SET_USER_INFO, payload: { displayName, photo, pk } };
export function setUserInfo(pk, displayName, photo) {
return { type: SET_USER_INFO, payload: { pk, displayName, photo } };
}
......@@ -6,7 +6,6 @@ const initialState = {
profile: {
pk: -1,
display_name: '',
photo: defaultProfileImage,
avatar: {
full: defaultProfileImage,
large: defaultProfileImage,
......@@ -52,17 +51,11 @@ export default function profile(state = initialState, action = {}) {
...state,
updating: true,
};
case profileActions.UPDATE_SUCCESS:
return {
...state,
updating: false,
success: true,
};
case profileActions.UPDATE_FAIL:
case profileActions.UPDATE_SUCCESS:
return {
...state,
updating: false,
success: true,
};
default:
return state;
......
......@@ -32,9 +32,9 @@ export default function session(state = initialState, action = {}) {
case sessionActions.SET_USER_INFO:
return {
...state,
pk: action.payload.pk,
displayName: action.payload.displayName,
photo: action.payload.photo,
pk: action.payload.pk,
};
case sessionActions.TOKEN_INVALID:
case sessionActions.SIGN_OUT:
......
import ImagePicker from 'react-native-image-picker';
import {
call, put, select, takeEvery,
call, put, select, takeEvery, cps
} from 'redux-saga/effects';
import { Sentry } from 'react-native-sentry';
import i18next from '../utils/i18n';
import { apiRequest } from '../utils/url';
import * as profileActions from '../actions/profile';
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) {
const { member } = action.payload;
const token = yield select(tokenSelector);
......@@ -31,30 +44,49 @@ function* profile(action) {
}
}
function* uploadProfilePicture(action) {
const { member } = action.payload;
const token = yield select(tokenSelector);
yield put(profileActions.updating());
function* updateAvatar() {
const options = {
title: t('Change profile picture'),
storageOptions: {
skipBackup: true,
path: 'images',
},
};
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());
const response = yield call(openImageLibrary, options);
const source = response.uri;
console.log(source);
} catch (e) {
// 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.CHANGE_AVATAR, updateAvatar);
}
export default profileSaga;
......@@ -12,6 +12,7 @@ import * as sessionActions from '../actions/session';
import * as pushNotificationsActions from '../actions/pushNotifications';
import { tokenSelector } from '../selectors/session';
export const IDENTIFIERKEY = '@MyStore:identifier';
export const USERNAMEKEY = '@MyStore:username';
export const TOKENKEY = '@MyStore:token';
export const DISPLAYNAMEKEY = '@MyStore:displayName';
......@@ -29,10 +30,11 @@ const t = i18next.getFixedT(undefined, 'sagas/session');
function* init() {
try {
const result = yield call([AsyncStorage, 'multiGet'], [
USERNAMEKEY, TOKENKEY, DISPLAYNAMEKEY, PHOTOKEY, PUSHCATEGORYKEY,
IDENTIFIERKEY, USERNAMEKEY, TOKENKEY, DISPLAYNAMEKEY, PHOTOKEY, PUSHCATEGORYKEY,
]);
const values = result.reduce(pairsToObject, {});
const id = values[IDENTIFIERKEY];
const username = values[USERNAMEKEY];
const token = values[TOKENKEY];
const displayName = values[DISPLAYNAMEKEY];
......@@ -41,7 +43,7 @@ function* init() {
if (username !== null && token !== null) {
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(pushNotificationsActions.register(pushCategories));
} else {
......@@ -125,10 +127,13 @@ function* userInfo() {
const userProfile = yield call(apiRequest, 'members/me', data);
yield call(AsyncStorage.multiSet, [
[IDENTIFIERKEY, userProfile.pk],
[DISPLAYNAMEKEY, userProfile.display_name],
[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) {
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 {
Animated,
BackHandler,
Easing,
Platform,
Text,
TextInput,
TouchableOpacity,
View,
SafeAreaView,
Animated, BackHandler, Easing, Platform, SafeAreaView, Text, TextInput, View,
} from 'react-native';
import PropTypes from 'prop-types';
import StatusBar from '@react-native-community/status-bar';
import Icon from 'react-native-vector-icons/MaterialIcons';
import styles from './style/SearchHeader';
import Colors from '../../style/Colors';
import IconButton from '../button/IconButton';
class SearchHeader extends Component {
constructor(props) {
......@@ -43,15 +34,11 @@ class SearchHeader extends Component {
const { leftIcon, leftIconAction } = this.props;
const { isSearching } = this.state;
return (
<TouchableOpacity
<IconButton
onPress={() => (isSearching ? this.updateSearch(false) : leftIconAction())}
>
<Icon
name={isSearching ? 'arrow-back' : leftIcon}
style={[styles.leftIcon, isSearching ? styles.magenta : styles.white]}
size={24}
/>
</TouchableOpacity>
name={isSearching ? 'arrow-back' : leftIcon}
style={[styles.leftIcon, isSearching ? styles.magenta : styles.white]}
/>
);
};
......@@ -80,27 +67,19 @@ class SearchHeader extends Component {
const { searchKey, isSearching } = this.state;
if (!isSearching) {
return (
<TouchableOpacity
<IconButton
onPress={() => this.updateSearch(true)}
>
<Icon
name="search"
style={[styles.rightIcon, styles.white]}
size={24}
/>
</TouchableOpacity>
name="search"
style={[styles.rightIcon, styles.white]}
/>
);
} if (searchKey) {
return (
<TouchableOpacity
<IconButton
onPress={() => this.updateSearchKey('')}
>
<Icon
name="close"
style={[styles.rightIcon, styles.gray]}
size={24}
/>
</TouchableOpacity>
name="close"
style={[styles.rightIcon, styles.gray]}
/>
);
}
return null;
......
......@@ -4,7 +4,7 @@ import { withTranslation } from 'react-i18next';
import { Text, View } from 'react-native';
import Moment from 'moment';
import CardSection from '../../components/cardSection/CardSection';
import styles from './style/Profile';
import styles from './style/ProfileScreen';
const AchievementSection = ({ profile, t, 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';
import { withTranslation } from 'react-i18next';
import { Text } from 'react-native';
import CardSection from '../../components/cardSection/CardSection';
import styles from './style/Profile';
import styles from './style/ProfileScreen';
const DescriptionSection = (
{ profile: { display_name: name, profile_description: description }, t },
......
......@@ -4,7 +4,7 @@ import { withTranslation } from 'react-i18next';
import { Text, View } from 'react-native';
import Moment from 'moment';
import CardSection from '../../components/cardSection/CardSection';
import styles from './style/Profile';
import styles from './style/ProfileScreen';
const PersonalInfoSection = ({ profile, t, openUrl }) => {
const profileInfo = {
......
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import {
Animated,
ImageBackground,
Platform,
ScrollView,
TouchableOpacity,
TouchableWithoutFeedback,
View,
Image,
Modal,
Animated, ImageBackground, Platform, ScrollView, TouchableHighlight, View,
} from 'react-native';
import StatusBar from '@react-native-community/status-bar';
import { withTranslation } from 'react-i18next';
import LinearGradient from 'react-native-linear-gradient';
import Icon from 'react-native-vector-icons/MaterialIcons';
import Moment from 'moment';
import ImagePicker from 'react-native-image-picker';
import StandardHeader from '../../components/standardHeader/StandardHeader';
import LoadingScreen from '../../components/loadingScreen/LoadingScreen';
import ErrorScreen from '../../components/errorScreen/ErrorScreen';
import LoadingScreen from '../../components/loadingScreen/LoadingScreen';
import StandardHeader from '../../components/standardHeader/StandardHeader';
import { STATUSBAR_HEIGHT } from '../../components/standardHeader/style/StandardHeader';
import Colors from '../../style/Colors';
import AvatarModal from './AvatarModal';
import AchievementSection from './AchievementSection';
import DescriptionSection from './DescriptionSection';
import PersonalInfoSection from './PersonalInfoSection';
import styles, {
HEADER_MAX_HEIGHT,
HEADER_MIN_HEIGHT,
HEADER_SCROLL_DISTANCE,
} from './style/Profile';
import styles, { HEADER_MAX_HEIGHT, HEADER_MIN_HEIGHT, HEADER_SCROLL_DISTANCE } from './style/ProfileScreen';
import IconButton from '../../components/button/IconButton';
class ProfileScreen extends Component {
constructor(props) {
......@@ -43,8 +27,21 @@ class ProfileScreen extends Component {
this.textHeight = Platform.OS === 'android' ? 27 : 22;
this.state = {
modalVisible: false,
image: this.props.profile.avatar.full,
headerEnabled: true,
};
this.scrollY.addListener(({ value }) => {
console.log('scrolling', value, this.state.headerEnabled, value > (HEADER_SCROLL_DISTANCE / 2));
if (this.state.headerEnabled && value > (HEADER_SCROLL_DISTANCE / 2)) {
this.setState({
headerEnabled: false,
});
} else if (!this.state.headerEnabled && value < (HEADER_SCROLL_DISTANCE / 2)) {
this.setState({
headerEnabled: true,
});
}
});
}
setModalVisible = (visible) => {
......@@ -53,9 +50,7 @@ class ProfileScreen extends Component {
});
};
isOwnProfilePage = () => {
return this.props.pk === this.props.profile.pk;
};
isOwnProfilePage = () => this.props.userPk === this.props.profile.pk;
getAppbar = () => {
const headerHeight = this.scrollY.interpolate({
......@@ -120,75 +115,48 @@ class ProfileScreen extends Component {
}
return (
<TouchableWithoutFeedback
onPress={() => this.setModalVisible(true)}
>
<Animated.View style={[styles.header, { height: headerHeight }]}>
<Animated.View
style={[
styles.backgroundImage,
{
opacity: imageOpacity,
transform: [{ translateY: imageTranslate }],
},
]}
<Animated.View style={[styles.header, { height: headerHeight }]}>
<Animated.View
style={[
styles.backgroundImage,
{
opacity: imageOpacity,
transform: [{ translateY: imageTranslate }],
},
]}
>
<ImageBackground
source={{ uri: this.props.profile.avatar.full }}
style={styles.backgroundImage}
resizeMode="cover"
>
<LinearGradient colors={['#55000000', '#000000']} style={styles.overlayGradient} />
</ImageBackground>
</Animated.View>
<Animated.View style={[styles.appBar, appBarBorderStyle]}>
<Animated.Text
style={[styles.title, textStyle]}
>
{this.props.profile.display_name}
</Animated.Text>
<TouchableHighlight
activeOpacity={0}
style={styles.touchableHeader}
underlayColor={this.state.headerEnabled ? Colors.semiTransparent : Colors.transparent}
onPress={() => (this.state.headerEnabled ? this.setModalVisible(true) : null)}
>
<ImageBackground
source={{ uri: this.state.image }}
style={styles.backgroundImage}
resizeMode="cover"
>
<LinearGradient colors={['#55000000', '#000000']} style={styles.overlayGradient}/>
</ImageBackground>
</Animated.View>
<Animated.View style={[styles.appBar, appBarBorderStyle]}>
<TouchableOpacity
onPress={this.props.goBack}
>
<Icon
name="arrow-back"