Verified Commit f5f87ec0 authored by Bram's avatar Bram Committed by Sébastiaan Versteeg
Browse files

Add basic profile picture updating functionality

parent a22ab52d
...@@ -79,6 +79,7 @@ dependencies { ...@@ -79,6 +79,7 @@ dependencies {
implementation project(':@react-native-community_status-bar') implementation project(':@react-native-community_status-bar')
implementation project(':@react-native-community_async-storage') implementation project(':@react-native-community_async-storage')
implementation project(':react-native-device-info') implementation project(':react-native-device-info')
implementation project(':react-native-image-picker')
implementation project(':react-native-screens') implementation project(':react-native-screens')
implementation project(':react-native-gesture-handler') implementation project(':react-native-gesture-handler')
implementation project(':react-native-sentry') implementation project(':react-native-sentry')
......
...@@ -4,10 +4,11 @@ ...@@ -4,10 +4,11 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission tools:node="remove" android:name="android.permission.READ_PHONE_STATE" /> <uses-permission tools:node="remove" android:name="android.permission.READ_PHONE_STATE" />
<uses-permission tools:node="remove" android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission tools:node="remove" android:name="android.permission.READ_EXTERNAL_STORAGE" />
<application <application
android:name=".MainApplication" android:name=".MainApplication"
......
...@@ -8,6 +8,7 @@ import com.reactnativecommunity.asyncstorage.AsyncStoragePackage; ...@@ -8,6 +8,7 @@ import com.reactnativecommunity.asyncstorage.AsyncStoragePackage;
import com.learnium.RNDeviceInfo.RNDeviceInfo; import com.learnium.RNDeviceInfo.RNDeviceInfo;
import com.swmansion.rnscreens.RNScreensPackage; import com.swmansion.rnscreens.RNScreensPackage;
import com.swmansion.gesturehandler.react.RNGestureHandlerPackage; import com.swmansion.gesturehandler.react.RNGestureHandlerPackage;
import com.imagepicker.ImagePickerPackage;
import cl.json.ShareApplication; import cl.json.ShareApplication;
import cl.json.RNSharePackage; import cl.json.RNSharePackage;
...@@ -44,6 +45,7 @@ public class MainApplication extends Application implements ShareApplication, Re ...@@ -44,6 +45,7 @@ public class MainApplication extends Application implements ShareApplication, Re
new RNDeviceInfo(), new RNDeviceInfo(),
new RNScreensPackage(), new RNScreensPackage(),
new RNGestureHandlerPackage(), new RNGestureHandlerPackage(),
new ImagePickerPackage(),
new RNSharePackage(), new RNSharePackage(),
new RNSentryPackage(), new RNSentryPackage(),
new SnackbarPackage(), new SnackbarPackage(),
......
...@@ -9,6 +9,8 @@ include ':react-native-screens' ...@@ -9,6 +9,8 @@ include ':react-native-screens'
project(':react-native-screens').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-screens/android') project(':react-native-screens').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-screens/android')
include ':react-native-gesture-handler' include ':react-native-gesture-handler'
project(':react-native-gesture-handler').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-gesture-handler/android') project(':react-native-gesture-handler').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-gesture-handler/android')
include ':react-native-image-picker'
project(':react-native-image-picker').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-image-picker/android')
include ':react-native-share' include ':react-native-share'
project(':react-native-share').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-share/android') project(':react-native-share').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-share/android')
include ':react-native-sentry' include ':react-native-sentry'
......
...@@ -2,6 +2,9 @@ export const PROFILE = 'PROFILE_PROFILE'; ...@@ -2,6 +2,9 @@ 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 UPDATING = 'PROFILE_UPDATING';
export const UPDATE_SUCCESS = 'PROFILE_UPDATE_SUCCESS';
export const UPDATE_FAIL = 'PROFILE_UPDATE_FAIL';
export function profile(member = 'me') { export function profile(member = 'me') {
return { return {
...@@ -16,6 +19,25 @@ export function fetching() { ...@@ -16,6 +19,25 @@ export function fetching() {
}; };
} }
export function updating() {
return {
type: UPDATING,
};
}
export function updateSuccess(profileData) {
return {
type: UPDATE_SUCCESS,
payload: { profileData },
};
}
export function updateFail() {
return {
type: UPDATE_FAIL,
};
}
export function success(profileData) { export function success(profileData) {
return { return {
type: SUCCESS, type: SUCCESS,
......
...@@ -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) { export function setUserInfo(displayName, photo, pk) {
return { type: SET_USER_INFO, payload: { displayName, photo } }; return { type: SET_USER_INFO, payload: { displayName, photo, pk } };
} }
...@@ -24,6 +24,7 @@ const initialState = { ...@@ -24,6 +24,7 @@ const initialState = {
}, },
success: false, success: false,
hasLoaded: false, hasLoaded: false,
updating: false,
}; };
export default function profile(state = initialState, action = {}) { export default function profile(state = initialState, action = {}) {
...@@ -46,6 +47,23 @@ export default function profile(state = initialState, action = {}) { ...@@ -46,6 +47,23 @@ export default function profile(state = initialState, action = {}) {
success: false, success: false,
hasLoaded: true, hasLoaded: true,
}; };
case profileActions.UPDATING:
return {
...state,
updating: true,
};
case profileActions.UPDATE_SUCCESS:
return {
...state,
updating: false,
success: true,
};
case profileActions.UPDATE_FAIL:
return {
...state,
updating: false,
success: true,
};
default: default:
return state; return state;
} }
......
...@@ -11,6 +11,7 @@ const initialState = { ...@@ -11,6 +11,7 @@ const initialState = {
token: '', token: '',
username: '', username: '',
displayName: '', displayName: '',
pk: -1,
photo: defaultProfileImage, photo: defaultProfileImage,
}; };
...@@ -33,6 +34,7 @@ export default function session(state = initialState, action = {}) { ...@@ -33,6 +34,7 @@ export default function session(state = initialState, action = {}) {
...state, ...state,
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:
......
...@@ -7,7 +7,7 @@ import { apiRequest } from '../utils/url'; ...@@ -7,7 +7,7 @@ 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 profile = function* profile(action) { function* profile(action) {
const { member } = action.payload; const { member } = action.payload;
const token = yield select(tokenSelector); const token = yield select(tokenSelector);
...@@ -29,10 +29,32 @@ const profile = function* profile(action) { ...@@ -29,10 +29,32 @@ const profile = function* profile(action) {
Sentry.captureException(error); Sentry.captureException(error);
yield put(profileActions.failure()); yield put(profileActions.failure());
} }
}; }
const profileSaga = function* eventSaga() { function* uploadProfilePicture(action) {
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() {
yield takeEvery(profileActions.PROFILE, profile); yield takeEvery(profileActions.PROFILE, profile);
}; }
export default profileSaga; export default profileSaga;
...@@ -128,7 +128,7 @@ function* userInfo() { ...@@ -128,7 +128,7 @@ function* userInfo() {
[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)); yield put(sessionActions.setUserInfo(userProfile.display_name, userProfile.avatar.medium, userProfile.pk));
} catch (error) { } catch (error) {
Sentry.captureException(error); Sentry.captureException(error);
} }
......
...@@ -6,12 +6,19 @@ import { ...@@ -6,12 +6,19 @@ import {
Platform, Platform,
ScrollView, ScrollView,
TouchableOpacity, TouchableOpacity,
TouchableWithoutFeedback,
View, View,
Image,
Modal,
} from 'react-native'; } from 'react-native';
import StatusBar from '@react-native-community/status-bar'; import StatusBar from '@react-native-community/status-bar';
import { withTranslation } from 'react-i18next'; import { withTranslation } from 'react-i18next';
import LinearGradient from 'react-native-linear-gradient'; import LinearGradient from 'react-native-linear-gradient';
import Icon from 'react-native-vector-icons/MaterialIcons'; 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 ErrorScreen from '../../components/errorScreen/ErrorScreen';
import LoadingScreen from '../../components/loadingScreen/LoadingScreen'; import LoadingScreen from '../../components/loadingScreen/LoadingScreen';
...@@ -29,14 +36,27 @@ import styles, { ...@@ -29,14 +36,27 @@ import styles, {
HEADER_SCROLL_DISTANCE, HEADER_SCROLL_DISTANCE,
} from './style/Profile'; } from './style/Profile';
class ProfileScreen extends Component { class ProfileScreen extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.scrollY = new Animated.Value(0); this.scrollY = new Animated.Value(0);
this.textHeight = Platform.OS === 'android' ? 27 : 22; this.textHeight = Platform.OS === 'android' ? 27 : 22;
this.state = {
modalVisible: false,
image: this.props.profile.avatar.full,
};
} }
setModalVisible = (visible) => {
this.setState({
modalVisible: visible,
});
};
isOwnProfilePage = () => {
return this.props.pk === this.props.profile.pk;
};
getAppbar = () => { getAppbar = () => {
const headerHeight = this.scrollY.interpolate({ const headerHeight = this.scrollY.interpolate({
inputRange: [0, HEADER_SCROLL_DISTANCE], inputRange: [0, HEADER_SCROLL_DISTANCE],
...@@ -76,8 +96,7 @@ class ProfileScreen extends Component { ...@@ -76,8 +96,7 @@ class ProfileScreen extends Component {
fontSize: textSize, fontSize: textSize,
bottom: textPosBottom, bottom: textPosBottom,
}; };
let appBarBorderStyle = { let appBarBorderStyle = {};
};
if (Platform.OS === 'android') { if (Platform.OS === 'android') {
textStyle = { textStyle = {
...textStyle, ...textStyle,
...@@ -101,44 +120,75 @@ class ProfileScreen extends Component { ...@@ -101,44 +120,75 @@ class ProfileScreen extends Component {
} }
return ( return (
<Animated.View style={[styles.header, { height: headerHeight }]}> <TouchableWithoutFeedback
<Animated.View onPress={() => this.setModalVisible(true)}
style={[ >
styles.backgroundImage, <Animated.View style={[styles.header, { height: headerHeight }]}>
{ <Animated.View
opacity: imageOpacity, style={[
transform: [{ translateY: imageTranslate }], 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]}>
<TouchableOpacity
onPress={this.props.goBack}
> >
<Icon <ImageBackground
name="arrow-back" source={{ uri: this.state.image }}
style={styles.icon} style={styles.backgroundImage}
size={24} resizeMode="cover"
/> >
</TouchableOpacity> <LinearGradient colors={['#55000000', '#000000']} style={styles.overlayGradient}/>
<Animated.Text </ImageBackground>
style={[styles.title, textStyle]} </Animated.View>
> <Animated.View style={[styles.appBar, appBarBorderStyle]}>
{this.props.profile.display_name} <TouchableOpacity
</Animated.Text> onPress={this.props.goBack}
>
<Icon
name="arrow-back"
style={styles.icon}
size={24}
/>
</TouchableOpacity>
<Animated.Text
style={[styles.title, textStyle]}
>
{this.props.profile.display_name}
</Animated.Text>
</Animated.View>
</Animated.View> </Animated.View>
</Animated.View> </TouchableWithoutFeedback>
); );
}; };
handleEditPress = () => {
const options = {
title: 'Select Avatar',
storageOptions: {
skipBackup: true,
path: 'images',
},
};
ImagePicker.launchImageLibrary(options, (response) => {
console.log('Response = ', response);
if (response.didCancel) {
console.log('User cancelled image picker');
} else if (response.error) {
console.log('ImagePicker Error: ', response.error);
} else if (response.customButton) {
console.log('User tapped custom button: ', response.customButton);
} else {
const source = response.uri;
this.setState({
image: source,
});
}
});
};
render() { render() {
const { const {
hasLoaded, profile, t, openUrl, success, hasLoaded, profile, t, openUrl, success,
...@@ -147,8 +197,8 @@ class ProfileScreen extends Component { ...@@ -147,8 +197,8 @@ class ProfileScreen extends Component {
if (!hasLoaded) { if (!hasLoaded) {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<StandardHeader /> <StandardHeader/>
<LoadingScreen /> <LoadingScreen/>
</View> </View>
); );
} if (!success) { } if (!success) {
...@@ -168,6 +218,44 @@ class ProfileScreen extends Component { ...@@ -168,6 +218,44 @@ class ProfileScreen extends Component {
translucent translucent
animated animated
/> />
<Modal
visible={this.state.modalVisible}
transparent={true}
onRequestClose={() => this.setModalVisible(false)}
animationType={'fade'}
>
<View
style={{
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(52, 52, 52, 0.8)',
width: '100%',
height: '100%'
}}
>
<Image
source={{ uri: this.state.image }}
style={{ width: '90%', height: '60%' }}
resizeMode={'contain'}
/>
<View
style={{ justifyContent: 'space-around', flexDirection: 'row' }}
>
{this.isOwnProfilePage() && (
<Icon
name="edit"
style={{ flex: 1, fontSize: 24, color: 'white', textAlign: 'center' }}
onPress={this.handleEditPress}
/>
)}
<Icon
name="cancel"
style={{ flex: 1, fontSize: 24, color: 'white', textAlign: 'center' }}
onPress={() => this.setModalVisible(false)}
/>
</View>
</View>
</Modal>
<ScrollView <ScrollView
style={styles.container} style={styles.container}
scrollEventThrottle={16} scrollEventThrottle={16}
...@@ -187,6 +275,7 @@ class ProfileScreen extends Component { ...@@ -187,6 +275,7 @@ class ProfileScreen extends Component {
} }
ProfileScreen.propTypes = { ProfileScreen.propTypes = {
pk: PropTypes.number.isRequired,
profile: PropTypes.shape({ profile: PropTypes.shape({
pk: PropTypes.number.isRequired, pk: PropTypes.number.isRequired,
display_name: PropTypes.string.isRequired, display_name: PropTypes.string.isRequired,
......
...@@ -6,6 +6,7 @@ const mapStateToProps = state => ({ ...@@ -6,6 +6,7 @@ const mapStateToProps = state => ({
profile: state.profile.profile, profile: state.profile.profile,
success: state.profile.success, success: state.profile.success,
hasLoaded: state.profile.hasLoaded, hasLoaded: state.profile.hasLoaded,
pk: state.session.pk,
}); });
const mapDispatchToProps = { const mapDispatchToProps = {
......
...@@ -9,6 +9,8 @@ target 'ThaliApp' do ...@@ -9,6 +9,8 @@ target 'ThaliApp' do
pod 'Firebase/Core', '~> 5.20.1' pod 'Firebase/Core', '~> 5.20.1'
pod 'Firebase/Messaging', '~> 5.20.1' pod 'Firebase/Messaging', '~> 5.20.1'
pod 'react-native-image-picker', :path => '../node_modules/react-native-image-picker'
target 'ThaliAppTests' do target 'ThaliAppTests' do
inherit! :search_paths inherit! :search_paths
# Pods for testing # Pods for testing
......
...@@ -80,5 +80,13 @@ ...@@ -80,5 +80,13 @@
</array> </array>
<key>UIViewControllerBasedStatusBarAppearance</key> <key>UIViewControllerBasedStatusBarAppearance</key>
<false/> <false/>
<key>NSPhotoLibraryUsageDescription</key>
<string>$(PRODUCT_NAME) would like access to your photo gallery</string>
<key>NSCameraUsageDescription</key>
<string>$(PRODUCT_NAME) would like to use your camera</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>$(PRODUCT_NAME) would like to save photos to your photo gallery</string>
<key>NSMicrophoneUsageDescription</key>
<string>$(PRODUCT_NAME) would like to your microphone (for videos)</string>
</dict> </dict>
</plist> </plist>