diff --git a/apps/easypid/src/app/(app)/_layout.tsx b/apps/easypid/src/app/(app)/_layout.tsx index c2c94ac8..b3a38550 100644 --- a/apps/easypid/src/app/(app)/_layout.tsx +++ b/apps/easypid/src/app/(app)/_layout.tsx @@ -99,6 +99,7 @@ export default function AppLayout() { + diff --git a/apps/easypid/src/app/(app)/pidSetup.tsx b/apps/easypid/src/app/(app)/pidSetup.tsx new file mode 100644 index 00000000..0bf74a2b --- /dev/null +++ b/apps/easypid/src/app/(app)/pidSetup.tsx @@ -0,0 +1,5 @@ +import { FunkePidSetupScreen } from '@easypid/features/pid/FunkePidSetupScreen' + +export default function PidSetup() { + return +} diff --git a/apps/easypid/src/app/authenticate.tsx b/apps/easypid/src/app/authenticate.tsx index 8978a71f..9626caa3 100644 --- a/apps/easypid/src/app/authenticate.tsx +++ b/apps/easypid/src/app/authenticate.tsx @@ -22,15 +22,26 @@ export default function Authenticate() { const biometricsType = useBiometricsType() const pinInputRef = useRef(null) const [isInitializingAgent, setIsInitializingAgent] = useState(false) + const [isAllowedToUnlockWithFaceId, setIsAllowedToUnlockWithFaceId] = useState(false) const isLoading = secureUnlock.state === 'acquired-wallet-key' || (secureUnlock.state === 'locked' && secureUnlock.isUnlocking) + // After resetting the wallet, we want to avoid prompting for face id immediately + // So we add an artificial delay + useEffect(() => { + const timer = setTimeout(() => { + setIsAllowedToUnlockWithFaceId(true) + }, 500) + + return () => clearTimeout(timer) + }, []) + // biome-ignore lint/correctness/useExhaustiveDependencies: canTryUnlockingUsingBiometrics not needed useEffect(() => { - if (secureUnlock.state === 'locked' && secureUnlock.canTryUnlockingUsingBiometrics) { + if (secureUnlock.state === 'locked' && secureUnlock.canTryUnlockingUsingBiometrics && isAllowedToUnlockWithFaceId) { secureUnlock.tryUnlockingUsingBiometrics() } - }, [secureUnlock.state]) + }, [secureUnlock.state, isAllowedToUnlockWithFaceId]) useEffect(() => { if (secureUnlock.state !== 'acquired-wallet-key') return diff --git a/apps/easypid/src/features/menu/FunkeMenuScreen.tsx b/apps/easypid/src/features/menu/FunkeMenuScreen.tsx index dcf639fc..e67c23aa 100644 --- a/apps/easypid/src/features/menu/FunkeMenuScreen.tsx +++ b/apps/easypid/src/features/menu/FunkeMenuScreen.tsx @@ -51,7 +51,7 @@ export function FunkeMenuScreen() { - Screen: React.FunctionComponent -}> - -export type OnboardingSteps = typeof onboardingSteps -export type OnboardingStep = OnboardingSteps[number] + ...pidSetupSteps, +] as const satisfies Array export type OnboardingContext = { currentStep: OnboardingStep['step'] progress: number - page: Page + page: OnboardingPage screen: React.JSX.Element reset: () => void } @@ -259,7 +135,7 @@ export function OnboardingContextProvider({ const [, setHasFinishedOnboarding] = useHasFinishedOnboarding() const pidDisplay = usePidDisplay() - const [selectedFlow, setSelectedFlow] = useState<'c' | 'bprime'>('c') + const [selectedFlow, setSelectedFlow] = useState('c') const [receivePidUseCase, setReceivePidUseCase] = useState() const [receivePidUseCaseState, setReceivePidUseCaseState] = useState() const [allowSimulatorCard, setAllowSimulatorCard] = useState(false) @@ -268,12 +144,7 @@ export function OnboardingContextProvider({ const [idCardPin, setIdCardPin] = useState() const [userName, setUserName] = useState() const [agent, setAgent] = useState() - const [idCardScanningState, setIdCardScanningState] = useState<{ - showScanModal: boolean - isCardAttached?: boolean - progress: number - state: 'readyToScan' | 'scanning' | 'complete' | 'error' - }>({ + const [idCardScanningState, setIdCardScanningState] = useState({ isCardAttached: undefined, progress: 0, state: 'readyToScan', @@ -343,7 +214,7 @@ export function OnboardingContextProvider({ const onPinReEnter = async (pin: string) => { // Spells BROKEN on the pin pad (with letters) // Allows bypassing the eID card and use a simulator card - const isSimulatorPinCode = pin === '276536' + const isSimulatorPinCode = pin === SIMULATOR_PIN if (isSimulatorPinCode) { setAllowSimulatorCard(true) diff --git a/apps/easypid/src/features/onboarding/screens/id-card-scan.tsx b/apps/easypid/src/features/onboarding/screens/id-card-scan.tsx index 5a71110c..4d2b7949 100644 --- a/apps/easypid/src/features/onboarding/screens/id-card-scan.tsx +++ b/apps/easypid/src/features/onboarding/screens/id-card-scan.tsx @@ -2,13 +2,13 @@ import { AnimatedNfcScan, Button, NfcScannerModalAndroid, Stack, YStack } from ' import { Platform } from 'react-native' -interface OnboardingIdCardScanProps { +export interface OnboardingIdCardScanProps { isCardAttached?: boolean scanningState: 'readyToScan' | 'scanning' | 'complete' | 'error' progress: number showScanModal: boolean onCancel: () => void - onStartScanning?: () => void + onStartScanning?: () => Promise } export function OnboardingIdCardScan({ diff --git a/apps/easypid/src/features/onboarding/screens/id-card-start.tsx b/apps/easypid/src/features/onboarding/screens/id-card-start.tsx index f47607e6..00eef092 100644 --- a/apps/easypid/src/features/onboarding/screens/id-card-start.tsx +++ b/apps/easypid/src/features/onboarding/screens/id-card-start.tsx @@ -5,14 +5,14 @@ import { useState } from 'react' interface OnboardingIdCardStartScanProps { goToNextStep: () => Promise - onSkipCardSetup: () => void + onSkipCardSetup?: () => void } export function OnboardingIdCardStart({ goToNextStep, onSkipCardSetup }: OnboardingIdCardStartScanProps) { const [isLoading, setIsLoading] = useState(false) const onSetupLater = () => { - if (isLoading) return + if (isLoading || !onSkipCardSetup) return setIsLoading(true) onSkipCardSetup() @@ -38,9 +38,11 @@ export function OnboardingIdCardStart({ goToNextStep, onSkipCardSetup }: Onboard - - Set up later - + {onSkipCardSetup && ( + + Set up later + + )} {isLoading ? : 'Continue'} diff --git a/apps/easypid/src/features/pid/FunkePidSetupScreen.tsx b/apps/easypid/src/features/pid/FunkePidSetupScreen.tsx new file mode 100644 index 00000000..e198b10a --- /dev/null +++ b/apps/easypid/src/features/pid/FunkePidSetupScreen.tsx @@ -0,0 +1,416 @@ +import { sendCommand } from '@animo-id/expo-ausweis-sdk' +import { type SdJwtVcHeader, SdJwtVcRecord } from '@credo-ts/core' +import { useSecureUnlock } from '@easypid/agent' +import { type PidSdJwtVcAttributes, usePidDisplay } from '@easypid/hooks' +import { + PinPossiblyReusedError, + type ReceivePidUseCaseBPrimeFlow, +} from '@easypid/use-cases/ReceivePidUseCaseBPrimeFlow' +import { ReceivePidUseCaseCFlow } from '@easypid/use-cases/ReceivePidUseCaseCFlow' +import type { + CardScanningErrorDetails, + ReceivePidUseCaseFlowOptions, + ReceivePidUseCaseState, +} from '@easypid/use-cases/ReceivePidUseCaseFlow' +import { type CardScanningState, SIMULATOR_PIN, getPidSetupSlideContent } from '@easypid/utils/sharedPidSetup' +import { SlideWizard, usePushToWallet } from '@package/app' +import { BiometricAuthenticationCancelledError, BiometricAuthenticationNotEnabledError } from 'packages/agent/src' +import { useToastController } from 'packages/ui/src' +import { capitalizeFirstLetter, getHostNameFromUrl, sleep } from 'packages/utils/src' +import type React from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' +import { Platform } from 'react-native' +import { addReceivedActivity } from '../activity/activityRecord' +import { PidCardScanSlide } from './PidCardScanSlide' +import { PidIdCardFetchSlide } from './PidEidCardFetchSlide' +import { PidEidCardPinSlide } from './PidEidCardPinSlide' +import { PidReviewRequestSlide } from './PidReviewRequestSlide' +import { PidSetupStartSlide } from './PidSetupStartSlide' +import { PidWalletPinSlide } from './PidWalletPinSlide' + +export function FunkePidSetupScreen() { + const toast = useToastController() + const pushToWallet = usePushToWallet() + const secureUnlock = useSecureUnlock() + const pidDisplay = usePidDisplay() + + const [idCardPin, setIdCardPin] = useState() + const [receivePidUseCase, setReceivePidUseCase] = useState() + const [receivePidUseCaseState, setReceivePidUseCaseState] = useState() + const [idCardScanningState, setIdCardScanningState] = useState({ + isCardAttached: undefined, + progress: 0, + state: 'readyToScan', + showScanModal: true, + }) + const [eidCardRequestedAccessRights, setEidCardRequestedAccessRights] = useState([]) + const [onIdCardPinReEnter, setOnIdCardPinReEnter] = useState<(idCardPin: string) => Promise>() + const [userName, setUserName] = useState() + const [isScanning, setIsScanning] = useState(false) + + const onEnterPin: ReceivePidUseCaseFlowOptions['onEnterPin'] = useCallback( + (options) => { + if (!idCardPin) { + // We need to hide the NFC modal on iOS, as we first need to ask the user for the pin again + if (Platform.OS === 'ios') sendCommand({ cmd: 'INTERRUPT' }) + + setIdCardScanningState((state) => ({ + ...state, + progress: 0, + state: 'error', + showScanModal: true, + isCardAttached: undefined, + })) + + // Ask user for PIN: + return new Promise((resolve) => { + setOnIdCardPinReEnter(() => { + return async (idCardPin: string) => { + setIdCardScanningState((state) => ({ + ...state, + showScanModal: true, + })) + setIsScanning(true) + // 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) + } + }) + + let promise: Promise + // On android we have a custom modal, so we can keep the timeout shorten, but we do want to show the error modal for a bit. + if (Platform.OS === 'android') { + promise = sleep(1000).then(async () => { + setIdCardScanningState((state) => ({ + ...state, + state: 'readyToScan', + showScanModal: false, + })) + + await sleep(500) + }) + } + // on iOS we need to wait 3 seconds for the NFC modal to close, as otherwise it will render the keyboard and the nfc modal at the same time... + else { + promise = sleep(3000) + } + + // Navigate to the id-card-pin and show a toast + promise.then(() => { + pushToWallet() + toast.show('Invalid eID card PIN entered', { + customData: { preset: 'danger' }, + }) + }) + }) + } + + setIdCardPin(undefined) + return idCardPin + }, + [idCardPin, toast.show, pushToWallet] + ) + + const onEnterPinRef = useRef({ onEnterPin }) + useEffect(() => { + onEnterPinRef.current.onEnterPin = onEnterPin + }, [onEnterPin]) + + const onIdCardStart = async ({ + walletPin, + allowSimulatorCard, + }: { walletPin: string; allowSimulatorCard: boolean }) => { + if (secureUnlock.state !== 'unlocked') { + toast.show('Wallet not unlocked', { customData: { preset: 'danger' } }) + pushToWallet() + return + } + + if (!walletPin) { + toast.show('Wallet PIN is missing', { customData: { preset: 'danger' } }) + pushToWallet() + return + } + + const baseOptions = { + agent: secureUnlock.context.agent, + onStateChange: setReceivePidUseCaseState, + onCardAttachedChanged: ({ isCardAttached }) => + setIdCardScanningState((state) => ({ + ...state, + isCardAttached, + state: state.state === 'readyToScan' && isCardAttached ? 'scanning' : state.state, + })), + onStatusProgress: ({ progress }) => setIdCardScanningState((state) => ({ ...state, progress })), + onEnterPin: (options) => onEnterPinRef.current.onEnterPin(options), + allowSimulatorCard, + } as const satisfies ReceivePidUseCaseFlowOptions + + if (!receivePidUseCase && receivePidUseCaseState !== 'initializing') { + // Flow is always 'c' flow for now. + const flow = ReceivePidUseCaseCFlow.initialize(baseOptions) + + return flow + .then(async ({ accessRights, authFlow }) => { + setReceivePidUseCase(authFlow) + setEidCardRequestedAccessRights(accessRights) + }) + .catch(async (e) => { + toast.show(e.message, { customData: { preset: 'danger' } }) + pushToWallet() + }) + } + } + + const onWalletPinEnter = async (pin: string) => { + // FIXME: We need to check if the pin is correct, but wallet is not locked. + // PIN is also not used now, as we only do C flow. + + // await secureUnlock.unlockUsingPin(pin).then(() => { + // setWalletPin(pin) + // }) + + const isSimulatorPinCode = pin === SIMULATOR_PIN + await onIdCardStart({ walletPin: pin, allowSimulatorCard: isSimulatorPinCode }) + } + + const onIdCardPinEnter = (pin: string) => setIdCardPin(pin) + + const onStartScanning = async () => { + if (receivePidUseCase?.state !== 'id-card-auth') { + toast.show('Not ready to receive PID', { customData: { preset: 'danger' } }) + pushToWallet() + return + } + + if (secureUnlock.state !== 'unlocked') { + toast.show('Wallet not unlocked', { customData: { preset: 'danger' } }) + pushToWallet() + return + } + + setIsScanning(true) + + // Authenticate + try { + await receivePidUseCase.authenticateUsingIdCard() + } catch (error) { + setIdCardScanningState((state) => ({ + ...state, + state: 'error', + })) + await sleep(500) + setIdCardScanningState((state) => ({ + ...state, + showScanModal: false, + })) + await sleep(500) + + const reason = (error as CardScanningErrorDetails).reason + if (reason === 'user_cancelled' || reason === 'cancelled') { + toast.show('Card scanning cancelled', { + customData: { + preset: 'danger', + }, + }) + setIsScanning(false) + pushToWallet() + } else { + toast.show('Something went wrong', { + message: 'Please try again.', + customData: { + preset: 'danger', + }, + }) + pushToWallet() + setIsScanning(false) + } + + return + } + } + + const onScanningComplete = async () => { + if (!receivePidUseCase) { + toast.show('Not ready to receive PID', { customData: { preset: 'danger' } }) + pushToWallet() + return + } + + try { + setIdCardScanningState((state) => ({ + ...state, + state: 'complete', + progress: 100, + })) + + // on iOS it takes around two seconds for the modal to close. On Android we wait 1 second + // and then close the modal + await new Promise((resolve) => setTimeout(resolve, 1000)) + setIdCardScanningState((state) => ({ ...state, showScanModal: false })) + await new Promise((resolve) => setTimeout(resolve, Platform.OS === 'android' ? 500 : 1000)) + + // Acquire access token + await receivePidUseCase.acquireAccessToken() + + await retrieveCredential() + } catch (error) { + if (error instanceof PinPossiblyReusedError) { + toast.show('Have you used this PIN before?', { + customData: { + preset: 'danger', + }, + }) + pushToWallet() + } else { + toast.show('Something went wrong', { + customData: { + preset: 'danger', + }, + }) + pushToWallet() + } + } + } + + const retrieveCredential = async () => { + if (receivePidUseCase?.state !== 'retrieve-credential') { + toast.show('Not ready to retrieve PID', { customData: { preset: 'danger' } }) + pushToWallet() + return + } + + if (secureUnlock.state !== 'unlocked') { + toast.show('Wallet not unlocked', { customData: { preset: 'danger' } }) + pushToWallet() + return + } + + try { + // Retrieve Credential + const credentials = await receivePidUseCase.retrieveCredentials() + + for (const credential of credentials) { + if (credential instanceof SdJwtVcRecord) { + const parsed = secureUnlock.context.agent.sdJwtVc.fromCompact( + credential.compactSdJwtVc + ) + setUserName( + `${capitalizeFirstLetter(parsed.prettyClaims.given_name.toLowerCase())} ${capitalizeFirstLetter( + parsed.prettyClaims.family_name.toLowerCase() + )}` + ) + + await addReceivedActivity(secureUnlock.context.agent, { + host: getHostNameFromUrl(parsed.prettyClaims.iss) as string, + name: pidDisplay?.issuer.name, + logo: pidDisplay?.issuer.logo, + backgroundColor: '#ffffff', // PID Logo needs white background + credentialIds: [credential.id], + }) + } + } + } catch (error) { + if (error instanceof BiometricAuthenticationCancelledError) { + toast.show('Biometric authentication cancelled', { + customData: { preset: 'danger' }, + }) + return + } + + // What if not supported?!? + if (error instanceof BiometricAuthenticationNotEnabledError) { + toast.show('Biometric authentication not enabled', { + customData: { preset: 'danger' }, + }) + pushToWallet() + return + } + + toast.show('Something went wrong', { customData: { preset: 'danger' } }) + pushToWallet() + } + } + + return ( + , + }, + { + step: 'id-card-pin', + progress: 30, + screen: ( + + ), + }, + { + step: 'id-card-requested-attributes', + progress: 40, + backIsCancel: true, + screen: ( + + ), + }, + { + step: 'id-card-pin', + progress: 50, + backIsCancel: true, + screen: ( + + ), + }, + { + step: 'id-card-start-scan', + progress: 60, + backIsCancel: true, + screen: ( + { + receivePidUseCase?.cancelIdCardScanning() + setIsScanning(false) + }} + showScanModal={!isScanning ? false : idCardScanningState.showScanModal ?? true} + onStartScanning={!isScanning ? onStartScanning : undefined} + /> + ), + }, + { + step: 'id-card-fetch', + progress: 80, + backIsCancel: true, + screen: ( + pushToWallet('replace')} + /> + ), + }, + ]} + confirmation={{ + title: 'Stop ID Setup?', + description: 'If you stop, you can do the setup later.', + }} + onCancel={pushToWallet} + /> + ) +} diff --git a/apps/easypid/src/features/pid/PidCardScanSlide.tsx b/apps/easypid/src/features/pid/PidCardScanSlide.tsx new file mode 100644 index 00000000..f9018bad --- /dev/null +++ b/apps/easypid/src/features/pid/PidCardScanSlide.tsx @@ -0,0 +1,35 @@ +import { Heading, Paragraph, YStack } from '@package/ui' +import { useWizard } from 'packages/app/src' +import { useState } from 'react' +import { OnboardingIdCardScan, type OnboardingIdCardScanProps } from '../onboarding/screens/id-card-scan' + +interface PidCardScanSlideProps extends OnboardingIdCardScanProps { + title: string + subtitle?: string +} + +export function PidCardScanSlide({ title, subtitle, onStartScanning, ...props }: PidCardScanSlideProps) { + const { onNext } = useWizard() + const [isLoading, setIsLoading] = useState(false) + + const onStartScan = async () => { + if (isLoading) return + setIsLoading(true) + + await onStartScanning?.().then(() => { + onNext() + }) + + setIsLoading(false) + } + + return ( + + + {title} + {subtitle && {subtitle}} + + + + ) +} diff --git a/apps/easypid/src/features/pid/PidEidCardFetchSlide.tsx b/apps/easypid/src/features/pid/PidEidCardFetchSlide.tsx new file mode 100644 index 00000000..1a6be849 --- /dev/null +++ b/apps/easypid/src/features/pid/PidEidCardFetchSlide.tsx @@ -0,0 +1,30 @@ +import { Heading, Paragraph, YStack } from '@package/ui' +import { useEffect } from 'react' +import { OnboardingIdCardFetch } from '../onboarding/screens/id-card-fetch' + +interface PidIdCardFetchSlideProps { + title: string + subtitle?: string + userName?: string + onFetch: () => void + onComplete: () => void +} + +export function PidIdCardFetchSlide({ title, subtitle, userName, onFetch, onComplete }: PidIdCardFetchSlideProps) { + // biome-ignore lint/correctness/useExhaustiveDependencies: We fetch when this slide is mounted + useEffect(() => { + // We can't navigate to the next step from the SlideWizard, so we start the fetching of the credential + // when this slide is mounted. + void onFetch() + }, []) + + return ( + + + {title} + {subtitle && {subtitle}} + + + + ) +} diff --git a/apps/easypid/src/features/pid/PidEidCardPinSlide.tsx b/apps/easypid/src/features/pid/PidEidCardPinSlide.tsx new file mode 100644 index 00000000..16d81372 --- /dev/null +++ b/apps/easypid/src/features/pid/PidEidCardPinSlide.tsx @@ -0,0 +1,34 @@ +import { Heading, Paragraph, YStack } from '@package/ui' +import { useWizard } from 'packages/app/src' +import { useState } from 'react' +import { OnboardingIdCardPinEnter } from '../onboarding/screens/id-card-pin' + +interface PidEidCardPinSlideProps { + title: string + subtitle?: string + onEnterPin: (pin: string) => void +} + +export function PidEidCardPinSlide({ title, subtitle, onEnterPin }: PidEidCardPinSlideProps) { + const { onNext } = useWizard() + const [isLoading, setIsLoading] = useState(false) + + const onSubmitPin = async (pin: string) => { + if (isLoading) return + setIsLoading(true) + + onEnterPin(pin) + onNext() + setIsLoading(false) + } + + return ( + + + {title} + {subtitle && {subtitle}} + + + + ) +} diff --git a/apps/easypid/src/features/pid/PidReviewRequestSlide.tsx b/apps/easypid/src/features/pid/PidReviewRequestSlide.tsx new file mode 100644 index 00000000..9815dfdb --- /dev/null +++ b/apps/easypid/src/features/pid/PidReviewRequestSlide.tsx @@ -0,0 +1,24 @@ +import { Heading, YStack } from '@package/ui' +import { useWizard } from 'packages/app/src' +import { OnboardingIdCardRequestedAttributes } from '../onboarding/screens/id-card-requested-attributes' + +interface PidReviewRequestSlideProps { + title: string + requestedAttributes: string[] +} + +export function PidReviewRequestSlide({ title, requestedAttributes }: PidReviewRequestSlideProps) { + const { onNext } = useWizard() + + return ( + + + {title} + + onNext()} + requestedAttributes={requestedAttributes} + /> + + ) +} diff --git a/apps/easypid/src/features/pid/PidSetupStartSlide.tsx b/apps/easypid/src/features/pid/PidSetupStartSlide.tsx new file mode 100644 index 00000000..a594db9e --- /dev/null +++ b/apps/easypid/src/features/pid/PidSetupStartSlide.tsx @@ -0,0 +1,32 @@ +import { Heading, Paragraph, YStack } from '@package/ui' +import { useWizard } from 'packages/app/src' +import { OnboardingIdCardStart } from '../onboarding/screens/id-card-start' + +interface PidSetupStartSlideProps { + title: string + subtitle?: string + caption?: string +} + +export function PidSetupStartSlide({ title, subtitle, caption }: PidSetupStartSlideProps) { + const { onNext } = useWizard() + + return ( + + + + {title} + {subtitle && {subtitle}} + {caption && ( + + Remember: {caption} + + )} + + + + onNext()} /> + + + ) +} diff --git a/apps/easypid/src/features/pid/PidWalletPinSlide.tsx b/apps/easypid/src/features/pid/PidWalletPinSlide.tsx new file mode 100644 index 00000000..c7a14fb5 --- /dev/null +++ b/apps/easypid/src/features/pid/PidWalletPinSlide.tsx @@ -0,0 +1,46 @@ +import { Heading, Paragraph, YStack } from '@package/ui' +import { PinDotsInput, type PinDotsInputRef, useWizard } from 'packages/app/src' +import { useRef, useState } from 'react' + +interface PidWalletPinSlideProps { + title: string + subtitle?: string + onEnterPin: (pin: string) => Promise +} + +export function PidWalletPinSlide({ title, subtitle, onEnterPin }: PidWalletPinSlideProps) { + const { onNext } = useWizard() + const [isLoading, setIsLoading] = useState(false) + const ref = useRef(null) + + const onSubmitPin = async (pin: string) => { + if (isLoading) return + setIsLoading(true) + + await onEnterPin(pin).then(() => { + onNext() + }) + + setIsLoading(false) + } + + return ( + + + + {title} + {subtitle && {subtitle}} + + + + + + + ) +} diff --git a/apps/easypid/src/features/wallet/FunkeWalletScreen.tsx b/apps/easypid/src/features/wallet/FunkeWalletScreen.tsx index ea3c0741..a545fc6c 100644 --- a/apps/easypid/src/features/wallet/FunkeWalletScreen.tsx +++ b/apps/easypid/src/features/wallet/FunkeWalletScreen.tsx @@ -5,7 +5,6 @@ import { Heading, HeroIcons, IconContainer, - Loader, Paragraph, ScrollView, Spacer, @@ -17,22 +16,20 @@ import { import { useRouter } from 'solito/router' import { useCredentialsWithCustomDisplay } from '@easypid/hooks/useCredentialsWithCustomDisplay' -import { useWalletReset } from '@easypid/hooks/useWalletReset' import { useHaptics, useNetworkCallback, useScrollViewPosition } from '@package/app/src/hooks' import { FunkeCredentialCard } from 'packages/app' -import { useState } from 'react' -import { FadeInDown, ZoomIn } from 'react-native-reanimated' +import { FadeIn, FadeInDown, ZoomIn } from 'react-native-reanimated' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { LatestActivityCard } from './components/LatestActivityCard' export function FunkeWalletScreen() { const { push } = useRouter() const { isLoading, credentials } = useCredentialsWithCustomDisplay() - const onResetWallet = useWalletReset() const { withHaptics } = useHaptics() const pushToMenu = withHaptics(() => push('/menu')) const pushToScanner = withHaptics(() => push('/scan')) + const pushToPidSetup = withHaptics(() => push('/pidSetup')) const pushToCards = withHaptics(() => push('/credentials')) const { @@ -43,7 +40,6 @@ export function FunkeWalletScreen() { const { handleScroll, isScrolledByOffset, scrollEventThrottle } = useScrollViewPosition() const { bottom } = useSafeAreaInsets() - const [scrollViewHeight, setScrollViewHeight] = useState(0) return ( @@ -66,13 +62,10 @@ export function FunkeWalletScreen() { onScroll={handleScroll} scrollEventThrottle={scrollEventThrottle} px="$4" - onLayout={(e) => { - setScrollViewHeight(e.nativeEvent.layout.height) - }} contentContainerStyle={{ - minHeight: credentials.length <= 1 ? scrollViewHeight : '100%', justifyContent: 'space-between', paddingBottom: bottom, + flexGrow: 1, }} > @@ -103,14 +96,16 @@ export function FunkeWalletScreen() { Scan QR-Code - {credentials.length === 0 && !isLoading ? ( + {isLoading ? ( + + ) : credentials.length === 0 && !isLoading ? ( @@ -127,26 +122,21 @@ export function FunkeWalletScreen() { br="$12" bg="$grey-100" color="$grey-900" - onPress={onResetWallet} + onPress={pushToPidSetup} scaleOnPress > Setup ID - ) : isLoading ? ( - - - - - ) : ( + ) : credentials.length !== 0 && !isLoading ? ( Recently used - + {credentials.slice(0, 2).map((credential) => ( push(`/credentials/${credential.id}`))} /> ))} - + {credentials.length > 2 && ( + ) : ( + )} - + push('/menu/about')} variant="sub" diff --git a/apps/easypid/src/utils/sharedPidSetup.ts b/apps/easypid/src/utils/sharedPidSetup.ts new file mode 100644 index 00000000..eed9d42f --- /dev/null +++ b/apps/easypid/src/utils/sharedPidSetup.ts @@ -0,0 +1,157 @@ +import { OnboardingIdCardBiometricsDisabled } from '@easypid/features/onboarding/screens/id-card-biometrics-disabled' +import { OnboardingIdCardFetch } from '@easypid/features/onboarding/screens/id-card-fetch' +import { OnboardingIdCardPinEnter } from '@easypid/features/onboarding/screens/id-card-pin' +import { OnboardingIdCardRequestedAttributes } from '@easypid/features/onboarding/screens/id-card-requested-attributes' +import { OnboardingIdCardScan } from '@easypid/features/onboarding/screens/id-card-scan' +import { OnboardingIdCardStart } from '@easypid/features/onboarding/screens/id-card-start' +import { OnboardingIdCardVerify } from '@easypid/features/onboarding/screens/id-card-verify' + +export const SIMULATOR_PIN = '276536' + +export type PidFlowTypes = 'c' | 'bprime' + +export interface CardScanningState { + showScanModal: boolean + isCardAttached?: boolean + progress: number + state: 'readyToScan' | 'scanning' | 'complete' | 'error' +} + +export const pidSetupSteps = [ + { + step: 'id-card-start', + alternativeFlow: false, + progress: 49.5, + page: { + type: 'content', + title: 'Scan your eID card to retrieve your data', + subtitle: 'Add your personal details once using your eID card and its PIN.', + caption: 'Your eID PIN was issued to you when you received your eID card.', + }, + Screen: OnboardingIdCardStart, + }, + { + step: 'id-card-requested-attributes', + alternativeFlow: false, + progress: 49.5, + page: { + type: 'content', + title: 'Review the request', + }, + Screen: OnboardingIdCardRequestedAttributes, + }, + { + step: 'id-card-pin', + alternativeFlow: false, + progress: 49.5, + page: { + type: 'content', + title: 'Enter your eID card PIN', + }, + Screen: OnboardingIdCardPinEnter, + }, + { + step: 'id-card-start-scan', + alternativeFlow: false, + progress: 66, + page: { + type: 'content', + title: 'Scan your eID card', + subtitle: 'Place your device on top of your eID card to scan it.', + animationKey: 'id-card-scan', + }, + Screen: OnboardingIdCardScan, + }, + { + step: 'id-card-scan', + alternativeFlow: false, + progress: 66, + page: { + type: 'content', + title: 'Scan your eID card', + subtitle: 'Place your device on top of your eID card to scan it.', + animationKey: 'id-card-scan', + }, + Screen: OnboardingIdCardScan, + }, + { + step: 'id-card-fetch', + alternativeFlow: false, + progress: 82.5, + page: { + type: 'content', + title: 'Fetching information', + }, + Screen: OnboardingIdCardFetch, + }, + { + step: 'id-card-verify', + progress: 82.5, + alternativeFlow: true, + page: { + type: 'content', + title: 'We need to verify it’s you', + subtitle: 'Your biometrics are required to verify your identity.', + animationKey: 'id-card', + }, + Screen: OnboardingIdCardVerify, + }, + { + step: 'id-card-biometrics-disabled', + progress: 82.5, + alternativeFlow: true, + page: { + type: 'content', + title: 'You need to enable biometrics', + subtitle: + 'To continue, make sure your device has biometric protection enabled, and that EasyPID is allowed to use biometrics.', + }, + Screen: OnboardingIdCardBiometricsDisabled, + }, + { + step: 'id-card-complete', + progress: 100, + alternativeFlow: false, + page: { + type: 'content', + title: 'Success!', + subtitle: 'Your information has been retrieved from your eID card.', + animationKey: 'id-card-success', + }, + Screen: OnboardingIdCardFetch, + }, +] as const satisfies Array + +export type OnboardingStep = { + step: string + progress: number + page: OnboardingPage + // if true will not be navigated to by goToNextStep + alternativeFlow: boolean + // biome-ignore lint/suspicious/noExplicitAny: + Screen: React.FunctionComponent +} + +export type OnboardingPage = + | { type: 'fullscreen' } + | { + type: 'content' + title: string + animation?: 'default' | 'delayed' + subtitle?: string + caption?: string + animationKey?: string + } + +export const getPidSetupSlideContent = (stepId: string) => { + const step = pidSetupSteps.find((s) => s.step === stepId) + if (!step || step.page.type !== 'content') { + return { title: '', subtitle: undefined, caption: undefined } + } + + return { + title: step.page.title, + subtitle: 'subtitle' in step.page ? step.page.subtitle : undefined, + caption: 'caption' in step.page ? step.page.caption : undefined, + } +} diff --git a/apps/easypid/tamagui.config.ts b/apps/easypid/tamagui.config.ts index 43fed0fd..5beb6fd7 100644 --- a/apps/easypid/tamagui.config.ts +++ b/apps/easypid/tamagui.config.ts @@ -65,6 +65,7 @@ const config = createTamagui({ ...tokens.color, tableBackgroundColor: tokens.color['grey-50'], tableBorderColor: '#ffffff', + idCardBackground: '#F1F2F0', }, }, }) diff --git a/packages/app/src/components/CardWithAttributes.tsx b/packages/app/src/components/CardWithAttributes.tsx index d20933cc..92236fca 100644 --- a/packages/app/src/components/CardWithAttributes.tsx +++ b/packages/app/src/components/CardWithAttributes.tsx @@ -132,15 +132,17 @@ export function CardWithAttributes({ )} - {(isRevoked || isExpired || isNotYetActive) && ( - - - + <> + + + + + )} ) diff --git a/packages/app/src/components/SlideWizard.tsx b/packages/app/src/components/SlideWizard.tsx index 71a86b6a..114581a4 100644 --- a/packages/app/src/components/SlideWizard.tsx +++ b/packages/app/src/components/SlideWizard.tsx @@ -19,9 +19,13 @@ type SlideWizardProps = { steps: SlideStep[] onCancel: () => void isError?: boolean + confirmation?: { + title: string + description: string + } } -export function SlideWizard({ steps, onCancel, isError }: SlideWizardProps) { +export function SlideWizard({ steps, onCancel, isError, confirmation }: SlideWizardProps) { const { handleScroll, isScrolledByOffset, scrollEventThrottle, onContentSizeChange, onLayout } = useScrollViewPosition(0) const { bottom } = useSafeAreaInsets() @@ -33,6 +37,7 @@ export function SlideWizard({ steps, onCancel, isError }: SlideWizardProps) { const [currentStepIndex, setCurrentStepIndex] = useState(0) const [isCompleted, setIsCompleted] = useState(false) const [isSheetOpen, setIsSheetOpen] = useState(false) + const [isNavigating, setIsNavigating] = useState(false) const scrollToTop = useCallback(() => { scrollViewRef.current?.scrollTo({ y: 0, animated: false }) @@ -79,7 +84,10 @@ export function SlideWizard({ steps, onCancel, isError }: SlideWizardProps) { opacity.value = withTiming(1, { duration: fadeInDuration, easing: easeOut }) translateX.value = withSequence( withTiming(isForward ? distance : -distance, { duration: 0 }), - withTiming(0, { duration: fadeInDuration, easing: easeOut }) + withTiming(0, { duration: fadeInDuration, easing: easeOut }, () => { + // Reset navigation state + runOnJS(setIsNavigating)(false) + }) ) }, fadeOutDuration + delay) }, @@ -94,24 +102,28 @@ export function SlideWizard({ steps, onCancel, isError }: SlideWizardProps) { } const onBack = useCallback(() => { + if (isNavigating) return if (isCompleted || currentStepIndex === 0 || steps[currentStepIndex].backIsCancel) { handleCancel() } else { + setIsNavigating(true) direction.value = 'backward' animateTransition(false) void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light) } - }, [currentStepIndex, animateTransition, direction, handleCancel, steps, isCompleted]) + }, [currentStepIndex, animateTransition, direction, handleCancel, steps, isCompleted, isNavigating]) const onNext = useCallback( (slide?: string) => { + if (isNavigating) return if (currentStepIndex < steps.length - 1) { + setIsNavigating(true) direction.value = 'forward' animateTransition(true, slide) void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light) } }, - [currentStepIndex, steps.length, animateTransition, direction] + [currentStepIndex, steps.length, animateTransition, direction, isNavigating] ) const completeProgressBar = useCallback(() => { @@ -152,8 +164,8 @@ export function SlideWizard({ steps, onCancel, isError }: SlideWizardProps) { } - +