diff options
author | Leon Jiang <35908040+leonyjiang@users.noreply.github.com> | 2020-08-05 14:15:06 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2020-08-05 17:15:06 -0400 |
commit | 1279249ee9355f88913578f51e3b0bf7d99672f6 (patch) | |
tree | 4a72890af331ffc818fffc9fb5395a80efe2d7de /src/components | |
parent | f9cf9b5d89d5e25b227814f0fc759257564cea89 (diff) |
[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
Diffstat (limited to 'src/components')
-rw-r--r-- | src/components/common/GradientBackground.tsx | 17 | ||||
-rw-r--r-- | src/components/common/index.ts | 1 | ||||
-rw-r--r-- | src/components/common/post/Post.tsx | 26 | ||||
-rw-r--r-- | src/components/common/post/PostHeader.tsx | 51 | ||||
-rw-r--r-- | src/components/common/post/index.ts | 1 | ||||
-rw-r--r-- | src/components/index.ts | 1 | ||||
-rw-r--r-- | src/components/profile/Avatar.tsx | 31 | ||||
-rw-r--r-- | src/components/profile/Content.tsx | 58 | ||||
-rw-r--r-- | src/components/profile/Cover.tsx | 41 | ||||
-rw-r--r-- | src/components/profile/Feed.tsx | 25 | ||||
-rw-r--r-- | src/components/profile/FollowCount.tsx | 42 | ||||
-rw-r--r-- | src/components/profile/Moment.tsx | 35 | ||||
-rw-r--r-- | src/components/profile/MomentsBar.tsx | 75 | ||||
-rw-r--r-- | src/components/profile/ProfileBody.tsx | 46 | ||||
-rw-r--r-- | src/components/profile/ProfileCutout.tsx | 23 | ||||
-rw-r--r-- | src/components/profile/ProfileHeader.tsx | 62 | ||||
-rw-r--r-- | src/components/profile/index.ts | 7 |
17 files changed, 533 insertions, 9 deletions
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<GradientBackgroundProps> = (props) => { return ( - <LinearGradient - locations={[0.89, 1]} - colors={['transparent', 'rgba(0, 0, 0, 0.6)']} - style={styles.container}> - <TouchableWithoutFeedback accessible={false} onPress={Keyboard.dismiss}> - <SafeAreaView {...props}>{props.children}</SafeAreaView> - </TouchableWithoutFeedback> - </LinearGradient> + <TouchableWithoutFeedback accessible={false} onPress={Keyboard.dismiss}> + <LinearGradient + locations={[0.89, 1]} + colors={['transparent', 'rgba(0, 0, 0, 0.6)']} + style={styles.container}> + {props.children} + </LinearGradient> + </TouchableWithoutFeedback> ); }; 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<PostProps> = ({post: {owner}}) => { + return ( + <> + <PostHeader owner={owner} /> + <View style={styles.image} /> + </> + ); +}; + +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<PostHeaderProps> = ({owner: {username}}) => { + const {avatar} = React.useContext(AuthContext); + return ( + <View style={styles.container}> + <View style={styles.leftElem}> + <Image + style={styles.avatar} + source={ + avatar + ? {uri: avatar} + : require('../../../assets/images/avatar-placeholder.png') + } + /> + <Text style={styles.username}>{username}</Text> + </View> + </View> + ); +}; + +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<AvatarProps> = ({style}) => { + const {avatar} = React.useContext(AuthContext); + return ( + <Image + style={[styles.image, style]} + source={ + avatar + ? {uri: avatar} + : require('../../assets/images/avatar-placeholder.png') + } + /> + ); +}; + +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<number>; + user: UserType; +} +const Content: React.FC<ContentProps> = ({y, user}) => { + const [profileBodyHeight, setProfileBodyHeight] = useState(0); + const onLayout = (e: LayoutChangeEvent) => { + const {height} = e.nativeEvent.layout; + setProfileBodyHeight(height); + }; + return ( + <ScrollView + style={styles.container} + onScroll={(e) => y.setValue(e.nativeEvent.contentOffset.y)} + showsVerticalScrollIndicator={false} + scrollEventThrottle={1} + stickyHeaderIndices={[2, 4]}> + <ProfileCutout> + <ProfileHeader /> + </ProfileCutout> + <ProfileBody {...{onLayout}} /> + <MomentsBar {...{y, profileBodyHeight}} /> + <Feed {...{user}} /> + <LinearGradient + locations={[0.89, 1]} + colors={['transparent', 'rgba(0, 0, 0, 0.6)']} + style={styles.gradient} + /> + </ScrollView> + ); +}; + +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<number>; +} +const Cover: React.FC<CoverProps> = ({y}) => { + const {cover} = React.useContext(AuthContext); + const scale: Animated.Node<number> = interpolate(y, { + inputRange: [-COVER_HEIGHT, 0], + outputRange: [1.5, 1.25], + extrapolateRight: Extrapolate.CLAMP, + }); + return ( + <Animated.View style={[styles.container, {transform: [{scale}]}]}> + <Image + style={styles.image} + source={ + cover + ? {uri: cover} + : require('../../assets/images/cover-placeholder.png') + } + /> + </Animated.View> + ); +}; + +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<FeedProps> = ({user}) => { + const posts: Array<PostType> = []; + const dummyPost: PostType = { + owner: user, + }; + for (let i = 0; i < 20; i++) { + posts.push(dummyPost); + } + return ( + <> + {posts.map((post, index) => ( + <Post key={index} post={post} /> + ))} + </> + ); +}; + +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<FollowCountProps> = ({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 ( + <View style={[styles.container, style]}> + <Text style={styles.count}>{displayed}</Text> + <Text style={styles.label}> + {mode === 'followers' ? 'Followers' : 'Following'} + </Text> + </View> + ); +}; + +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<MomentProps> = ({style}) => { + return ( + <LinearGradient + colors={['#9F00FF', '#27EAE9']} + useAngle={true} + angle={154.72} + angleCenter={{x: 0.5, y: 0.5}} + style={[styles.gradient, style]}> + <View style={styles.image} /> + </LinearGradient> + ); +}; + +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<number>; + profileBodyHeight: number; +} +const MomentsBar: React.FC<MomentsBarProps> = ({y, profileBodyHeight}) => { + const moments: Array<JSX.Element> = []; + for (let i = 0; i < 10; i++) { + moments.push(<Moment key={i} style={styles.moment} />); + } + const shadowOpacity: Animated.Node<number> = 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<number> = 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<number> = interpolate(y, { + inputRange: [ + 0, + PROFILE_CUTOUT_BOTTOM_Y + profileBodyHeight - 30, + PROFILE_CUTOUT_BOTTOM_Y + profileBodyHeight, + ], + outputRange: [30, 30, 15], + extrapolate: Extrapolate.CLAMP, + }); + return ( + <View style={[styles.container, {shadowOpacity}]}> + <ScrollView + horizontal + showsHorizontalScrollIndicator={false} + style={{paddingTop, paddingBottom}} + contentContainerStyle={styles.contentContainer}> + {moments} + </ScrollView> + </View> + ); +}; + +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<ProfileBodyProps> = ({onLayout}) => { + const { + profile, + user: {username}, + } = React.useContext(AuthContext); + const {biography, website} = profile; + return ( + <View onLayout={onLayout} style={styles.container}> + <Text style={styles.username}>{`@${username}`}</Text> + <Text style={styles.biography}>{`${biography}`}</Text> + <Text style={styles.website}>{`${website}`}</Text> + </View> + ); +}; + +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 ( + <Svg width={SCREEN_WIDTH} height={PROFILE_CUTOUT_BOTTOM_Y}> + <Polygon + points={`0,${PROFILE_CUTOUT_CORNER_Y} ${PROFILE_CUTOUT_CORNER_X},${PROFILE_CUTOUT_TOP_Y} ${SCREEN_WIDTH},${PROFILE_CUTOUT_TOP_Y} ${SCREEN_WIDTH},${PROFILE_CUTOUT_BOTTOM_Y}, 0,${PROFILE_CUTOUT_BOTTOM_Y}`} + fill={'white'} + /> + {children} + </Svg> + ); +}; + +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 ( + <View style={styles.container}> + <View style={styles.row}> + <Avatar style={styles.avatar} /> + <View style={styles.header}> + <Text style={styles.name}>{name}</Text> + <View style={styles.row}> + <FollowCount + style={styles.follows} + mode="followers" + count={318412} + /> + <FollowCount style={styles.follows} mode="following" count={1036} /> + </View> + </View> + </View> + </View> + ); +}; + +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'; |