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 && ( - - - - - )} - - - - - + + + + - - - - + + + + + + + + + + + + + + + + + + + + + ) }