From 713d169915a82edfcfe4b44622e3dce8c6adaf0c Mon Sep 17 00:00:00 2001 From: Shravya Ramesh <37447613+shravyaramesh@users.noreply.github.com> Date: Tue, 17 Nov 2020 18:06:14 -0800 Subject: [TMA-382] Edit profile screen (#121) * added more icon * a less fat icon * and the actual icon asset * bottom drawer skeleton done * removed warning, better code * a more completed skeleton done * bottom drawer done! * Added content container, sent birthday picker props, minor styling * differenciating defined and undefined birthdate in birthdate, datepicker * removed restricting width for TaggDropDown * Added edit profile screen to navigator stack * Add EditProfile view, refresh profile view on save * Removes unnecessary import * Stores gender and birthdate as part of ProfileType * Added gender, birthdate, isEditProfile to AuthProv * Conditional view applied for edit profile button * Includes discarded changes in previous merge- BD * removed unused icon * resolved scary warnings * added icon to drawer * Small fix * minor code improvement * sc * fixed birthday bug * custom gender updation fixed * small change to birthday default value * missed something * cleaned up types! Warnings gone! * fixed another gender picker bug * fixed gender bug and cleaned up logic * removed warning, MUCH better code now Co-authored-by: Ivan Chen Co-authored-by: Ashm Walia --- src/assets/icons/more_horiz-24px.svg | 1 + src/assets/ionicons/person-outline.svg | 1 + src/components/common/BottomDrawer.tsx | 118 +++++ src/components/common/SocialLinkModal.tsx | 3 +- src/components/common/TaggDatePicker.tsx | 22 +- src/components/common/index.ts | 1 + src/components/moments/Moment.tsx | 4 +- src/components/onboarding/BirthDatePicker.tsx | 9 +- src/components/onboarding/TaggDropDown.tsx | 7 +- src/components/profile/MoreInfoDrawer.tsx | 88 ++++ src/components/profile/ProfileBody.tsx | 4 +- src/components/profile/ProfileHeader.tsx | 39 +- src/components/profile/ToggleButton.tsx | 5 +- src/components/profile/index.ts | 5 +- src/components/search/RecentSearches.tsx | 3 +- src/components/taggs/TwitterTaggPost.tsx | 4 +- src/constants/constants.ts | 7 +- src/routes/authentication/AuthProvider.tsx | 13 +- src/routes/profile/Profile.tsx | 12 + src/routes/profile/ProfileStack.tsx | 4 + src/screens/onboarding/ProfileOnboarding.tsx | 150 ++++--- src/screens/profile/EditProfile.tsx | 591 ++++++++++++++++++++++++++ src/screens/profile/MomentCommentsScreen.tsx | 2 +- src/screens/profile/ProfileScreen.tsx | 9 +- src/screens/profile/index.ts | 1 + src/screens/search/SearchScreen.tsx | 4 +- src/services/UserProfileService.ts | 7 +- src/types/types.ts | 2 + 28 files changed, 1001 insertions(+), 115 deletions(-) create mode 100644 src/assets/icons/more_horiz-24px.svg create mode 100644 src/assets/ionicons/person-outline.svg create mode 100644 src/components/common/BottomDrawer.tsx create mode 100644 src/components/profile/MoreInfoDrawer.tsx create mode 100644 src/screens/profile/EditProfile.tsx (limited to 'src') diff --git a/src/assets/icons/more_horiz-24px.svg b/src/assets/icons/more_horiz-24px.svg new file mode 100644 index 00000000..3d4fc0b5 --- /dev/null +++ b/src/assets/icons/more_horiz-24px.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/ionicons/person-outline.svg b/src/assets/ionicons/person-outline.svg new file mode 100644 index 00000000..fa39dc76 --- /dev/null +++ b/src/assets/ionicons/person-outline.svg @@ -0,0 +1 @@ +Person \ No newline at end of file diff --git a/src/components/common/BottomDrawer.tsx b/src/components/common/BottomDrawer.tsx new file mode 100644 index 00000000..bef9434a --- /dev/null +++ b/src/components/common/BottomDrawer.tsx @@ -0,0 +1,118 @@ +import React, {Fragment, ReactText, useEffect, useRef, useState} from 'react'; +import { + Modal, + StyleSheet, + TouchableWithoutFeedback, + View, + ViewProps, +} from 'react-native'; +import Animated from 'react-native-reanimated'; +import BottomSheet from 'reanimated-bottom-sheet'; +import {SCREEN_HEIGHT, SCREEN_WIDTH} from '../../utils'; + +interface BottomDrawerProps extends ViewProps { + initialSnapPosition?: ReactText; + isOpen: boolean; + setIsOpen: (open: boolean) => void; + showHeader: boolean; +} + +// More examples here: +// https://github.com/osdnk/react-native-reanimated-bottom-sheet/tree/master/Example +const BottomDrawer: React.FC = (props) => { + const {isOpen, setIsOpen, showHeader, initialSnapPosition} = props; + const drawerRef = useRef(null); + const [modalVisible, setModalVisible] = useState(isOpen); + const bgAlpha = new Animated.Value(isOpen ? 1 : 0); + + useEffect(() => { + if (isOpen) { + setModalVisible(true); + } else { + bgAlpha.setValue(0); + drawerRef.current && drawerRef.current.snapTo(1); + } + }, [isOpen]); + + const renderContent = () => { + return {props.children}; + }; + + const renderHeader = () => { + return showHeader ? ( + + + + + + ) : ( + + ); + }; + + return ( + { + drawerRef.current && drawerRef.current.snapTo(0); + }}> + { + setModalVisible(false); + setIsOpen(false); + }} + /> + + { + setIsOpen(false); + }}> + + + + ); +}; + +const styles = StyleSheet.create({ + header: { + backgroundColor: '#f7f5eee8', + shadowColor: '#000000', + paddingTop: 20, + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + }, + panelHeader: { + alignItems: 'center', + }, + panelHandle: { + width: 40, + height: 8, + borderRadius: 4, + backgroundColor: '#00000040', + marginBottom: 10, + }, + backgroundView: { + height: SCREEN_HEIGHT, + width: SCREEN_WIDTH, + }, +}); + +export default BottomDrawer; diff --git a/src/components/common/SocialLinkModal.tsx b/src/components/common/SocialLinkModal.tsx index 3cea2567..b307a62c 100644 --- a/src/components/common/SocialLinkModal.tsx +++ b/src/components/common/SocialLinkModal.tsx @@ -1,6 +1,7 @@ import React from 'react'; import {Modal, StyleSheet, Text, TouchableHighlight, View} from 'react-native'; import {TextInput} from 'react-native-gesture-handler'; +import { TAGG_TEXT_LIGHT_BLUE } from '../../constants'; import {SCREEN_WIDTH} from '../../utils'; interface SocialLinkModalProps { @@ -104,7 +105,7 @@ const styles = StyleSheet.create({ fontSize: 14, /* identical to box height */ textAlign: 'center', - color: '#698DD3', + color: TAGG_TEXT_LIGHT_BLUE, }, textInput: { height: 20, diff --git a/src/components/common/TaggDatePicker.tsx b/src/components/common/TaggDatePicker.tsx index d8010251..059bf620 100644 --- a/src/components/common/TaggDatePicker.tsx +++ b/src/components/common/TaggDatePicker.tsx @@ -1,3 +1,4 @@ +import moment from 'moment'; import React, {useState} from 'react'; import DatePicker from 'react-native-date-picker'; @@ -5,23 +6,24 @@ interface TaggDatePickerProps { handleDateUpdate: (_: Date) => void; maxDate: Date; textColor: string; + date: Date | undefined; } -const TaggDatePicker: React.FC = ({ - handleDateUpdate, - maxDate, - textColor, -}) => { - const [date, setDate] = useState(new Date()); +const TaggDatePicker: React.FC = (props) => { + const [date, setDate] = useState( + props.date + ? new Date(moment(props.date).add(1, 'day').format('YYYY-MM-DD')) + : undefined, + ); return ( { setDate(newDate); - handleDateUpdate(newDate); + props.handleDateUpdate(newDate); }} /> ); diff --git a/src/components/common/index.ts b/src/components/common/index.ts index c7ed13cd..883dae61 100644 --- a/src/components/common/index.ts +++ b/src/components/common/index.ts @@ -13,4 +13,5 @@ export {default as SocialLinkModal} from './SocialLinkModal'; export {default as ComingSoon} from './ComingSoon'; export {default as PostCarousel} from './PostCarousel'; export {default as TaggDatePicker} from './TaggDatePicker'; +export {default as BottomDrawer} from './BottomDrawer'; export * from './post'; diff --git a/src/components/moments/Moment.tsx b/src/components/moments/Moment.tsx index f905bfe3..9e138ef3 100644 --- a/src/components/moments/Moment.tsx +++ b/src/components/moments/Moment.tsx @@ -6,7 +6,7 @@ import {ScrollView, TouchableOpacity} from 'react-native-gesture-handler'; import LinearGradient from 'react-native-linear-gradient'; import PlusIcon from '../../assets/icons/plus_icon-01.svg'; import BigPlusIcon from '../../assets/icons/plus_icon-02.svg'; -import {MOMENTS_TITLE_COLOR} from '../../constants'; +import {TAGG_TEXT_LIGHT_BLUE} from '../../constants'; import {SCREEN_WIDTH} from '../../utils'; import ImagePicker from 'react-native-image-crop-picker'; import MomentTile from './MomentTile'; @@ -104,7 +104,7 @@ const styles = StyleSheet.create({ titleText: { fontSize: 16, fontWeight: 'bold', - color: MOMENTS_TITLE_COLOR, + color: TAGG_TEXT_LIGHT_BLUE, }, scrollContainer: { height: SCREEN_WIDTH / 2, diff --git a/src/components/onboarding/BirthDatePicker.tsx b/src/components/onboarding/BirthDatePicker.tsx index f97f1a72..0fc597c3 100644 --- a/src/components/onboarding/BirthDatePicker.tsx +++ b/src/components/onboarding/BirthDatePicker.tsx @@ -15,6 +15,8 @@ import {TaggDatePicker} from '../common'; interface BirthDatePickerProps extends TextInputProps { handleBDUpdate: (_: Date) => void; width?: number | string; + date: Date | undefined; + showPresetdate: boolean; } const BirthDatePicker = React.forwardRef( @@ -23,7 +25,7 @@ const BirthDatePicker = React.forwardRef( const maxDate = moment().subtract(13, 'y').subtract(1, 'd'); return maxDate.toDate(); }; - const [date, setDate] = useState(new Date(0)); + const [date, setDate] = useState(props.date); const [hidden, setHidden] = useState(true); const [updated, setUpdated] = useState(false); const textColor = updated ? 'white' : '#ddd'; @@ -42,7 +44,9 @@ const BirthDatePicker = React.forwardRef( style={[styles.input, {width: props.width}, {color: textColor}]} ref={ref} {...props}> - {updated ? moment(date).format('YYYY-MM-DD') : 'Date of Birth'} + {(updated || props.showPresetdate) && date + ? moment(date).format('YYYY-MM-DD') + : 'Date of Birth'} @@ -67,6 +71,7 @@ const BirthDatePicker = React.forwardRef( handleDateUpdate={updateDate} maxDate={getMaxDate()} textColor={'black'} + date={date} /> diff --git a/src/components/onboarding/TaggDropDown.tsx b/src/components/onboarding/TaggDropDown.tsx index a45426ca..db531cc4 100644 --- a/src/components/onboarding/TaggDropDown.tsx +++ b/src/components/onboarding/TaggDropDown.tsx @@ -1,8 +1,8 @@ import React from 'react'; -import RNSelectPicker from 'react-native-picker-select'; -import {View, StyleSheet, TextInputProps} from 'react-native'; +import {StyleSheet, View} from 'react-native'; +import RNSelectPicker, {PickerSelectProps} from 'react-native-picker-select'; -interface TaggDropDownProps extends TextInputProps { +interface TaggDropDownProps extends PickerSelectProps { width?: number | string; } @@ -19,7 +19,6 @@ const TaggDropDown = React.forwardRef((props: TaggDropDownProps, ref: any) => { const styles = StyleSheet.create({ container: { - width: '66.67%', alignItems: 'center', marginVertical: 11, }, diff --git a/src/components/profile/MoreInfoDrawer.tsx b/src/components/profile/MoreInfoDrawer.tsx new file mode 100644 index 00000000..719c1894 --- /dev/null +++ b/src/components/profile/MoreInfoDrawer.tsx @@ -0,0 +1,88 @@ +import {useNavigation} from '@react-navigation/native'; +import React, {useContext} from 'react'; +import {StyleSheet, Text, TouchableOpacity, View} from 'react-native'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; +import {TAGG_TEXT_LIGHT_BLUE} from '../../constants'; +import {SCREEN_HEIGHT, SCREEN_WIDTH} from '../../utils'; +import {AuthContext} from '../../routes'; +import {BottomDrawer} from '../common'; +import PersonOutline from '../../assets/ionicons/person-outline.svg'; + +interface MoreInfoDrawerProps { + isOpen: boolean; + setIsOpen: (visible: boolean) => void; + isProfileView: boolean; +} + +const MoreInfoDrawer: React.FC = (props) => { + const insets = useSafeAreaInsets(); + const initialSnapPosition = 160 + insets.bottom; + const navigation = useNavigation(); + const { + user: {userId, username}, + } = useContext(AuthContext); + + const goToEditProfile = () => { + navigation.push('EditProfile', { + userId: userId, + username: username, + }); + props.setIsOpen(false); + }; + + return ( + + + + + Edit Profile + + + props.setIsOpen(false)}> + {/* Just a placeholder "icon" for easier alignment */} + + Cancel + + + + ); +}; + +const styles = StyleSheet.create({ + panel: { + height: SCREEN_HEIGHT, + backgroundColor: 'white', + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + }, + panelButton: { + height: 80, + flexDirection: 'row', + alignItems: 'center', + }, + panelButtonTitle: { + fontSize: 18, + fontWeight: 'bold', + color: 'black', + }, + icon: { + height: 25, + width: 25, + color: 'black', + marginLeft: SCREEN_WIDTH * 0.3, + marginRight: 25, + }, + panelButtonTitleCancel: { + fontSize: 18, + fontWeight: 'bold', + color: TAGG_TEXT_LIGHT_BLUE, + }, + divider: {height: 1, borderWidth: 1, borderColor: '#e7e7e7'}, +}); + +export default MoreInfoDrawer; diff --git a/src/components/profile/ProfileBody.tsx b/src/components/profile/ProfileBody.tsx index db8c6e0b..c0253533 100644 --- a/src/components/profile/ProfileBody.tsx +++ b/src/components/profile/ProfileBody.tsx @@ -1,6 +1,6 @@ import React from 'react'; import {StyleSheet, View, Text, LayoutChangeEvent} from 'react-native'; -import {TOGGLE_BUTTON_TYPE} from '../../constants'; +import {TAGG_DARK_BLUE, TOGGLE_BUTTON_TYPE} from '../../constants'; import {AuthContext, ProfileContext} from '../../routes/'; import ToggleButton from './ToggleButton'; @@ -80,7 +80,7 @@ const styles = StyleSheet.create({ }, website: { fontSize: 16, - color: '#4E699C', + color: TAGG_DARK_BLUE, marginBottom: 5, }, }); diff --git a/src/components/profile/ProfileHeader.tsx b/src/components/profile/ProfileHeader.tsx index 6f11e806..62949746 100644 --- a/src/components/profile/ProfileHeader.tsx +++ b/src/components/profile/ProfileHeader.tsx @@ -1,10 +1,12 @@ -import React from 'react'; - +import React, {useState} from 'react'; +import {StyleSheet, Text, TouchableOpacity, View} from 'react-native'; +import MoreIcon from '../../assets/icons/more_horiz-24px.svg'; +import {TAGG_DARK_BLUE} from '../../constants'; +import {AuthContext, ProfileContext} from '../../routes/'; +import {SCREEN_HEIGHT, SCREEN_WIDTH} from '../../utils'; import Avatar from './Avatar'; +import MoreInfoDrawer from './MoreInfoDrawer'; import FollowCount from './FollowCount'; -import {View, Text, StyleSheet} from 'react-native'; -import {SCREEN_HEIGHT, SCREEN_WIDTH} from '../../utils'; -import {AuthContext, ProfileContext} from '../../routes/'; type ProfileHeaderProps = { isProfileView: boolean; @@ -22,8 +24,26 @@ const ProfileHeader: React.FC = ({ } = isProfileView ? React.useContext(ProfileContext) : React.useContext(AuthContext); + const [drawerVisible, setDrawerVisible] = useState(false); + return ( + {!isProfileView && ( + <> + { + setDrawerVisible(true); + }}> + + + + + )} @@ -51,8 +71,7 @@ const ProfileHeader: React.FC = ({ const styles = StyleSheet.create({ container: { top: SCREEN_HEIGHT / 2.4, - paddingHorizontal: SCREEN_WIDTH / 20, - marginBottom: SCREEN_HEIGHT / 10, + width: '100%', position: 'absolute', }, row: { @@ -76,6 +95,12 @@ const styles = StyleSheet.create({ follows: { marginHorizontal: SCREEN_HEIGHT / 50, }, + more: { + position: 'absolute', + right: '5%', + marginTop: '4%', + zIndex: 1, + }, }); export default ProfileHeader; diff --git a/src/components/profile/ToggleButton.tsx b/src/components/profile/ToggleButton.tsx index ff14cdde..4c8cb5b9 100644 --- a/src/components/profile/ToggleButton.tsx +++ b/src/components/profile/ToggleButton.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import {StyleSheet, Text} from 'react-native'; import {TouchableOpacity} from 'react-native-gesture-handler'; +import { TAGG_TEXT_LIGHT_BLUE } from '../../constants'; import {getToggleButtonText, SCREEN_HEIGHT, SCREEN_WIDTH} from '../../utils'; type ToggleButtonProps = { @@ -31,14 +32,14 @@ const styles = StyleSheet.create({ height: SCREEN_WIDTH * 0.1, borderRadius: 8, marginTop: '3%', - borderColor: '#698dd3', + borderColor: TAGG_TEXT_LIGHT_BLUE, backgroundColor: 'white', borderWidth: 3, marginHorizontal: '1%', }, text: { fontWeight: 'bold', - color: '#698dd3', + color: TAGG_TEXT_LIGHT_BLUE, }, }); export default ToggleButton; diff --git a/src/components/profile/index.ts b/src/components/profile/index.ts index 2e9c23ea..0f57347b 100644 --- a/src/components/profile/index.ts +++ b/src/components/profile/index.ts @@ -3,5 +3,6 @@ export {default as Content} from './Content'; export {default as ProfileCutout} from './ProfileCutout'; export {default as ProfileBody} from './ProfileBody'; export {default as ProfileHeader} from './ProfileHeader'; -export {default as ProfilePreview} from '../profile/ProfilePreview'; -export {default as Followers} from '../profile/Followers'; +export {default as ProfilePreview} from './ProfilePreview'; +export {default as Followers} from './Followers'; +export {default as MoreInfoDrawer} from './MoreInfoDrawer'; diff --git a/src/components/search/RecentSearches.tsx b/src/components/search/RecentSearches.tsx index a5c08984..f47f8879 100644 --- a/src/components/search/RecentSearches.tsx +++ b/src/components/search/RecentSearches.tsx @@ -7,6 +7,7 @@ import { TouchableOpacityProps, } from 'react-native'; import {ProfilePreviewType} from 'src/types'; +import { TAGG_TEXT_LIGHT_BLUE } from '../../constants'; import SearchResults from './SearchResults'; interface RecentSearchesProps extends TouchableOpacityProps { @@ -45,7 +46,7 @@ const styles = StyleSheet.create({ clear: { fontSize: 17, fontWeight: 'bold', - color: '#698DD3', + color: TAGG_TEXT_LIGHT_BLUE, }, }); diff --git a/src/components/taggs/TwitterTaggPost.tsx b/src/components/taggs/TwitterTaggPost.tsx index 158b5995..fb4cbd0f 100644 --- a/src/components/taggs/TwitterTaggPost.tsx +++ b/src/components/taggs/TwitterTaggPost.tsx @@ -3,7 +3,7 @@ import {Image, Linking, StyleSheet, View} from 'react-native'; import {Text} from 'react-native-animatable'; import Hyperlink from 'react-native-hyperlink'; import LinearGradient from 'react-native-linear-gradient'; -import {AVATAR_DIM, TAGGS_GRADIENT} from '../../constants'; +import {AVATAR_DIM, TAGGS_GRADIENT, TAGG_TEXT_LIGHT_BLUE} from '../../constants'; import {TwitterPostType} from '../../types'; import {SCREEN_WIDTH} from '../../utils'; import {DateLabel, PostCarousel} from '../common'; @@ -238,7 +238,7 @@ const styles = StyleSheet.create({ }, replyShowThisThread: { fontSize: 15, - color: '#698dd3', + color: TAGG_TEXT_LIGHT_BLUE, }, }); diff --git a/src/constants/constants.ts b/src/constants/constants.ts index dbd79b45..c2003fb4 100644 --- a/src/constants/constants.ts +++ b/src/constants/constants.ts @@ -1,5 +1,7 @@ import {SCREEN_WIDTH, SCREEN_HEIGHT} from '../utils'; +export const CHIN_HEIGHT = 34; + export const PROFILE_CUTOUT_TOP_Y = SCREEN_HEIGHT / 2.3; export const PROFILE_CUTOUT_BOTTOM_Y = SCREEN_HEIGHT / 1.8; export const PROFILE_CUTOUT_CORNER_X = SCREEN_WIDTH / 2.9; @@ -46,6 +48,9 @@ export const LINKEDIN_FONT_COLOR: string = '#78B5FD'; export const SNAPCHAT_FONT_COLOR: string = '#FFFC00'; export const YOUTUBE_FONT_COLOR: string = '#FCA4A4'; +export const TAGG_DARK_BLUE = '#4E699C'; +export const TAGG_TEXT_LIGHT_BLUE: string = '#698DD3'; + export const TAGGS_GRADIENT = { start: '#9F00FF', end: '#27EAE9', @@ -81,5 +86,3 @@ export const defaultMoments: Array = [ 'Creativity', 'Activity', ]; - -export const MOMENTS_TITLE_COLOR: string = '#698DD3'; diff --git a/src/routes/authentication/AuthProvider.tsx b/src/routes/authentication/AuthProvider.tsx index 7da47b71..7046d04f 100644 --- a/src/routes/authentication/AuthProvider.tsx +++ b/src/routes/authentication/AuthProvider.tsx @@ -40,6 +40,8 @@ interface AuthContextProps { blockedUsers: ProfilePreviewType[]; blockedUsersNeedUpdate: boolean; updateBlockedUsers: (value: boolean) => void; + isEditedProfile: boolean; + updateIsEditedProfile: (value: boolean) => void; } const NO_USER: UserType = { @@ -51,6 +53,8 @@ const NO_PROFILE: ProfileType = { biography: '', website: '', name: '', + gender: '', + birthday: undefined, }; const NO_SOCIAL_ACCOUNTS: Record = { @@ -79,6 +83,8 @@ export const AuthContext = createContext({ blockedUsers: [], blockedUsersNeedUpdate: true, updateBlockedUsers: () => {}, + isEditedProfile: false, + updateIsEditedProfile: () => {}, }); /** @@ -110,6 +116,7 @@ const AuthProvider: React.FC = ({children}) => { const [blockedUsersNeedUpdate, setBlockedUsersNeedUpdate] = useState( true, ); + const [isEditedProfile, setIsEditedProfile] = useState(false); const {userId} = user; useEffect(() => { @@ -149,7 +156,7 @@ const AuthProvider: React.FC = ({children}) => { } }; loadData(); - }, [userId]); + }, [userId, isEditedProfile]); useEffect(() => { const loadNewMoments = async () => { @@ -245,6 +252,7 @@ const AuthProvider: React.FC = ({children}) => { followersNeedUpdate, blockedUsers, blockedUsersNeedUpdate, + isEditedProfile, login: (id, username) => { setUser({...user, userId: id, username}); }, @@ -274,6 +282,9 @@ const AuthProvider: React.FC = ({children}) => { updateBlockedUsers: (value) => { setBlockedUsersNeedUpdate(value); }, + updateIsEditedProfile: (value: boolean) => { + setIsEditedProfile(value); + }, }}> {children} diff --git a/src/routes/profile/Profile.tsx b/src/routes/profile/Profile.tsx index bffa22ce..b6672c85 100644 --- a/src/routes/profile/Profile.tsx +++ b/src/routes/profile/Profile.tsx @@ -7,6 +7,7 @@ import { ProfileScreen, MomentCommentsScreen, FollowersListScreen, + EditProfile, } from '../../screens'; import {ProfileStack, ProfileStackParams} from './ProfileStack'; import {RouteProp} from '@react-navigation/native'; @@ -99,6 +100,17 @@ const Profile: React.FC = ({route}) => { component={FollowersListScreen} initialParams={{isProfileView: isProfileView}} /> + ); }; diff --git a/src/routes/profile/ProfileStack.tsx b/src/routes/profile/ProfileStack.tsx index b1e86214..5590f78a 100644 --- a/src/routes/profile/ProfileStack.tsx +++ b/src/routes/profile/ProfileStack.tsx @@ -33,6 +33,10 @@ export type ProfileStackParams = { isFollowers: boolean; list: ProfilePreviewType[]; }; + EditProfile: { + userId: boolean; + username: ProfilePreviewType[]; + }; }; export const ProfileStack = createStackNavigator(); diff --git a/src/screens/onboarding/ProfileOnboarding.tsx b/src/screens/onboarding/ProfileOnboarding.tsx index 3979de38..f21f3864 100644 --- a/src/screens/onboarding/ProfileOnboarding.tsx +++ b/src/screens/onboarding/ProfileOnboarding.tsx @@ -15,7 +15,6 @@ import { Background, TaggBigInput, TaggInput, - TaggDatePicker, TaggDropDown, BirthDatePicker, } from '../../components'; @@ -52,12 +51,13 @@ const ProfileOnboarding: React.FC = ({ navigation, }) => { const {userId, username} = route.params; + let emptyDate: string | undefined; const [form, setForm] = React.useState({ largePic: '', smallPic: '', website: '', bio: '', - birthdate: '', + birthdate: emptyDate, gender: '', isValidWebsite: true, isValidBio: true, @@ -65,7 +65,7 @@ const ProfileOnboarding: React.FC = ({ attemptedSubmit: false, token: '', }); - const [customGender, setCustomGender] = React.useState(); + const [customGender, setCustomGender] = React.useState(Boolean); // refs for changing focus const bioRef = React.useRef(); @@ -232,7 +232,7 @@ const ProfileOnboarding: React.FC = ({ const handleBirthdateUpdate = (birthdate: Date) => { setForm({ ...form, - birthdate: moment(birthdate).format('YYYY-MM-DD'), + birthdate: birthdate && moment(birthdate).format('YYYY-MM-DD'), }); }; @@ -357,81 +357,85 @@ const ProfileOnboarding: React.FC = ({ - handleFocusChange('bio')} - blurOnSubmit={false} - valid={form.isValidWebsite} - attemptedSubmit={form.attemptedSubmit} - invalidWarning={'Website must be a valid link to your website'} - width={280} - /> - handleFocusChange('bio')} - blurOnSubmit={false} - ref={bioRef} - valid={form.isValidBio} - attemptedSubmit={form.attemptedSubmit} - invalidWarning={ - 'Bio must be less than 150 characters and must contain valid characters' - } - width={280} - /> - - handleGenderUpdate(value)} - items={[ - {label: 'Male', value: 'male'}, - {label: 'Female', value: 'female'}, - {label: 'Custom', value: 'custom'}, - ]} - placeholder={{ - label: 'Gender', - value: null, - color: '#ddd', - }} - /> - {customGender && ( + handleFocusChange('bio')} + blurOnSubmit={false} + valid={form.isValidWebsite} + attemptedSubmit={form.attemptedSubmit} + invalidWarning={'Website must be a valid link to your website'} + width={280} + /> + handleFocusChange('bio')} blurOnSubmit={false} - ref={customGenderRef} - onChangeText={handleCustomGenderUpdate} - onSubmitEditing={() => handleSubmit()} - valid={form.isValidGender} + ref={bioRef} + valid={form.isValidBio} attemptedSubmit={form.attemptedSubmit} - invalidWarning={'Custom field can only contain letters and hyphens'} + invalidWarning={ + 'Bio must be less than 150 characters and must contain valid characters' + } width={280} /> - )} - - Let's start! - + + handleGenderUpdate(value)} + items={[ + {label: 'Male', value: 'male'}, + {label: 'Female', value: 'female'}, + {label: 'Custom', value: 'custom'}, + ]} + placeholder={{ + label: 'Gender', + value: null, + color: '#ddd', + }} + /> + {customGender && ( + handleSubmit()} + valid={form.isValidGender} + attemptedSubmit={form.attemptedSubmit} + invalidWarning={'Custom field can only contain letters and hyphens'} + width={280} + /> + )} + + Let's start! + + ); }; @@ -441,6 +445,11 @@ const styles = StyleSheet.create({ flexDirection: 'row', marginBottom: '5%', }, + contentContainer: { + position: 'relative', + width: 280, + alignSelf: 'center', + }, largeProfileUploader: { justifyContent: 'center', alignItems: 'center', @@ -493,6 +502,7 @@ const styles = StyleSheet.create({ height: 40, borderRadius: 5, marginTop: '5%', + alignSelf: 'center', }, submitBtnLabel: { fontSize: 16, diff --git a/src/screens/profile/EditProfile.tsx b/src/screens/profile/EditProfile.tsx new file mode 100644 index 00000000..01b67155 --- /dev/null +++ b/src/screens/profile/EditProfile.tsx @@ -0,0 +1,591 @@ +import React, {useCallback, useEffect, useState} from 'react'; +import {RouteProp} from '@react-navigation/native'; +import moment from 'moment'; +import {StackNavigationProp} from '@react-navigation/stack'; +import { + Text, + StatusBar, + StyleSheet, + Image, + TouchableOpacity, + Alert, + View, + SafeAreaView, +} from 'react-native'; +import {Button} from 'react-native-elements'; +import { + Background, + TaggBigInput, + TaggInput, + TaggDropDown, + BirthDatePicker, + TabsGradient, +} from '../../components'; +import {OnboardingStackParams} from '../../routes/onboarding'; +import ImagePicker from 'react-native-image-crop-picker'; +import { + EDIT_PROFILE_ENDPOINT, + websiteRegex, + bioRegex, + genderRegex, +} from '../../constants'; +import AsyncStorage from '@react-native-community/async-storage'; +import {AuthContext} from '../../routes'; +import Animated from 'react-native-reanimated'; +import {SCREEN_HEIGHT} from '../../utils'; + +type ProfileOnboardingScreenRouteProp = RouteProp< + OnboardingStackParams, + 'ProfileOnboarding' +>; +type ProfileOnboardingScreenNavigationProp = StackNavigationProp< + OnboardingStackParams, + 'ProfileOnboarding' +>; +interface ProfileOnboardingProps { + route: ProfileOnboardingScreenRouteProp; + navigation: ProfileOnboardingScreenNavigationProp; +} + +/** + * Create profile screen for onboarding. + * @param navigation react-navigation navigation object + */ + +const ProfileOnboarding: React.FC = ({ + route, + navigation, +}) => { + const y: Animated.Value = Animated.useValue(0); + const {userId, username} = route.params; + const { + profile: {website, biography, birthday, gender}, + avatar, + cover, + updateIsEditedProfile, + } = React.useContext(AuthContext); + const [needsUpdate, setNeedsUpdate] = useState(false); + + useEffect(() => { + updateIsEditedProfile(needsUpdate); + }, [needsUpdate, updateIsEditedProfile]); + + const [isCustomGender, setIsCustomGender] = React.useState( + gender !== '' && gender !== 'female' && gender !== 'male', + ); + + const [form, setForm] = React.useState({ + largePic: cover ? cover : '', + smallPic: avatar ? avatar : '', + website: website ? website : '', + bio: biography ? biography : '', + birthdate: birthday && moment(birthday).format('YYYY-MM-DD'), + gender: isCustomGender ? 'custom' : gender, + customGenderText: isCustomGender ? gender : '', + isValidWebsite: true, + isValidBio: true, + isValidGender: true, + attemptedSubmit: false, + }); + // refs for changing focus + const bioRef = React.useRef(); + const birthdateRef = React.useRef(); + const genderRef = React.useRef(); + const customGenderRef = React.useRef(); + /** + * Handles focus change to the next input field. + * @param field key for field to move focus onto + */ + const handleFocusChange = (field: string): void => { + switch (field) { + case 'bio': + const bioField: any = bioRef.current; + bioField.focus(); + break; + case 'birthdate': + const birthdateField: any = birthdateRef.current; + birthdateField.focus(); + break; + case 'gender': + const genderField: any = genderRef.current; + genderField.focus(); + break; + case 'customGender': + const customGenderField: any = customGenderRef.current; + customGenderField.focus(); + break; + default: + return; + } + }; + + /** + * Profile screen "Add Large Profile Pic Here" button + */ + const LargeProfilePic = () => ( + + {form.largePic ? ( + + ) : ( + ADD LARGE PROFILE PIC HERE + )} + + ); + + /** + * Profile screen "Add Smaller Profile Pic Here" button + */ + const SmallProfilePic = () => ( + + {form.smallPic ? ( + + ) : ( + ADD SMALLER PIC + )} + + ); + + /* + * Handles tap on add profile picture buttons by navigating to camera access + * and selecting a picture from gallery for large profile picture + */ + const goToGalleryLargePic = () => { + ImagePicker.openPicker({ + width: 580, + height: 580, + cropping: true, + cropperToolbarTitle: 'Large profile picture', + mediaType: 'photo', + }) + .then((picture) => { + if ('path' in picture) { + setForm({ + ...form, + largePic: picture.path, + }); + } + }) + .catch(() => {}); + }; + + /* + * Handles tap on add profile picture buttons by navigating to camera access + * and selecting a picture from gallery for small profile picture + */ + const goToGallerySmallPic = () => { + ImagePicker.openPicker({ + width: 580, + height: 580, + cropping: true, + cropperToolbarTitle: 'Small profile picture', + mediaType: 'photo', + cropperCircleOverlay: true, + }) + .then((picture) => { + if ('path' in picture) { + setForm({ + ...form, + smallPic: picture.path, + }); + } + }) + .catch(() => {}); + }; + + /* + * Handles changes to the website field value and verifies the input by updating state and running a validation function. + */ + const handleWebsiteUpdate = (website: string) => { + website = website.trim(); + let isValidWebsite: boolean = websiteRegex.test(website); + setForm({ + ...form, + website, + isValidWebsite, + }); + }; + + /* + * Handles changes to the bio field value and verifies the input by updating state and running a validation function. + */ + const handleBioUpdate = (bio: string) => { + let isValidBio: boolean = bioRegex.test(bio); + setForm({ + ...form, + bio, + isValidBio, + }); + }; + + const handleGenderUpdate = (gender: string) => { + if (gender === 'custom') { + setForm({...form, gender}); + setIsCustomGender(true); + } else if (gender === null) { + // not doing anything will make the picker "bounce back" + } else { + setIsCustomGender(false); + let isValidGender: boolean = true; + setForm({ + ...form, + gender, + isValidGender, + }); + } + }; + + const handleCustomGenderUpdate = (customGenderText: string) => { + let isValidGender: boolean = genderRegex.test(customGenderText); + customGenderText = customGenderText.replace(' ', '-'); + setForm({ + ...form, + customGenderText, + isValidGender, + }); + }; + + const handleBirthdateUpdate = (birthdate: Date) => { + setForm({ + ...form, + birthdate: birthdate && moment(birthdate).format('YYYY-MM-DD'), + }); + }; + + const handleSubmit = useCallback(async () => { + if (!form.largePic) { + Alert.alert('Please upload a large profile picture!'); + return; + } + if (!form.smallPic) { + Alert.alert('Please upload a small profile picture!'); + return; + } + if (!form.attemptedSubmit) { + setForm({ + ...form, + attemptedSubmit: true, + }); + } + let invalidFields: boolean = false; + const request = new FormData(); + if (form.largePic) { + request.append('largeProfilePicture', { + uri: form.largePic, + name: 'large_profile_pic.jpg', + type: 'image/jpg', + }); + } + if (form.smallPic) { + request.append('smallProfilePicture', { + uri: form.smallPic, + name: 'small_profile_pic.jpg', + type: 'image/jpg', + }); + } + if (form.website) { + if (form.isValidWebsite) { + request.append('website', form.website); + } else { + setForm({...form, attemptedSubmit: false}); + setTimeout(() => setForm({...form, attemptedSubmit: true})); + invalidFields = true; + } + } + + if (form.bio) { + if (form.isValidBio) { + request.append('biography', form.bio); + } else { + setForm({...form, attemptedSubmit: false}); + setTimeout(() => setForm({...form, attemptedSubmit: true})); + invalidFields = true; + } + } + + if (form.birthdate) { + request.append('birthday', form.birthdate); + } + + if (isCustomGender) { + if (form.isValidGender) { + request.append('gender', form.customGenderText); + } else { + setForm({...form, attemptedSubmit: false}); + setTimeout(() => setForm({...form, attemptedSubmit: true})); + invalidFields = true; + } + } else { + if (form.isValidGender) { + request.append('gender', form.gender); + } + } + + if (invalidFields) { + return; + } + + const endpoint = EDIT_PROFILE_ENDPOINT + `${userId}/`; + try { + const token = await AsyncStorage.getItem('token'); + let response = await fetch(endpoint, { + method: 'PATCH', + headers: { + 'Content-Type': 'multipart/form-data', + Authorization: 'Token ' + token, + }, + body: request, + }); + let statusCode = response.status; + let data = await response.json(); + if (statusCode === 200) { + setNeedsUpdate(true); + navigation.pop(); + } else if (statusCode === 400) { + Alert.alert( + 'Profile update failed. 😔', + data.error || 'Something went wrong! 😭', + ); + } else { + Alert.alert( + 'Something went wrong! 😭', + "Would you believe me if I told you that I don't know what happened?", + ); + } + } catch (error) { + Alert.alert( + 'Profile creation failed 😓', + 'Please double-check your network connection and retry.', + ); + return { + name: 'Profile creation error', + description: error, + }; + } + }, [isCustomGender, form, navigation, userId]); + + React.useLayoutEffect(() => { + navigation.setOptions({ + headerRight: () => ( +