Skip to content

Commit

Permalink
feat(Mobile): add notifications opt-In screen (#4837)
Browse files Browse the repository at this point in the history
* chore: adds new images for notification onboarding optIn

* refactor: enables notifications method on hook

* refactor: adds Notification item & adpat for new 2 button pattern

* refactor: adds new variant to button

* refactor: adds logic to handle themed driven images & buttons

* chore: adds jest mocks and fix tests

* chore: adds navigation and onboarding awareness to Service

* refactor: rollbacks Onboarding changes in favour of OptIn screens

* feat: adds reusable floating view

* feat: adds reusable OptIn component

* refactor: rollback tests changes on onboarding flow

* chore: adds auxiliary constants

* chore: moves mock to central place

* feat: adds optIn on navigation stack

* chore: adds a (temp) icon for enable notification OptIn

* refactor: adds new UX on how Notifications will be propmt to be enabled
  • Loading branch information
Jonathansoufer authored Jan 31, 2025
1 parent 750d476 commit 70f4db2
Show file tree
Hide file tree
Showing 23 changed files with 337 additions and 76 deletions.
1 change: 1 addition & 0 deletions apps/mobile/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ function RootLayout() {
<Stack.Screen name="signers/[address]" options={{ headerShown: true, title: '' }} />
<Stack.Screen name="import-signers" options={{ headerShown: true, title: '' }} />
<Stack.Screen name="app-settings" options={{ headerShown: true, title: 'Settings' }} />
<Stack.Screen name="notifications-opt-in" options={{ headerShown: true, title: '' }} />
<Stack.Screen name="+not-found" />
</Stack>
</SafeToastProvider>
Expand Down
41 changes: 41 additions & 0 deletions apps/mobile/app/notifications-opt-in.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import React from 'react'
import { useColorScheme } from 'react-native'
import { OptIn } from '@/src/components/OptIn'
import useNotifications from '@/src/hooks/useNotifications'
import { router, useFocusEffect } from 'expo-router'

function NotificationsOptIn() {
const { enableNotifications, isAppNotificationEnabled } = useNotifications()
const colorScheme = useColorScheme()

useFocusEffect(() => {
if (isAppNotificationEnabled) {
router.replace('/(tabs)')
}
})

const image =
colorScheme === 'dark'
? require('@/assets/images/notifications-dark.png')
: require('@/assets/images/notifications-light.png')

return (
<OptIn
testID="notifications-opt-in"
title="Stay in the loop with account activity"
description="Get notified when you receive assets, and when transactions require your action."
image={image}
isVisible
ctaButton={{
onPress: enableNotifications,
label: 'Enable notifications',
}}
secondaryButton={{
onPress: () => router.back(),
label: 'Maybe later',
}}
/>
)
}

export default NotificationsOptIn
Binary file added apps/mobile/assets/images/notifications-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion apps/mobile/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ module.exports = {
'<rootDir>/__mocks__/fileMock.js',
},
transformIgnorePatterns: [
'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg|react-redux|moti/.*)',
'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg|@notifee/react-native|react-redux|moti/.*)',
],
testPathIgnorePatterns: ['/node_modules/', '/e2e/'],
}
61 changes: 61 additions & 0 deletions apps/mobile/src/components/FloatingContainer/FloatingContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { Layout } from '@/src/store/constants'
import React, { FC, useMemo } from 'react'
import { useSafeAreaInsets } from 'react-native-safe-area-context'

import { KeyboardAvoidingView, KeyboardAvoidingViewProps, Platform, StyleSheet, View } from 'react-native'

interface FloatingContainerProps {
children: React.ReactNode
noOffset?: boolean
sticky?: boolean
keyboardAvoidEnabled?: boolean
onLayout?: KeyboardAvoidingViewProps['onLayout']
testID?: string
}

export const FloatingContainer: FC<FloatingContainerProps> = ({
children,
noOffset,
sticky,
keyboardAvoidEnabled,
onLayout,
testID,
}: FloatingContainerProps) => {
const bottomInset = useSafeAreaInsets().bottom
const deviceBottom = Layout.isSmallDevice ? 10 : 20

const bottomPadding = useMemo(() => {
return Math.max(bottomInset, deviceBottom)
}, [bottomInset])

const keyboardVerticalOffset = useMemo(() => {
return noOffset ? 0 : Platform.select({ ios: 40, default: 0 })
}, [noOffset])

return (
<KeyboardAvoidingView
testID={testID}
behavior={sticky ? 'height' : 'position'}
keyboardVerticalOffset={keyboardVerticalOffset}
enabled={keyboardAvoidEnabled}
style={[styles.floatingContainer, { paddingBottom: bottomPadding }]}
onLayout={onLayout}
>
<View style={styles.childContainer}>{children}</View>
</KeyboardAvoidingView>
)
}

const styles = StyleSheet.create({
floatingContainer: {
position: 'fixed',
bottom: -40,
width: '100%',
zIndex: 1,
},
childContainer: {
flexDirection: 'column',
justifyContent: 'space-between',
flexGrow: 1,
},
})
2 changes: 2 additions & 0 deletions apps/mobile/src/components/FloatingContainer/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import { FloatingContainer } from './FloatingContainer'
export { FloatingContainer }
76 changes: 76 additions & 0 deletions apps/mobile/src/components/OptIn/OptIn.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import React from 'react'
import { ImageSourcePropType, StyleSheet } from 'react-native'
import { View, Image, Text } from 'tamagui'
import { SafeButton } from '@/src/components/SafeButton'
import { WINDOW_HEIGHT } from '@/src/store/constants'
import { FloatingContainer } from '../FloatingContainer'

interface OptInProps {
title: string
ctaButton: {
onPress: () => void
label: string
}
kicker?: string
description?: string
image?: ImageSourcePropType
secondaryButton?: {
onPress: () => void
label: string
}
testID?: string
isVisible?: boolean
}

export const OptIn: React.FC<OptInProps> = React.memo(
({ testID, kicker, title, description, image, ctaButton, secondaryButton, isVisible }: OptInProps) => {
if (!isVisible) {
return
}

return (
<View
testID={testID}
style={styles.wrapper}
padding="$4"
gap="$8"
alignItems="center"
justifyContent="flex-start"
>
{kicker && (
<Text textAlign="center" fontWeight={700} fontSize="$4" lineHeight="$6">
{kicker}
</Text>
)}
<Text textAlign="center" fontWeight={600} fontSize="$8" lineHeight="$8">
{title}
</Text>
{description && (
<Text textAlign="center" fontWeight={400} fontSize="$4">
{description}
</Text>
)}
{image && <Image style={styles.image} source={image} />}

<FloatingContainer sticky testID="notifications-opt-in-cta-buttons">
<SafeButton onPress={ctaButton.onPress} label={ctaButton.label} />
{secondaryButton && (
<SafeButton variant="secondary" onPress={secondaryButton.onPress} label={secondaryButton.label} />
)}
</FloatingContainer>
</View>
)
},
)

const styles = StyleSheet.create({
wrapper: {
flex: 1,
},
image: {
width: '100%',
height: Math.abs(WINDOW_HEIGHT * 0.42),
},
})

OptIn.displayName = 'OptIn'
2 changes: 2 additions & 0 deletions apps/mobile/src/components/OptIn/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import { OptIn } from './OptIn'
export { OptIn }
11 changes: 8 additions & 3 deletions apps/mobile/src/components/SafeButton/SafeButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { styled, Text, View } from 'tamagui'
interface SafeButtonProps {
onPress: () => void
label: string
variant?: 'primary' | 'secondary'
}

export const StyledButtonWrapper = styled(View, {
Expand All @@ -14,11 +15,15 @@ export const StyledButtonWrapper = styled(View, {
borderRadius: 8,
})

export function SafeButton({ onPress, label }: SafeButtonProps) {
export function SafeButton({ onPress, label, variant = 'primary' }: SafeButtonProps) {
const variantStyles =
variant === 'primary'
? { backgroundColor: '$primary', fontColor: '$background' }
: { backgroundColor: 'inherit', fontColor: '$primary' }
return (
<TouchableOpacity onPress={onPress}>
<StyledButtonWrapper backgroundColor="$primary">
<Text fontSize="$4" fontWeight={600} color="$background">
<StyledButtonWrapper backgroundColor={variantStyles.backgroundColor}>
<Text fontSize="$4" fontWeight={600} color={variantStyles.fontColor}>
{label}
</Text>
</StyledButtonWrapper>
Expand Down
23 changes: 22 additions & 1 deletion apps/mobile/src/features/Assets/Assets.container.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import React from 'react'
import React, { useEffect } from 'react'

import { SafeTab } from '@/src/components/SafeTab'

import { TokensContainer } from '@/src/features/Assets/components/Tokens'
import { NFTsContainer } from '@/src/features/Assets/components/NFTs'
import { AssetsHeaderContainer } from '@/src/features/Assets/components/AssetsHeader'
import useNotifications from '@/src/hooks/useNotifications'
import { useRouter } from 'expo-router'
import { useAppDispatch } from '@/src/store/hooks'
import { updatePromptAttempts } from '@/src/store/notificationsSlice'

const tabItems = [
{
Expand All @@ -18,5 +22,22 @@ const tabItems = [
]

export function AssetsContainer() {
const { isAppNotificationEnabled, promptAttempts } = useNotifications()
const dispatch = useAppDispatch()
const router = useRouter()

/*
* If the user has not enabled notifications and has not been prompted to enable them,
* redirect to the opt-in screen
* */

const shouldShowOptIn = !isAppNotificationEnabled && !promptAttempts

useEffect(() => {
if (shouldShowOptIn) {
dispatch(updatePromptAttempts(1))
router.navigate('/notifications-opt-in')
}
}, [])
return <SafeTab items={tabItems} headerHeight={200} renderHeader={AssetsHeaderContainer} />
}
28 changes: 24 additions & 4 deletions apps/mobile/src/features/Assets/components/Navbar/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { selectMyAccountsMode, toggleMode } from '@/src/store/myAccountsSlice'
import { MyAccountsContainer, MyAccountsFooter } from '../MyAccounts'
import { useMyAccountsSortable } from '../MyAccounts/hooks/useMyAccountsSortable'
import { useAppDispatch, useAppSelector } from '@/src/store/hooks'
import { router } from 'expo-router'
import { selectAppNotificationStatus } from '@/src/store/notificationsSlice'

const dropdownLabelProps = {
fontSize: '$5',
Expand All @@ -24,8 +26,16 @@ export const Navbar = () => {
const dispatch = useAppDispatch()
const isEdit = useAppSelector(selectMyAccountsMode)
const activeSafe = useAppSelector(selectActiveSafe)
const isAppNotificationEnabled = useAppSelector(selectAppNotificationStatus)
const { safes, onDragEnd } = useMyAccountsSortable()

const handleNotificationAccess = () => {
if (!isAppNotificationEnabled) {
router.navigate('/notifications-opt-in')
}
// TODO: navigate to notifications list when notifications are enabled
}

const toggleEditMode = () => {
dispatch(toggleMode())
}
Expand Down Expand Up @@ -53,10 +63,14 @@ export const Navbar = () => {
)
}
/>

<TouchableOpacity>
<SafeFontIcon name="apps" />
</TouchableOpacity>
<View style={styles.rightButtonContainer}>
<TouchableOpacity onPress={handleNotificationAccess}>
<SafeFontIcon name="lightbulb" />
</TouchableOpacity>
<TouchableOpacity>
<SafeFontIcon name="apps" />
</TouchableOpacity>
</View>
</SafeAreaView>
</BlurredIdenticonBackground>
</View>
Expand All @@ -72,4 +86,10 @@ const styles = StyleSheet.create({
paddingVertical: 16,
paddingBottom: 0,
},
rightButtonContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 12,
},
})
14 changes: 1 addition & 13 deletions apps/mobile/src/features/Onboarding/Onboarding.container.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,7 @@
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>
)
return <OnboardingCarousel items={items} />
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,25 @@ export type CarouselItem = {
name: string
description?: string
image?: React.ReactNode
imagePosition?: 'top' | 'bottom'
}

interface CarouselItemProps {
item: CarouselItem
}

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

{imagePosition === 'top' && image}
<YStack gap="$8" paddingHorizontal="$5">
<YStack>{title}</YStack>

<Text textAlign="center" fontSize={'$4'}>
{description}
</Text>
</YStack>
{imagePosition === 'bottom' && image}
</View>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ 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',
Expand All @@ -16,10 +14,6 @@ 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>
)
return <OnboardingCarousel {...args} items={items} />
},
}
Loading

0 comments on commit 70f4db2

Please sign in to comment.