From bcd519a4ade12396c0f1f4de53d81f3bfd595e52 Mon Sep 17 00:00:00 2001 From: Hailey Date: Sat, 9 Mar 2024 00:10:37 -0800 Subject: [PATCH] implement design changes use row for buttons on web use RNGH FlatList random color at start improve logic update dialog for web update dialog style on mobile some more progress create dialog simplify context start implementing design --- .../icons/image_stroke2_corner0_rounded.svg | 1 + .../icons/pencil_stroke2_corner0_rounded.svg | 1 + src/components/Dialog/index.tsx | 9 +- src/components/Dialog/types.ts | 2 + src/components/icons/Image.tsx | 5 + src/components/icons/Pencil.tsx | 5 + .../Onboarding/StepProfile/AvatarCircle.tsx | 138 ++++------ .../StepProfile/AvatarCreatorCircle.tsx | 44 +++ .../StepProfile/AvatarCreatorItems.tsx | 176 ++++++------ .../StepProfile/PlaceholderCanvas.tsx | 2 +- src/screens/Onboarding/StepProfile/index.tsx | 253 +++++++++++++----- 11 files changed, 390 insertions(+), 246 deletions(-) create mode 100644 assets/icons/image_stroke2_corner0_rounded.svg create mode 100644 assets/icons/pencil_stroke2_corner0_rounded.svg create mode 100644 src/components/icons/Image.tsx create mode 100644 src/components/icons/Pencil.tsx create mode 100644 src/screens/Onboarding/StepProfile/AvatarCreatorCircle.tsx diff --git a/assets/icons/image_stroke2_corner0_rounded.svg b/assets/icons/image_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..389020b0d1 --- /dev/null +++ b/assets/icons/image_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/pencil_stroke2_corner0_rounded.svg b/assets/icons/pencil_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..7341989894 --- /dev/null +++ b/assets/icons/pencil_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx index f0e7b7e82b..336715cc3d 100644 --- a/src/components/Dialog/index.tsx +++ b/src/components/Dialog/index.tsx @@ -184,12 +184,17 @@ export function Outer({ ) } -export function Inner({children, style}: DialogInnerProps) { +export function Inner({ + children, + style, + noHorizontalPadding, +}: DialogInnerProps) { const insets = useSafeAreaInsets() return ( | DialogInnerPropsBase<{ label: string accessibilityLabelledBy?: undefined accessibilityDescribedBy?: undefined + noHorizontalPadding?: boolean }> diff --git a/src/components/icons/Image.tsx b/src/components/icons/Image.tsx new file mode 100644 index 0000000000..03702a0f46 --- /dev/null +++ b/src/components/icons/Image.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Image_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5Zm16 0H5v7.213l1.246-.932.044-.03a3 3 0 0 1 3.863.454c1.468 1.58 2.941 2.749 4.847 2.749 1.703 0 2.855-.555 4-1.618V5Zm0 10.357c-1.112.697-2.386 1.097-4 1.097-2.81 0-4.796-1.755-6.313-3.388a1 1 0 0 0-1.269-.164L5 14.712V19h14v-3.643ZM15 8a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm-3 1a3 3 0 1 1 6 0 3 3 0 0 1-6 0Z', +}) diff --git a/src/components/icons/Pencil.tsx b/src/components/icons/Pencil.tsx new file mode 100644 index 0000000000..1b7fc17cf4 --- /dev/null +++ b/src/components/icons/Pencil.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Pencil_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M13.586 1.5a2 2 0 0 1 2.828 0L19.5 4.586a2 2 0 0 1 0 2.828l-13 13A2 2 0 0 1 5.086 21H1a1 1 0 0 1-1-1v-4.086A2 2 0 0 1 .586 14.5l13-13ZM15 2.914l-13 13V19h3.086l13-13L15 2.914ZM11 20a1 1 0 0 1 1-1h7a1 1 0 1 1 0 2h-7a1 1 0 0 1-1-1Z', +}) diff --git a/src/screens/Onboarding/StepProfile/AvatarCircle.tsx b/src/screens/Onboarding/StepProfile/AvatarCircle.tsx index b82df0087b..897f791894 100644 --- a/src/screens/Onboarding/StepProfile/AvatarCircle.tsx +++ b/src/screens/Onboarding/StepProfile/AvatarCircle.tsx @@ -1,42 +1,40 @@ import React from 'react' -import {LayoutAnimation, Pressable, PressableProps, View} from 'react-native' -import {Image} from 'expo-image' -import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times' -import {Camera_Stroke2_Corner0_Rounded as Camera} from '#/components/icons/Camera' -import {useAvatar, useSetAvatar} from '#/screens/Onboarding/StepProfile/index' +import {atoms as a, native, useTheme, web} from '#/alf' +import {useAvatar} from '#/screens/Onboarding/StepProfile/index' +import {TouchableOpacity, TouchableOpacityProps, View} from 'react-native' +import {Pencil_Stroke2_Corner0_Rounded as Pencil} from '#/components/icons/Pencil' +import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Image' +import {Image as ExpoImage} from 'expo-image' +import {AvatarCreatorCircle} from '#/screens/Onboarding/StepProfile/AvatarCreatorCircle' -import {atoms as a, useTheme} from '#/alf' -import {usePhotoLibraryPermission} from 'lib/hooks/usePermissions' -import {openPicker} from 'lib/media/picker.shared' -import {isNative, isWeb} from 'platform/detection' -import {openCropper} from 'lib/media/picker' -import {compressIfNeeded} from 'lib/media/manip' - -export function AvatarBottomButton({...props}: PressableProps) { +function AvatarBottomButton({...props}: TouchableOpacityProps) { const t = useTheme() return ( - {props.children} - + ) } -export function AvatarCircle() { +export function AvatarCircle({ + openLibrary, + openCreator, +}: { + openLibrary: () => unknown + openCreator: () => unknown +}) { const t = useTheme() - const avatar = useAvatar() - const setAvatar = useSetAvatar() - const Icon = avatar.placeholder.component - const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() + const {avatar} = useAvatar() const styles = React.useMemo( () => ({ @@ -45,84 +43,42 @@ export function AvatarCircle() { a.overflow_hidden, a.align_center, a.justify_center, - t.atoms.border_contrast_high, + t.atoms.border_contrast_low, + t.atoms.bg_contrast_25, { - height: 150, - width: 150, - borderWidth: 2, - backgroundColor: avatar.backgroundColor, + height: 200, + width: 200, }, + web({borderWidth: 2}), + native({borderWidth: 1}), ], }), - [avatar.backgroundColor, t.atoms.border_contrast_high], + [t.atoms.bg_contrast_25, t.atoms.border_contrast_low], ) - const onCameraPress = React.useCallback(async () => { - if (!(await requestPhotoAccessIfNeeded())) { - return - } - - const items = await openPicker({ - aspect: [1, 1], - }) - let image = items[0] - if (!image) return - - // TODO we need an alf modal for the cropper - if (!isWeb) { - image = await openCropper({ - mediaType: 'photo', - cropperCircleOverlay: true, - height: image.height, - width: image.width, - path: image.path, - }) - } - image = await compressIfNeeded(image, 1000000) - - LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) - // If we are on mobile, prefetching the image will load the image into memory before we try and display it, - // stopping any brief flickers. - if (isNative) { - await Image.prefetch(image.path) - } - - setAvatar(prev => ({ - ...prev, - image, - })) - }, [requestPhotoAccessIfNeeded, setAvatar]) - - const onPressRemoveAvatar = React.useCallback(() => { - LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) - setAvatar(prev => ({ - ...prev, - image: undefined, - })) - }, [setAvatar]) - - if (avatar.image) { - return ( - - - - - - - ) - } - return ( - - - - - + {avatar.useCreatedAvatar ? ( + + ) : avatar.image ? ( + + ) : ( + + + + )} + + ) diff --git a/src/screens/Onboarding/StepProfile/AvatarCreatorCircle.tsx b/src/screens/Onboarding/StepProfile/AvatarCreatorCircle.tsx new file mode 100644 index 0000000000..a81bef1d34 --- /dev/null +++ b/src/screens/Onboarding/StepProfile/AvatarCreatorCircle.tsx @@ -0,0 +1,44 @@ +import React from 'react' +import {View} from 'react-native' +import {Avatar} from '#/screens/Onboarding/StepProfile/index' + +import {atoms as a, native, useTheme, web} from '#/alf' + +export function AvatarCreatorCircle({ + avatar, + size = 125, +}: { + avatar: Avatar + size?: number +}) { + const t = useTheme() + const Icon = avatar.placeholder.component + + const styles = React.useMemo( + () => ({ + imageContainer: [ + a.rounded_full, + a.overflow_hidden, + a.align_center, + a.justify_center, + t.atoms.border_contrast_high, + { + height: size, + width: size, + backgroundColor: avatar.backgroundColor, + }, + web({borderWidth: 2}), + native({borderWidth: 1}), + ], + }), + [avatar.backgroundColor, size, t.atoms.border_contrast_high], + ) + + return ( + + + + + + ) +} diff --git a/src/screens/Onboarding/StepProfile/AvatarCreatorItems.tsx b/src/screens/Onboarding/StepProfile/AvatarCreatorItems.tsx index 667365bc4a..19cdd363d3 100644 --- a/src/screens/Onboarding/StepProfile/AvatarCreatorItems.tsx +++ b/src/screens/Onboarding/StepProfile/AvatarCreatorItems.tsx @@ -1,13 +1,11 @@ import React from 'react' -import {FlatList, ListRenderItemInfo, Pressable, View} from 'react-native' -import Animated, { - useAnimatedStyle, - useSharedValue, - withTiming, -} from 'react-native-reanimated' +import {ListRenderItemInfo, Pressable, View} from 'react-native' +// Using the FlatList from RNGH allows us to nest the scroll views on Android. The default FlatList won't allow +// scrolling inside of the vertical ScrollView +import {FlatList} from 'react-native-gesture-handler' -import {atoms as a, useTheme} from '#/alf' -import {useAvatar, useSetAvatar} from '#/screens/Onboarding/StepProfile/index' +import {atoms as a, native, useBreakpoints, useTheme, web} from '#/alf' +import {Avatar} from '#/screens/Onboarding/StepProfile/index' import { AvatarColor, avatarColors, @@ -15,75 +13,44 @@ import { EmojiName, emojiNames, } from '#/screens/Onboarding/StepProfile/types' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -const WITH_TIMING_CONFIG = {duration: 150} - -function AnimatedCircle({ - selected, - children, -}: React.PropsWithChildren<{selected: boolean}>) { +function Circle({children}: React.PropsWithChildren<{}>) { const t = useTheme() - const {isTabletOrDesktop} = useWebMediaQueries() - const size = useSharedValue(selected ? 1.2 : 1) const styles = React.useMemo( () => ({ - imageContainer: [ + container: [ a.rounded_full, a.overflow_hidden, a.align_center, a.justify_center, - {height: 150, width: 150, borderWidth: 2}, + t.atoms.bg_contrast_25, + web({borderWidth: 2}), + native({borderWidth: 1}), + { + height: 60, + width: 60, + margin: 4, + }, ], - paletteContainer: { - height: 70, - width: 70, - margin: isTabletOrDesktop ? 8 : 2, - }, }), - [isTabletOrDesktop], + [t.atoms.bg_contrast_25], ) - React.useEffect(() => { - if (selected && size.value !== 1.2) { - size.value = withTiming(1.2, WITH_TIMING_CONFIG) - } else if (!selected && size.value !== 1) { - size.value = withTiming(1, WITH_TIMING_CONFIG) - } - }, [selected, size]) - - // On mobile we want to expand the height/width of the container so the items around it get moved as well. On - // desktop, we don't want anything around the item to move so we just increase the scale. - const animatedStyle = useAnimatedStyle(() => { - if (isTabletOrDesktop) { - return { - transform: [{scale: size.value}], - } - } - return { - height: 70 * size.value, - width: 70 * size.value, - } - }) - return ( - + {children} - + ) } -function ColorItem({color}: {color: AvatarColor}) { - const avatar = useAvatar() - const setAvatar = useSetAvatar() - +function ColorItem({ + color, + setAvatar, +}: { + color: AvatarColor + setAvatar: React.Dispatch> +}) { const onPress = React.useCallback(() => { setAvatar(prev => ({ ...prev, @@ -92,20 +59,26 @@ function ColorItem({color}: {color: AvatarColor}) { }, [color, setAvatar]) return ( - + - + ) } -function EmojiItem({emojiName}: {emojiName: EmojiName}) { +function EmojiItem({ + emojiName, + avatar, + setAvatar, +}: { + emojiName: EmojiName + avatar: Avatar + setAvatar: React.Dispatch> +}) { const t = useTheme() - const avatar = useAvatar() - const setAvatar = useSetAvatar() const Icon = React.useMemo(() => emojiItems[emojiName].component, [emojiName]) const onPress = React.useCallback(() => { @@ -115,60 +88,87 @@ function EmojiItem({emojiName}: {emojiName: EmojiName}) { })) }, [emojiName, setAvatar]) + const selected = React.useMemo( + () => avatar.placeholder.name === emojiName, + [avatar.placeholder.name, emojiName], + ) + return ( - + - + - + ) } -function colorRenderItem({item}: ListRenderItemInfo) { - return -} -function emojiRenderItem({item}: ListRenderItemInfo) { - return -} - -export function AvatarCreatorItems({type}: {type: 'emojis' | 'colors'}) { - const {isTabletOrDesktop} = useWebMediaQueries() +export function AvatarCreatorItems({ + type, + avatar, + setAvatar, +}: { + type: 'emojis' | 'colors' + avatar: Avatar + setAvatar: React.Dispatch> +}) { + const {gtMobile} = useBreakpoints() const styles = React.useMemo( () => ({ - flatListOuter: isTabletOrDesktop + flatListOuter: gtMobile ? { - height: 435, + height: 338, } - : [a.flex_row, a.align_center, {height: 100}], + : [a.flex_row, a.align_center, {height: 70}], }), - [isTabletOrDesktop], + [gtMobile], + ) + + const colorRenderItem = React.useCallback( + ({item}: ListRenderItemInfo) => { + return + }, + [setAvatar], + ) + + const emojiRenderItem = React.useCallback( + ({item}: ListRenderItemInfo) => { + return ( + + ) + }, + [avatar, setAvatar], ) return ( // Changing the value of numColumns on the fly isn't supported, so we want the flatlist to re-render whenever // the size of the screen changes. Should only happen when `isTabletOrDesktop` changes. - key={isTabletOrDesktop ? 0 : 1} + key={gtMobile ? 0 : 1} data={type === 'colors' ? avatarColors : emojiNames} renderItem={type === 'colors' ? colorRenderItem : emojiRenderItem} - style={[isTabletOrDesktop && {marginHorizontal: 10}]} + keyExtractor={item => item} + style={[gtMobile && {marginHorizontal: 10}]} contentContainerStyle={[ a.align_center, - isTabletOrDesktop && type === 'colors' && a.pr_xs, - !isTabletOrDesktop && {paddingHorizontal: 40}, + gtMobile && type === 'colors' && a.pr_xs, + !gtMobile && {paddingHorizontal: 40}, ]} - numColumns={isTabletOrDesktop && type === 'emojis' ? 4 : undefined} - showsHorizontalScrollIndicator={isTabletOrDesktop && type === 'colors'} - horizontal={!isTabletOrDesktop} + numColumns={gtMobile && type === 'emojis' ? 4 : undefined} + showsHorizontalScrollIndicator={gtMobile && type === 'colors'} + horizontal={!gtMobile} /> ) diff --git a/src/screens/Onboarding/StepProfile/PlaceholderCanvas.tsx b/src/screens/Onboarding/StepProfile/PlaceholderCanvas.tsx index a75b9ddae9..2ab79d3a79 100644 --- a/src/screens/Onboarding/StepProfile/PlaceholderCanvas.tsx +++ b/src/screens/Onboarding/StepProfile/PlaceholderCanvas.tsx @@ -15,7 +15,7 @@ export interface PlaceholderCanvasRef { // "screenshot". export const PlaceholderCanvas = React.forwardRef( function PlaceholderCanvas({}, ref) { - const avatar = useAvatar() + const {avatar} = useAvatar() const viewshotRef = React.useRef() const Icon = avatar.placeholder.component diff --git a/src/screens/Onboarding/StepProfile/index.tsx b/src/screens/Onboarding/StepProfile/index.tsx index f25ec78275..2e83bc8ab5 100644 --- a/src/screens/Onboarding/StepProfile/index.tsx +++ b/src/screens/Onboarding/StepProfile/index.tsx @@ -11,8 +11,6 @@ import { Description, OnboardingControls, } from '#/screens/Onboarding/Layout' -import {AvatarCreatorItems} from '#/screens/Onboarding/StepProfile/AvatarCreatorItems' -import {AvatarCircle} from '#/screens/Onboarding/StepProfile/AvatarCircle' import {Emoji, emojiItems, AvatarColor, avatarColors} from './types' import { PlaceholderCanvas, @@ -21,10 +19,19 @@ import { import {Button, ButtonText, ButtonIcon} from '#/components/Button' import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' import {IconCircle} from '#/components/IconCircle' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useAnalytics} from '#/lib/analytics/analytics' +import {AvatarCircle} from '#/screens/Onboarding/StepProfile/AvatarCircle' +import * as Dialog from '#/components/Dialog' +import {AvatarCreatorItems} from '#/screens/Onboarding/StepProfile/AvatarCreatorItems' +import {AvatarCreatorCircle} from '#/screens/Onboarding/StepProfile/AvatarCreatorCircle' +import {openPicker} from 'lib/media/picker.shared' +import {isNative, isWeb} from 'platform/detection' +import {openCropper} from 'lib/media/picker' +import {compressIfNeeded} from 'lib/media/manip' +import {Image as ExpoImage} from 'expo-image/build/Image' +import {usePhotoLibraryPermission} from 'lib/hooks/usePermissions' -interface Avatar { +export interface Avatar { image?: { path: string mime: string @@ -34,25 +41,33 @@ interface Avatar { } backgroundColor: AvatarColor placeholder: Emoji + useCreatedAvatar: boolean } -const AvatarContext = React.createContext({} as Avatar) -const SetAvatarContext = React.createContext< - React.Dispatch> ->({} as React.Dispatch>) +interface IAvatarContext { + avatar: Avatar + setAvatar: React.Dispatch> +} + +const AvatarContext = React.createContext({} as IAvatarContext) export const useAvatar = () => React.useContext(AvatarContext) -export const useSetAvatar = () => React.useContext(SetAvatarContext) + +const randomColor = + avatarColors[Math.floor(Math.random() * avatarColors.length)] export function StepProfile() { const {_} = useLingui() const t = useTheme() - const {isTabletOrDesktop} = useWebMediaQueries() const {gtMobile} = useBreakpoints() const {track} = useAnalytics() + const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() + const creatorControl = Dialog.useDialogControl() + const {state, dispatch} = React.useContext(Context) const [avatar, setAvatar] = React.useState({ placeholder: emojiItems.at, - backgroundColor: avatarColors[0], + backgroundColor: randomColor, + useCreatedAvatar: false, }) const canvasRef = React.useRef(null) @@ -63,7 +78,7 @@ export function StepProfile() { const onContinue = React.useCallback(async () => { let imageUri = avatar?.image?.path - if (!imageUri) { + if (!imageUri || avatar.useCreatedAvatar) { imageUri = await canvasRef.current?.capture() } @@ -77,61 +92,171 @@ export function StepProfile() { dispatch({type: 'next'}) track('OnboardingV2:StepProfile:End') - }, [avatar?.image, dispatch, track]) + }, [ + avatar.image?.mime, + avatar.image?.path, + avatar.useCreatedAvatar, + dispatch, + track, + ]) + + const onDoneCreating = React.useCallback(() => { + setAvatar(prev => ({ + ...prev, + useCreatedAvatar: true, + })) + creatorControl.close() + }, [creatorControl]) + + const openLibrary = React.useCallback(async () => { + if (!(await requestPhotoAccessIfNeeded())) { + return + } + + const items = await openPicker({ + aspect: [1, 1], + }) + let image = items[0] + if (!image) return + + // TODO we need an alf modal for the cropper + if (!isWeb) { + image = await openCropper({ + mediaType: 'photo', + cropperCircleOverlay: true, + height: image.height, + width: image.width, + path: image.path, + }) + } + image = await compressIfNeeded(image, 1000000) + + // If we are on mobile, prefetching the image will load the image into memory before we try and display it, + // stopping any brief flickers. + if (isNative) { + await ExpoImage.prefetch(image.path) + } + + setAvatar(prev => ({ + ...prev, + image, + useCreatedAvatar: false, + })) + }, [requestPhotoAccessIfNeeded, setAvatar]) + + const onSecondaryPress = React.useCallback(() => { + if (avatar.useCreatedAvatar) { + openLibrary() + } else { + creatorControl.open() + } + }, [avatar.useCreatedAvatar, creatorControl, openLibrary]) + + const value = React.useMemo( + () => ({ + avatar, + setAvatar, + }), + [avatar], + ) return ( - - - <> - - - + + + + + + <Trans>Give your profile a face</Trans> + + + + Help people know you're not a bot by uploading a picture or + creating an avatar. + + + + + + + - - <Trans>Set your profile picture</Trans> - - - - Help people know you're not a bot by uploading a picture or - creating an avatar! - - - - - - - - {!avatar.image && ( - - - - - )} - - - - - + + + + - - - - + + + + + + + + + + + + + + + + + + + + + ) }