diff --git a/apps/funke/app/(app)/index.tsx b/apps/funke/app/(app)/index.tsx deleted file mode 100644 index 5d93e64f..00000000 --- a/apps/funke/app/(app)/index.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { useAppAgent } from '@/agent' -import { addMessageListener } from '@animo-id/expo-ausweis-sdk' -import { Button, Paragraph, XStack, YStack } from '@package/ui' -import { Stack } from 'expo-router' -import { useEffect, useState } from 'react' -import { useSafeAreaInsets } from 'react-native-safe-area-context' -import { ReceivePidUseCase, type ReceivePidUseCaseState } from '../../use-cases/ReceivePidUseCase' - -export default function Screen() { - const { top } = useSafeAreaInsets() - // FIXME: should be useReceivePidUseCase as the state is now not updated.... - const [receivePidUseCase, setReceivePidUseCase] = useState() - const [state, setState] = useState('not-initialized') - const [credential, setCredential] = useState() - const { agent } = useAppAgent() - - useEffect(() => { - ReceivePidUseCase.initialize({ agent, onStateChange: setState }).then((pidUseCase) => { - setReceivePidUseCase(pidUseCase) - }) - }, [agent]) - - const nextStep = async () => { - if (!receivePidUseCase) return - if (state === 'error') return - if (state === 'acquire-access-token') return - if (state === 'id-card-auth') { - await receivePidUseCase.authenticateUsingIdCard() - return - } - if (state === 'retrieve-credential') { - const credential = await receivePidUseCase.retrieveCredential() - setCredential(credential.compactSdJwtVc) - } - } - - return ( - <> - { - return - }, - }} - /> - - State: {state} - - ID Card Auth - - - Retrieve credential - - credential: {credential} - - - ) -} diff --git a/apps/funke/app/onboarding/_layout.tsx b/apps/funke/app/onboarding/_layout.tsx deleted file mode 100644 index 9d27dfa6..00000000 --- a/apps/funke/app/onboarding/_layout.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import { Slot } from 'expo-router' -import { useResetWalletDevMenu } from '../../utils/resetWallet' - -export default function RootLayout() { - useResetWalletDevMenu() - - return -} diff --git a/apps/funke/app/onboarding/index.tsx b/apps/funke/app/onboarding/index.tsx deleted file mode 100644 index 391067dc..00000000 --- a/apps/funke/app/onboarding/index.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { Redirect } from 'expo-router' -import { Text, View } from 'react-native' - -import { initializeAppAgent, useSecureUnlock } from '@/agent' -import { WalletInvalidKeyError } from '@credo-ts/core' -import * as SplashScreen from 'expo-splash-screen' -import { useEffect } from 'react' - -/** - * Onboarding screen is redirect to from app layout when app is not configured - */ -export default function Onboarding() { - const secureUnlock = useSecureUnlock() - - useEffect(() => { - // TODO: prevent multi-initialization - if (secureUnlock.state !== 'acquired-wallet-key') return - - initializeAppAgent({ - walletKey: secureUnlock.walletKey, - }) - .then((agent) => secureUnlock.setWalletKeyValid({ agent })) - .catch((error) => { - if (error instanceof WalletInvalidKeyError) { - secureUnlock.setWalletKeyInvalid() - } - - // TODO: handle other - console.error(error) - }) - }, [secureUnlock]) - - // We want to wait until the agent is initialized before redirecting - if (secureUnlock.state === 'acquired-wallet-key') { - return ( - - Loading ... onboarding - - ) - } - - if (secureUnlock.state !== 'not-configured') { - return - } - - // TODO: where to put this? - void SplashScreen.hideAsync() - - const onboarding = () => { - secureUnlock.setup('123456') - } - - return ( - - Onboarding - - ) -} diff --git a/apps/funke/metro.config.js b/apps/funke/metro.config.js index 0becc4d5..38af6f2d 100644 --- a/apps/funke/metro.config.js +++ b/apps/funke/metro.config.js @@ -17,7 +17,7 @@ config.resolver.nodeModulesPaths = [ config.resolver.sourceExts = ['js', 'json', 'ts', 'tsx', 'cjs', 'mjs'] config.resolver.extraNodeModules = { // Needed for cosmjs trying to import node crypto - crypto: require.resolve('./polyfills/crypto.ts'), + crypto: require.resolve('./src/polyfills/crypto.ts'), } module.exports = config diff --git a/apps/funke/package.json b/apps/funke/package.json index 57bd043a..2b6b8539 100644 --- a/apps/funke/package.json +++ b/apps/funke/package.json @@ -23,6 +23,7 @@ "@package/app": "workspace:*", "@package/secure-store": "workspace:*", "@package/ui": "workspace:*", + "@package/utils": "workspace:*", "@react-native-community/blur": "^4.3.2", "@react-native-community/netinfo": "11.3.1", "@react-native-masked-view/masked-view": "0.3.1", diff --git a/apps/funke/agent/index.ts b/apps/funke/src/agent/index.ts similarity index 100% rename from apps/funke/agent/index.ts rename to apps/funke/src/agent/index.ts diff --git a/apps/funke/agent/initialize.ts b/apps/funke/src/agent/initialize.ts similarity index 83% rename from apps/funke/agent/initialize.ts rename to apps/funke/src/agent/initialize.ts index 07b34622..035be89e 100644 --- a/apps/funke/agent/initialize.ts +++ b/apps/funke/src/agent/initialize.ts @@ -1,5 +1,5 @@ +import { trustedX509Certificates } from '@funke/constants' import { initializeFunkeAgent } from '@package/agent' -import { trustedX509Certificates } from '../constants' export function initializeAppAgent({ walletKey }: { walletKey: string }) { return initializeFunkeAgent({ diff --git a/apps/funke/app/(app)/(home)/scan.tsx b/apps/funke/src/app/(app)/(home)/scan.tsx similarity index 100% rename from apps/funke/app/(app)/(home)/scan.tsx rename to apps/funke/src/app/(app)/(home)/scan.tsx diff --git a/apps/funke/app/(app)/_layout.tsx b/apps/funke/src/app/(app)/_layout.tsx similarity index 95% rename from apps/funke/app/(app)/_layout.tsx rename to apps/funke/src/app/(app)/_layout.tsx index ad6c14ec..c8e4a2e0 100644 --- a/apps/funke/app/(app)/_layout.tsx +++ b/apps/funke/src/app/(app)/_layout.tsx @@ -1,6 +1,6 @@ import { Redirect, Stack } from 'expo-router' -import { useSecureUnlock } from '@/agent' +import { useSecureUnlock } from '@funke/agent' import { AgentProvider } from '@package/agent' import { useResetWalletDevMenu } from '../../utils/resetWallet' diff --git a/apps/funke/app/(app)/credentials/[id].tsx b/apps/funke/src/app/(app)/credentials/[id].tsx similarity index 100% rename from apps/funke/app/(app)/credentials/[id].tsx rename to apps/funke/src/app/(app)/credentials/[id].tsx diff --git a/apps/funke/src/app/(app)/index.tsx b/apps/funke/src/app/(app)/index.tsx new file mode 100644 index 00000000..28e5c9cf --- /dev/null +++ b/apps/funke/src/app/(app)/index.tsx @@ -0,0 +1,24 @@ +import { WalletScreen } from '@package/app' +import { XStack } from '@package/ui' +import { Stack } from 'expo-router' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import inAppLogo from '../../../assets/in-app-logo.png' + +export default function Screen() { + const { top } = useSafeAreaInsets() + + return ( + <> + { + return + }, + }} + /> + + + ) +} diff --git a/apps/funke/app/(app)/notifications/inbox.tsx b/apps/funke/src/app/(app)/notifications/inbox.tsx similarity index 100% rename from apps/funke/app/(app)/notifications/inbox.tsx rename to apps/funke/src/app/(app)/notifications/inbox.tsx diff --git a/apps/funke/app/(app)/notifications/openIdCredential.tsx b/apps/funke/src/app/(app)/notifications/openIdCredential.tsx similarity index 100% rename from apps/funke/app/(app)/notifications/openIdCredential.tsx rename to apps/funke/src/app/(app)/notifications/openIdCredential.tsx diff --git a/apps/funke/app/(app)/notifications/openIdPresentation.tsx b/apps/funke/src/app/(app)/notifications/openIdPresentation.tsx similarity index 100% rename from apps/funke/app/(app)/notifications/openIdPresentation.tsx rename to apps/funke/src/app/(app)/notifications/openIdPresentation.tsx diff --git a/apps/funke/app/[...unmatched].tsx b/apps/funke/src/app/[...unmatched].tsx similarity index 100% rename from apps/funke/app/[...unmatched].tsx rename to apps/funke/src/app/[...unmatched].tsx diff --git a/apps/funke/app/_layout.tsx b/apps/funke/src/app/_layout.tsx similarity index 72% rename from apps/funke/app/_layout.tsx rename to apps/funke/src/app/_layout.tsx index 4c592107..cf853e46 100644 --- a/apps/funke/app/_layout.tsx +++ b/apps/funke/src/app/_layout.tsx @@ -4,13 +4,13 @@ import { DefaultTheme, ThemeProvider } from '@react-navigation/native' import { Slot } from 'expo-router' import * as SplashScreen from 'expo-splash-screen' -import tamaguiConfig from '../tamagui.config' +import tamaguiConfig from '../../tamagui.config' void SplashScreen.preventAutoHideAsync() export const unstable_settings = { // Ensure any route can link back to `/` - initialRouteName: '(app)/index', + initialRouteName: '/(app)/index', } export default function RootLayout() { @@ -19,7 +19,15 @@ export default function RootLayout() { return ( - + diff --git a/apps/funke/app/authenticate.tsx b/apps/funke/src/app/authenticate.tsx similarity index 58% rename from apps/funke/app/authenticate.tsx rename to apps/funke/src/app/authenticate.tsx index 635a2fbc..2271f62f 100644 --- a/apps/funke/app/authenticate.tsx +++ b/apps/funke/src/app/authenticate.tsx @@ -1,12 +1,10 @@ import { Redirect } from 'expo-router' -import { KeyboardAvoidingView } from 'react-native' -import { initializeAppAgent, useSecureUnlock } from '@/agent' import { WalletInvalidKeyError } from '@credo-ts/core' -import { HeroIcons, Paragraph, PinDotsInput, type PinDotsInputRef, YStack } from '@package/ui' +import { initializeAppAgent, useSecureUnlock } from '@funke/agent' +import { FlexPage, HeroIcons, Paragraph, PinDotsInput, type PinDotsInputRef, Stack, YStack } from '@package/ui' import * as SplashScreen from 'expo-splash-screen' import { useEffect, useRef } from 'react' -import { useSafeAreaInsets } from 'react-native-safe-area-context' import { Circle } from 'tamagui' import { useResetWalletDevMenu } from '../utils/resetWallet' @@ -17,7 +15,6 @@ export default function Authenticate() { useResetWalletDevMenu() const secureUnlock = useSecureUnlock() - const safeAreaInsets = useSafeAreaInsets() const pinInputRef = useRef(null) const isLoading = secureUnlock.state === 'acquired-wallet-key' || (secureUnlock.state === 'locked' && secureUnlock.isUnlocking) @@ -34,13 +31,12 @@ export default function Authenticate() { initializeAppAgent({ walletKey: secureUnlock.walletKey, }) - .then((agent) => secureUnlock.setWalletKeyValid({ agent })) + .then((agent) => secureUnlock.setWalletKeyValid({ agent }, { enableBiometrics: true })) .catch((error) => { if (error instanceof WalletInvalidKeyError) { secureUnlock.setWalletKeyInvalid() pinInputRef.current?.clear() pinInputRef.current?.shake() - pinInputRef.current?.focus() } // TODO: handle other @@ -56,7 +52,6 @@ export default function Authenticate() { return } - // TODO: where to put this? void SplashScreen.hideAsync() const unlockUsingPin = async (pin: string) => { @@ -65,29 +60,15 @@ export default function Authenticate() { } return ( - - - - - - - Enter your app pin code - - + + + + + + Enter your app pin code + - + + ) } diff --git a/apps/funke/src/app/onboarding/_layout.tsx b/apps/funke/src/app/onboarding/_layout.tsx new file mode 100644 index 00000000..d7a3e4a1 --- /dev/null +++ b/apps/funke/src/app/onboarding/_layout.tsx @@ -0,0 +1,16 @@ +import { OnboardingContextProvider } from '@funke/features/onboarding' +import { Slot } from 'expo-router' +import * as SplashScreen from 'expo-splash-screen' +import { useResetWalletDevMenu } from '../../utils/resetWallet' + +export default function RootLayout() { + useResetWalletDevMenu() + + void SplashScreen.hideAsync() + + return ( + + + + ) +} diff --git a/apps/funke/src/app/onboarding/index.tsx b/apps/funke/src/app/onboarding/index.tsx new file mode 100644 index 00000000..1abab9b4 --- /dev/null +++ b/apps/funke/src/app/onboarding/index.tsx @@ -0,0 +1,43 @@ +import { useOnboardingContext } from '@funke/features/onboarding' +import { FlexPage, OnboardingScreensHeader } from '@package/ui' +import Animated, { FadeInRight, FadeOutLeft } from 'react-native-reanimated' + +export default function OnboardingScreens() { + const onboardingContext = useOnboardingContext() + + let page: React.JSX.Element + if (onboardingContext.page.type === 'fullscreen') { + page = onboardingContext.screen + } else { + page = ( + + + + {onboardingContext.screen} + + + ) + } + + return ( + + {page} + + ) +} diff --git a/apps/funke/constants.ts b/apps/funke/src/constants.ts similarity index 100% rename from apps/funke/constants.ts rename to apps/funke/src/constants.ts diff --git a/apps/funke/crypto/aes.ts b/apps/funke/src/crypto/aes.ts similarity index 62% rename from apps/funke/crypto/aes.ts rename to apps/funke/src/crypto/aes.ts index d894148d..41c0662e 100644 --- a/apps/funke/crypto/aes.ts +++ b/apps/funke/src/crypto/aes.ts @@ -1,4 +1,4 @@ +import { FUNKE_WALLET_INSTANCE_LONG_TERM_AES_KEY_ID } from '@funke/constants' import { aes128Gcm } from '@package/agent' -import { FUNKE_WALLET_INSTANCE_LONG_TERM_AES_KEY_ID } from '../constants' export const funkeAes128Gcm = aes128Gcm(FUNKE_WALLET_INSTANCE_LONG_TERM_AES_KEY_ID) diff --git a/apps/funke/crypto/bPrime.ts b/apps/funke/src/crypto/bPrime.ts similarity index 100% rename from apps/funke/crypto/bPrime.ts rename to apps/funke/src/crypto/bPrime.ts diff --git a/apps/funke/src/features/onboarding/index.tsx b/apps/funke/src/features/onboarding/index.tsx new file mode 100644 index 00000000..2956542f --- /dev/null +++ b/apps/funke/src/features/onboarding/index.tsx @@ -0,0 +1 @@ +export * from './onboardingContext' diff --git a/apps/funke/src/features/onboarding/onboardingContext.tsx b/apps/funke/src/features/onboarding/onboardingContext.tsx new file mode 100644 index 00000000..267b0c1a --- /dev/null +++ b/apps/funke/src/features/onboarding/onboardingContext.tsx @@ -0,0 +1,420 @@ +import { sendCommand } from '@animo-id/expo-ausweis-sdk' +import type { SdJwtVcHeader } from '@credo-ts/core' +import { initializeAppAgent, useSecureUnlock } from '@funke/agent' +import { + ReceivePidUseCase, + type ReceivePidUseCaseOptions, + type ReceivePidUseCaseState, +} from '@funke/use-cases/ReceivePidUseCase' +import { type FunkeAppAgent, storeCredential } from '@package/agent' +import { useToastController } from '@package/ui' +import { capitalizeFirstLetter } from '@package/utils' +import { useRouter } from 'expo-router' +import type React from 'react' +import { type PropsWithChildren, createContext, useCallback, useContext, useEffect, useRef, useState } from 'react' +import { OnboardingBiometrics } from './screens/biometrics' +import { OnboardingIdCardFetch } from './screens/id-card-fetch' +import { OnboardingIdCardPinEnter } from './screens/id-card-pin' +import { OnboardingIdCardScan } from './screens/id-card-scan' +import { OnboardingIdCardStartScan } from './screens/id-card-start-scan' +import { OnboardingIntroductionSteps } from './screens/introduction-steps' +import OnboardingPinEnter from './screens/pin' +import OnboardingWelcome from './screens/welcome' + +type Page = { type: 'fullscreen' } | { type: 'content'; title: string; subtitle?: string; animationKey?: string } + +// Same animation key means the content won't fade out and then in again. So if the two screens have most content in common +// this looks nicer. +const onboardingStepsCFlow = [ + { + step: 'welcome', + progress: 0, + page: { + type: 'fullscreen', + }, + Screen: OnboardingWelcome, + }, + { + step: 'introduction-steps', + progress: 0, + page: { + type: 'content', + title: 'Setup digital identity', + subtitle: "To setup your digital identity we'll follow the following steps:", + }, + Screen: OnboardingIntroductionSteps, + }, + { + step: 'pin', + progress: 33, + page: { + type: 'content', + title: 'Pick a 6-digit app pin', + subtitle: 'This will be used to unlock the Ausweis Wallet.', + animationKey: 'pin', + }, + Screen: OnboardingPinEnter, + }, + { + step: 'pin-reenter', + progress: 33, + page: { + type: 'content', + title: 'Re-enter your pin', + animationKey: 'pin', + }, + Screen: OnboardingPinEnter, + }, + { + step: 'biometrics', + progress: 33, + page: { + type: 'content', + title: 'Let’s secure your wallet', + subtitle: + 'The Ausweis wallet will be unlocked using the biometrics functionality of your phone. This is to make sure only you can enter your wallet.', + }, + Screen: OnboardingBiometrics, + }, + { + step: 'id-card-pin', + progress: 66, + page: { + type: 'content', + title: 'Enter your eID pin', + subtitle: 'This will be used in the next step to unlock your eID.', + }, + Screen: OnboardingIdCardPinEnter, + }, + { + step: 'id-card-start-scan', + progress: 66, + page: { + type: 'content', + title: 'Place your eID card at the top of you phone.', + animationKey: 'id-card-scan', + }, + Screen: OnboardingIdCardStartScan, + }, + { + step: 'id-card-scan', + progress: 66, + page: { + type: 'content', + title: 'Keep your eID card still', + animationKey: 'id-card-scan', + }, + Screen: OnboardingIdCardScan, + }, + { + step: 'id-card-fetch', + progress: 66, + page: { + type: 'content', + title: 'Setting up identity', + animationKey: 'id-card-final', + }, + Screen: OnboardingIdCardFetch, + }, + { + step: 'id-card-complete', + progress: 100, + page: { + type: 'content', + title: 'Your wallet is ready', + animationKey: 'id-card-final', + }, + Screen: OnboardingIdCardFetch, + }, +] as const satisfies Array<{ + step: string + progress: number + page: Page + // biome-ignore lint/suspicious/noExplicitAny: + Screen: React.FunctionComponent +}> + +export type OnboardingSteps = typeof onboardingStepsCFlow +export type OnboardingStep = OnboardingSteps[number] + +export type OnboardingContext = { + currentStep: OnboardingStep['step'] + progress: number + page: Page + screen: React.JSX.Element +} + +export const OnboardingContext = createContext({} as OnboardingContext) + +export function OnboardingContextProvider({ + initialStep, + children, +}: PropsWithChildren<{ initialStep?: OnboardingStep['step']; flow?: 'c' | 'bprime' }>) { + const toast = useToastController() + const secureUnlock = useSecureUnlock() + const [currentStepName, setCurrentStepName] = useState(initialStep ?? 'welcome') + const router = useRouter() + + const [receivePidUseCase, setReceivePidUseCase] = useState() + const [receivePidUseCaseState, setReceivePidUseCaseState] = useState() + const [walletPin, setWalletPin] = useState() + const [idCardPin, setIdCardPin] = useState() + const [userName, setUserName] = useState() + const [agent, setAgent] = useState() + + const currentStep = onboardingStepsCFlow.find((step) => step.step === currentStepName) + if (!currentStep) throw new Error(`Invalid step ${currentStepName}`) + + const goToNextStep = useCallback(() => { + const currentStepIndex = onboardingStepsCFlow.findIndex((step) => step.step === currentStepName) + const nextStep = onboardingStepsCFlow[currentStepIndex + 1] + + if (nextStep) { + setCurrentStepName(nextStep.step) + } else { + // Navigate to the actual app. + router.replace('/') + } + }, [currentStepName, router]) + + const goToPreviousStep = useCallback(() => { + const currentStepIndex = onboardingStepsCFlow.findIndex((step) => step.step === currentStepName) + const previousStep = onboardingStepsCFlow[currentStepIndex - 1] + + if (previousStep) { + setCurrentStepName(previousStep.step) + } + }, [currentStepName]) + + const onPinEnter = async (pin: string) => { + setWalletPin(pin) + goToNextStep() + } + + // Bit sad but if we try to call this in the initializeAgent callback sometimes the state hasn't updated + // in the secure unlock yet, which means that it will throw an error, so we use an effect. Probably need + // to do a refactor on this and move more logic outside of the react world, as it's a bit weird with state + useEffect(() => { + if (secureUnlock.state !== 'acquired-wallet-key' || !agent) return + + secureUnlock.setWalletKeyValid({ agent }, { enableBiometrics: true }) + }, [secureUnlock, agent]) + + const initializeAgent = useCallback(async (walletKey: string) => { + const agent = await initializeAppAgent({ + walletKey, + }) + setAgent(agent) + }, []) + + const onPinReEnter = async (pin: string) => { + if (walletPin !== pin) { + toast.show('Pin entries do not match', { customData: { preset: 'danger' } }) + setWalletPin(undefined) + goToPreviousStep() + throw new Error('Pin entries do not match') + } + + if (secureUnlock.state !== 'not-configured') { + router.replace('/') + return + } + + return secureUnlock + .setup(walletPin) + .then(({ walletKey }) => initializeAgent(walletKey)) + .then(() => goToNextStep()) + .catch((e) => { + reset({ error: e, resetToStep: 'welcome' }) + throw e + }) + } + + const [onIdCardPinReEnter, setOnIdCardPinReEnter] = useState<(idCardPin: string) => Promise>() + + const onEnterPin: ReceivePidUseCaseOptions['onEnterPin'] = useCallback( + (options) => { + console.log('options', options, idCardPin) + if (!idCardPin) { + // We need to hide the NFC modal on iOS, as we first need to ask the user for the pin again + sendCommand({ cmd: 'INTERRUPT' }) + + // Ask user for PIN: + return new Promise((resolve) => { + setOnIdCardPinReEnter(() => { + return async (idCardPin: string) => { + setCurrentStepName('id-card-scan') + // UI blocks if we immediately resolve the PIN, we first want to make sure we navigate to the id-card-scan page again + setTimeout(() => resolve(idCardPin), 100) + setOnIdCardPinReEnter(undefined) + } + }) + // If we don't wait for a bit, it will render the keyboard and the nfc modal at the same time... + setTimeout(() => { + toast.show('Invalid PIN entered for eID Card. Please try again', { customData: { preset: 'danger' } }) + setCurrentStepName('id-card-pin') + }, 3000) + }) + } + + setIdCardPin(undefined) + return idCardPin + }, + [idCardPin, toast.show] + ) + + // Bit unfortunate, but we need to keep it as ref, as otherwise the value passed to ReceivePidUseCase.initialize will not get updated and we + // don't have access to the pin. We should probably change this to something like useCase.setPin() and then .continue + const onEnterPinRef = useRef({ onEnterPin }) + useEffect(() => { + onEnterPinRef.current.onEnterPin = onEnterPin + }, [onEnterPin]) + + const onIdCardPinEnter = async (pin: string) => { + setIdCardPin(pin) + + if (secureUnlock.state !== 'unlocked') { + reset({ error: 'onIdCardPinEnter: Secure unlock state is not unlocked', resetToStep: 'welcome' }) + throw new Error('onIdCardPinEnter: Secure unlock state is not unlocked') + } + + if (!receivePidUseCase && receivePidUseCaseState !== 'initializing') { + return ReceivePidUseCase.initialize({ + agent: secureUnlock.context.agent, + onStateChange: setReceivePidUseCaseState, + onEnterPin: (options) => onEnterPinRef.current.onEnterPin(options), + }) + .then((receivePidUseCase) => { + setReceivePidUseCase(receivePidUseCase) + goToNextStep() + }) + .catch((e) => { + reset({ error: e, resetToStep: 'id-card-pin' }) + throw e + }) + } + + goToNextStep() + return + } + + const reset = ({ resetToStep = 'welcome', error }: { error?: unknown; resetToStep: OnboardingStep['step'] }) => { + if (error) console.error(error) + + const stepsToCompleteAfterReset = onboardingStepsCFlow + .slice(onboardingStepsCFlow.findIndex((step) => step.step === resetToStep)) + .map((step) => step.step) + + if (stepsToCompleteAfterReset.includes('pin')) { + // Reset PIN state + setWalletPin(undefined) + setAgent(undefined) + } + + // Reset eID Card state + if (stepsToCompleteAfterReset.includes('id-card-pin')) { + // TODO: we need to be able to re-initialize the expo ausweis sdk + setReceivePidUseCaseState(undefined) + setReceivePidUseCase(undefined) + setOnIdCardPinReEnter(undefined) + } + if (stepsToCompleteAfterReset.includes('id-card-fetch')) { + setUserName(undefined) + } + if (stepsToCompleteAfterReset.includes('id-card-pin')) { + setIdCardPin(undefined) + } + + // TODO: if we already have the agent, we should either remove the wallet and start again, + // or we need to start from the id card flow + setCurrentStepName(resetToStep) + + toast.show('Error occurred during onboarding', { + message: 'Please try again.', + customData: { + preset: 'danger', + }, + }) + } + + const onStartScanning = async () => { + if (receivePidUseCase?.state !== 'id-card-auth') { + reset({ resetToStep: 'id-card-pin', error: 'onStartScanning: receivePidUseCaseState is not id-card-auth' }) + return + } + + // FIXME: we should probably remove the database here. + if (secureUnlock.state !== 'unlocked') { + reset({ resetToStep: 'welcome', error: 'onStartScanning: secureUnlock.state is not unlocked' }) + return + } + + try { + goToNextStep() + // Authenticate + await receivePidUseCase.authenticateUsingIdCard() + + // The modal on iOS is so slooooooow. + // TODO: we probably don't need this on Android + setTimeout(async () => { + try { + setCurrentStepName('id-card-fetch') + + // Acquire access token + await receivePidUseCase.acquireAccessToken() + + // Retrieve Credential + const credential = await receivePidUseCase.retrieveCredential() + await storeCredential(secureUnlock.context.agent, credential) + const parsed = secureUnlock.context.agent.sdJwtVc.fromCompact< + SdJwtVcHeader, + { given_name: string; family_name: string } + >(credential.compactSdJwtVc) + setUserName( + `${capitalizeFirstLetter(parsed.prettyClaims.given_name.toLowerCase())} ${capitalizeFirstLetter(parsed.prettyClaims.family_name.toLowerCase())}` + ) + setCurrentStepName('id-card-complete') + } catch (error) { + reset({ resetToStep: 'id-card-pin', error }) + } + }, 2000) + } catch (error) { + reset({ resetToStep: 'id-card-pin', error }) + } + } + + let screen: React.JSX.Element + if (currentStep.step === 'pin' || currentStep.step === 'pin-reenter') { + screen = + } else if (currentStep.step === 'id-card-pin') { + screen = + } else if (currentStep.step === 'id-card-start-scan') { + screen = + } else if (currentStep.step === 'id-card-complete') { + screen = + } else { + screen = + } + + return ( + + {children} + + ) +} + +export function useOnboardingContext() { + const value = useContext(OnboardingContext) + if (!value) { + throw new Error('useOnboardingContext must be wrapped in a ') + } + + return value +} diff --git a/apps/funke/src/features/onboarding/screens/biometrics.tsx b/apps/funke/src/features/onboarding/screens/biometrics.tsx new file mode 100644 index 00000000..430e586a --- /dev/null +++ b/apps/funke/src/features/onboarding/screens/biometrics.tsx @@ -0,0 +1,19 @@ +import { AnimatedFingerprintIcon, Button, YStack } from '@package/ui' +import React from 'react' + +interface OnboardingBiometricsProps { + goToNextStep: () => void +} + +export function OnboardingBiometrics({ goToNextStep }: OnboardingBiometricsProps) { + return ( + + + + + + Activate Biometrics + + + ) +} diff --git a/apps/funke/src/features/onboarding/screens/id-card-fetch.tsx b/apps/funke/src/features/onboarding/screens/id-card-fetch.tsx new file mode 100644 index 00000000..ace13eb3 --- /dev/null +++ b/apps/funke/src/features/onboarding/screens/id-card-fetch.tsx @@ -0,0 +1,27 @@ +import { Button, HeroIcons, IdCard, Stack, YStack } from '@package/ui' +import React from 'react' +import Animated, { FadeIn, LinearTransition } from 'react-native-reanimated' + +import germanIssuerImage from '../../../../assets/german-issuer-image.png' + +export interface OnboardingIdCardFetchProps { + goToNextStep: () => void + userName?: string +} + +export function OnboardingIdCardFetch({ goToNextStep, userName }: OnboardingIdCardFetchProps) { + return ( + + + + {userName && ( + + + Go to wallet + + + )} + + + ) +} diff --git a/apps/funke/src/features/onboarding/screens/id-card-pin.tsx b/apps/funke/src/features/onboarding/screens/id-card-pin.tsx new file mode 100644 index 00000000..11fac5ea --- /dev/null +++ b/apps/funke/src/features/onboarding/screens/id-card-pin.tsx @@ -0,0 +1,71 @@ +import { IdCard, Paragraph, Stack, XStack, YStack } from '@package/ui' +import React, { forwardRef, useImperativeHandle, useRef, useState } from 'react' +import type { TextInput } from 'react-native' +import { Input } from 'tamagui' + +import germanIssuerImage from '../../../../assets/german-issuer-image.png' + +export interface OnboardingIdCardPinEnterProps { + goToNextStep: (idCardPin: string) => Promise +} + +const pinLength = 6 + +export const OnboardingIdCardPinEnter = forwardRef(({ goToNextStep }: OnboardingIdCardPinEnterProps, ref) => { + const [pin, setPin] = useState('') + const inputRef = useRef(null) + + useImperativeHandle(ref, () => ({ + focus: () => inputRef.current?.focus(), + clear: () => setPin(''), + })) + + const isLoading = pin.length === pinLength + + const pinValues = new Array(pinLength).fill(0).map((_, i) => pin[i]) + const onChangePin = (newPin: string) => { + if (isLoading) return + const sanitized = newPin.replace(/[^0-9]/g, '') + setPin(sanitized) + + if (sanitized.length === pinLength) { + // If we don't do this the 6th value will never be rendered and that looks weird + setTimeout(() => goToNextStep(newPin).catch(() => setPin('')), 100) + } + } + + return ( + <> + inputRef.current?.focus()} + maxLength={pinLength} + onChangeText={onChangePin} + autoFocus + flex={1} + height={0} + width={0} + inputMode="numeric" + secureTextEntry + /> + + + + {pinValues.map((digit, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: index is the correct key here + + + {digit ?? ' '} + + + + ))} + + + + ) +}) diff --git a/apps/funke/src/features/onboarding/screens/id-card-scan.tsx b/apps/funke/src/features/onboarding/screens/id-card-scan.tsx new file mode 100644 index 00000000..63ff831a --- /dev/null +++ b/apps/funke/src/features/onboarding/screens/id-card-scan.tsx @@ -0,0 +1,15 @@ +import { Stack } from '@package/ui' + +import { NfcCardScanningPlacementImage } from '@package/ui/src/images/NfcScanningCardPlacementImage' + +export function OnboardingIdCardScan() { + return ( + + + + + {/* This is here to have the same layout as id-card-start-scan */} + + + ) +} diff --git a/apps/funke/src/features/onboarding/screens/id-card-start-scan.tsx b/apps/funke/src/features/onboarding/screens/id-card-start-scan.tsx new file mode 100644 index 00000000..8d875513 --- /dev/null +++ b/apps/funke/src/features/onboarding/screens/id-card-start-scan.tsx @@ -0,0 +1,20 @@ +import { Button, Stack } from '@package/ui' + +import { NfcCardScanningPlacementImage } from '@package/ui/src/images/NfcScanningCardPlacementImage' + +interface OnboardingIdCardStartScanProps { + goToNextStep: () => void +} + +export function OnboardingIdCardStartScan({ goToNextStep }: OnboardingIdCardStartScanProps) { + return ( + + + + + + Start scanning + + + ) +} diff --git a/apps/funke/src/features/onboarding/screens/introduction-steps.tsx b/apps/funke/src/features/onboarding/screens/introduction-steps.tsx new file mode 100644 index 00000000..6c5cbb9b --- /dev/null +++ b/apps/funke/src/features/onboarding/screens/introduction-steps.tsx @@ -0,0 +1,42 @@ +import { Button, HeroIcons, OnboardingStepItem, Paragraph, YStack } from '@package/ui' +import React from 'react' + +interface OnboardingIntroductionStepsProps { + goToNextStep: () => void +} + +export function OnboardingIntroductionSteps({ goToNextStep }: OnboardingIntroductionStepsProps) { + return ( + <> + + } + /> + } + /> + } + /> + + + {/* TODO: grey-700 vs secondary */} + + You'll need your passport to setup the wallet + + + Continue + + + + ) +} diff --git a/apps/funke/src/features/onboarding/screens/pin.tsx b/apps/funke/src/features/onboarding/screens/pin.tsx new file mode 100644 index 00000000..f587e60b --- /dev/null +++ b/apps/funke/src/features/onboarding/screens/pin.tsx @@ -0,0 +1,20 @@ +import { PinDotsInput, type PinDotsInputRef, YStack } from '@package/ui' +import React, { useRef } from 'react' + +export interface OnboardingPinEnterProps { + goToNextStep: (pin: string) => Promise +} + +export default function OnboardingPinEnter({ goToNextStep }: OnboardingPinEnterProps) { + const pinRef = useRef(null) + + const onPinComplete = (pin: string) => + goToNextStep(pin) + .then(() => pinRef.current?.clear()) + .catch(() => { + pinRef.current?.shake() + pinRef.current?.clear() + }) + + return +} diff --git a/apps/funke/src/features/onboarding/screens/welcome.tsx b/apps/funke/src/features/onboarding/screens/welcome.tsx new file mode 100644 index 00000000..74f16b0c --- /dev/null +++ b/apps/funke/src/features/onboarding/screens/welcome.tsx @@ -0,0 +1,46 @@ +import { Button, FlexPage, Heading, HeroIcons, Separator, XStack, YStack } from '@package/ui' +import React from 'react' +import Animated, { FadingTransition } from 'react-native-reanimated' +import { LinearGradient } from 'tamagui/linear-gradient' + +export interface OnboardingWelcomeProps { + goToNextStep: () => void +} + +export default function OnboardingWelcome({ goToNextStep }: OnboardingWelcomeProps) { + return ( + + + + + {/* This stack ensures the right spacing */} + + + Ausweis Wallet + + + Your digital Identity + + + + + + + + + Get Started + + + + + + ) +} diff --git a/apps/funke/polyfills/crypto.ts b/apps/funke/src/polyfills/crypto.ts similarity index 100% rename from apps/funke/polyfills/crypto.ts rename to apps/funke/src/polyfills/crypto.ts diff --git a/apps/funke/storage/index.ts b/apps/funke/src/storage/index.ts similarity index 100% rename from apps/funke/storage/index.ts rename to apps/funke/src/storage/index.ts diff --git a/apps/funke/storage/seedCredential.ts b/apps/funke/src/storage/seedCredential.ts similarity index 89% rename from apps/funke/storage/seedCredential.ts rename to apps/funke/src/storage/seedCredential.ts index a85a7fc1..8bc13ae7 100644 --- a/apps/funke/storage/seedCredential.ts +++ b/apps/funke/src/storage/seedCredential.ts @@ -1,6 +1,6 @@ import type { Agent } from '@credo-ts/core' +import { FUNKE_WALLET_SEED_CREDENTIAL_RECORD_ID } from '@funke/constants' import { walletJsonStore } from '@package/agent' -import { FUNKE_WALLET_SEED_CREDENTIAL_RECORD_ID } from '../constants' export const seedCredentialStorage = { store: async (agent: Agent, seedCredential: string) => diff --git a/apps/funke/use-cases/ReceivePidUseCase.ts b/apps/funke/src/use-cases/ReceivePidUseCase.ts similarity index 53% rename from apps/funke/use-cases/ReceivePidUseCase.ts rename to apps/funke/src/use-cases/ReceivePidUseCase.ts index d1384932..c4740915 100644 --- a/apps/funke/use-cases/ReceivePidUseCase.ts +++ b/apps/funke/src/use-cases/ReceivePidUseCase.ts @@ -1,5 +1,10 @@ -import type { AppAgent } from '@/agent' -import { AusweisAuthFlow } from '@animo-id/expo-ausweis-sdk' +import { + AusweisAuthFlow, + type AusweisAuthFlowOptions, + addMessageListener, + sendCommand, +} from '@animo-id/expo-ausweis-sdk' +import type { AppAgent } from '@funke/agent' import { type OpenId4VciRequestTokenResponse, type OpenId4VciResolvedAuthorizationRequest, @@ -9,22 +14,26 @@ import { resolveOpenId4VciOffer, } from '@package/agent' -export interface ReceivePidUseCaseOptions { +export interface ReceivePidUseCaseOptions extends Pick { agent: AppAgent onStateChange?: (newState: ReceivePidUseCaseState) => void + onEnterPin: ( + options: Parameters[0] & { currentSessionPinAttempts: number } + ) => string | Promise } export type ReceivePidUseCaseState = 'id-card-auth' | 'acquire-access-token' | 'retrieve-credential' | 'error' export class ReceivePidUseCase { - private agent: AppAgent + private options: ReceivePidUseCaseOptions private resolvedCredentialOffer: OpenId4VciResolvedCredentialOffer private resolvedAuthorizationRequest: OpenId4VciResolvedAuthorizationRequest private idCardAuthFlow: AusweisAuthFlow private accessToken?: OpenId4VciRequestTokenResponse + private refreshUrl?: string + private currentSessionPinAttempts = 0 - private onStateChange?: (newState: ReceivePidUseCaseState) => void private currentState: ReceivePidUseCaseState = 'id-card-auth' public get state() { return this.currentState @@ -32,40 +41,55 @@ export class ReceivePidUseCase { private static SD_JWT_VC_OFFER = 'openid-credential-offer://?credential_offer=%7B%22credential_issuer%22%3A%22https%3A%2F%2Fdemo.pid-issuer.bundesdruckerei.de%2Fc%22%2C%22credential_configuration_ids%22%3A%5B%22pid-sd-jwt%22%5D%2C%22grants%22%3A%7B%22authorization_code%22%3A%7B%7D%7D%7D' + private static MDL_OFFER = + 'openid-credential-offer://?credential_offer=%7B%22credential_issuer%22%3A%22https%3A%2F%2Fdemo.pid-issuer.bundesdruckerei.de%2Fc%22%2C%22credential_configuration_ids%22%3A%5B%22pid-mso-mdoc%22%5D%2C%22grants%22%3A%7B%22authorization_code%22%3A%7B%7D%7D%7D' private static CLIENT_ID = '7598ca4c-cc2e-4ff1-a4b4-ed58f249e274' private static REDIRECT_URI = 'https://funke.animo.id/redirect' + private errorCallbacks: AusweisAuthFlowOptions['onError'][] = [this.handleError] + private successCallbacks: AusweisAuthFlowOptions['onSuccess'][] = [ + ({ refreshUrl }) => { + this.refreshUrl = refreshUrl + this.assertState({ expectedState: 'id-card-auth', newState: 'acquire-access-token' }) + }, + ] + private constructor( - agent: AppAgent, + options: ReceivePidUseCaseOptions, resolvedAuthorizationRequest: OpenId4VciResolvedAuthorizationRequest, - resolvedCredentialOffer: OpenId4VciResolvedCredentialOffer, - onStateChange?: (newState: ReceivePidUseCaseState) => void + resolvedCredentialOffer: OpenId4VciResolvedCredentialOffer ) { - this.agent = agent this.resolvedAuthorizationRequest = resolvedAuthorizationRequest this.resolvedCredentialOffer = resolvedCredentialOffer - this.onStateChange = onStateChange + this.options = options this.idCardAuthFlow = new AusweisAuthFlow({ - onEnterPin: () => { - return '123456' - }, - onError: (e) => { - this.handleError() + onEnterPin: async (options) => { + const pin = await this.options.onEnterPin({ + ...options, + currentSessionPinAttempts: this.currentSessionPinAttempts, + }) + this.currentSessionPinAttempts += 1 + return pin }, - onSuccess: async ({ refreshUrl }) => { - await this.acquireAccessToken(refreshUrl) + onError: (error) => { + for (const errorCallback of this.errorCallbacks) { + errorCallback(error) + } }, - onInsertCard: () => { - // TODO: ui trigger + onSuccess: ({ refreshUrl }) => { + for (const successCallback of this.successCallbacks) { + successCallback({ refreshUrl }) + } }, + onInsertCard: () => this.options.onInsertCard?.(), }) - this.onStateChange?.('id-card-auth') + this.options.onStateChange?.('id-card-auth') } - public static async initialize({ agent, onStateChange }: ReceivePidUseCaseOptions) { + public static async initialize(options: ReceivePidUseCaseOptions) { const resolved = await resolveOpenId4VciOffer({ - agent, + agent: options.agent, offer: { uri: ReceivePidUseCase.SD_JWT_VC_OFFER }, authorization: { clientId: ReceivePidUseCase.CLIENT_ID, @@ -77,15 +101,10 @@ export class ReceivePidUseCase { throw new Error('Expected authorization_code grant, but not found') } - return new ReceivePidUseCase( - agent, - resolved.resolvedAuthorizationRequest, - resolved.resolvedCredentialOffer, - onStateChange - ) + return new ReceivePidUseCase(options, resolved.resolvedAuthorizationRequest, resolved.resolvedCredentialOffer) } - public async authenticateUsingIdCard() { + public authenticateUsingIdCard() { if (this.idCardAuthFlow.isActive) { throw new Error('authentication flow already active') } @@ -94,9 +113,31 @@ export class ReceivePidUseCase { throw new Error(`Current state is ${this.currentState}. Expected id-card-auth`) } - this.idCardAuthFlow.start({ - tcTokenUrl: this.resolvedAuthorizationRequest.authorizationRequestUri, + this.currentSessionPinAttempts = 0 + + // We return an authentication promise to make it easier to track the state + // We remove the callbacks once the error or success is triggered. + const authenticationPromise = new Promise((resolve, reject) => { + const successCallback: AusweisAuthFlowOptions['onSuccess'] = ({ refreshUrl }) => { + this.errorCallbacks = this.errorCallbacks.filter((c) => c === errorCallback) + this.successCallbacks = this.successCallbacks.filter((c) => c === successCallback) + resolve(null) + } + const errorCallback: AusweisAuthFlowOptions['onError'] = (error) => { + this.errorCallbacks = this.errorCallbacks.filter((c) => c === errorCallback) + this.successCallbacks = this.successCallbacks.filter((c) => c === successCallback) + reject(error) + } + + this.successCallbacks.push(successCallback) + this.errorCallbacks.push(errorCallback) + + this.idCardAuthFlow.start({ + tcTokenUrl: this.resolvedAuthorizationRequest.authorizationRequestUri, + }) }) + + return authenticationPromise } public async retrieveCredential() { @@ -109,7 +150,7 @@ export class ReceivePidUseCase { const credentialConfigurationIdToRequest = this.resolvedCredentialOffer.offeredCredentials[0].id const credentialRecord = await receiveCredentialFromOpenId4VciOffer({ - agent: this.agent, + agent: this.options.agent, accessToken: this.accessToken, resolvedCredentialOffer: this.resolvedCredentialOffer, credentialConfigurationIdToRequest, @@ -128,11 +169,15 @@ export class ReceivePidUseCase { } } - private async acquireAccessToken(refreshUrl: string) { - this.assertState({ expectedState: 'id-card-auth', newState: 'acquire-access-token' }) + public async acquireAccessToken() { + this.assertState({ expectedState: 'acquire-access-token' }) try { - const authorizationCodeResponse = await fetch(refreshUrl) + if (!this.refreshUrl) { + throw new Error('Expected refreshUrl be defined in state acquire-access-token') + } + + const authorizationCodeResponse = await fetch(this.refreshUrl) if (!authorizationCodeResponse.ok) { this.handleError() return @@ -151,12 +196,13 @@ export class ReceivePidUseCase { ...this.resolvedAuthorizationRequest, code: authorizationCode, }, - agent: this.agent, + agent: this.options.agent, }) this.assertState({ expectedState: 'acquire-access-token', newState: 'retrieve-credential' }) } catch (error) { this.handleError() + throw error } } @@ -170,12 +216,13 @@ export class ReceivePidUseCase { if (newState) { this.currentState = newState - this.onStateChange?.(newState) + this.options.onStateChange?.(newState) } } private handleError() { this.currentState = 'error' - this.onStateChange?.('error') + + this.options.onStateChange?.('error') } } diff --git a/apps/funke/utils/resetWallet.ts b/apps/funke/src/utils/resetWallet.ts similarity index 100% rename from apps/funke/utils/resetWallet.ts rename to apps/funke/src/utils/resetWallet.ts diff --git a/apps/funke/tamagui.config.ts b/apps/funke/tamagui.config.ts index 36f12fd5..f5e35d04 100644 --- a/apps/funke/tamagui.config.ts +++ b/apps/funke/tamagui.config.ts @@ -16,6 +16,10 @@ export const tokensInput = { const tokens = createTokens({ ...tokensInput, + size: { + ...tokensInput.size, + buttonHeight: 53, + }, color: { ...hexColors, // Re-use existing colors for positive/warnings etc. background: hexColors.white, @@ -50,12 +54,7 @@ const config = createTamagui({ body: fontOpenSans, }, themes: { - light: { - ...tokens.color, - - // Button - buttonHeight: 53, - }, + light: tokens.color, }, }) diff --git a/apps/funke/tsconfig.json b/apps/funke/tsconfig.json index 39f1d548..17a8c7b0 100644 --- a/apps/funke/tsconfig.json +++ b/apps/funke/tsconfig.json @@ -3,7 +3,8 @@ "compilerOptions": { "rootDir": "./", "paths": { - "@/*": ["./apps/funke/*"] + "@funke/*": ["./apps/funke/src/*"] } - } + }, + "include": ["./src/**/*.ts", "./src/**/*.tsx", "tamagui.config.ts"] } diff --git a/apps/storybook/components/funke/IdCardPinScreen.stories.tsx b/apps/storybook/components/funke/IdCardPinScreen.stories.tsx index 5864ae94..c4907d40 100644 --- a/apps/storybook/components/funke/IdCardPinScreen.stories.tsx +++ b/apps/storybook/components/funke/IdCardPinScreen.stories.tsx @@ -5,9 +5,9 @@ import type { TextInput } from 'react-native' import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated' import { Input } from 'tamagui' +import { OnboardingScreensHeader } from '@package/ui/src/components/OnboardingScreensHeader' import { useArgs } from '@storybook/addons' import { IdCard } from './IdCard' -import { OnboardingScreensHeader } from './OnboardingScreensHeader' const germanIssuerImage = require('../../../funke/assets/german-issuer-image.png') diff --git a/apps/storybook/components/funke/IdCardScanningIntroductionScreen.stories.tsx b/apps/storybook/components/funke/IdCardScanningScreen.stories.tsx similarity index 93% rename from apps/storybook/components/funke/IdCardScanningIntroductionScreen.stories.tsx rename to apps/storybook/components/funke/IdCardScanningScreen.stories.tsx index d8714238..099cf0e0 100644 --- a/apps/storybook/components/funke/IdCardScanningIntroductionScreen.stories.tsx +++ b/apps/storybook/components/funke/IdCardScanningScreen.stories.tsx @@ -5,8 +5,8 @@ import React from 'react' // TODO: src import? import { NfcCardScanningPlacementImage } from '@package/ui/src/images/NfcScanningCardPlacementImage' +import { OnboardingScreensHeader } from '@package/ui/src/components/OnboardingScreensHeader' import { YStack } from 'tamagui' -import { OnboardingScreensHeader } from './OnboardingScreensHeader' const IdCardScanningScreen = ({ isScanning }: { isScanning: boolean }) => { const title = isScanning ? 'Keep your card still' : 'Place your card on top of your phone' diff --git a/apps/storybook/components/funke/OnboardingStepsScreen.stories.tsx b/apps/storybook/components/funke/OnboardingStepsScreen.stories.tsx index c36725b6..16744766 100644 --- a/apps/storybook/components/funke/OnboardingStepsScreen.stories.tsx +++ b/apps/storybook/components/funke/OnboardingStepsScreen.stories.tsx @@ -1,7 +1,7 @@ import { Button, Heading, HeroIcons, Page, Paragraph, Stack, XStack, YStack } from '@package/ui' +import { OnboardingScreensHeader } from '@package/ui/src/components/OnboardingScreensHeader' import type { Meta, StoryObj } from '@storybook/react' import React from 'react' -import { OnboardingScreensHeader } from './OnboardingScreensHeader' interface OnboardingStepItemProps { stepName: string diff --git a/apps/storybook/components/funke/PinScreen.stories.tsx b/apps/storybook/components/funke/PinScreen.stories.tsx index 31e341a8..4191044b 100644 --- a/apps/storybook/components/funke/PinScreen.stories.tsx +++ b/apps/storybook/components/funke/PinScreen.stories.tsx @@ -1,9 +1,9 @@ import { Page, XStack, YStack } from '@package/ui' +import { OnboardingScreensHeader } from '@package/ui/src/components/OnboardingScreensHeader' import type { Meta, StoryObj } from '@storybook/react' import React, { useRef, useState } from 'react' import type { TextInput } from 'react-native' import { Circle, Input } from 'tamagui' -import { OnboardingScreensHeader } from './OnboardingScreensHeader' interface PinScreenProps { pinLength: number diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 7bdafd44..ecd55d4f 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -76,7 +76,7 @@ export const initializeFunkeAgent = async ({ keyDerivationMethod: keyDerivation === 'raw' ? KeyDerivationMethod.Raw : KeyDerivationMethod.Argon2IMod, }, autoUpdateStorageOnStartup: true, - logger: appLogger(LogLevel.debug), + // logger: appLogger(LogLevel.debug), }, modules: { ariesAskar: askarModule, diff --git a/packages/app/src/features/wallet/WalletScreen.tsx b/packages/app/src/features/wallet/WalletScreen.tsx index 4ff0bb12..8a7f6719 100644 --- a/packages/app/src/features/wallet/WalletScreen.tsx +++ b/packages/app/src/features/wallet/WalletScreen.tsx @@ -25,9 +25,10 @@ import { useNetworkCallback, useScrollViewPosition } from '../../hooks' type WalletScreenProps = { logo: number + showInbox?: boolean } -export function WalletScreen({ logo }: WalletScreenProps) { +export function WalletScreen({ logo, showInbox = true }: WalletScreenProps) { const { push } = useRouter() const { isLoading, credentials } = useCredentialsForDisplay() const firstThreeRecords = credentials.slice(0, 3) @@ -94,7 +95,7 @@ export function WalletScreen({ logo }: WalletScreenProps) { )} - + {showInbox && } {credentials.length === 0 ? ( diff --git a/packages/secure-store/secure-wallet-key/SecureUnlockProvider.tsx b/packages/secure-store/secure-wallet-key/SecureUnlockProvider.tsx index 4495c827..8f5681fb 100644 --- a/packages/secure-store/secure-wallet-key/SecureUnlockProvider.tsx +++ b/packages/secure-store/secure-wallet-key/SecureUnlockProvider.tsx @@ -35,20 +35,20 @@ export type SecureUnlockReturnInitializing = { } export type SecureUnlockReturnNotConfigured = { state: 'not-configured' - setup: (pin: string) => void + setup: (pin: string) => Promise<{ walletKey: string }> } export type SecureUnlockReturnLocked = { state: 'locked' - tryUnlockingUsingBiometrics: () => Promise + tryUnlockingUsingBiometrics: () => Promise canTryUnlockingUsingBiometrics: boolean - unlockUsingPin: (pin: string) => Promise + unlockUsingPin: (pin: string) => Promise isUnlocking: boolean } export type SecureUnlockReturnWalletKeyAcquired> = { state: 'acquired-wallet-key' walletKey: string unlockMethod: SecureUnlockMethod - setWalletKeyValid: (context: Context) => void + setWalletKeyValid: (context: Context, options: { enableBiometrics: boolean }) => void setWalletKeyInvalid: () => void } export type SecureUnlockReturnUnlocked> = { @@ -112,13 +112,13 @@ function _useSecureUnlockState>(): Secur setWalletKey(undefined) setUnlockMethod(undefined) }, - setWalletKeyValid: (context) => { + setWalletKeyValid: (context, options) => { setContext(context) setState('unlocked') // TODO: need extra option to know whether user wants to use biometrics? // TODO: do we need to check whether already stored? - if (canUseBiometrics) { + if (canUseBiometrics && options.enableBiometrics) { void secureWalletKey.storeWalletKey(walletKey, secureWalletKey.walletKeyVersion) } }, @@ -135,10 +135,10 @@ function _useSecureUnlockState>(): Secur context, unlockMethod, lock: () => { + setState('locked') setWalletKey(undefined) setUnlockMethod(undefined) setContext(undefined) - setState('locked') }, } } @@ -150,7 +150,7 @@ function _useSecureUnlockState>(): Secur canTryUnlockingUsingBiometrics, tryUnlockingUsingBiometrics: async () => { // TODO: need to somehow inform user that the unlocking went wrong - if (!canTryUnlockingUsingBiometrics) return + if (!canTryUnlockingUsingBiometrics) return null setIsUnlocking(true) setCanTryUnlockingUsingBiometrics(false) @@ -162,6 +162,8 @@ function _useSecureUnlockState>(): Secur setUnlockMethod('biometrics') setState('acquired-wallet-key') } + + return walletKey } catch (error) { // If use cancelled we won't allow trying using biometrics again if (error instanceof KeychainError && error.reason === 'userCancelled') { @@ -174,6 +176,8 @@ function _useSecureUnlockState>(): Secur } finally { setIsUnlocking(false) } + + return null }, unlockUsingPin: async (pin: string) => { setIsUnlocking(true) @@ -183,6 +187,8 @@ function _useSecureUnlockState>(): Secur setWalletKey(walletKey) setUnlockMethod('pin') setState('acquired-wallet-key') + + return walletKey } finally { setIsUnlocking(false) } @@ -200,6 +206,7 @@ function _useSecureUnlockState>(): Secur setWalletKey(walletKey) setUnlockMethod('pin') setState('acquired-wallet-key') + return { walletKey } }, } } diff --git a/packages/ui/src/base/Button.tsx b/packages/ui/src/base/Button.tsx index 26b20785..25e9dabd 100644 --- a/packages/ui/src/base/Button.tsx +++ b/packages/ui/src/base/Button.tsx @@ -9,7 +9,7 @@ const Btn = styled(TButton, { pressStyle: { opacity: 0.8, }, - height: '$buttonHeight', + height: '$size.buttonHeight', }) export const SolidButton = styled(Btn, { diff --git a/packages/ui/src/base/Page.tsx b/packages/ui/src/base/Page.tsx index 21e46a23..2cadfa95 100644 --- a/packages/ui/src/base/Page.tsx +++ b/packages/ui/src/base/Page.tsx @@ -1,5 +1,6 @@ -import { styled } from 'tamagui' +import { YStack, styled } from 'tamagui' +import { useSafeAreaInsets } from 'react-native-safe-area-context' import { Stack } from './Stacks' export const Page = styled(Stack, { @@ -12,3 +13,29 @@ export const Page = styled(Stack, { right: 0, bottom: 0, }) + +const FlexPageBase = styled(Stack, { + name: 'FlexPage', + backgroundColor: '$background', + 'flex-1': true, + gap: '$6', + padding: '$4', +}) + +export const FlexPage = FlexPageBase.styleable<{ safeArea?: boolean | 'x' | 'y' | 'l' | 'b' | 't' | 'r' }>( + (props, ref) => { + const safeAreaInsets = useSafeAreaInsets() + + // Not defined means true, not sure why defaultVariant doesn't work + const safeArea = props.safeArea ?? true + + const top = safeArea === true || safeArea === 'y' || safeArea === 't' ? safeAreaInsets.top : undefined + const bottom = safeArea === true || safeArea === 'y' || safeArea === 'b' ? safeAreaInsets.bottom : undefined + const left = safeArea === true || safeArea === 'x' || safeArea === 'l' ? safeAreaInsets.left : undefined + const right = safeArea === true || safeArea === 'x' || safeArea === 'r' ? safeAreaInsets.right : undefined + + return ( + + ) + } +) diff --git a/packages/ui/src/components/IdCard.tsx b/packages/ui/src/components/IdCard.tsx new file mode 100644 index 00000000..48c8e5f9 --- /dev/null +++ b/packages/ui/src/components/IdCard.tsx @@ -0,0 +1,91 @@ +import { useEffect } from 'react' +import { StyleSheet } from 'react-native' +import Animated, { + withRepeat, + withTiming, + withSequence, + useSharedValue, + Easing, + useAnimatedStyle, + withDelay, +} from 'react-native-reanimated' +import { Circle } from 'tamagui' +import { LinearGradient } from 'tamagui/linear-gradient' +import { Paragraph, Stack, XStack, YStack } from '../base' +import { HeroIcons, Image } from '../content' + +export interface IdCardProps { + icon: keyof typeof iconMapping + issuerImage: number + userName?: string +} + +const iconMapping = { + locked: , + loading: , + complete: , +} as const + +export function IdCard({ icon, issuerImage, userName }: IdCardProps) { + const rotation = useSharedValue(0) + + const animatedStyle = useAnimatedStyle(() => { + return { + transform: [{ rotate: `${rotation.value}deg` }], + } + }) + + useEffect(() => { + if (icon === 'loading') { + rotation.value = withSequence( + withTiming(360, { duration: 1500, easing: Easing.inOut(Easing.ease) }), + withTiming(0, { duration: 0 }), + withRepeat( + withSequence( + withDelay(500, withTiming(360, { duration: 1500, easing: Easing.inOut(Easing.ease) })), + withTiming(0, { duration: 0 }) + ), + -1 + ) + ) + } else { + rotation.value = 0 + } + }, [icon, rotation]) + + return ( + + + + + + Personalausweis + + {userName ?? '********'} + + + + + + + + + + {iconMapping[icon]} + + + + + ) +} diff --git a/apps/storybook/components/funke/OnboardingScreensHeader.tsx b/packages/ui/src/components/OnboardingScreensHeader.tsx similarity index 84% rename from apps/storybook/components/funke/OnboardingScreensHeader.tsx rename to packages/ui/src/components/OnboardingScreensHeader.tsx index 7fc91f63..d250bd44 100644 --- a/apps/storybook/components/funke/OnboardingScreensHeader.tsx +++ b/packages/ui/src/components/OnboardingScreensHeader.tsx @@ -1,5 +1,6 @@ -import { Heading, Paragraph, ProgressBar, YStack } from '@package/ui' import type { ComponentProps } from 'react' +import { Heading, Paragraph, YStack } from '../base' +import { ProgressBar } from '../content' interface OnboardingScreensHeaderProps extends ComponentProps { progress: number diff --git a/packages/ui/src/components/OnboardingStepItem.tsx b/packages/ui/src/components/OnboardingStepItem.tsx new file mode 100644 index 00000000..0e096052 --- /dev/null +++ b/packages/ui/src/components/OnboardingStepItem.tsx @@ -0,0 +1,34 @@ +import { Heading, Paragraph, Stack, XStack, YStack } from '../base' + +interface OnboardingStepItemProps { + stepName: string + description: string + title: string + icon: JSX.Element +} + +export const OnboardingStepItem = ({ stepName, title, description, icon }: OnboardingStepItemProps) => { + return ( + + + {icon} + + + + {stepName} + + {title} + + {description} + + + + ) +} diff --git a/packages/ui/src/components/PinDotsInput.tsx b/packages/ui/src/components/PinDotsInput.tsx index 17c511bb..97357334 100644 --- a/packages/ui/src/components/PinDotsInput.tsx +++ b/packages/ui/src/components/PinDotsInput.tsx @@ -3,26 +3,33 @@ import type { TextInput } from 'react-native' import Animated, { useSharedValue, withRepeat, withSequence, withTiming, withDelay } from 'react-native-reanimated' import { Circle, Input } from 'tamagui' import { XStack, YStack } from '../base' +import { PinPad, PinValues } from './PinPad' interface PinDotsInputProps { pinLength: number onPinComplete: (pin: string) => void - autoFocus?: boolean isLoading?: boolean + useNativeKeyboard?: boolean } export interface PinDotsInputRef { + /** Only applicable if using native keyboard */ focus: () => void clear: () => void shake: () => void } export const PinDotsInput = forwardRef( - ({ onPinComplete, pinLength, autoFocus, isLoading }: PinDotsInputProps, ref: ForwardedRef) => { + ( + { onPinComplete, pinLength, isLoading, useNativeKeyboard = true }: PinDotsInputProps, + ref: ForwardedRef + ) => { const [pin, setPin] = useState('') const inputRef = useRef(null) - const pinDots = new Array(pinLength).fill(0).map((_, i) => isLoading || pin[i] !== undefined) + const isInLoadingState = isLoading || pin.length === pinLength + + const pinDots = new Array(pinLength).fill(0).map((_, i) => isInLoadingState || pin[i] !== undefined) const translationAnimations = pinDots.map(() => useSharedValue(0)) const shakeAnimation = useSharedValue(0) @@ -39,7 +46,7 @@ export const PinDotsInput = forwardRef( useEffect(() => { translationAnimations.forEach((animation, index) => { // Go back down in 75 milliseconds - if (!isLoading) { + if (!isInLoadingState) { animation.value = withTiming(0, { duration: 75 }) return } @@ -59,7 +66,7 @@ export const PinDotsInput = forwardRef( ) ) }) - }, [...translationAnimations, translationAnimations.forEach, translationAnimations.length, isLoading]) + }, [...translationAnimations, translationAnimations.forEach, translationAnimations.length, isInLoadingState]) useImperativeHandle( ref, @@ -71,16 +78,41 @@ export const PinDotsInput = forwardRef( [startShakeAnimation] ) + const onPressPinNumber = (character: PinValues) => { + if (character === PinValues.Backspace) { + setPin((pin) => pin.slice(0, pin.length - 1)) + return + } + + if (character === PinValues.Empty) { + return + } + + setPin((currentPin) => { + const newPin = currentPin + character + + if (newPin.length === pinLength) { + // If we don't do this the 6th dot will never be rendered and that looks weird + setTimeout(() => onPinComplete(newPin), 100) + } + + return newPin + }) + } + const onChangePin = (newPin: string) => { + if (isLoading) return const sanitized = newPin.replace(/[^0-9]/g, '') setPin(sanitized) - if (sanitized.length === 6) { - onPinComplete(sanitized) + + if (sanitized.length === pinLength) { + // If we don't do this the 6th dot will never be rendered and that looks weird + setTimeout(() => onPinComplete(newPin), 100) } } return ( - inputRef.current?.focus()}> + inputRef.current?.focus()}> {pinDots.map((filled, i) => ( @@ -96,22 +128,26 @@ export const PinDotsInput = forwardRef( ))} - inputRef.current?.focus()} - maxLength={pinLength} - onChangeText={onChangePin} - autoFocus={autoFocus} - flex={1} - height={0} - width={0} - inputMode="numeric" - secureTextEntry - /> + {useNativeKeyboard ? ( + inputRef.current?.focus()} + maxLength={pinLength} + onChangeText={onChangePin} + autoFocus + flex={1} + height={0} + width={0} + inputMode="numeric" + secureTextEntry + /> + ) : ( + + )} ) } diff --git a/packages/ui/src/components/PinPad.tsx b/packages/ui/src/components/PinPad.tsx new file mode 100644 index 00000000..f51d1675 --- /dev/null +++ b/packages/ui/src/components/PinPad.tsx @@ -0,0 +1,77 @@ +import { Circle, Paragraph, Text } from 'tamagui' +import { Button, Stack, XStack, YStack } from '../base' +import { HeroIcons } from '../content' + +export enum PinValues { + One = '1', + Two = '2', + Three = '3', + Four = '4', + Five = '5', + Six = '6', + Seven = '7', + Eight = '8', + Nine = '9', + Empty = '', + Zero = '0', + Backspace = 'backspace', +} + +export interface PinNumberProps extends PinPadProps { + character: PinValues +} + +const PinNumber = ({ character, onPressPinNumber, disabled }: PinNumberProps) => { + return ( + onPressPinNumber(character)} + disabled={disabled} + > + {character === PinValues.Backspace ? ( + + ) : ( + + {character} + + )} + + ) +} + +export interface PinPadProps { + onPressPinNumber: (character: PinValues) => void + disabled?: boolean +} + +export const PinPad = ({ onPressPinNumber, disabled }: PinPadProps) => { + const pinValues = [ + [PinValues.One, PinValues.Two, PinValues.Three], + [PinValues.Four, PinValues.Five, PinValues.Six], + [PinValues.Seven, PinValues.Eight, PinValues.Nine], + [PinValues.Empty, PinValues.Zero, PinValues.Backspace], + ] + + const pinNumbers = pinValues.map((rowItems, rowIndex) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: + + {rowItems.map((value, columnIndex) => ( + + rowIndex + }`} + character={value} + onPressPinNumber={onPressPinNumber} + disabled={disabled} + /> + ))} + + )) + + return {pinNumbers} +} diff --git a/packages/ui/src/components/index.tsx b/packages/ui/src/components/index.tsx index 93cb845d..7137c8a1 100644 --- a/packages/ui/src/components/index.tsx +++ b/packages/ui/src/components/index.tsx @@ -1 +1,5 @@ export * from './PinDotsInput' +export * from './OnboardingScreensHeader' +export * from './OnboardingStepItem' +export * from './IdCard' +export * from './PinPad' diff --git a/packages/ui/src/config/tamagui.config.ts b/packages/ui/src/config/tamagui.config.ts index 2b1bd293..a9101434 100644 --- a/packages/ui/src/config/tamagui.config.ts +++ b/packages/ui/src/config/tamagui.config.ts @@ -66,7 +66,10 @@ export const tokensInput = { ...hexColors, background: hexColors['grey-200'], }, - size, + size: { + ...size, + buttonHeight: size.$4, + }, radius: { ...radius, button: 8, @@ -93,12 +96,7 @@ export const configInput = { }, tokens, themes: { - light: { - ...tokens.color, - - // Button - buttonHeight: size.$4, - }, + light: tokens.color, }, } as const satisfies CreateTamaguiProps diff --git a/packages/ui/src/content/AnimatedFingerprintIcon.tsx b/packages/ui/src/content/AnimatedFingerprintIcon.tsx new file mode 100644 index 00000000..4904e0a9 --- /dev/null +++ b/packages/ui/src/content/AnimatedFingerprintIcon.tsx @@ -0,0 +1,99 @@ +import { useEffect } from 'react' +import Animated, { + Easing, + useSharedValue, + withDelay, + withRepeat, + withTiming, + withSequence, +} from 'react-native-reanimated' +import { Path, Svg } from 'react-native-svg' +import { Circle } from 'tamagui' +import { YStack } from '../base' + +const AnimatedCircle = Animated.createAnimatedComponent(Circle) +const AnimatedStack = Animated.createAnimatedComponent(YStack) + +// Start is a number between 0 and 1 to note the starting point. +function FingerprintAnimationCircle({ start = 0 }: { start?: number }) { + const maxScale = 175 + const minScale = 55 + + // Calculate initial positions based on start + const scale = useSharedValue(minScale + start * (maxScale - minScale)) + const opacity = useSharedValue(start <= 0.11 ? 1 : 1 - (start - (0.11 / 89) * 100)) + const zIndex = useSharedValue(Math.ceil(start * 4)) + + useEffect(() => { + const initialDuration = (1 - start) * 4500 + + const opacityDelay = start >= 0.11 ? 0 : ((0.11 - start) / 0.11) * 500 + opacity.value = withSequence( + withDelay( + opacityDelay, + withTiming(0, { duration: initialDuration - opacityDelay, easing: Easing.inOut(Easing.ease) }) + ), + withTiming(1, { duration: 0 }), + withRepeat( + withSequence( + withDelay(500, withTiming(0, { duration: 4000, easing: Easing.inOut(Easing.ease) })), + withTiming(1, { duration: 0 }) + ), + -1, + false + ) + ) + + zIndex.value = withSequence( + withTiming(1, { duration: initialDuration, easing: Easing.steps(Math.ceil(start * 4) - 1, true) }), + withTiming(4, { duration: 0 }), + withRepeat( + withSequence(withTiming(1, { duration: 4500, easing: Easing.steps(3, true) }), withTiming(4, { duration: 0 })), + -1, + false + ) + ) + + scale.value = withSequence( + withTiming(maxScale, { duration: initialDuration, easing: Easing.inOut(Easing.ease) }), + withTiming(minScale, { duration: 0 }), + withRepeat( + withSequence( + withTiming(maxScale, { duration: 4500, easing: Easing.inOut(Easing.ease) }), + withTiming(minScale, { duration: 0 }) + ), + -1, + false + ) + ) + }, [opacity, zIndex, scale, start]) + + return ( + + + + ) +} + +export const AnimatedFingerprintIcon = () => { + return ( + + + + + + + + + ) +} diff --git a/packages/ui/src/content/Icon.tsx b/packages/ui/src/content/Icon.tsx index 78b03050..b1cab297 100644 --- a/packages/ui/src/content/Icon.tsx +++ b/packages/ui/src/content/Icon.tsx @@ -5,6 +5,9 @@ import type { NumberProp, SvgProps } from 'react-native-svg' import { ArrowPathIcon, ArrowRightIcon, + BackspaceIcon, + CheckCircleIcon, + ExclamationCircleIcon, GlobeAltIcon, IdentificationIcon, KeyIcon, @@ -50,4 +53,7 @@ export const HeroIcons = { ArrowPath: wrapHeroIcon(ArrowPathIcon), LockClosed: wrapHeroIcon(LockClosedIcon), ArrowRight: wrapHeroIcon(ArrowRightIcon), + Backspace: wrapHeroIcon(BackspaceIcon), + ExclamationCircle: wrapHeroIcon(ExclamationCircleIcon), + CheckCircle: wrapHeroIcon(CheckCircleIcon), } as const diff --git a/packages/ui/src/content/Image.tsx b/packages/ui/src/content/Image.tsx index 4253362e..e9fe8608 100644 --- a/packages/ui/src/content/Image.tsx +++ b/packages/ui/src/content/Image.tsx @@ -2,7 +2,7 @@ import { SvgUri } from 'react-native-svg' import { Image as TImage } from 'tamagui' export interface ImageProps { - src: string + src: string | number alt?: string width?: number | string height?: number | string @@ -12,11 +12,12 @@ export interface ImageProps { // FIXME: tamagui image is not working for svg's export const Image = ({ src, alt, width, height, isImageLoaded, resizeMode = 'contain' }: ImageProps) => { - if (src.endsWith('.svg')) return + if (typeof src === 'string' && src.endsWith('.svg')) + return return ( isImageLoaded() : undefined} width={width} height={height} diff --git a/packages/ui/src/content/Logo.tsx b/packages/ui/src/content/Logo.tsx index 9429eed9..dc23433d 100644 --- a/packages/ui/src/content/Logo.tsx +++ b/packages/ui/src/content/Logo.tsx @@ -4,4 +4,4 @@ type LogoProps = { source: number } -export const Logo = ({ source }: LogoProps) => +export const Logo = ({ source }: LogoProps) => diff --git a/packages/ui/src/content/ProgressBar.tsx b/packages/ui/src/content/ProgressBar.tsx index 1cf8073f..9959391f 100644 --- a/packages/ui/src/content/ProgressBar.tsx +++ b/packages/ui/src/content/ProgressBar.tsx @@ -15,7 +15,7 @@ const _ProgressBar = styled(Progress, { const ProgressBarStyled = _ProgressBar.styleable(({ indicatorColor, ...props }, ref) => { return ( <_ProgressBar {...props} ref={ref}> - {props.children ?? } + {props.children ?? } ) }) diff --git a/packages/ui/src/content/index.ts b/packages/ui/src/content/index.ts index 6fccedbf..5adbaa09 100644 --- a/packages/ui/src/content/index.ts +++ b/packages/ui/src/content/index.ts @@ -3,3 +3,4 @@ export * from './Spinner' export * from './Image' export * from './Logo' export * from './ProgressBar' +export * from './AnimatedFingerprintIcon' diff --git a/packages/ui/src/panels/CustomToast.tsx b/packages/ui/src/panels/CustomToast.tsx index e2afcd34..0167e6fa 100644 --- a/packages/ui/src/panels/CustomToast.tsx +++ b/packages/ui/src/panels/CustomToast.tsx @@ -17,7 +17,12 @@ export const CustomToast = () => { p={0} width="100%" > - + ) } diff --git a/packages/ui/src/panels/ToastContainer.tsx b/packages/ui/src/panels/ToastContainer.tsx index 3c73249a..c41140a3 100644 --- a/packages/ui/src/panels/ToastContainer.tsx +++ b/packages/ui/src/panels/ToastContainer.tsx @@ -1,32 +1,57 @@ import { useToastController } from '@tamagui/toast' +import { YStack } from 'tamagui' import { Paragraph, Stack, XStack } from '../base' -import { LucideIcons } from '../content' +import { HeroIcons, LucideIcons } from '../content' + +declare module '@tamagui/toast' { + interface CustomData { + /** + * @default "none" + */ + preset?: 'danger' | 'success' | 'none' + } +} interface ToastContainerProps { title: string + message?: string safeAreaMargin?: boolean + variant?: 'danger' | 'success' | 'none' +} + +const iconMapping = { + danger: , + success: , + none: undefined, } -export const ToastContainer = ({ title, safeAreaMargin = false }: ToastContainerProps) => { +export const ToastContainer = ({ title, message, safeAreaMargin = false, variant = 'none' }: ToastContainerProps) => { const toast = useToastController() + + const icon = iconMapping[variant] return ( - {title} - toast.hide()}> + {icon && ( + + {icon} + + )} + + {title} + {message && {message}} + + toast.hide()}> diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 55b122c7..67b55c1f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -90,6 +90,9 @@ importers: '@package/ui': specifier: workspace:* version: link:../../packages/ui + '@package/utils': + specifier: workspace:* + version: link:../../packages/utils '@react-native-community/blur': specifier: ^4.3.2 version: 4.4.0(react-native@0.74.2(@babel/core@7.25.2)(@babel/preset-env@7.25.2(@babel/core@7.25.2))(@types/react@18.2.79)(react@18.2.0))(react@18.2.0) diff --git a/tsconfig.json b/tsconfig.json index 584aba4e..1e5dcd33 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,12 +10,14 @@ "esModuleInterop": true, "resolveJsonModule": true, "baseUrl": ".", + "types": ["png"], "skipLibCheck": true, "jsx": "react-native", "typeRoots": ["./types"], "paths": { "@package/*": ["./packages/*"], - "@/*": ["./apps/funke/*", "./apps/paradym/*"] + "@funke/*": ["./apps/funke/src/*"], + "@paradym/*": ["./apps/paradym/*"] } }, "exclude": ["node_modules", "build"] diff --git a/types/png.d.ts b/types/png.d.ts new file mode 100644 index 00000000..20b3e522 --- /dev/null +++ b/types/png.d.ts @@ -0,0 +1,4 @@ +declare module '*.png' { + const value: number + export default value +}