Skip to content

Commit

Permalink
feat: Create initial onboarding screens (#58)
Browse files Browse the repository at this point in the history
* feat: create onboarding structure

* feat: create onboarding carousel components

* feat: create onboarding header component

* feat: create onboarding container

* feat: crate re-usable SafeButton component

* feat: cover onboarding components with unit tests

* feat: add storybook for carousel component

* feat: fix unit tests

* feat: remove hardcoded colors and test warnings

* fix: remove unused testID

* fix: remove magic numbers

* feat: use tamagui variables
  • Loading branch information
clovisdasilvaneto authored and compojoom committed Dec 20, 2024
1 parent 951aae1 commit 013110a
Show file tree
Hide file tree
Showing 28 changed files with 678 additions and 0 deletions.
7 changes: 7 additions & 0 deletions apps/mobile/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { BottomSheetModalProvider } from '@gorhom/bottom-sheet'
import { PortalProvider } from '@tamagui/portal'
import { SafeToastProvider } from '@/src/theme/provider/toastProvider'
import { configureReanimatedLogger, ReanimatedLogLevel } from 'react-native-reanimated'
import { OnboardingHeader } from '@/src/features/Onboarding/components/OnboardingHeader'

configureReanimatedLogger({
level: ReanimatedLogLevel.warn,
Expand Down Expand Up @@ -43,6 +44,12 @@ function RootLayout() {
),
})}
>
<Stack.Screen
name="index"
options={{
header: OnboardingHeader,
}}
/>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="pending-transactions" options={{ headerShown: true, title: '' }} />
<Stack.Screen name="signers" options={{ headerShown: true, title: 'Signers' }} />
Expand Down
8 changes: 8 additions & 0 deletions apps/mobile/app/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Onboarding } from '@/src/features/Onboarding'
import React from 'react'

function OnboardingPage() {
return <Onboarding />
}

export default OnboardingPage
Binary file added apps/mobile/assets/images/anywhere.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/mobile/assets/images/illustration.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/mobile/assets/images/safe-wallet.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 18 additions & 0 deletions apps/mobile/src/components/SafeButton/SafeButton.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { Meta, StoryObj } from '@storybook/react'
import { SafeButton } from '@/src/components/SafeButton'
import { action } from '@storybook/addon-actions'

const meta: Meta<typeof SafeButton> = {
title: 'SafeButton',
component: SafeButton,
args: {
label: 'Get started',
onPress: action('onPress'),
},
}

export default meta

type Story = StoryObj<typeof SafeButton>

export const Default: Story = {}
27 changes: 27 additions & 0 deletions apps/mobile/src/components/SafeButton/SafeButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import React from 'react'
import { TouchableOpacity } from 'react-native'
import { styled, Text, View } from 'tamagui'

interface SafeButtonProps {
onPress: () => void
label: string
}

export const StyledButtonWrapper = styled(View, {
height: 48,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 8,
})

export function SafeButton({ onPress, label }: SafeButtonProps) {
return (
<TouchableOpacity onPress={onPress}>
<StyledButtonWrapper backgroundColor="$primary">
<Text fontSize="$4" fontWeight={600} color="$background">
{label}
</Text>
</StyledButtonWrapper>
</TouchableOpacity>
)
}
2 changes: 2 additions & 0 deletions apps/mobile/src/components/SafeButton/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import { SafeButton } from './SafeButton'
export { SafeButton }
26 changes: 26 additions & 0 deletions apps/mobile/src/features/Onboarding/Onboarding.container.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import React from 'react'
import { Onboarding } from './Onboarding.container'
import { fireEvent, render } from '@/src/tests/test-utils'

const mockNavigate = jest.fn()

jest.mock('expo-router', () => ({
useRouter: () => ({
navigate: mockNavigate,
}),
}))

describe('Onboarding Component', () => {
it('renders correctly', () => {
const { getAllByText } = render(<Onboarding />)
expect(getAllByText('Get started')).toHaveLength(1)
})

it('navigates on button press', () => {
const { getByText } = render(<Onboarding />)
const button = getByText('Get started')

fireEvent.press(button)
expect(mockNavigate).toHaveBeenCalledWith('/(tabs)')
})
})
19 changes: 19 additions & 0 deletions apps/mobile/src/features/Onboarding/Onboarding.container.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import React from 'react'
import { OnboardingCarousel } from './components/OnboardingCarousel'
import { items } from './components/OnboardingCarousel/items'
import { useRouter } from 'expo-router'
import { SafeButton } from '@/src/components/SafeButton'

