From f0a602a51c25a5f4efdbe33c3de4a68262b42c33 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Sat, 20 Jan 2024 15:19:33 -0600 Subject: [PATCH] Outer layout, some primitive updates --- assets/icons/at_stroke2_corner0_rounded.svg | 1 + .../chevronLeft_stroke2_corner0_rounded.svg | 1 + .../chevronRight_stroke2_corner0_rounded.svg | 1 + .../icons/loader_stroke2_corner0_rounded.svg | 1 + .../plusLarge_stroke2_corner0_rounded.svg | 1 + src/alf/atoms.ts | 6 + src/alf/index.tsx | 1 + src/alf/types.ts | 10 + src/components/Button.tsx | 15 +- src/components/Loader.tsx | 41 +++++ src/components/Portal.tsx | 81 ++++---- src/components/Typography.tsx | 21 ++- src/components/icons/Chevron.tsx | 4 + src/components/icons/Loader.tsx | 5 + src/components/icons/Plus.tsx | 5 + src/lib/build-flags.ts | 2 + src/screens/Onboarding/Layout.tsx | 173 ++++++++++++++++++ src/screens/Onboarding/StepAlgoFeeds.tsx | 39 ++++ src/screens/Onboarding/StepFollowingFeed.tsx | 40 ++++ src/screens/Onboarding/StepInterests.tsx | 63 +++++++ .../Onboarding/StepSuggestedFollows.tsx | 49 +++++ src/screens/Onboarding/StepTopicalFeeds.tsx | 40 ++++ src/screens/Onboarding/context.ts | 36 ++++ src/screens/Onboarding/index.tsx | 88 +++++++++ .../createNativeStackNavigatorWithAuth.tsx | 9 +- 25 files changed, 684 insertions(+), 49 deletions(-) create mode 100644 assets/icons/at_stroke2_corner0_rounded.svg create mode 100644 assets/icons/chevronLeft_stroke2_corner0_rounded.svg create mode 100644 assets/icons/chevronRight_stroke2_corner0_rounded.svg create mode 100644 assets/icons/loader_stroke2_corner0_rounded.svg create mode 100644 assets/icons/plusLarge_stroke2_corner0_rounded.svg create mode 100644 src/components/Loader.tsx create mode 100644 src/components/icons/Loader.tsx create mode 100644 src/components/icons/Plus.tsx create mode 100644 src/screens/Onboarding/Layout.tsx create mode 100644 src/screens/Onboarding/StepAlgoFeeds.tsx create mode 100644 src/screens/Onboarding/StepFollowingFeed.tsx create mode 100644 src/screens/Onboarding/StepInterests.tsx create mode 100644 src/screens/Onboarding/StepSuggestedFollows.tsx create mode 100644 src/screens/Onboarding/StepTopicalFeeds.tsx create mode 100644 src/screens/Onboarding/context.ts create mode 100644 src/screens/Onboarding/index.tsx diff --git a/assets/icons/at_stroke2_corner0_rounded.svg b/assets/icons/at_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..8d30d7c8c5 --- /dev/null +++ b/assets/icons/at_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/chevronLeft_stroke2_corner0_rounded.svg b/assets/icons/chevronLeft_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..d9a8660f7f --- /dev/null +++ b/assets/icons/chevronLeft_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/chevronRight_stroke2_corner0_rounded.svg b/assets/icons/chevronRight_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..b57fd03986 --- /dev/null +++ b/assets/icons/chevronRight_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/loader_stroke2_corner0_rounded.svg b/assets/icons/loader_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..9dbc013798 --- /dev/null +++ b/assets/icons/loader_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/plusLarge_stroke2_corner0_rounded.svg b/assets/icons/plusLarge_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..8d568437b5 --- /dev/null +++ b/assets/icons/plusLarge_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts index 203c2f282a..c5de29c024 100644 --- a/src/alf/atoms.ts +++ b/src/alf/atoms.ts @@ -104,6 +104,9 @@ export const atoms = { flex: { display: 'flex', }, + flex_col: { + flexDirection: 'column', + }, flex_row: { flexDirection: 'row', }, @@ -188,6 +191,9 @@ export const atoms = { lineHeight: tokens.fontSize._5xl, }, leading_tight: { + lineHeight: 1.15, + }, + leading_snug: { lineHeight: 1.25, }, leading_normal: { diff --git a/src/alf/index.tsx b/src/alf/index.tsx index 69a8798539..06d6ebf01c 100644 --- a/src/alf/index.tsx +++ b/src/alf/index.tsx @@ -2,6 +2,7 @@ import React from 'react' import {Dimensions} from 'react-native' import * as themes from '#/alf/themes' +export * from '#/alf/types' export * as tokens from '#/alf/tokens' export {atoms} from '#/alf/atoms' export * from '#/alf/util/platform' diff --git a/src/alf/types.ts b/src/alf/types.ts index 76ac05d40e..dd8d816d2b 100644 --- a/src/alf/types.ts +++ b/src/alf/types.ts @@ -1,3 +1,5 @@ +import {StyleProp, ViewStyle, TextStyle} from 'react-native' + type LiteralToCommon = T extends number ? number : T extends string @@ -14,3 +16,11 @@ export type Mutable = { ? LiteralToCommon : Mutable } + +export type TextStyleProp = { + style?: StyleProp +} + +export type ViewStyleProp = { + style?: StyleProp +} diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 491362007c..77aa1c4d4e 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -273,15 +273,15 @@ export function Button({ if (shape === 'default') { if (size === 'large') { - baseStyles.push({paddingVertical: 15}, a.px_2xl, a.rounded_sm, a.gap_sm) + baseStyles.push({paddingVertical: 15}, a.px_2xl, a.rounded_sm, a.gap_md) } else if (size === 'small') { baseStyles.push({paddingVertical: 9}, a.px_md, a.rounded_sm, a.gap_sm) } } else if (shape === 'round' || shape === 'square') { if (size === 'large') { - baseStyles.push(a.p_lg) + baseStyles.push({padding: 15}) } else if (size === 'small') { - baseStyles.push(a.p_md) + baseStyles.push({padding: 10}) } if (shape === 'round') { @@ -360,6 +360,7 @@ export function Button({ flatten(style), a.flex_row, a.align_center, + a.justify_center, a.overflow_hidden, ...baseStyles, ...(state.hovered || state.pressed ? hoverStyles : []), @@ -506,9 +507,7 @@ export function ButtonText({children, style, ...rest}: ButtonTextProps) { const textStyles = useSharedButtonTextStyles() return ( - + {children} ) @@ -519,11 +518,11 @@ export function ButtonIcon({ }: { icon: React.ComponentType }) { - const {size} = useButtonContext() + const {size, disabled} = useButtonContext() const textStyles = useSharedButtonTextStyles() return ( - + ({ + transform: [{rotate: rotation.value + 'deg'}], + })) + + React.useEffect(() => { + rotation.value = withRepeat( + withTiming(360, {duration: 500, easing: Easing.linear}), + -1, + ) + }, [rotation]) + + return ( + + + + ) +} diff --git a/src/components/Portal.tsx b/src/components/Portal.tsx index 1813d9e05e..d696f986b4 100644 --- a/src/components/Portal.tsx +++ b/src/components/Portal.tsx @@ -12,45 +12,54 @@ type ComponentMap = { [id: string]: Component } -export const Context = React.createContext({ - outlet: null, - append: () => {}, - remove: () => {}, -}) +export function createPortalGroup() { + const Context = React.createContext({ + outlet: null, + append: () => {}, + remove: () => {}, + }) -export function Provider(props: React.PropsWithChildren<{}>) { - const map = React.useRef({}) - const [outlet, setOutlet] = React.useState(null) + function Provider(props: React.PropsWithChildren<{}>) { + const map = React.useRef({}) + const [outlet, setOutlet] = React.useState(null) - const append = React.useCallback((id, component) => { - if (map.current[id]) return - map.current[id] = {component} - setOutlet(<>{Object.values(map.current)}) - }, []) + const append = React.useCallback((id, component) => { + if (map.current[id]) return + map.current[id] = {component} + setOutlet(<>{Object.values(map.current)}) + }, []) - const remove = React.useCallback(id => { - delete map.current[id] - setOutlet(<>{Object.values(map.current)}) - }, []) + const remove = React.useCallback(id => { + delete map.current[id] + setOutlet(<>{Object.values(map.current)}) + }, []) - return ( - - {props.children} - - ) + return ( + + {props.children} + + ) + } + + function Outlet() { + const ctx = React.useContext(Context) + return ctx.outlet + } + + function Portal({children}: React.PropsWithChildren<{}>) { + const {append, remove} = React.useContext(Context) + const id = React.useId() + React.useEffect(() => { + append(id, children as Component) + return () => remove(id) + }, [id, children, append, remove]) + return null + } + + return {Provider, Outlet, Portal} } -export function Outlet() { - const ctx = React.useContext(Context) - return ctx.outlet -} - -export function Portal({children}: React.PropsWithChildren<{}>) { - const {append, remove} = React.useContext(Context) - const id = React.useId() - React.useEffect(() => { - append(id, children as Component) - return () => remove(id) - }, [id, children, append, remove]) - return null -} +const DefaultPortal = createPortalGroup() +export const Provider = DefaultPortal.Provider +export const Outlet = DefaultPortal.Outlet +export const Portal = DefaultPortal.Portal diff --git a/src/components/Typography.tsx b/src/components/Typography.tsx index 66cf0720de..daf06aedfe 100644 --- a/src/components/Typography.tsx +++ b/src/components/Typography.tsx @@ -3,6 +3,19 @@ import {Text as RNText, TextProps} from 'react-native' import {useTheme, atoms, web, flatten} from '#/alf' +/** + * Util to calculate lineHeight from a text size atom and a leading atom + * + * Example: + * `leading(atoms.text_md, atoms.leading_normal)` // => 24 + */ +export function leading< + Size extends {fontSize: number}, + Leading extends {lineHeight: number}, +>(textSize: Size, leading: Leading) { + return (textSize.fontSize || atoms.text_md.fontSize) * leading.lineHeight +} + export function Text({style, ...rest}: TextProps) { const t = useTheme() return @@ -111,9 +124,11 @@ export function P({style, ...rest}: TextProps) { role: 'paragraph', }) || {} const _style = flatten(style) - const lineHeight = - (_style?.lineHeight || atoms.text_md.lineHeight) * - atoms.leading_normal.lineHeight + const lineHeight = leading( + // @ts-ignore + _style?.fontSize ? _style : atoms.text_md, + atoms.leading_normal, + ) return ( ) { + const t = useTheme() + const insets = useSafeAreaInsets() + const {gtMobile} = useBreakpoints() + const onboardDispatch = useOnboardingDispatch() + const {state, dispatch} = React.useContext(Context) + + return ( + + {IS_DEV && ( + + + + )} + + {state.hasPrev && ( + + + + + + )} + + + + + + + {Array(state.totalSteps) + .fill(0) + .map((_, i) => ( + + ))} + + + + + {children} + + {isWeb && gtMobile && ( + + + + )} + + + + + + {(!isWeb || !gtMobile) && ( + + + + + + )} + + ) +} + +export function Title({ + children, + style, +}: React.PropsWithChildren) { + return ( +

+ {children} +

+ ) +} + +export function Description({ + children, + style, +}: React.PropsWithChildren) { + const t = useTheme() + return

{children}

+} diff --git a/src/screens/Onboarding/StepAlgoFeeds.tsx b/src/screens/Onboarding/StepAlgoFeeds.tsx new file mode 100644 index 0000000000..3cf4955d12 --- /dev/null +++ b/src/screens/Onboarding/StepAlgoFeeds.tsx @@ -0,0 +1,39 @@ +import React from 'react' +import {View} from 'react-native' + +import {atoms as a} from '#/alf' +import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' + +import {Context} from '#/screens/Onboarding/context' +import { + Title, + Description, + OnboardingControls, +} from '#/screens/Onboarding/Layout' + +export function StepAlgoFeeds() { + const {state, dispatch} = React.useContext(Context) + + return ( + + Choose your algorithmic feeds + + Feeds are created by users and can give you entirely new experiences. + + + + + + + ) +} diff --git a/src/screens/Onboarding/StepFollowingFeed.tsx b/src/screens/Onboarding/StepFollowingFeed.tsx new file mode 100644 index 0000000000..9f64242679 --- /dev/null +++ b/src/screens/Onboarding/StepFollowingFeed.tsx @@ -0,0 +1,40 @@ +import React from 'react' +import {View} from 'react-native' + +import {atoms as a} from '#/alf' +import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' + +import {Context} from '#/screens/Onboarding/context' +import { + Title, + Description, + OnboardingControls, +} from '#/screens/Onboarding/Layout' + +export function StepFollowingFeed() { + const {state, dispatch} = React.useContext(Context) + + return ( + + Your default feed is "Following" + + It only show posts from the people your follow, in the order they were + posted. + + + + + + + ) +} diff --git a/src/screens/Onboarding/StepInterests.tsx b/src/screens/Onboarding/StepInterests.tsx new file mode 100644 index 0000000000..8f69aa51f1 --- /dev/null +++ b/src/screens/Onboarding/StepInterests.tsx @@ -0,0 +1,63 @@ +import React from 'react' +import {View} from 'react-native' + +import {useTheme, atoms as a} from '#/alf' +import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' +import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {Loader} from '#/components/Loader' + +import {Context} from '#/screens/Onboarding/context' +import { + Title, + Description, + OnboardingControls, +} from '#/screens/Onboarding/Layout' + +export function StepInterests() { + const t = useTheme() + const {state, dispatch} = React.useContext(Context) + const [saving, setSaving] = React.useState(false) + + const saveInterests = React.useCallback(async () => { + setSaving(true) + await new Promise(y => setTimeout(y, 1000)) + setSaving(false) + dispatch({type: 'next'}) + }, [setSaving, dispatch]) + + return ( + + + + + + What are your interests? + + We'll use this to help customize your experience. + + + + + + + ) +} diff --git a/src/screens/Onboarding/StepSuggestedFollows.tsx b/src/screens/Onboarding/StepSuggestedFollows.tsx new file mode 100644 index 0000000000..a41c39b9c4 --- /dev/null +++ b/src/screens/Onboarding/StepSuggestedFollows.tsx @@ -0,0 +1,49 @@ +import React from 'react' +import {View} from 'react-native' + +import {atoms as a, useBreakpoints} from '#/alf' +import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' + +import {Context} from '#/screens/Onboarding/context' +import { + Title, + Description, + OnboardingControls, +} from '#/screens/Onboarding/Layout' + +export function StepSuggestedFollows() { + const {state, dispatch} = React.useContext(Context) + const {gtMobile} = useBreakpoints() + + return ( + + Here are some accounts for your to follow: + Based on your interest in Pets and Books. + + + + + + + + + ) +} diff --git a/src/screens/Onboarding/StepTopicalFeeds.tsx b/src/screens/Onboarding/StepTopicalFeeds.tsx new file mode 100644 index 0000000000..0d63db94be --- /dev/null +++ b/src/screens/Onboarding/StepTopicalFeeds.tsx @@ -0,0 +1,40 @@ +import React from 'react' +import {View} from 'react-native' + +import {atoms as a} from '#/alf' +import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' + +import {Context} from '#/screens/Onboarding/context' +import { + Title, + Description, + OnboardingControls, +} from '#/screens/Onboarding/Layout' + +export function StepTopicalFeeds() { + const {state, dispatch} = React.useContext(Context) + + return ( + + Feeds can be topic-based as well! + + Here are some topic-based feeds based on your interests in Pets and + Books. + + + + + + + ) +} diff --git a/src/screens/Onboarding/context.ts b/src/screens/Onboarding/context.ts new file mode 100644 index 0000000000..51c2976e41 --- /dev/null +++ b/src/screens/Onboarding/context.ts @@ -0,0 +1,36 @@ +import React from 'react' + +export type OnboardingState = { + hasPrev: boolean + totalSteps: number + activeStep: + | 'interests' + | 'suggestedFollows' + | 'followingFeed' + | 'algoFeeds' + | 'topicalFeeds' + activeStepIndex: number +} + +export type OnboardingAction = + | { + type: 'next' + } + | { + type: 'prev' + } + +export const initialState: OnboardingState = { + hasPrev: false, + totalSteps: 5, + activeStep: 'interests', + activeStepIndex: 1, +} + +export const Context = React.createContext<{ + state: OnboardingState + dispatch: React.Dispatch +}>({ + state: initialState, + dispatch: () => {}, +}) diff --git a/src/screens/Onboarding/index.tsx b/src/screens/Onboarding/index.tsx new file mode 100644 index 0000000000..e3bd3bde87 --- /dev/null +++ b/src/screens/Onboarding/index.tsx @@ -0,0 +1,88 @@ +import React from 'react' + +import {logger} from '#/logger' +import {Portal} from '#/components/Portal' + +import { + Context, + OnboardingState, + OnboardingAction, + initialState, +} from '#/screens/Onboarding/context' +import {Layout, OnboardingControls} from '#/screens/Onboarding/Layout' +import {StepInterests} from '#/screens/Onboarding/StepInterests' +import {StepSuggestedFollows} from '#/screens/Onboarding/StepSuggestedFollows' +import {StepFollowingFeed} from '#/screens/Onboarding/StepFollowingFeed' +import {StepAlgoFeeds} from '#/screens/Onboarding/StepAlgoFeeds' +import {StepTopicalFeeds} from '#/screens/Onboarding/StepTopicalFeeds' + +function reducer(s: OnboardingState, a: OnboardingAction): OnboardingState { + const next = s + + switch (a.type) { + case 'next': { + if (s.activeStep === 'interests') { + next.activeStep = 'suggestedFollows' + next.activeStepIndex = 2 + } else if (s.activeStep === 'suggestedFollows') { + next.activeStep = 'followingFeed' + next.activeStepIndex = 3 + } else if (s.activeStep === 'followingFeed') { + next.activeStep = 'algoFeeds' + next.activeStepIndex = 4 + } else if (s.activeStep === 'algoFeeds') { + next.activeStep = 'topicalFeeds' + next.activeStepIndex = 5 + } + break + } + case 'prev': { + if (s.activeStep === 'suggestedFollows') { + next.activeStep = 'interests' + next.activeStepIndex = 1 + } else if (s.activeStep === 'followingFeed') { + next.activeStep = 'suggestedFollows' + next.activeStepIndex = 2 + } else if (s.activeStep === 'algoFeeds') { + next.activeStep = 'followingFeed' + next.activeStepIndex = 3 + } else if (s.activeStep === 'topicalFeeds') { + next.activeStep = 'algoFeeds' + next.activeStepIndex = 4 + } + break + } + } + + const state = { + ...next, + hasPrev: next.activeStep !== 'interests', + } + + logger.debug(`onboarding`, state) + + return state +} + +export function Onboarding() { + const [state, dispatch] = React.useReducer(reducer, initialState) + + return ( + + + ({state, dispatch}), [state, dispatch])}> + + {state.activeStep === 'interests' && } + {state.activeStep === 'suggestedFollows' && ( + + )} + {state.activeStep === 'followingFeed' && } + {state.activeStep === 'algoFeeds' && } + {state.activeStep === 'topicalFeeds' && } + + + + + ) +} diff --git a/src/view/shell/createNativeStackNavigatorWithAuth.tsx b/src/view/shell/createNativeStackNavigatorWithAuth.tsx index 9fea6e49f1..948da3f94c 100644 --- a/src/view/shell/createNativeStackNavigatorWithAuth.tsx +++ b/src/view/shell/createNativeStackNavigatorWithAuth.tsx @@ -1,6 +1,6 @@ import * as React from 'react' import {View} from 'react-native' -import {PWI_ENABLED} from '#/lib/build-flags' +import {PWI_ENABLED, NEW_ONBOARDING_ENABLED} from '#/lib/build-flags' // Based on @react-navigation/native-stack/src/createNativeStackNavigator.ts // MIT License @@ -37,6 +37,7 @@ import {useSession} from '#/state/session' import {isWeb} from 'platform/detection' import {LoggedOut} from '../com/auth/LoggedOut' import {Onboarding} from '../com/auth/Onboarding' +import {Onboarding as NewOnboarding} from '#/screens/Onboarding' type NativeStackNavigationOptionsWithAuth = NativeStackNavigationOptions & { requireAuth?: boolean @@ -107,7 +108,11 @@ function NativeStackNavigator({ return setShowLoggedOut(false)} /> } if (onboardingState.isActive) { - return + if (NEW_ONBOARDING_ENABLED) { + return + } else { + return + } } const newDescriptors: typeof descriptors = {} for (let key in descriptors) {