From 1279249ee9355f88913578f51e3b0bf7d99672f6 Mon Sep 17 00:00:00 2001 From: Leon Jiang <35908040+leonyjiang@users.noreply.github.com> Date: Wed, 5 Aug 2020 14:15:06 -0700 Subject: [TMA-122] User Profile Screen UI (#27) * Fix yarn lint issues * Add react-native-svg to project * Create UserType & PostType * Create temporary Post component * Fix import cycle warning, update AuthContext * Update onboarding screen imports * Update config files * Add rn-fetch-blob package * Update types * Add profile fetching to AuthContext * Update post component * Import placeholder images from designs * Add profile UI components * Create screen offset constants * Add new api endpoints * Create screen layout utils * Create Profile screen UI * Remove some unused styling * Restructure ProfileScreen and fix animations * Add gradient back to screen * Update Moment circle styling --- src/components/common/GradientBackground.tsx | 17 +++---- src/components/common/index.ts | 1 + src/components/common/post/Post.tsx | 26 ++++++++++ src/components/common/post/PostHeader.tsx | 51 +++++++++++++++++++ src/components/common/post/index.ts | 1 + src/components/index.ts | 1 + src/components/profile/Avatar.tsx | 31 ++++++++++++ src/components/profile/Content.tsx | 58 +++++++++++++++++++++ src/components/profile/Cover.tsx | 41 +++++++++++++++ src/components/profile/Feed.tsx | 25 ++++++++++ src/components/profile/FollowCount.tsx | 42 ++++++++++++++++ src/components/profile/Moment.tsx | 35 +++++++++++++ src/components/profile/MomentsBar.tsx | 75 ++++++++++++++++++++++++++++ src/components/profile/ProfileBody.tsx | 46 +++++++++++++++++ src/components/profile/ProfileCutout.tsx | 23 +++++++++ src/components/profile/ProfileHeader.tsx | 62 +++++++++++++++++++++++ src/components/profile/index.ts | 7 +++ 17 files changed, 533 insertions(+), 9 deletions(-) create mode 100644 src/components/common/post/Post.tsx create mode 100644 src/components/common/post/PostHeader.tsx create mode 100644 src/components/common/post/index.ts create mode 100644 src/components/profile/Avatar.tsx create mode 100644 src/components/profile/Content.tsx create mode 100644 src/components/profile/Cover.tsx create mode 100644 src/components/profile/Feed.tsx create mode 100644 src/components/profile/FollowCount.tsx create mode 100644 src/components/profile/Moment.tsx create mode 100644 src/components/profile/MomentsBar.tsx create mode 100644 src/components/profile/ProfileBody.tsx create mode 100644 src/components/profile/ProfileCutout.tsx create mode 100644 src/components/profile/ProfileHeader.tsx create mode 100644 src/components/profile/index.ts (limited to 'src/components') diff --git a/src/components/common/GradientBackground.tsx b/src/components/common/GradientBackground.tsx index f363bd61..c1247ca2 100644 --- a/src/components/common/GradientBackground.tsx +++ b/src/components/common/GradientBackground.tsx @@ -5,20 +5,19 @@ import { TouchableWithoutFeedback, Keyboard, ViewProps, - SafeAreaView, } from 'react-native'; interface GradientBackgroundProps extends ViewProps {} const GradientBackground: React.FC = (props) => { return ( - - - {props.children} - - + + + {props.children} + + ); }; diff --git a/src/components/common/index.ts b/src/components/common/index.ts index a1bcc558..826675ff 100644 --- a/src/components/common/index.ts +++ b/src/components/common/index.ts @@ -4,3 +4,4 @@ export {default as RadioCheckbox} from './RadioCheckbox'; export {default as TaggInput} from './TaggInput'; export {default as NavigationIcon} from './NavigationIcon'; export {default as GradientBackground} from './GradientBackground'; +export {default as Post} from './post'; diff --git a/src/components/common/post/Post.tsx b/src/components/common/post/Post.tsx new file mode 100644 index 00000000..d6c5a7d6 --- /dev/null +++ b/src/components/common/post/Post.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import {StyleSheet, View} from 'react-native'; +import {PostType} from '../../../types'; +import PostHeader from './PostHeader'; +import {SCREEN_WIDTH} from '../../../utils'; + +interface PostProps { + post: PostType; +} +const Post: React.FC = ({post: {owner}}) => { + return ( + <> + + + + ); +}; + +const styles = StyleSheet.create({ + image: { + width: SCREEN_WIDTH, + height: SCREEN_WIDTH, + backgroundColor: '#eee', + }, +}); +export default Post; diff --git a/src/components/common/post/PostHeader.tsx b/src/components/common/post/PostHeader.tsx new file mode 100644 index 00000000..8558d21d --- /dev/null +++ b/src/components/common/post/PostHeader.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import {UserType} from '../../../types'; +import {View, StyleSheet, Image, Text} from 'react-native'; +import {AuthContext} from '../../../routes/authentication'; + +const AVATAR_DIM = 35; +interface PostHeaderProps { + owner: UserType; +} +const PostHeader: React.FC = ({owner: {username}}) => { + const {avatar} = React.useContext(AuthContext); + return ( + + + + {username} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + justifyContent: 'space-between', + padding: 10, + backgroundColor: 'white', + }, + leftElem: { + flexDirection: 'row', + alignItems: 'center', + }, + avatar: { + width: AVATAR_DIM, + height: AVATAR_DIM, + borderRadius: AVATAR_DIM / 2, + marginRight: 10, + }, + username: { + fontSize: 18, + }, +}); + +export default PostHeader; diff --git a/src/components/common/post/index.ts b/src/components/common/post/index.ts new file mode 100644 index 00000000..033f8a8d --- /dev/null +++ b/src/components/common/post/index.ts @@ -0,0 +1 @@ +export {default} from './Post'; diff --git a/src/components/index.ts b/src/components/index.ts index 724b14ac..48b7df05 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,2 +1,3 @@ export * from './common'; export * from './onboarding'; +export * from './profile'; diff --git a/src/components/profile/Avatar.tsx b/src/components/profile/Avatar.tsx new file mode 100644 index 00000000..a0f7596c --- /dev/null +++ b/src/components/profile/Avatar.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import {Image, StyleSheet} from 'react-native'; +import {AuthContext} from '../../routes/authentication'; + +const PROFILE_DIM = 100; +interface AvatarProps { + style: object; +} +const Avatar: React.FC = ({style}) => { + const {avatar} = React.useContext(AuthContext); + return ( + + ); +}; + +const styles = StyleSheet.create({ + image: { + height: PROFILE_DIM, + width: PROFILE_DIM, + borderRadius: PROFILE_DIM / 2, + }, +}); + +export default Avatar; diff --git a/src/components/profile/Content.tsx b/src/components/profile/Content.tsx new file mode 100644 index 00000000..82b5fdc0 --- /dev/null +++ b/src/components/profile/Content.tsx @@ -0,0 +1,58 @@ +import React, {useState} from 'react'; +import {StyleSheet, LayoutChangeEvent} from 'react-native'; +import Animated from 'react-native-reanimated'; +const {ScrollView} = Animated; + +import {UserType} from '../../types'; +import ProfileCutout from './ProfileCutout'; +import ProfileHeader from './ProfileHeader'; +import ProfileBody from './ProfileBody'; +import MomentsBar from './MomentsBar'; +import Feed from './Feed'; +import LinearGradient from 'react-native-linear-gradient'; +import {SCREEN_HEIGHT, SCREEN_WIDTH} from '../../utils'; + +interface ContentProps { + y: Animated.Value; + user: UserType; +} +const Content: React.FC = ({y, user}) => { + const [profileBodyHeight, setProfileBodyHeight] = useState(0); + const onLayout = (e: LayoutChangeEvent) => { + const {height} = e.nativeEvent.layout; + setProfileBodyHeight(height); + }; + return ( + y.setValue(e.nativeEvent.contentOffset.y)} + showsVerticalScrollIndicator={false} + scrollEventThrottle={1} + stickyHeaderIndices={[2, 4]}> + + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + gradient: { + height: SCREEN_HEIGHT, + width: SCREEN_WIDTH, + position: 'absolute', + }, +}); + +export default Content; diff --git a/src/components/profile/Cover.tsx b/src/components/profile/Cover.tsx new file mode 100644 index 00000000..01199f06 --- /dev/null +++ b/src/components/profile/Cover.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import {Image, StyleSheet} from 'react-native'; +import Animated from 'react-native-reanimated'; +import {IMAGE_WIDTH, COVER_HEIGHT} from '../../constants'; +import {AuthContext} from '../../routes/authentication'; + +const {interpolate, Extrapolate} = Animated; +interface CoverProps { + y: Animated.Value; +} +const Cover: React.FC = ({y}) => { + const {cover} = React.useContext(AuthContext); + const scale: Animated.Node = interpolate(y, { + inputRange: [-COVER_HEIGHT, 0], + outputRange: [1.5, 1.25], + extrapolateRight: Extrapolate.CLAMP, + }); + return ( + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + position: 'absolute', + }, + image: { + width: IMAGE_WIDTH, + height: COVER_HEIGHT, + }, +}); +export default Cover; diff --git a/src/components/profile/Feed.tsx b/src/components/profile/Feed.tsx new file mode 100644 index 00000000..6780f8c5 --- /dev/null +++ b/src/components/profile/Feed.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import {PostType, UserType} from '../../types'; +import {Post} from '../common'; + +interface FeedProps { + user: UserType; +} +const Feed: React.FC = ({user}) => { + const posts: Array = []; + const dummyPost: PostType = { + owner: user, + }; + for (let i = 0; i < 20; i++) { + posts.push(dummyPost); + } + return ( + <> + {posts.map((post, index) => ( + + ))} + + ); +}; + +export default Feed; diff --git a/src/components/profile/FollowCount.tsx b/src/components/profile/FollowCount.tsx new file mode 100644 index 00000000..72817e7a --- /dev/null +++ b/src/components/profile/FollowCount.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import {View, Text, StyleSheet, ViewProps} from 'react-native'; + +interface FollowCountProps extends ViewProps { + mode: 'followers' | 'following'; + count: number; +} + +const FollowCount: React.FC = ({style, mode, count}) => { + const displayed: string = + count < 5e3 + ? `${count}` + : count < 1e5 + ? `${(count / 1e3).toFixed(1)}k` + : count < 1e6 + ? `${(count / 1e3).toFixed(0)}k` + : `${count / 1e6}m`; + return ( + + {displayed} + + {mode === 'followers' ? 'Followers' : 'Following'} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + }, + count: { + fontWeight: '700', + fontSize: 18, + }, + label: { + fontWeight: '400', + fontSize: 16, + }, +}); + +export default FollowCount; diff --git a/src/components/profile/Moment.tsx b/src/components/profile/Moment.tsx new file mode 100644 index 00000000..eaf43fea --- /dev/null +++ b/src/components/profile/Moment.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import {View, StyleSheet, ViewProps} from 'react-native'; +import LinearGradient from 'react-native-linear-gradient'; + +interface MomentProps extends ViewProps {} +const Moment: React.FC = ({style}) => { + return ( + + + + ); +}; + +const styles = StyleSheet.create({ + gradient: { + width: 80, + height: 80, + borderRadius: 40, + justifyContent: 'center', + alignItems: 'center', + }, + image: { + width: 72, + height: 72, + borderRadius: 37.5, + backgroundColor: 'pink', + }, +}); + +export default Moment; diff --git a/src/components/profile/MomentsBar.tsx b/src/components/profile/MomentsBar.tsx new file mode 100644 index 00000000..dcc88d89 --- /dev/null +++ b/src/components/profile/MomentsBar.tsx @@ -0,0 +1,75 @@ +// @refresh react +import React from 'react'; +import {StyleSheet} from 'react-native'; +import Animated from 'react-native-reanimated'; +import Moment from './Moment'; +import {PROFILE_CUTOUT_BOTTOM_Y} from '../../constants'; +import {StatusBarHeight} from '../../utils'; + +const {View, ScrollView, interpolate, Extrapolate} = Animated; +interface MomentsBarProps { + y: Animated.Value; + profileBodyHeight: number; +} +const MomentsBar: React.FC = ({y, profileBodyHeight}) => { + const moments: Array = []; + for (let i = 0; i < 10; i++) { + moments.push(); + } + const shadowOpacity: Animated.Node = interpolate(y, { + inputRange: [ + PROFILE_CUTOUT_BOTTOM_Y + profileBodyHeight, + PROFILE_CUTOUT_BOTTOM_Y + profileBodyHeight + 20, + ], + outputRange: [0, 0.2], + extrapolate: Extrapolate.CLAMP, + }); + const paddingTop: Animated.Node = interpolate(y, { + inputRange: [ + 0, + PROFILE_CUTOUT_BOTTOM_Y + profileBodyHeight - 30, + PROFILE_CUTOUT_BOTTOM_Y + profileBodyHeight, + ], + outputRange: [20, 20, StatusBarHeight], + extrapolate: Extrapolate.CLAMP, + }); + const paddingBottom: Animated.Node = interpolate(y, { + inputRange: [ + 0, + PROFILE_CUTOUT_BOTTOM_Y + profileBodyHeight - 30, + PROFILE_CUTOUT_BOTTOM_Y + profileBodyHeight, + ], + outputRange: [30, 30, 15], + extrapolate: Extrapolate.CLAMP, + }); + return ( + + + {moments} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + backgroundColor: 'white', + shadowColor: '#000', + shadowRadius: 10, + shadowOffset: {width: 0, height: 2}, + zIndex: 1, + }, + contentContainer: { + alignItems: 'center', + paddingHorizontal: 15, + }, + moment: { + marginHorizontal: 14, + }, +}); + +export default MomentsBar; diff --git a/src/components/profile/ProfileBody.tsx b/src/components/profile/ProfileBody.tsx new file mode 100644 index 00000000..e8d8de62 --- /dev/null +++ b/src/components/profile/ProfileBody.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import {StyleSheet, View, Text, LayoutChangeEvent} from 'react-native'; +import {AuthContext} from '../../routes/authentication'; + +interface ProfileBodyProps { + onLayout: (event: LayoutChangeEvent) => void; +} +const ProfileBody: React.FC = ({onLayout}) => { + const { + profile, + user: {username}, + } = React.useContext(AuthContext); + const {biography, website} = profile; + return ( + + {`@${username}`} + {`${biography}`} + {`${website}`} + + ); +}; + +const styles = StyleSheet.create({ + container: { + paddingVertical: 5, + paddingHorizontal: 20, + backgroundColor: 'white', + }, + username: { + fontWeight: '600', + fontSize: 16, + marginBottom: 5, + }, + biography: { + fontSize: 16, + lineHeight: 22, + marginBottom: 5, + }, + website: { + fontSize: 16, + color: '#4E699C', + marginBottom: 5, + }, +}); + +export default ProfileBody; diff --git a/src/components/profile/ProfileCutout.tsx b/src/components/profile/ProfileCutout.tsx new file mode 100644 index 00000000..c5deb06d --- /dev/null +++ b/src/components/profile/ProfileCutout.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import Svg, {Polygon} from 'react-native-svg'; +import { + PROFILE_CUTOUT_CORNER_Y, + PROFILE_CUTOUT_CORNER_X, + PROFILE_CUTOUT_TOP_Y, + PROFILE_CUTOUT_BOTTOM_Y, +} from '../../constants'; +import {SCREEN_WIDTH} from '../../utils'; + +const ProfileCutout: React.FC = ({children}) => { + return ( + + + {children} + + ); +}; + +export default ProfileCutout; diff --git a/src/components/profile/ProfileHeader.tsx b/src/components/profile/ProfileHeader.tsx new file mode 100644 index 00000000..ec382357 --- /dev/null +++ b/src/components/profile/ProfileHeader.tsx @@ -0,0 +1,62 @@ +import React from 'react'; + +import Avatar from './Avatar'; +import FollowCount from './FollowCount'; +import {View, Text, StyleSheet} from 'react-native'; +import {SCREEN_HEIGHT, SCREEN_WIDTH} from '../../utils'; +import {AuthContext} from '../../routes/authentication'; + +const ProfileHeader: React.FC = () => { + const { + profile: {name}, + } = React.useContext(AuthContext); + return ( + + + + + {name} + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + top: SCREEN_HEIGHT / 2.4, + paddingHorizontal: SCREEN_WIDTH / 20, + marginBottom: SCREEN_HEIGHT / 10, + }, + row: { + flexDirection: 'row', + }, + header: { + justifyContent: 'center', + alignItems: 'center', + marginTop: SCREEN_HEIGHT / 40, + marginLeft: SCREEN_WIDTH / 10, + marginBottom: SCREEN_HEIGHT / 50, + }, + avatar: { + bottom: SCREEN_HEIGHT / 80, + }, + name: { + fontSize: 20, + fontWeight: '700', + marginBottom: SCREEN_HEIGHT / 80, + }, + follows: { + marginHorizontal: SCREEN_HEIGHT / 50, + }, +}); + +export default ProfileHeader; diff --git a/src/components/profile/index.ts b/src/components/profile/index.ts new file mode 100644 index 00000000..2052ee5b --- /dev/null +++ b/src/components/profile/index.ts @@ -0,0 +1,7 @@ +export {default as Cover} from './Cover'; +export {default as Content} from './Content'; +export {default as ProfileCutout} from './ProfileCutout'; +export {default as MomentsBar} from './MomentsBar'; +export {default as ProfileBody} from './ProfileBody'; +export {default as ProfileHeader} from './ProfileHeader'; +export {default as Feed} from './Feed'; -- cgit v1.2.3-70-g09d2