export function Onboarding() {
const router = useRouter()

const onGetStartedPress = () => {
router.navigate('/(tabs)')
}

return (
<OnboardingCarousel items={items}>
<SafeButton onPress={onGetStartedPress} label="Get started" />
</OnboardingCarousel>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import React from 'react'
import { CarouselFeedback } from './CarouselFeedback'
import { render } from '@/src/tests/test-utils'
import darkPalette from '@/src/theme/palettes/darkPalette'

describe('CarouselFeedback', () => {
it('renders with active state', () => {
const { getByTestId } = render(<CarouselFeedback isActive={true} />)

const carouselFeedback = getByTestId('carousel-feedback')

expect(carouselFeedback.props.style.backgroundColor).toBe(darkPalette.background.default)
})

it('renders with inactive state', () => {
const { getByTestId } = render(<CarouselFeedback isActive={false} />)
const carouselFeedback = getByTestId('carousel-feedback')

expect(carouselFeedback.props.style.backgroundColor).toBe(darkPalette.primary.light)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import React, { useEffect } from 'react'
import Animated, { useSharedValue, withSpring } from 'react-native-reanimated'
import { useTheme } from 'tamagui'

interface CarouselFeedbackProps {
isActive: boolean
}

const UNACTIVE_WIDTH = 4
const ACTIVE_WIDTH = 14

export function CarouselFeedback({ isActive }: CarouselFeedbackProps) {
const width = useSharedValue(UNACTIVE_WIDTH)
const theme = useTheme()

useEffect(() => {
if (isActive) {
width.value = withSpring(ACTIVE_WIDTH)
} else {
width.value = withSpring(UNACTIVE_WIDTH)
}
}, [isActive])

return (
<Animated.View
testID="carousel-feedback"
style={{
borderRadius: 50,
backgroundColor: isActive ? theme.color.get() : theme.colorSecondary.get(),
height: UNACTIVE_WIDTH,
width,
}}
/>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import React from 'react'
import { CarouselItem } from './CarouselItem' // adjust the import path as necessary
import { Text } from 'tamagui'
import { render } from '@/src/tests/test-utils'

describe('CarouselItem Component', () => {
it('renders correctly with all props', () => {
const item = {
title: <Text>Test Title</Text>,
description: 'Test Description',
image: <Text>Test Image</Text>,
name: 'nevinha',
}

const { getByText } = render(<CarouselItem item={item} />)

expect(getByText('Test Title')).toBeTruthy()
expect(getByText('Test Description')).toBeTruthy()
expect(getByText('Test Image')).toBeTruthy()
})

it('renders correctly without optional props', () => {
const item = {
title: <Text>Test Title</Text>,
name: 'Test Name',
}

const { getByText, queryByText } = render(<CarouselItem item={item} />)

expect(getByText('Test Title')).toBeTruthy()
expect(queryByText('Test Description')).toBeNull() // Description is optional and not provided
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Text, View, YStack } from 'tamagui'

export type CarouselItem = {
title: string | React.ReactNode
name: string
description?: string
image?: React.ReactNode
}

interface CarouselItemProps {
item: CarouselItem
}

export const CarouselItem = ({ item: { title, description, image } }: CarouselItemProps) => {
return (
<View gap="$8" alignItems="center" justifyContent="center">
{image}

<YStack gap="$8" paddingHorizontal="$5">
<YStack>{title}</YStack>

<Text textAlign="center" fontSize={'$4'}>
{description}
</Text>
</YStack>
</View>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { Meta, StoryObj } from '@storybook/react'
import React from 'react'
import { OnboardingCarousel } from './OnboardingCarousel'
import { items } from './items'
import { SafeButton } from '@/src/components/SafeButton'
import { action } from '@storybook/addon-actions'

const meta: Meta<typeof OnboardingCarousel> = {
title: 'Carousel',
component: OnboardingCarousel,
}

export default meta

type Story = StoryObj<typeof OnboardingCarousel>

export const Default: Story = {
render: function Render(args) {
return (
<OnboardingCarousel {...args} items={items}>
<SafeButton label="Get started" onPress={action('onPress')} />
</OnboardingCarousel>
)
},
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import React from 'react'
import { OnboardingCarousel } from './OnboardingCarousel'
import { Text, View } from 'tamagui'
import { render } from '@/src/tests/test-utils'

describe('OnboardingCarousel', () => {
const items = [
{ name: 'Item1', title: <Text>Item1 Title</Text> },
{ name: 'Item2', title: <Text>Item2 Title</Text> },
{ name: 'Item3', title: <Text>Item3 Title</Text> },
]

// react-native-collapsible-tab-view does not returns any information about the tabs children
// that is why we only test the children component here =/
it('renders without crashing', () => {
const { getByTestId } = render(
<OnboardingCarousel items={items}>
<View testID="child-element">Child Element</View>
</OnboardingCarousel>,
)

expect(getByTestId('child-element')).toBeTruthy()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import React, { useState } from 'react'
import { CarouselItem } from './CarouselItem'
import { View } from 'tamagui'
import { Tabs } from 'react-native-collapsible-tab-view'
import { CarouselFeedback } from './CarouselFeedback'

interface OnboardingCarouselProps {
items: CarouselItem[]
children: React.ReactNode
}

export function OnboardingCarousel({ items, children }: OnboardingCarouselProps) {
const [activeTab, setActiveTab] = useState(items[0].name)

return (
<View flex={1} justifyContent={'space-between'} position="relative" paddingVertical="$10">
<Tabs.Container
onTabChange={(event) => setActiveTab(event.tabName)}
initialTabName={items[0].name}
renderTabBar={() => <></>}
>
{items.map((item, index) => (
<Tabs.Tab name={item.name} key={`${item.name}-${index}`}>
<CarouselItem key={index} item={item} />
</Tabs.Tab>
))}
</Tabs.Container>

<View paddingHorizontal={20}>
<View gap="$1" flexDirection="row" alignItems="center" justifyContent="center" marginBottom="$6">
{items.map((item) => (
<CarouselFeedback key={item.name} isActive={activeTab === item.name} />
))}
</View>

{children}
</View>
</View>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import { OnboardingCarousel } from './OnboardingCarousel'
export { OnboardingCarousel }
Loading

0 comments on commit 013110a

Please sign in to comment.