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
This commit is contained in:
Hailey
2024-03-09 00:10:37 -08:00
parent 3d80b8befe
commit bcd519a4ad
11 changed files with 390 additions and 246 deletions
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 513 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 21 21"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 371 B

+7 -2
View File
@@ -184,12 +184,17 @@ export function Outer({
) )
} }
export function Inner({children, style}: DialogInnerProps) { export function Inner({
children,
style,
noHorizontalPadding,
}: DialogInnerProps) {
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
return ( return (
<BottomSheetView <BottomSheetView
style={[ style={[
a.p_xl, a.py_xl,
!noHorizontalPadding && a.px_xl,
{ {
paddingTop: 40, paddingTop: 40,
borderTopLeftRadius: 40, borderTopLeftRadius: 40,
+2
View File
@@ -54,9 +54,11 @@ export type DialogInnerProps =
label?: undefined label?: undefined
accessibilityLabelledBy: A11yProps['aria-labelledby'] accessibilityLabelledBy: A11yProps['aria-labelledby']
accessibilityDescribedBy: string accessibilityDescribedBy: string
noHorizontalPadding?: boolean
}> }>
| DialogInnerPropsBase<{ | DialogInnerPropsBase<{
label: string label: string
accessibilityLabelledBy?: undefined accessibilityLabelledBy?: undefined
accessibilityDescribedBy?: undefined accessibilityDescribedBy?: undefined
noHorizontalPadding?: boolean
}> }>
+5
View File
@@ -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',
})
+5
View File
@@ -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',
})
@@ -1,42 +1,40 @@
import React from 'react' import React from 'react'
import {LayoutAnimation, Pressable, PressableProps, View} from 'react-native' import {atoms as a, native, useTheme, web} from '#/alf'
import {Image} from 'expo-image' import {useAvatar} from '#/screens/Onboarding/StepProfile/index'
import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times' import {TouchableOpacity, TouchableOpacityProps, View} from 'react-native'
import {Camera_Stroke2_Corner0_Rounded as Camera} from '#/components/icons/Camera' import {Pencil_Stroke2_Corner0_Rounded as Pencil} from '#/components/icons/Pencil'
import {useAvatar, useSetAvatar} from '#/screens/Onboarding/StepProfile/index' 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' function AvatarBottomButton({...props}: TouchableOpacityProps) {
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) {
const t = useTheme() const t = useTheme()
return ( return (
<Pressable <TouchableOpacity
{...props} {...props}
style={[ style={[
a.absolute, a.absolute,
a.rounded_full, a.rounded_full,
a.align_center, a.align_center,
a.justify_center, a.justify_center,
t.name === 'light' ? t.atoms.bg_contrast_800 : t.atoms.bg_contrast_200, {backgroundColor: t.palette.primary_500},
{height: 48, width: 48, bottom: 2, right: 2}, {height: 48, width: 48, bottom: 2, right: 2},
]}> ]}>
{props.children} {props.children}
</Pressable> </TouchableOpacity>
) )
} }
export function AvatarCircle() { export function AvatarCircle({
openLibrary,
openCreator,
}: {
openLibrary: () => unknown
openCreator: () => unknown
}) {
const t = useTheme() const t = useTheme()
const avatar = useAvatar() const {avatar} = useAvatar()
const setAvatar = useSetAvatar()
const Icon = avatar.placeholder.component
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
const styles = React.useMemo( const styles = React.useMemo(
() => ({ () => ({
@@ -45,84 +43,42 @@ export function AvatarCircle() {
a.overflow_hidden, a.overflow_hidden,
a.align_center, a.align_center,
a.justify_center, a.justify_center,
t.atoms.border_contrast_high, t.atoms.border_contrast_low,
t.atoms.bg_contrast_25,
{ {
height: 150, height: 200,
width: 150, width: 200,
borderWidth: 2,
backgroundColor: avatar.backgroundColor,
}, },
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 (
<View>
<Image
source={avatar.image.path}
style={styles.imageContainer}
accessibilityIgnoresInvertColors
/>
<AvatarBottomButton onPress={onPressRemoveAvatar}>
<Times size="lg" style={{color: t.palette.white}} />
</AvatarBottomButton>
</View>
)
}
return ( return (
<View> <View>
<View style={styles.imageContainer}> {avatar.useCreatedAvatar ? (
<Icon height={85} width={85} style={{color: t.palette.white}} /> <AvatarCreatorCircle avatar={avatar} size={200} />
</View> ) : avatar.image ? (
<AvatarBottomButton onPress={onCameraPress}> <ExpoImage
<Camera size="xl" style={{color: t.palette.white}} /> source={avatar.image.path}
style={styles.imageContainer}
accessibilityIgnoresInvertColors
transition={{duration: 300, effect: 'cross-dissolve'}}
/>
) : (
<View style={styles.imageContainer}>
<ImageIcon
height={100}
width={100}
style={{color: t.palette.contrast_200}}
/>
</View>
)}
<AvatarBottomButton
onPress={avatar.useCreatedAvatar ? openCreator : openLibrary}>
<Pencil size="md" style={{color: t.palette.white}} />
</AvatarBottomButton> </AvatarBottomButton>
</View> </View>
) )
@@ -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 (
<View>
<View style={styles.imageContainer}>
<Icon height={85} width={85} style={{color: t.palette.white}} />
</View>
</View>
)
}
@@ -1,13 +1,11 @@
import React from 'react' import React from 'react'
import {FlatList, ListRenderItemInfo, Pressable, View} from 'react-native' import {ListRenderItemInfo, Pressable, View} from 'react-native'
import Animated, { // Using the FlatList from RNGH allows us to nest the scroll views on Android. The default FlatList won't allow
useAnimatedStyle, // scrolling inside of the vertical ScrollView
useSharedValue, import {FlatList} from 'react-native-gesture-handler'
withTiming,
} from 'react-native-reanimated'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, native, useBreakpoints, useTheme, web} from '#/alf'
import {useAvatar, useSetAvatar} from '#/screens/Onboarding/StepProfile/index' import {Avatar} from '#/screens/Onboarding/StepProfile/index'
import { import {
AvatarColor, AvatarColor,
avatarColors, avatarColors,
@@ -15,75 +13,44 @@ import {
EmojiName, EmojiName,
emojiNames, emojiNames,
} from '#/screens/Onboarding/StepProfile/types' } from '#/screens/Onboarding/StepProfile/types'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
const WITH_TIMING_CONFIG = {duration: 150} function Circle({children}: React.PropsWithChildren<{}>) {
function AnimatedCircle({
selected,
children,
}: React.PropsWithChildren<{selected: boolean}>) {
const t = useTheme() const t = useTheme()
const {isTabletOrDesktop} = useWebMediaQueries()
const size = useSharedValue(selected ? 1.2 : 1)
const styles = React.useMemo( const styles = React.useMemo(
() => ({ () => ({
imageContainer: [ container: [
a.rounded_full, a.rounded_full,
a.overflow_hidden, a.overflow_hidden,
a.align_center, a.align_center,
a.justify_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 ( return (
<Animated.View <View style={[styles.container, t.atoms.border_contrast_high]}>
style={[
styles.imageContainer,
styles.paletteContainer,
t.atoms.border_contrast_high,
animatedStyle,
]}>
{children} {children}
</Animated.View> </View>
) )
} }
function ColorItem({color}: {color: AvatarColor}) { function ColorItem({
const avatar = useAvatar() color,
const setAvatar = useSetAvatar() setAvatar,
}: {
color: AvatarColor
setAvatar: React.Dispatch<React.SetStateAction<Avatar>>
}) {
const onPress = React.useCallback(() => { const onPress = React.useCallback(() => {
setAvatar(prev => ({ setAvatar(prev => ({
...prev, ...prev,
@@ -92,20 +59,26 @@ function ColorItem({color}: {color: AvatarColor}) {
}, [color, setAvatar]) }, [color, setAvatar])
return ( return (
<AnimatedCircle selected={avatar.backgroundColor === color}> <Circle>
<Pressable <Pressable
accessibilityRole="button" accessibilityRole="button"
style={[a.h_full, a.w_full, {backgroundColor: color}]} style={[a.h_full, a.w_full, {backgroundColor: color}]}
onPress={onPress} onPress={onPress}
/> />
</AnimatedCircle> </Circle>
) )
} }
function EmojiItem({emojiName}: {emojiName: EmojiName}) { function EmojiItem({
emojiName,
avatar,
setAvatar,
}: {
emojiName: EmojiName
avatar: Avatar
setAvatar: React.Dispatch<React.SetStateAction<Avatar>>
}) {
const t = useTheme() const t = useTheme()
const avatar = useAvatar()
const setAvatar = useSetAvatar()
const Icon = React.useMemo(() => emojiItems[emojiName].component, [emojiName]) const Icon = React.useMemo(() => emojiItems[emojiName].component, [emojiName])
const onPress = React.useCallback(() => { const onPress = React.useCallback(() => {
@@ -115,60 +88,87 @@ function EmojiItem({emojiName}: {emojiName: EmojiName}) {
})) }))
}, [emojiName, setAvatar]) }, [emojiName, setAvatar])
const selected = React.useMemo(
() => avatar.placeholder.name === emojiName,
[avatar.placeholder.name, emojiName],
)
return ( return (
<AnimatedCircle selected={avatar.placeholder.name === emojiName}> <Circle>
<Pressable <Pressable
accessibilityRole="button" accessibilityRole="button"
style={[a.flex_1, a.justify_center, a.align_center]} style={[a.flex_1, a.justify_center, a.align_center]}
onPress={onPress}> onPress={onPress}>
<Icon style={[t.atoms.text_contrast_medium]} height={40} width={40} /> <Icon
style={selected ? t.atoms.text : t.atoms.text_contrast_low}
height={30}
width={30}
/>
</Pressable> </Pressable>
</AnimatedCircle> </Circle>
) )
} }
function colorRenderItem({item}: ListRenderItemInfo<AvatarColor>) { export function AvatarCreatorItems({
return <ColorItem color={item} /> type,
} avatar,
function emojiRenderItem({item}: ListRenderItemInfo<EmojiName>) { setAvatar,
return <EmojiItem emojiName={item} /> }: {
} type: 'emojis' | 'colors'
avatar: Avatar
export function AvatarCreatorItems({type}: {type: 'emojis' | 'colors'}) { setAvatar: React.Dispatch<React.SetStateAction<Avatar>>
const {isTabletOrDesktop} = useWebMediaQueries() }) {
const {gtMobile} = useBreakpoints()
const styles = React.useMemo( 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<AvatarColor>) => {
return <ColorItem color={item} setAvatar={setAvatar} />
},
[setAvatar],
)
const emojiRenderItem = React.useCallback(
({item}: ListRenderItemInfo<EmojiName>) => {
return (
<EmojiItem emojiName={item} avatar={avatar} setAvatar={setAvatar} />
)
},
[avatar, setAvatar],
) )
return ( return (
<View <View
style={[ style={[
styles.flatListOuter, styles.flatListOuter,
isTabletOrDesktop && type === 'colors' && {width: 125}, gtMobile && type === 'colors' && {width: 125},
]}> ]}>
<FlatList<any> <FlatList<any>
// Changing the value of numColumns on the fly isn't supported, so we want the flatlist to re-render whenever // 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. // 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} data={type === 'colors' ? avatarColors : emojiNames}
renderItem={type === 'colors' ? colorRenderItem : emojiRenderItem} renderItem={type === 'colors' ? colorRenderItem : emojiRenderItem}
style={[isTabletOrDesktop && {marginHorizontal: 10}]} keyExtractor={item => item}
style={[gtMobile && {marginHorizontal: 10}]}
contentContainerStyle={[ contentContainerStyle={[
a.align_center, a.align_center,
isTabletOrDesktop && type === 'colors' && a.pr_xs, gtMobile && type === 'colors' && a.pr_xs,
!isTabletOrDesktop && {paddingHorizontal: 40}, !gtMobile && {paddingHorizontal: 40},
]} ]}
numColumns={isTabletOrDesktop && type === 'emojis' ? 4 : undefined} numColumns={gtMobile && type === 'emojis' ? 4 : undefined}
showsHorizontalScrollIndicator={isTabletOrDesktop && type === 'colors'} showsHorizontalScrollIndicator={gtMobile && type === 'colors'}
horizontal={!isTabletOrDesktop} horizontal={!gtMobile}
/> />
</View> </View>
) )
@@ -15,7 +15,7 @@ export interface PlaceholderCanvasRef {
// "screenshot". // "screenshot".
export const PlaceholderCanvas = React.forwardRef<PlaceholderCanvasRef, {}>( export const PlaceholderCanvas = React.forwardRef<PlaceholderCanvasRef, {}>(
function PlaceholderCanvas({}, ref) { function PlaceholderCanvas({}, ref) {
const avatar = useAvatar() const {avatar} = useAvatar()
const viewshotRef = React.useRef() const viewshotRef = React.useRef()
const Icon = avatar.placeholder.component const Icon = avatar.placeholder.component
+189 -64
View File
@@ -11,8 +11,6 @@ import {
Description, Description,
OnboardingControls, OnboardingControls,
} from '#/screens/Onboarding/Layout' } 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 {Emoji, emojiItems, AvatarColor, avatarColors} from './types'
import { import {
PlaceholderCanvas, PlaceholderCanvas,
@@ -21,10 +19,19 @@ import {
import {Button, ButtonText, ButtonIcon} from '#/components/Button' import {Button, ButtonText, ButtonIcon} from '#/components/Button'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
import {IconCircle} from '#/components/IconCircle' import {IconCircle} from '#/components/IconCircle'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {useAnalytics} from '#/lib/analytics/analytics' 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?: { image?: {
path: string path: string
mime: string mime: string
@@ -34,25 +41,33 @@ interface Avatar {
} }
backgroundColor: AvatarColor backgroundColor: AvatarColor
placeholder: Emoji placeholder: Emoji
useCreatedAvatar: boolean
} }
const AvatarContext = React.createContext<Avatar>({} as Avatar) interface IAvatarContext {
const SetAvatarContext = React.createContext< avatar: Avatar
React.Dispatch<React.SetStateAction<Avatar>> setAvatar: React.Dispatch<React.SetStateAction<Avatar>>
>({} as React.Dispatch<React.SetStateAction<Avatar>>) }
const AvatarContext = React.createContext<IAvatarContext>({} as IAvatarContext)
export const useAvatar = () => React.useContext(AvatarContext) export const useAvatar = () => React.useContext(AvatarContext)
export const useSetAvatar = () => React.useContext(SetAvatarContext)
const randomColor =
avatarColors[Math.floor(Math.random() * avatarColors.length)]
export function StepProfile() { export function StepProfile() {
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
const {isTabletOrDesktop} = useWebMediaQueries()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const {track} = useAnalytics() const {track} = useAnalytics()
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
const creatorControl = Dialog.useDialogControl()
const {state, dispatch} = React.useContext(Context) const {state, dispatch} = React.useContext(Context)
const [avatar, setAvatar] = React.useState<Avatar>({ const [avatar, setAvatar] = React.useState<Avatar>({
placeholder: emojiItems.at, placeholder: emojiItems.at,
backgroundColor: avatarColors[0], backgroundColor: randomColor,
useCreatedAvatar: false,
}) })
const canvasRef = React.useRef<PlaceholderCanvasRef>(null) const canvasRef = React.useRef<PlaceholderCanvasRef>(null)
@@ -63,7 +78,7 @@ export function StepProfile() {
const onContinue = React.useCallback(async () => { const onContinue = React.useCallback(async () => {
let imageUri = avatar?.image?.path let imageUri = avatar?.image?.path
if (!imageUri) { if (!imageUri || avatar.useCreatedAvatar) {
imageUri = await canvasRef.current?.capture() imageUri = await canvasRef.current?.capture()
} }
@@ -77,61 +92,171 @@ export function StepProfile() {
dispatch({type: 'next'}) dispatch({type: 'next'})
track('OnboardingV2:StepProfile:End') 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 ( return (
<SetAvatarContext.Provider value={setAvatar}> <AvatarContext.Provider value={value}>
<AvatarContext.Provider value={avatar}> <View style={[a.align_start, t.atoms.bg, a.justify_between]}>
<> <View style={[gtMobile ? a.px_5xl : a.px_xl]}>
<View style={[a.align_start, t.atoms.bg]}> <IconCircle icon={StreamingLive} style={[a.mb_2xl]} />
<View style={[gtMobile ? a.px_5xl : a.px_xl]}> <Title>
<IconCircle icon={StreamingLive} style={[a.mb_2xl]} /> <Trans>Give your profile a face</Trans>
</Title>
<Description>
<Trans>
Help people know you're not a bot by uploading a picture or
creating an avatar.
</Trans>
</Description>
</View>
<View style={[a.w_full, a.align_center, {paddingTop: 80}]}>
<AvatarCircle
openLibrary={openLibrary}
openCreator={creatorControl.open}
/>
</View>
<View style={[a.w_full, a.px_2xl, a.pt_5xl]} />
<Title> <OnboardingControls.Portal>
<Trans>Set your profile picture</Trans> <View style={[a.gap_md, gtMobile && {flexDirection: 'row-reverse'}]}>
</Title> <Button
<Description> key={state.activeStep} // remove focus state on nav
<Trans> variant="gradient"
Help people know you're not a bot by uploading a picture or color="gradient_sky"
creating an avatar! size="large"
</Trans> label={_(msg`Continue to next step`)}
</Description> onPress={onContinue}>
</View> <ButtonText>
<View style={[a.w_full, a.pt_5xl]}> <Trans>Continue</Trans>
<View style={[a.align_center, a.pb_5xl]}> </ButtonText>
<AvatarCircle /> <ButtonIcon icon={ChevronRight} position="right" />
</View> </Button>
{!avatar.image && ( <Button
<View key={state.activeStep} // remove focus state on nav
style={ variant="ghost"
isTabletOrDesktop color="primary"
? [a.flex_row, a.justify_between] size="large"
: undefined label={_(msg`Open avatar creator`)}
}> onPress={onSecondaryPress}>
<AvatarCreatorItems type="emojis" /> <ButtonText>
<AvatarCreatorItems type="colors" /> {avatar.useCreatedAvatar ? (
</View> <Trans>Upload a photo instead</Trans>
)} ) : (
</View> <Trans>Create an avatar instead</Trans>
)}
<OnboardingControls.Portal> </ButtonText>
<Button </Button>
key={state.activeStep} // remove focus state on nav
variant="gradient"
color="gradient_sky"
size="large"
label={_(msg`Continue to next step`)}
onPress={onContinue}>
<ButtonText>
<Trans>Continue</Trans>
</ButtonText>
<ButtonIcon icon={ChevronRight} position="right" />
</Button>
</OnboardingControls.Portal>
</View> </View>
<PlaceholderCanvas ref={canvasRef} /> </OnboardingControls.Portal>
</> </View>
</AvatarContext.Provider>
</SetAvatarContext.Provider> <Dialog.Outer control={creatorControl}>
<Dialog.Handle />
<Dialog.Inner label="Avatar creator" noHorizontalPadding>
<View style={[a.align_center, {paddingTop: 20}]}>
<AvatarCreatorCircle avatar={avatar} />
</View>
<View
style={[
a.pt_2xl,
a.align_center,
a.justify_center,
gtMobile && a.flex_row,
]}>
<AvatarCreatorItems
type="emojis"
avatar={avatar}
setAvatar={setAvatar}
/>
<AvatarCreatorItems
type="colors"
avatar={avatar}
setAvatar={setAvatar}
/>
</View>
<View style={[a.px_xl, a.pt_4xl]}>
<Button
key={state.activeStep} // remove focus state on nav
variant="solid"
color="primary"
size="large"
label={_(msg`Done`)}
onPress={onDoneCreating}>
<ButtonText>
<Trans>Done</Trans>
</ButtonText>
</Button>
</View>
</Dialog.Inner>
</Dialog.Outer>
<PlaceholderCanvas ref={canvasRef} />
</AvatarContext.Provider>
) )
} }