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

Merge branch 'feature/8-profile-view' into 'master'

Created profile view

Closes #8

See merge request !43
parents fe0e3aec 14c0f478
...@@ -11,3 +11,5 @@ export const LOADEVENTSUCCESS = 'LOADEVENTSUCCESS'; ...@@ -11,3 +11,5 @@ export const LOADEVENTSUCCESS = 'LOADEVENTSUCCESS';
export const LOADEVENTFAILURE = 'LOADEVENTFAILURE'; export const LOADEVENTFAILURE = 'LOADEVENTFAILURE';
export const RESETLOGINSTATE = 'RESETLOGINSTATE'; export const RESETLOGINSTATE = 'RESETLOGINSTATE';
export const WELCOME = 'WELCOME'; export const WELCOME = 'WELCOME';
export const LOADPROFILESUCCESS = 'LOADPROFILESUCCESS';
export const LOADPROFILEFAILURE = 'LOADPROFILEFAILURE';
import * as types from './actionTypes';
import { navigate } from './navigation';
import { apiUrl } from '../url';
export function success(profile) {
return {
type: types.LOADPROFILESUCCESS,
profile,
};
}
export function fail() {
return {
type: types.LOADPROFILEFAILURE,
};
}
export function loadProfile(token, member = 'me') {
return (dispatch) => {
const data = {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Token ${token}`,
},
};
return fetch(`${apiUrl}/members/${member}/`, data)
.then(
response => response.json(),
)
.then(
(response) => {
dispatch(success(response));
dispatch(navigate('profile'));
},
)
.catch(
() => {
dispatch(fail());
dispatch(navigate('profile'));
},
);
};
}
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Image, Text, ViewPropTypes, StyleSheet } from 'react-native'; import { Image, Text, ViewPropTypes, StyleSheet, TouchableHighlight } from 'react-native';
import LinearGradient from 'react-native-linear-gradient'; import LinearGradient from 'react-native-linear-gradient';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { url } from '../url'; import { url } from '../url';
import styles from './style/memberView'; import styles from './style/memberView';
import SquareView from './SquareView'; import SquareView from './SquareView';
import { loadProfile } from '../actions/profile';
const regex = new RegExp(`^(${url}/media/public)/(avatars/[^\\.]+)\\.(jpg|jpeg|png|gif)`); const regex = new RegExp(`^(${url}/media/public)/(avatars/[^\\.]+)\\.(jpg|jpeg|png|gif)`);
...@@ -19,10 +21,15 @@ const MemberView = (props) => { ...@@ -19,10 +21,15 @@ const MemberView = (props) => {
return ( return (
<SquareView style={props.style}> <SquareView style={props.style}>
<Image style={styles.image} source={{ uri: photo }}> <TouchableHighlight
<LinearGradient colors={['#55000000', '#000000']} style={styles.overlayGradient} /> style={styles.image}
<Text style={styles.nameText}>{props.member.name}</Text> onPress={() => props.loadProfile(props.token, props.member.member)}
</Image> >
<Image style={styles.image} source={{ uri: photo }}>
<LinearGradient colors={['#55000000', '#000000']} style={styles.overlayGradient} />
<Text style={styles.nameText}>{props.member.name}</Text>
</Image>
</TouchableHighlight>
</SquareView> </SquareView>
); );
}; };
...@@ -31,8 +38,11 @@ MemberView.propTypes = { ...@@ -31,8 +38,11 @@ MemberView.propTypes = {
member: PropTypes.shape({ member: PropTypes.shape({
name: PropTypes.string, name: PropTypes.string,
photo: PropTypes.string, photo: PropTypes.string,
member: PropTypes.number,
}).isRequired, }).isRequired,
style: ViewPropTypes.style, style: ViewPropTypes.style,
token: PropTypes.string.isRequired,
loadProfile: PropTypes.func.isRequired,
}; };
const defaultStyles = StyleSheet.create({ const defaultStyles = StyleSheet.create({
...@@ -42,4 +52,12 @@ MemberView.defaultProps = { ...@@ -42,4 +52,12 @@ MemberView.defaultProps = {
style: defaultStyles, style: defaultStyles,
}; };
export default connect(() => ({}), () => ({}))(MemberView); const mapStateToProps = state => ({
token: state.session.token,
});
const mapDispatchToProps = dispatch => ({
loadProfile: (token, pk) => dispatch(loadProfile(token, pk)),
});
export default connect(mapStateToProps, mapDispatchToProps)(MemberView);
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { ScrollView, Text, View, Animated, TouchableOpacity } from 'react-native';
import { connect } from 'react-redux';
import Icon from 'react-native-vector-icons/MaterialIcons';
import LinearGradient from 'react-native-linear-gradient';
import Moment from 'moment';
import { back } from '../actions/navigation';
import styles, { HEADER_MIN_HEIGHT, HEADER_MAX_HEIGHT, HEADER_SCROLL_DISTANCE } from './style/profile';
const getDescription = profile => ([
<Text style={[styles.sectionHeader, styles.marginTop]} key="title">{`Over ${profile.display_name}`}</Text>,
<View style={styles.card} key="content">
<Text
style={[
styles.data,
styles.item,
!profile.profile_description && styles.italics]}
>{profile.profile_description || 'Dit lid heeft nog geen beschrijving geschreven'}</Text>
</View>,
]);
const getPersonalInfo = (profile) => {
const profileInfo = {
starting_year: {
title: 'Cohort',
display: x => x,
},
programme: {
title: 'Studie',
display: x => (x === 'computingscience' ? 'Computing science' : 'Information sciences'),
},
website: {
title: 'Website',
display: x => x,
},
birthday: {
title: 'Verjaardag',
display: x => Moment(x).format('D MMMM YYYY'),
},
};
const profileData = Object.keys(profileInfo).map((key) => {
if (profile[key]) {
return {
title: profileInfo[key].title,
value: profileInfo[key].display(profile[key]),
};
}
return null;
}).filter(n => n);
if (profileData) {
return [
<Text style={styles.sectionHeader} key="title">Persoonlijke gegevens</Text>,
<View style={styles.card} key="content">
{profileData.map((item, i) => (
<View style={[styles.item, i !== 0 && styles.borderTop]} key={item.title}>
<Text style={styles.description}>{item.title}</Text>
<Text style={styles.data}>{item.value}</Text>
</View>
))}
</View>,
];
}
return <View />;
};
const getAchievements = (profile) => {
if (profile.achievements) {
return [
<Text style={styles.sectionHeader} key="title">Verdiensten voor Thalia</Text>,
<View style={styles.card} key="content">
{profile.achievements.map((achievement, i) => (
<View style={[styles.item, i !== 0 && styles.borderTop]} key={achievement.name}>
<Text style={styles.description}>{achievement.name}</Text>
{achievement.periods && achievement.periods.map((period) => {
let start = Moment(period.since);
start = start.isSame(Moment([1970, 1, 1]), 'day') ? '?' : start.format('D MMMM YYYY');
const end = period.until ? Moment(period.until).format('D MMMM YYYY') : 'heden';
let text = '';
if (period.role) {
text = `${period.role}: `;
} else if (period.chair) {
text = 'Voorzitter: ';
}
text += `${start} - ${end}`;
return <Text style={styles.data} key={period.since}>{text}</Text>;
})}
</View>
))}
</View>,
];
}
return <View />;
};
class Profile extends Component {
constructor(props) {
super(props);
this.scrollY = new Animated.Value(0);
}
render() {
const headerHeight = this.props.success ? this.scrollY.interpolate({
inputRange: [0, HEADER_SCROLL_DISTANCE],
outputRange: [HEADER_MAX_HEIGHT, HEADER_MIN_HEIGHT],
extrapolate: 'clamp',
}) : HEADER_MIN_HEIGHT;
const imageOpacity = this.props.success ? this.scrollY.interpolate({
inputRange: [0, HEADER_SCROLL_DISTANCE / 2, HEADER_SCROLL_DISTANCE],
outputRange: [1, 1, 0],
extrapolate: 'clamp',
}) : 0;
const imageTranslate = this.scrollY.interpolate({
inputRange: [0, HEADER_SCROLL_DISTANCE],
outputRange: [0, -50],
extrapolate: 'clamp',
});
const textSize = this.props.success ? this.scrollY.interpolate({
inputRange: [0, HEADER_SCROLL_DISTANCE / 2, HEADER_SCROLL_DISTANCE],
outputRange: [30, 30, 20],
extrapolate: 'clamp',
}) : 20;
const textPosLeft = this.props.success ? this.scrollY.interpolate({
inputRange: [0, HEADER_SCROLL_DISTANCE / 2, HEADER_SCROLL_DISTANCE],
outputRange: [20, 20, 60],
extrapolate: 'clamp',
}) : 60;
const textPosBottom = this.props.success ? this.scrollY.interpolate({
inputRange: [0, HEADER_SCROLL_DISTANCE / 2, HEADER_SCROLL_DISTANCE],
outputRange: [20, 20, (HEADER_MIN_HEIGHT - 24) / 2],
extrapolate: 'clamp',
}) : (HEADER_MIN_HEIGHT - 24) / 2;
return (
<View style={styles.container}>
{
this.props.success ? (
<ScrollView
style={styles.container}
scrollEventThrottle={16}
onScroll={Animated.event([{ nativeEvent: { contentOffset: { y: this.scrollY } } }])}
>
<View style={styles.content}>
{getDescription(this.props.profile)}
{getPersonalInfo(this.props.profile)}
{getAchievements(this.props.profile)}
</View>
</ScrollView>
) : (
<View style={styles.container}>
<Text style={styles.errorText}>
Ophalen profiel mislukt.
</Text>
</View>
)
}
<Animated.View style={[styles.header, { height: headerHeight }]}>
<Animated.Image
style={[
styles.backgroundImage,
{
opacity: imageOpacity,
transform: [{ translateY: imageTranslate }],
},
]}
source={{ uri: this.props.profile.photo }}
>
<LinearGradient colors={['#55000000', '#000000']} style={styles.overlayGradient} />
</Animated.Image>
<Animated.View style={styles.appBar}>
<TouchableOpacity
onPress={this.props.back}
>
<Icon
name="arrow-back"
style={styles.icon}
size={24}
/>
</TouchableOpacity>
<Animated.Text
style={[styles.title, {
left: textPosLeft,
bottom: textPosBottom,
fontSize: textSize,
}]}
>{this.props.success ? this.props.profile.display_name : 'Profiel'}</Animated.Text>
</Animated.View>
</Animated.View>
</View>
);
}
}
Profile.propTypes = {
profile: PropTypes.shape({
pk: PropTypes.number.isRequired,
display_name: PropTypes.string.isRequired,
photo: PropTypes.string.isRequired,
profile_description: PropTypes.string,
birthday: PropTypes.string,
starting_year: PropTypes.number,
programme: PropTypes.string,
website: PropTypes.string,
membership_type: PropTypes.string,
achievements: PropTypes.arrayOf(PropTypes.shape({
name: PropTypes.string.isRequired,
earliest: PropTypes.string,
periods: PropTypes.arrayOf(PropTypes.shape({
chair: PropTypes.bool.isRequired,
until: PropTypes.string,
since: PropTypes.string.isRequired,
role: PropTypes.string,
})),
})).isRequired,
}).isRequired,
success: PropTypes.bool.isRequired,
back: PropTypes.func.isRequired,
};
const mapStateToProps = state => ({
profile: state.profile.profile,
success: state.profile.success,
});
const mapDispatchToProps = dispatch => ({
back: () => dispatch(back()),
});
export default connect(mapStateToProps, mapDispatchToProps)(Profile);
...@@ -7,8 +7,9 @@ import Icon from 'react-native-vector-icons/MaterialIcons'; ...@@ -7,8 +7,9 @@ import Icon from 'react-native-vector-icons/MaterialIcons';
import { colors } from '../style'; import { colors } from '../style';
import styles from './style/sidebar'; import styles from './style/sidebar';
import * as actions from '../actions/navigation'; import * as navigationActions from '../actions/navigation';
import * as loginActions from '../actions/login'; import * as loginActions from '../actions/login';
import { loadProfile } from '../actions/profile';
const background = require('../img/huygens.jpg'); const background = require('../img/huygens.jpg');
...@@ -45,7 +46,7 @@ const Sidebar = (props) => { ...@@ -45,7 +46,7 @@ const Sidebar = (props) => {
style={styles.sidebar} style={styles.sidebar}
> >
<TouchableHighlight <TouchableHighlight
onPress={() => props.navigate('profile')} onPress={() => props.loadProfile(props.token)}
style={styles.headerButton} style={styles.headerButton}
> >
<Image <Image
...@@ -88,19 +89,24 @@ Sidebar.propTypes = { ...@@ -88,19 +89,24 @@ Sidebar.propTypes = {
currentScene: PropTypes.string.isRequired, currentScene: PropTypes.string.isRequired,
displayName: PropTypes.string.isRequired, displayName: PropTypes.string.isRequired,
photo: PropTypes.string.isRequired, photo: PropTypes.string.isRequired,
navigate: PropTypes.func.isRequired, token: PropTypes.string.isRequired,
logout: PropTypes.func.isRequired, logout: PropTypes.func.isRequired,
loadProfile: PropTypes.func.isRequired,
// eslint-disable-next-line react/no-unused-prop-types
navigate: PropTypes.func.isRequired,
}; };
const mapStateToProps = state => ({ const mapStateToProps = state => ({
currentScene: state.navigation.currentScene, currentScene: state.navigation.currentScene,
displayName: state.session.displayName, displayName: state.session.displayName,
photo: state.session.photo, photo: state.session.photo,
token: state.session.token,
}); });
const mapDispatchToProps = dispatch => ({ const mapDispatchToProps = dispatch => ({
navigate: (scene, newSection = false) => dispatch(actions.navigate(scene, newSection)), navigate: (scene, newSection = false) => dispatch(navigationActions.navigate(scene, newSection)),
logout: () => dispatch(loginActions.logout()), logout: () => dispatch(loginActions.logout()),
loadProfile: token => dispatch(loadProfile(token)),
}); });
export default connect(mapStateToProps, mapDispatchToProps)(Sidebar); export default connect(mapStateToProps, mapDispatchToProps)(Sidebar);
...@@ -10,6 +10,7 @@ import Welcome from './Welcome'; ...@@ -10,6 +10,7 @@ import Welcome from './Welcome';
import Sidebar from './Sidebar'; import Sidebar from './Sidebar';
import Event from './Event'; import Event from './Event';
import Calendar from './Calendar'; import Calendar from './Calendar';
import Profile from './Profile';
import * as actions from '../actions/navigation'; import * as actions from '../actions/navigation';
import styles from './style/navigator'; import styles from './style/navigator';
...@@ -36,6 +37,8 @@ const sceneToComponent = (scene) => { ...@@ -36,6 +37,8 @@ const sceneToComponent = (scene) => {
return <Event />; return <Event />;
case 'eventList': case 'eventList':
return <Calendar />; return <Calendar />;
case 'profile':
return <Profile />;
default: default:
return <Welcome />; return <Welcome />;
} }
...@@ -92,19 +95,20 @@ const ReduxNavigator = (props) => { ...@@ -92,19 +95,20 @@ const ReduxNavigator = (props) => {
<View style={styles.statusBar}> <View style={styles.statusBar}>
<StatusBar backgroundColor={colors.darkMagenta} barStyle="light-content" /> <StatusBar backgroundColor={colors.darkMagenta} barStyle="light-content" />
</View> </View>
<View style={styles.appBar}> {currentScene !== 'profile' && (
<TouchableOpacity <View style={styles.appBar}>
onPress={props.isFirstScene ? () => props.updateDrawer(!props.drawerOpen) : props.back} <TouchableOpacity
> onPress={props.isFirstScene ? () => props.updateDrawer(!props.drawerOpen) : props.back}
<Icon >
name={props.isFirstScene ? 'menu' : 'arrow-back'} <Icon
onClick={props.back} name={props.isFirstScene ? 'menu' : 'arrow-back'}
style={styles.icon} onClick={props.back}
size={24} style={styles.icon}
/> size={24}
</TouchableOpacity> />
<Text style={styles.title}>{sceneToTitle(currentScene)}</Text> </TouchableOpacity>
</View> <Text style={styles.title}>{sceneToTitle(currentScene)}</Text>
</View>)}
{sceneToComponent(currentScene)} {sceneToComponent(currentScene)}
<SnackBar visible={loginState === 'success'} textMessage={'Login success'} /> <SnackBar visible={loginState === 'success'} textMessage={'Login success'} />
</Drawer>); </Drawer>);
......
...@@ -3,7 +3,7 @@ import { Platform, StyleSheet } from 'react-native'; ...@@ -3,7 +3,7 @@ import { Platform, StyleSheet } from 'react-native';
import { colors } from '../../style'; import { colors } from '../../style';
const STATUSBAR_HEIGHT = Platform.OS === 'ios' ? 20 : 0; const STATUSBAR_HEIGHT = Platform.OS === 'ios' ? 20 : 0;
const APPBAR_HEIGHT = Platform.OS === 'ios' ? 44 : 56; export const APPBAR_HEIGHT = Platform.OS === 'ios' ? 44 : 56;
export const TOTAL_BAR_HEIGHT = APPBAR_HEIGHT + 20; export const TOTAL_BAR_HEIGHT = APPBAR_HEIGHT + 20;
......
import { StyleSheet } from 'react-native';
import { colors } from '../../style';
import { APPBAR_HEIGHT } from './navigator';
export const HEADER_MIN_HEIGHT = APPBAR_HEIGHT;
export const HEADER_MAX_HEIGHT = 200;
export const HEADER_SCROLL_DISTANCE = HEADER_MAX_HEIGHT - HEADER_MIN_HEIGHT;
const styles = StyleSheet.create({
container: {
backgroundColor: colors.background,
flex: 1,
},
card: {
backgroundColor: colors.white,
borderRadius: 2,
elevation: 2,
marginLeft: 8,
marginRight: 8,
marginTop: 10,
marginBottom: 10,
},
item: {
padding: 16,
backgroundColor: colors.white,
},
borderTop: {
borderTopColor: colors.dividerGrey,
borderTopWidth: 1,
},
description: {
fontSize: 14,
lineHeight: 24,
color: colors.black,
fontFamily: 'sans-serif-medium',
},
data: {
fontSize: 14,
lineHeight: 24,
color: colors.gray,
fontFamily: 'sans-serif-medium',
},
sectionHeader: {
backgroundColor: colors.background,
fontFamily: 'sans-serif-medium',
fontSize: 14,
color: colors.textColour,
marginLeft: 18,
},
marginTop: {
marginTop: 10,
},
italics: {
fontStyle: 'italic',
},
header: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
backgroundColor: colors.magenta,
overflow: 'hidden',
elevation: 4,
},
appBar: {
backgroundColor: colors.transparent,
flex: 1,
justifyContent: 'flex-start',
alignItems: 'flex-start',
flexWrap: 'wrap',
flexDirection: 'row',
},
content: {
marginTop: HEADER_MAX_HEIGHT,
},
icon: {