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 (
-
-
- <>
-
-
-
+
+
+
+
+
+ Give your profile a face
+
+
+
+ Help people know you're not a bot by uploading a picture or
+ creating an avatar.
+
+
+
+
+
+
+
-
- Set your profile picture
-
-
-
- Help people know you're not a bot by uploading a picture or
- creating an avatar!
-
-
-
-
-
-
-
- {!avatar.image && (
-
-
-
-
- )}
-
-
-
-
-
+
+
+
+
-
- >
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
)
}