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:
@@ -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 |
@@ -184,12 +184,17 @@ export function Outer({
|
||||
)
|
||||
}
|
||||
|
||||
export function Inner({children, style}: DialogInnerProps) {
|
||||
export function Inner({
|
||||
children,
|
||||
style,
|
||||
noHorizontalPadding,
|
||||
}: DialogInnerProps) {
|
||||
const insets = useSafeAreaInsets()
|
||||
return (
|
||||
<BottomSheetView
|
||||
style={[
|
||||
a.p_xl,
|
||||
a.py_xl,
|
||||
!noHorizontalPadding && a.px_xl,
|
||||
{
|
||||
paddingTop: 40,
|
||||
borderTopLeftRadius: 40,
|
||||
|
||||
@@ -54,9 +54,11 @@ export type DialogInnerProps =
|
||||
label?: undefined
|
||||
accessibilityLabelledBy: A11yProps['aria-labelledby']
|
||||
accessibilityDescribedBy: string
|
||||
noHorizontalPadding?: boolean
|
||||
}>
|
||||
| DialogInnerPropsBase<{
|
||||
label: string
|
||||
accessibilityLabelledBy?: undefined
|
||||
accessibilityDescribedBy?: undefined
|
||||
noHorizontalPadding?: boolean
|
||||
}>
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
@@ -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 {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 (
|
||||
<Pressable
|
||||
<TouchableOpacity
|
||||
{...props}
|
||||
style={[
|
||||
a.absolute,
|
||||
a.rounded_full,
|
||||
a.align_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},
|
||||
]}>
|
||||
{props.children}
|
||||
</Pressable>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<View>
|
||||
<Image
|
||||
source={avatar.image.path}
|
||||
style={styles.imageContainer}
|
||||
accessibilityIgnoresInvertColors
|
||||
/>
|
||||
<AvatarBottomButton onPress={onPressRemoveAvatar}>
|
||||
<Times size="lg" style={{color: t.palette.white}} />
|
||||
</AvatarBottomButton>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View style={styles.imageContainer}>
|
||||
<Icon height={85} width={85} style={{color: t.palette.white}} />
|
||||
</View>
|
||||
<AvatarBottomButton onPress={onCameraPress}>
|
||||
<Camera size="xl" style={{color: t.palette.white}} />
|
||||
{avatar.useCreatedAvatar ? (
|
||||
<AvatarCreatorCircle avatar={avatar} size={200} />
|
||||
) : avatar.image ? (
|
||||
<ExpoImage
|
||||
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>
|
||||
</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 {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 (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.imageContainer,
|
||||
styles.paletteContainer,
|
||||
t.atoms.border_contrast_high,
|
||||
animatedStyle,
|
||||
]}>
|
||||
<View style={[styles.container, t.atoms.border_contrast_high]}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function ColorItem({color}: {color: AvatarColor}) {
|
||||
const avatar = useAvatar()
|
||||
const setAvatar = useSetAvatar()
|
||||
|
||||
function ColorItem({
|
||||
color,
|
||||
setAvatar,
|
||||
}: {
|
||||
color: AvatarColor
|
||||
setAvatar: React.Dispatch<React.SetStateAction<Avatar>>
|
||||
}) {
|
||||
const onPress = React.useCallback(() => {
|
||||
setAvatar(prev => ({
|
||||
...prev,
|
||||
@@ -92,20 +59,26 @@ function ColorItem({color}: {color: AvatarColor}) {
|
||||
}, [color, setAvatar])
|
||||
|
||||
return (
|
||||
<AnimatedCircle selected={avatar.backgroundColor === color}>
|
||||
<Circle>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
style={[a.h_full, a.w_full, {backgroundColor: color}]}
|
||||
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 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 (
|
||||
<AnimatedCircle selected={avatar.placeholder.name === emojiName}>
|
||||
<Circle>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
style={[a.flex_1, a.justify_center, a.align_center]}
|
||||
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>
|
||||
</AnimatedCircle>
|
||||
</Circle>
|
||||
)
|
||||
}
|
||||
|
||||
function colorRenderItem({item}: ListRenderItemInfo<AvatarColor>) {
|
||||
return <ColorItem color={item} />
|
||||
}
|
||||
function emojiRenderItem({item}: ListRenderItemInfo<EmojiName>) {
|
||||
return <EmojiItem emojiName={item} />
|
||||
}
|
||||
|
||||
export function AvatarCreatorItems({type}: {type: 'emojis' | 'colors'}) {
|
||||
const {isTabletOrDesktop} = useWebMediaQueries()
|
||||
export function AvatarCreatorItems({
|
||||
type,
|
||||
avatar,
|
||||
setAvatar,
|
||||
}: {
|
||||
type: 'emojis' | 'colors'
|
||||
avatar: Avatar
|
||||
setAvatar: React.Dispatch<React.SetStateAction<Avatar>>
|
||||
}) {
|
||||
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<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 (
|
||||
<View
|
||||
style={[
|
||||
styles.flatListOuter,
|
||||
isTabletOrDesktop && type === 'colors' && {width: 125},
|
||||
gtMobile && type === 'colors' && {width: 125},
|
||||
]}>
|
||||
<FlatList<any>
|
||||
// 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}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface PlaceholderCanvasRef {
|
||||
// "screenshot".
|
||||
export const PlaceholderCanvas = React.forwardRef<PlaceholderCanvasRef, {}>(
|
||||
function PlaceholderCanvas({}, ref) {
|
||||
const avatar = useAvatar()
|
||||
const {avatar} = useAvatar()
|
||||
const viewshotRef = React.useRef()
|
||||
const Icon = avatar.placeholder.component
|
||||
|
||||
|
||||
@@ -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<Avatar>({} as Avatar)
|
||||
const SetAvatarContext = React.createContext<
|
||||
React.Dispatch<React.SetStateAction<Avatar>>
|
||||
>({} as React.Dispatch<React.SetStateAction<Avatar>>)
|
||||
interface IAvatarContext {
|
||||
avatar: Avatar
|
||||
setAvatar: React.Dispatch<React.SetStateAction<Avatar>>
|
||||
}
|
||||
|
||||
const AvatarContext = React.createContext<IAvatarContext>({} 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<Avatar>({
|
||||
placeholder: emojiItems.at,
|
||||
backgroundColor: avatarColors[0],
|
||||
backgroundColor: randomColor,
|
||||
useCreatedAvatar: false,
|
||||
})
|
||||
|
||||
const canvasRef = React.useRef<PlaceholderCanvasRef>(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 (
|
||||
<SetAvatarContext.Provider value={setAvatar}>
|
||||
<AvatarContext.Provider value={avatar}>
|
||||
<>
|
||||
<View style={[a.align_start, t.atoms.bg]}>
|
||||
<View style={[gtMobile ? a.px_5xl : a.px_xl]}>
|
||||
<IconCircle icon={StreamingLive} style={[a.mb_2xl]} />
|
||||
<AvatarContext.Provider value={value}>
|
||||
<View style={[a.align_start, t.atoms.bg, a.justify_between]}>
|
||||
<View style={[gtMobile ? a.px_5xl : a.px_xl]}>
|
||||
<IconCircle icon={StreamingLive} style={[a.mb_2xl]} />
|
||||
<Title>
|
||||
<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>
|
||||
<Trans>Set your profile picture</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.pt_5xl]}>
|
||||
<View style={[a.align_center, a.pb_5xl]}>
|
||||
<AvatarCircle />
|
||||
</View>
|
||||
{!avatar.image && (
|
||||
<View
|
||||
style={
|
||||
isTabletOrDesktop
|
||||
? [a.flex_row, a.justify_between]
|
||||
: undefined
|
||||
}>
|
||||
<AvatarCreatorItems type="emojis" />
|
||||
<AvatarCreatorItems type="colors" />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<OnboardingControls.Portal>
|
||||
<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>
|
||||
<OnboardingControls.Portal>
|
||||
<View style={[a.gap_md, gtMobile && {flexDirection: 'row-reverse'}]}>
|
||||
<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>
|
||||
<Button
|
||||
key={state.activeStep} // remove focus state on nav
|
||||
variant="ghost"
|
||||
color="primary"
|
||||
size="large"
|
||||
label={_(msg`Open avatar creator`)}
|
||||
onPress={onSecondaryPress}>
|
||||
<ButtonText>
|
||||
{avatar.useCreatedAvatar ? (
|
||||
<Trans>Upload a photo instead</Trans>
|
||||
) : (
|
||||
<Trans>Create an avatar instead</Trans>
|
||||
)}
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
<PlaceholderCanvas ref={canvasRef} />
|
||||
</>
|
||||
</AvatarContext.Provider>
|
||||
</SetAvatarContext.Provider>
|
||||
</OnboardingControls.Portal>
|
||||
</View>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user