diff --git a/apps/easypid/assets/Blob.tsx b/apps/easypid/assets/Blob.tsx new file mode 100644 index 00000000..c8d28034 --- /dev/null +++ b/apps/easypid/assets/Blob.tsx @@ -0,0 +1,36 @@ +import { View } from 'react-native' +import Svg, { Path, G, type SvgProps, Defs, LinearGradient, Stop } from 'react-native-svg' + +export function Blob({ color = 'black', ...props }: SvgProps) { + return ( + + + + + + + + + + + + + + + + ) +} diff --git a/apps/easypid/src/app/(app)/(home)/offline.tsx b/apps/easypid/src/app/(app)/(home)/offline.tsx new file mode 100644 index 00000000..6bf48822 --- /dev/null +++ b/apps/easypid/src/app/(app)/(home)/offline.tsx @@ -0,0 +1,5 @@ +import { FunkeOfflineQrScreen } from '@easypid/features/wallet/FunkeOfflineQrScreen' + +export default function Screen() { + return +} diff --git a/apps/easypid/src/app/(app)/(home)/scan.tsx b/apps/easypid/src/app/(app)/(home)/scan.tsx index af9d8c19..10e3e29f 100644 --- a/apps/easypid/src/app/(app)/(home)/scan.tsx +++ b/apps/easypid/src/app/(app)/(home)/scan.tsx @@ -1,5 +1,4 @@ -import { FunkeQrScannerScreen } from '@easypid/features/scan/FunkeQrScannerScreen' -import type { CredentialDataHandlerOptions } from '@package/app' +import { type CredentialDataHandlerOptions, QrScannerScreen } from '@package/app' // When going form the scanner we want to replace (as we have the modal) export const credentialDataHandlerOptions = { @@ -7,5 +6,5 @@ export const credentialDataHandlerOptions = { } satisfies CredentialDataHandlerOptions export default function Screen() { - return + return } diff --git a/apps/easypid/src/app/(app)/_layout.tsx b/apps/easypid/src/app/(app)/_layout.tsx index ed96bc62..c147ce01 100644 --- a/apps/easypid/src/app/(app)/_layout.tsx +++ b/apps/easypid/src/app/(app)/_layout.tsx @@ -108,6 +108,12 @@ export default function AppLayout() { }} name="(home)/scan" /> + - { - return - }, - }} - /> - - - ) + return } diff --git a/apps/easypid/src/app/onboarding/index.tsx b/apps/easypid/src/app/onboarding/index.tsx index 24acad67..55a7acf0 100644 --- a/apps/easypid/src/app/onboarding/index.tsx +++ b/apps/easypid/src/app/onboarding/index.tsx @@ -1,5 +1,6 @@ import { useHasFinishedOnboarding, useOnboardingContext } from '@easypid/features/onboarding' -import { FlexPage, Heading, Paragraph, ProgressHeader, YStack } from '@package/ui' +import { useHaptics } from '@package/app' +import { AnimatedStack, FlexPage, Heading, Paragraph, ProgressHeader, YStack } from '@package/ui' import type React from 'react' import { useEffect, useRef } from 'react' import { AccessibilityInfo, Alert } from 'react-native' @@ -7,6 +8,7 @@ import { findNodeHandle } from 'react-native' import Animated, { FadeIn, FadeInRight, FadeOut } from 'react-native-reanimated' export default function OnboardingScreens() { + const { withHaptics } = useHaptics() const [hasFinishedOnboarding] = useHasFinishedOnboarding() const onboardingContext = useOnboardingContext() const headerRef = useRef(null) @@ -21,7 +23,7 @@ export default function OnboardingScreens() { } }, [onboardingContext.currentStep]) - const onReset = () => { + const onReset = withHaptics(() => { Alert.alert('Reset Onboarding', 'Are you sure you want to reset the onboarding process?', [ { text: 'Cancel', @@ -29,12 +31,12 @@ export default function OnboardingScreens() { }, { text: 'Yes', - onPress: () => { + onPress: withHaptics(() => { onboardingContext.reset() - }, + }), }, ]) - } + }) if (hasFinishedOnboarding) return null @@ -43,10 +45,10 @@ export default function OnboardingScreens() { page = onboardingContext.screen } else { page = ( - - + + - + )} {onboardingContext.page.subtitle && {onboardingContext.page.subtitle}} - {onboardingContext.page.caption && ( - - Remember: {onboardingContext.page.caption} - - )} {onboardingContext.screen} diff --git a/apps/easypid/src/features/activity/FunkeActivityDetailScreen.tsx b/apps/easypid/src/features/activity/FunkeActivityDetailScreen.tsx index 2364428e..ca3fa18c 100644 --- a/apps/easypid/src/features/activity/FunkeActivityDetailScreen.tsx +++ b/apps/easypid/src/features/activity/FunkeActivityDetailScreen.tsx @@ -61,6 +61,7 @@ export function FunkeActivityDetailScreen() { 'No information was provided on the purpose of the data request. Be cautious' } logo={activity.entity.logo} + overAskingResponse={{ validRequest: 'could_not_determine', reason: '' }} /> diff --git a/apps/easypid/src/features/activity/FunkeActivityScreen.tsx b/apps/easypid/src/features/activity/FunkeActivityScreen.tsx index 3bf6be35..2780f206 100644 --- a/apps/easypid/src/features/activity/FunkeActivityScreen.tsx +++ b/apps/easypid/src/features/activity/FunkeActivityScreen.tsx @@ -29,8 +29,8 @@ export function FunkeActivityScreen({ entityId }: { entityId?: string }) { return ( - - + + Activity diff --git a/apps/easypid/src/features/menu/FunkeAboutScreen.tsx b/apps/easypid/src/features/menu/FunkeAboutScreen.tsx index 456df373..4ab8514e 100644 --- a/apps/easypid/src/features/menu/FunkeAboutScreen.tsx +++ b/apps/easypid/src/features/menu/FunkeAboutScreen.tsx @@ -18,8 +18,8 @@ export function FunkeAboutScreen() { return ( - - + + About the wallet diff --git a/apps/easypid/src/features/menu/FunkeFeedbackScreen.tsx b/apps/easypid/src/features/menu/FunkeFeedbackScreen.tsx index c03ba60f..737c9377 100644 --- a/apps/easypid/src/features/menu/FunkeFeedbackScreen.tsx +++ b/apps/easypid/src/features/menu/FunkeFeedbackScreen.tsx @@ -11,8 +11,8 @@ export function FunkeFeedbackScreen() { return ( - - + + Feedback diff --git a/apps/easypid/src/features/menu/FunkeMenuScreen.tsx b/apps/easypid/src/features/menu/FunkeMenuScreen.tsx index 4dcbfcaf..470e53f4 100644 --- a/apps/easypid/src/features/menu/FunkeMenuScreen.tsx +++ b/apps/easypid/src/features/menu/FunkeMenuScreen.tsx @@ -1,6 +1,6 @@ import React from 'react' -import { useScrollViewPosition } from '@package/app/src/hooks' +import { useHaptics, useScrollViewPosition } from '@package/app/src/hooks' import { Button, FlexPage, Heading, HeroIcons, ScrollView, Stack, XStack, YStack, useScaleAnimation } from '@package/ui' import { usePidCredential } from '@easypid/hooks' @@ -61,7 +61,6 @@ export function FunkeMenuScreen() { icon: HeroIcons.IdentificationFilled, title: 'Setup digital ID', }} - onPress={onResetWallet} idx={0} /> ) @@ -69,11 +68,9 @@ export function FunkeMenuScreen() { return ( - - - - Menu - + + + Menu void }) => { const { pressStyle, handlePressIn, handlePressOut } = useScaleAnimation() + const { withHaptics } = useHaptics() const content = ( Linking.openURL('mailto:ana@animo.id?subject=Feedback on the Funke EUDI Wallet')} + onPress={withHaptics(() => Linking.openURL('mailto:ana@animo.id?subject=Feedback on the Funke EUDI Wallet'))} asChild > {content} @@ -158,14 +156,20 @@ const MenuItem = ({ item, idx, onPress }: { item: (typeof menuItems)[number]; id if (item.href === '/') { return ( - + onPress)}> {content} ) } return ( - + undefined)} + onPressIn={handlePressIn} + onPressOut={handlePressOut} + href={item.href} + asChild + > {content} ) diff --git a/apps/easypid/src/features/menu/FunkeSettingsScreen.tsx b/apps/easypid/src/features/menu/FunkeSettingsScreen.tsx index ee8210dc..e2c86faa 100644 --- a/apps/easypid/src/features/menu/FunkeSettingsScreen.tsx +++ b/apps/easypid/src/features/menu/FunkeSettingsScreen.tsx @@ -14,8 +14,8 @@ export function FunkeSettingsScreen() { return ( - - + + Settings diff --git a/apps/easypid/src/features/onboarding/onboardingContext.tsx b/apps/easypid/src/features/onboarding/onboardingContext.tsx index 2e41c15c..820bd686 100644 --- a/apps/easypid/src/features/onboarding/onboardingContext.tsx +++ b/apps/easypid/src/features/onboarding/onboardingContext.tsx @@ -23,6 +23,7 @@ import { getCredentialForDisplay, getCredentialForDisplayId, } from '@package/agent' +import { useHaptics } from '@package/app' import { secureWalletKey } from '@package/secure-store/secureUnlock' import { useToastController } from '@package/ui' import { capitalizeFirstLetter, getHostNameFromUrl, sleep } from '@package/utils' @@ -142,6 +143,7 @@ export function OnboardingContextProvider({ }: PropsWithChildren<{ initialStep?: OnboardingStep['step'] }>) { + const { successHaptic, lightHaptic } = useHaptics() const toast = useToastController() const secureUnlock = useSecureUnlock() const [currentStepName, setCurrentStepName] = useState(initialStep ?? 'welcome') @@ -168,6 +170,12 @@ export function OnboardingContextProvider({ const currentStep = onboardingSteps.find((step) => step.step === currentStepName) if (!currentStep) throw new Error(`Invalid step ${currentStepName}`) + useEffect(() => { + if (currentStepName && currentStepName !== 'welcome' && currentStepName !== 'pin-reenter') { + lightHaptic() + } + }, [lightHaptic, currentStepName]) + const goToNextStep = useCallback(() => { const currentStepIndex = onboardingSteps.findIndex((step) => step.step === currentStepName) // goToNextStep excludes alternative flows @@ -198,8 +206,9 @@ export function OnboardingContextProvider({ // Wait 500ms before navigating to home setTimeout(() => { router.replace('/') + successHaptic() }, 500) - }, [router, setHasFinishedOnboarding, receivePidUseCase]) + }, [router, setHasFinishedOnboarding, receivePidUseCase, successHaptic]) const onPinEnter = async (pin: string) => { setWalletPin(pin) diff --git a/apps/easypid/src/features/onboarding/screens/id-card-pin.tsx b/apps/easypid/src/features/onboarding/screens/id-card-pin.tsx index e11bfd06..a6db4341 100644 --- a/apps/easypid/src/features/onboarding/screens/id-card-pin.tsx +++ b/apps/easypid/src/features/onboarding/screens/id-card-pin.tsx @@ -66,7 +66,8 @@ export const OnboardingIdCardPinEnter = forwardRef(({ goToNextStep }: Onboarding return ( - + {/* Overflow issue only present on smaller devices, so set to max height */} + (null) + // Make the pin pad fixed to the bottom of the screen on smaller devices + const { bottom } = useSafeAreaInsets() + const shouldStickToBottom = bottom < 16 + const onSubmitPin = async (pin: string) => { if (isLoading) return setIsLoading(true) @@ -30,7 +35,7 @@ export function PidWalletPinSlide({ title, subtitle, onEnterPin }: PidWalletPinS } return ( - + {title} diff --git a/apps/easypid/src/features/proximity/mdocProximity.ts b/apps/easypid/src/features/proximity/mdocProximity.ts index a0280542..f6713f43 100644 --- a/apps/easypid/src/features/proximity/mdocProximity.ts +++ b/apps/easypid/src/features/proximity/mdocProximity.ts @@ -25,13 +25,19 @@ type ShareDeviceResponseOptions = { submission: FormattedSubmission } -const PERMISSIONS = [ - 'android.permission.ACCESS_FINE_LOCATION', - 'android.permission.BLUETOOTH_CONNECT', - 'android.permission.BLUETOOTH_SCAN', - 'android.permission.BLUETOOTH_ADVERTISE', - 'android.permission.ACCESS_COARSE_LOCATION', -] as const as Permission[] +// Determine if device is running Android 12 or higher +const isAndroid12OrHigher = Platform.OS === 'android' && Platform.Version >= 31 + +// Older devices require different permissions for BLE transfers +const PERMISSIONS = ( + isAndroid12OrHigher + ? [ + 'android.permission.BLUETOOTH_CONNECT', + 'android.permission.BLUETOOTH_SCAN', + 'android.permission.BLUETOOTH_ADVERTISE', + ] + : ['android.permission.ACCESS_FINE_LOCATION', 'android.permission.ACCESS_COARSE_LOCATION'] +) as Permission[] export const requestMdocPermissions = async () => { if (Platform.OS !== 'android') return diff --git a/apps/easypid/src/features/scan/FunkeQrScannerScreen.tsx b/apps/easypid/src/features/scan/FunkeQrScannerScreen.tsx deleted file mode 100644 index 3c309904..00000000 --- a/apps/easypid/src/features/scan/FunkeQrScannerScreen.tsx +++ /dev/null @@ -1,282 +0,0 @@ -import { QrScanner } from '@package/scanner' -import { - AnimatedStack, - Heading, - HeroIcons, - Loader, - Page, - Paragraph, - Spinner, - Stack, - useSpringify, - useToastController, -} from '@package/ui' -import { useIsFocused } from '@react-navigation/native' -import { useRouter } from 'expo-router' -import React, { useEffect, useState } from 'react' -import QRCode from 'react-native-qrcode-svg' - -import { type CredentialDataHandlerOptions, isAndroid, useCredentialDataHandler, useHaptics } from '@package/app' -import { Alert, Linking, Platform, useWindowDimensions } from 'react-native' -import { FadeIn, FadeOut, LinearTransition, useAnimatedStyle, withTiming } from 'react-native-reanimated' -import { useSafeAreaInsets } from 'react-native-safe-area-context' - -import easypidLogo from '../../../assets/icon-rounded.png' -import { - checkMdocPermissions, - getMdocQrCode, - requestMdocPermissions, - shutdownDataTransfer, - waitForDeviceRequest, -} from '../proximity' - -const unsupportedUrlPrefixes = ['_oob='] - -interface QrScannerScreenProps { - credentialDataHandlerOptions?: CredentialDataHandlerOptions -} - -export function FunkeQrScannerScreen({ credentialDataHandlerOptions }: QrScannerScreenProps) { - const { back } = useRouter() - const { handleCredentialData } = useCredentialDataHandler() - const { bottom, top } = useSafeAreaInsets() - const toast = useToastController() - const isFocused = useIsFocused() - - const [showMyQrCode, setShowMyQrCode] = useState(false) - const [helpText, setHelpText] = useState('') - const [isProcessing, setIsProcessing] = useState(false) - const [isLoading, setIsLoading] = useState(false) - const [qrCodeData, setQrCodeData] = useState() - const [arePermissionsGranted, setArePermissionsGranted] = useState(false) - - useEffect(() => { - void checkMdocPermissions().then((result) => { - setArePermissionsGranted(!!result) - }) - }, []) - - useEffect(() => { - if (showMyQrCode) { - void getMdocQrCode().then(setQrCodeData) - } else { - setQrCodeData(undefined) - } - }, [showMyQrCode]) - - const onCancel = () => { - back() - shutdownDataTransfer() - } - - const onScan = async (scannedData: string) => { - if (isProcessing || !isFocused) return - setIsProcessing(true) - setIsLoading(true) - - const result = await handleCredentialData(scannedData, credentialDataHandlerOptions) - if (!result.success) { - const isUnsupportedUrl = - unsupportedUrlPrefixes.find((x) => scannedData.includes(x)) || result.error === 'invitation_type_not_allowed' - setHelpText( - isUnsupportedUrl - ? 'This QR-code is not supported yet. Try scanning a different one.' - : result.message - ? result.message - : 'Invalid QR code. Try scanning a different one.' - ) - setIsLoading(false) - } - - await new Promise((resolve) => setTimeout(resolve, 5000)) - setHelpText('') - setIsLoading(false) - setIsProcessing(false) - } - - const handleQrButtonPress = async () => { - if (Platform.OS !== 'android') { - toast.show('This feature is not supported on your OS yet.', { customData: { preset: 'warning' } }) - back() - return - } - - if (arePermissionsGranted) { - setShowMyQrCode(true) - } else { - const permissions = await requestMdocPermissions() - if (!permissions) { - toast.show('Failed to request permissions.', { customData: { preset: 'danger' } }) - return - } - - // Check if any permission is in 'never_ask_again' state - const hasNeverAskAgain = Object.values(permissions).some((status) => status === 'never_ask_again') - - if (hasNeverAskAgain) { - Alert.alert( - 'Please enable required permissions in your phone settings', - 'Sharing with QR-Code needs access to Bluetooth and Location.', - [ - { - text: 'Open Settings', - onPress: () => Linking.openSettings(), - }, - ] - ) - return - } - } - } - - const animatedQrOverlayOpacity = useAnimatedStyle( - () => ({ - opacity: withTiming(showMyQrCode ? 1 : 0, { duration: showMyQrCode ? 300 : 200 }), - }), - [showMyQrCode] - ) - - return ( - <> - {!showMyQrCode && ( - { - void onScan(data) - }} - helpText={helpText} - /> - )} - - - - - - - - {showMyQrCode && } - - - {isLoading && ( - - - - Loading invitation - - - )} - - - - - {showMyQrCode ? 'Scan QR code' : 'Show my QR code'} - - {showMyQrCode ? ( - - ) : ( - - )} - - - - - ) -} - -function FunkeQrOverlay({ qrCodeData }: { qrCodeData?: string }) { - const { width } = useWindowDimensions() - const { bottom, top } = useSafeAreaInsets() - const { withHaptics } = useHaptics() - const { replace } = useRouter() - - useEffect(() => { - if (qrCodeData) { - void waitForDeviceRequest().then((data) => { - if (data) { - pushToOfflinePresentation({ - sessionTranscript: Buffer.from(data.sessionTranscript).toString('base64'), - deviceRequest: Buffer.from(data.deviceRequest).toString('base64'), - }) - return - } - }) - } - }, [qrCodeData]) - - // Navigate to offline presentation route - const pushToOfflinePresentation = withHaptics((data: { sessionTranscript: string; deviceRequest: string }) => - replace({ - pathname: '/notifications/offlinePresentation', - params: data, - }) - ) - - return ( - - - - Share with QR code - - A verifier needs to scan your QR-Code. - - - {qrCodeData ? ( - - - - ) : ( - - )} - - - - ) -} diff --git a/apps/easypid/src/features/share/FunkeRequestedAttributesDetailScreen.tsx b/apps/easypid/src/features/share/FunkeRequestedAttributesDetailScreen.tsx index 7d3d215e..d8933a7b 100644 --- a/apps/easypid/src/features/share/FunkeRequestedAttributesDetailScreen.tsx +++ b/apps/easypid/src/features/share/FunkeRequestedAttributesDetailScreen.tsx @@ -19,7 +19,12 @@ import React, { useEffect, useRef, useState } from 'react' import { useRouter } from 'solito/router' import { CredentialAttributes, TextBackButton } from '@package/app/src/components' -import { useHaptics, useHasInternetConnection, useScrollViewPosition } from '@package/app/src/hooks' +import { + useHaptics, + useHasInternetConnection, + useHeaderRightAction, + useScrollViewPosition, +} from '@package/app/src/hooks' import { type CredentialForDisplayId, metadataForDisplay, useCredentialForDisplayById } from '@package/agent' import { useNavigation } from 'expo-router' @@ -45,16 +50,14 @@ export function FunkeRequestedAttributesDetailScreen({ const router = useRouter() const [scrollViewHeight, setScrollViewHeight] = useState(0) const { withHaptics } = useHaptics() - const navigation = useNavigation() const [isSheetOpen, setIsSheetOpen] = useState(false) const scrollViewRef = useRef(null) - useEffect(() => { - navigation.setOptions({ - headerRight: () => } onPress={() => setIsSheetOpen(true)} />, - }) - }, [navigation]) + useHeaderRightAction({ + icon: , + onPress: withHaptics(() => setIsSheetOpen(true)), + }) const { isVisible: isMetadataVisible, diff --git a/apps/easypid/src/features/wallet/FunkeCredentialDetailAttributesScreen.tsx b/apps/easypid/src/features/wallet/FunkeCredentialDetailAttributesScreen.tsx index 151ca914..3aeb13cb 100644 --- a/apps/easypid/src/features/wallet/FunkeCredentialDetailAttributesScreen.tsx +++ b/apps/easypid/src/features/wallet/FunkeCredentialDetailAttributesScreen.tsx @@ -81,7 +81,7 @@ export function FunkeCredentialDetailAttributesScreen({ borderColor={isScrolledByOffset ? '$grey-200' : '$background'} /> - + Card attributes - - + + Cards @@ -161,7 +161,7 @@ function FunkeCredentialRowCard({ name, backgroundColor, textColor, logo, onPres Issued on {formatDate(new Date(), { includeTime: false })} - } /> + } /> ) } diff --git a/apps/easypid/src/features/wallet/FunkeFederationDetailScreen.tsx b/apps/easypid/src/features/wallet/FunkeFederationDetailScreen.tsx index b8b3723c..89d1c592 100644 --- a/apps/easypid/src/features/wallet/FunkeFederationDetailScreen.tsx +++ b/apps/easypid/src/features/wallet/FunkeFederationDetailScreen.tsx @@ -44,7 +44,7 @@ export function FunkeFederationDetailScreen({ borderColor={isScrolledByOffset ? '$grey-200' : '$background'} /> - + About this party () + const [arePermissionsGranted, setArePermissionsGranted] = useState(false) + const [arePermissionsRequested, setArePermissionsRequested] = useMMKVBoolean('arePermissionsRequested', mmkv) + + useEffect(() => { + void checkMdocPermissions().then((result) => { + setArePermissionsGranted(!!result) + if (!result) { + void requestPermissions() + } + }) + }, []) + + useEffect(() => { + if (arePermissionsGranted) { + void getMdocQrCode().then(setQrCodeData) + } else { + setQrCodeData(undefined) + } + }, [arePermissionsGranted]) + + const handlePermissions = async () => { + const permissions = await requestMdocPermissions() + + if (!permissions) { + toast.show('Failed to request permissions.', { customData: { preset: 'danger' } }) + return { granted: false, shouldShowSettings: false } + } + + // Check if any permission is in 'never_ask_again' state + const hasNeverAskAgain = Object.values(permissions).some((status) => status === 'never_ask_again') + + if (hasNeverAskAgain) { + return { granted: false, shouldShowSettings: true } + } + + const permissionStatus = await checkMdocPermissions() + return { granted: !!permissionStatus, shouldShowSettings: false } + } + + const requestPermissions = async () => { + // First request without checking the never_ask_again state + if (!arePermissionsRequested) { + const { granted } = await handlePermissions() + setArePermissionsRequested(true) + setArePermissionsGranted(granted) + return + } + + // Subsequent requests need to check for the never_ask_again state + const { granted, shouldShowSettings } = await handlePermissions() + + if (shouldShowSettings) { + back() + Alert.alert( + 'Please enable required permissions in your phone settings', + 'Sharing with QR-Code needs access to Bluetooth and Location.', + [ + { + text: 'Open Settings', + onPress: () => Linking.openSettings(), + }, + ] + ) + return + } + + setArePermissionsGranted(granted) + } + + useEffect(() => { + if (qrCodeData) { + void waitForDeviceRequest().then((data) => { + if (data) { + pushToOfflinePresentation({ + sessionTranscript: Buffer.from(data.sessionTranscript).toString('base64'), + deviceRequest: Buffer.from(data.deviceRequest).toString('base64'), + }) + return + } + }) + } + }, [qrCodeData]) + + // Navigate to offline presentation route + const pushToOfflinePresentation = withHaptics((data: { sessionTranscript: string; deviceRequest: string }) => + replace({ + pathname: '/notifications/offlinePresentation', + params: data, + }) + ) + + useEffect(() => { + // Cleanup function that runs when component unmounts + return () => { + shutdownDataTransfer() + } + }, []) + + const onCancel = () => { + back() + shutdownDataTransfer() + } + + return ( + + + + Share with QR code + + A verifier needs to scan your QR-Code. + + + {qrCodeData ? ( + + + + ) : ( + + )} + + + {onCancel && ( + + + Cancel + + + )} + + + + ) +} diff --git a/apps/easypid/src/features/wallet/FunkeWalletScreen.tsx b/apps/easypid/src/features/wallet/FunkeWalletScreen.tsx index ef2c1fbe..2af05fdf 100644 --- a/apps/easypid/src/features/wallet/FunkeWalletScreen.tsx +++ b/apps/easypid/src/features/wallet/FunkeWalletScreen.tsx @@ -1,6 +1,7 @@ import { AnimatedStack, Button, + CustomIcons, FlexPage, Heading, HeroIcons, @@ -8,176 +9,103 @@ import { Paragraph, ScrollView, Spacer, - Stack, XStack, YStack, - useScaleAnimation, + useSpringify, + useToastController, } from '@package/ui' -import { useRouter } from 'solito/router' +import { useRouter } from 'expo-router' -import { useCredentialsForDisplay } from '@package/agent' -import { useHaptics, useNetworkCallback, useScrollViewPosition } from '@package/app/src/hooks' -import { FunkeCredentialCard } from 'packages/app' -import { FadeIn, FadeInDown, ZoomIn } from 'react-native-reanimated' -import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { useFirstNameFromPidCredential } from '@easypid/hooks' +import { useHaptics } from '@package/app/src/hooks' +import { Platform } from 'react-native' +import { FadeIn } from 'react-native-reanimated' +import { Blob } from '../../../assets/Blob' +import { ActionCard } from './components/ActionCard' +import { AllCardsCard } from './components/AllCardsCard' import { LatestActivityCard } from './components/LatestActivityCard' export function FunkeWalletScreen() { const { push } = useRouter() - const { isLoading, credentials } = useCredentialsForDisplay() const { withHaptics } = useHaptics() + const toast = useToastController() + + const { userName, isLoading } = useFirstNameFromPidCredential() const pushToMenu = withHaptics(() => push('/menu')) const pushToScanner = withHaptics(() => push('/scan')) const pushToPidSetup = withHaptics(() => push('/pidSetup')) - const pushToCards = withHaptics(() => push('/credentials')) - - const { - pressStyle: qrPressStyle, - handlePressIn: qrHandlePressIn, - handlePressOut: qrHandlePressOut, - } = useScaleAnimation({ scaleInValue: 0.95 }) + const pushToAbout = withHaptics(() => push('/menu/about')) + const pushToOffline = () => { + if (Platform.OS === 'ios') { + toast.show('This feature is not supported on your OS yet.', { customData: { preset: 'warning' } }) + return + } - const { handleScroll, isScrolledByOffset, scrollEventThrottle } = useScrollViewPosition() - const { bottom } = useSafeAreaInsets() + withHaptics(() => push('/offline'))() + } return ( - - {/* Header */} - - } onPress={pushToMenu} /> - + + + + - {/* Body */} - 0} - onScroll={handleScroll} - scrollEventThrottle={scrollEventThrottle} - px="$4" - contentContainerStyle={{ - justifyContent: 'space-between', - paddingBottom: bottom, - flexGrow: 1, - }} - > - - - - - - - - - Scan QR-Code - - - {isLoading ? ( - - ) : credentials.length === 0 && !isLoading ? ( - - - - There's nothing here, yet - - Setup your ID or use the QR scanner to receive credentials. - - - + + } onPress={pushToMenu} /> + + + + + + + - Setup ID - - - - ) : credentials.length !== 0 && !isLoading ? ( - - - - - Recently used + {userName ? `Hello, ${userName}!` : 'Hello!'} - - {credentials.slice(0, 2).map((credential) => ( - push(`/credentials/${credential.id}`))} - /> - ))} - - {credentials.length > 2 && ( - - See all cards - - + Receive or share from your wallet + + + } + title="Scan QR-code" + onPress={pushToScanner} + /> + } + title="Present In-person" + onPress={pushToOffline} + /> + + + {userName ? ( + + How does it work? + + ) : ( + + Setup your ID + )} + + + + + + - - ) : ( - - )} - - - - + + + + + + ) } diff --git a/apps/easypid/src/features/wallet/components/ActionCard.tsx b/apps/easypid/src/features/wallet/components/ActionCard.tsx new file mode 100644 index 00000000..5439edfb --- /dev/null +++ b/apps/easypid/src/features/wallet/components/ActionCard.tsx @@ -0,0 +1,44 @@ +import { AnimatedStack, Heading, Stack, XStack, YStack, useScaleAnimation } from '@package/ui' +import type { ReactNode } from 'react' + +interface ActionCardProps { + variant?: 'primary' | 'secondary' + icon: ReactNode + title: string + onPress: () => void +} + +export function ActionCard({ icon, title, onPress, variant = 'primary' }: ActionCardProps) { + const { + pressStyle: qrPressStyle, + handlePressIn: qrHandlePressIn, + handlePressOut: qrHandlePressOut, + } = useScaleAnimation({ scaleInValue: 0.95 }) + + return ( + + + + {icon} + + + {title.split(' ').map((word) => ( + + {word} + + ))} + + + ) +} diff --git a/apps/easypid/src/features/wallet/components/AllCardsCard.tsx b/apps/easypid/src/features/wallet/components/AllCardsCard.tsx new file mode 100644 index 00000000..b49fa6c1 --- /dev/null +++ b/apps/easypid/src/features/wallet/components/AllCardsCard.tsx @@ -0,0 +1,14 @@ +import { useRouter } from 'expo-router' +import { useCredentialsForDisplay } from 'packages/agent/src' +import { useHaptics } from 'packages/app/src' +import { InfoButton } from 'packages/ui/src' + +export function AllCardsCard() { + const { push } = useRouter() + const { withHaptics } = useHaptics() + + const { credentials } = useCredentialsForDisplay() + const pushToCards = withHaptics(() => push('/credentials')) + + return +} diff --git a/apps/easypid/src/features/wallet/components/LatestActivityCard.tsx b/apps/easypid/src/features/wallet/components/LatestActivityCard.tsx index 1aa92b33..e2829188 100644 --- a/apps/easypid/src/features/wallet/components/LatestActivityCard.tsx +++ b/apps/easypid/src/features/wallet/components/LatestActivityCard.tsx @@ -16,7 +16,11 @@ export function LatestActivityCard() { const pushToActivity = withHaptics(() => push('/activity')) const content = useMemo(() => { - if (!latestActivity) return null + if (!latestActivity) + return { + title: 'Recent activity', + description: 'No activity yet', + } if (latestActivity.type === 'shared') { const isPlural = latestActivity.request.credentials.length > 1 return { @@ -28,7 +32,7 @@ export function LatestActivityCard() { const credential = credentials.find((c) => c.id === latestActivity.credentialIds[0]) return { title: formatRelativeDate(new Date(latestActivity.date)), - description: `Added ${credential?.display.name ?? '1 card'}`, + description: `Added ${credential?.display.name}`, } } return null diff --git a/apps/easypid/src/hooks/usePidCredential.tsx b/apps/easypid/src/hooks/usePidCredential.tsx index 82ba095f..1cd99ffd 100644 --- a/apps/easypid/src/hooks/usePidCredential.tsx +++ b/apps/easypid/src/hooks/usePidCredential.tsx @@ -1,6 +1,6 @@ import { ClaimFormat, MdocRecord, SdJwtVcRecord } from '@credo-ts/core' import { type CredentialForDisplay, type CredentialMetadata, useCredentialsForDisplay } from '@package/agent' -import { sanitizeString } from '@package/utils' +import { capitalizeFirstLetter, sanitizeString } from '@package/utils' type Attributes = { given_name: string @@ -384,3 +384,19 @@ export function usePidCredential() { ), } as const } + +export function useFirstNameFromPidCredential() { + const { credential, isLoading } = usePidCredential() + + if (!credential?.attributes || typeof credential.attributes.given_name !== 'string') { + return { + userName: '', + isLoading, + } + } + + return { + userName: capitalizeFirstLetter(credential.attributes.given_name.toLowerCase()), + isLoading, + } +} diff --git a/apps/easypid/src/hooks/useWalletReset.tsx b/apps/easypid/src/hooks/useWalletReset.tsx index 86ded6b4..50e4c574 100644 --- a/apps/easypid/src/hooks/useWalletReset.tsx +++ b/apps/easypid/src/hooks/useWalletReset.tsx @@ -1,5 +1,7 @@ import { useSecureUnlock } from '@easypid/agent' import { resetWallet } from '@easypid/utils/resetWallet' +import { useHaptics } from '@package/app/src/hooks' + import { useRouter } from 'expo-router' import { useCallback } from 'react' import { Alert } from 'react-native' @@ -7,21 +9,24 @@ import { Alert } from 'react-native' export const useWalletReset = () => { const secureUnlock = useSecureUnlock() const router = useRouter() + const { withHaptics } = useHaptics() - const onResetWallet = useCallback(() => { - Alert.alert('Reset Wallet', 'Are you sure you want to reset the wallet?', [ - { - text: 'Cancel', - style: 'cancel', - }, - { - text: 'Yes', - onPress: () => { - resetWallet(secureUnlock).then(() => router.replace('onboarding')) + const onResetWallet = withHaptics( + useCallback(() => { + Alert.alert('Reset Wallet', 'Are you sure you want to reset the wallet?', [ + { + text: 'Cancel', + style: 'cancel', + }, + { + text: 'Yes', + onPress: withHaptics(() => { + resetWallet(secureUnlock).then(() => router.replace('onboarding')) + }), }, - }, - ]) - }, [secureUnlock, router]) + ]) + }, [secureUnlock, router, withHaptics]) + ) return onResetWallet } diff --git a/apps/easypid/src/utils/sharedPidSetup.ts b/apps/easypid/src/utils/sharedPidSetup.ts index f0ebda14..5c187eab 100644 --- a/apps/easypid/src/utils/sharedPidSetup.ts +++ b/apps/easypid/src/utils/sharedPidSetup.ts @@ -4,7 +4,6 @@ import { OnboardingIdCardFetch } from '@easypid/features/onboarding/screens/id-c 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' @@ -140,7 +139,6 @@ export type OnboardingPage = title: string animation?: 'default' | 'delayed' subtitle?: string - caption?: string animationKey?: string } diff --git a/apps/easypid/tsconfig.json b/apps/easypid/tsconfig.json index c23a76b7..62c6515f 100644 --- a/apps/easypid/tsconfig.json +++ b/apps/easypid/tsconfig.json @@ -1,11 +1,5 @@ { "extends": "../../tsconfig.json", - "compilerOptions": { - "rootDir": "./", - "paths": { - "@easypid/*": ["./apps/easypid/src/*"] - } - }, "include": [ "./src/**/*.ts", "./src/**/*.tsx", diff --git a/packages/app/src/components/SlideWizard.tsx b/packages/app/src/components/SlideWizard.tsx index 78ec90b4..6eeaa226 100644 --- a/packages/app/src/components/SlideWizard.tsx +++ b/packages/app/src/components/SlideWizard.tsx @@ -1,11 +1,11 @@ import { AnimatedStack, FlexPage, ProgressHeader, ScrollableStack, Stack } from '@package/ui' -import * as Haptics from 'expo-haptics' import type React from 'react' import { type ForwardedRef, forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react' import { Keyboard, type ScrollView } from 'react-native' import { Easing, runOnJS, useAnimatedStyle, useSharedValue, withSequence, withTiming } from 'react-native-reanimated' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { ConfirmationSheet } from '../components/ConfirmationSheet' +import { useHaptics } from '../hooks/useHaptics' import { useScrollViewPosition } from '../hooks/useScrollViewPosition' import { WizardProvider } from './WizardContext' @@ -41,6 +41,7 @@ export const SlideWizard = forwardRef( const opacity = useSharedValue(1) const translateX = useSharedValue(0) const scrollViewRef = useRef(null) + const { withHaptics } = useHaptics() const [currentStepIndex, setCurrentStepIndex] = useState(0) const [isCompleted, setIsCompleted] = useState(false) @@ -102,39 +103,43 @@ export const SlideWizard = forwardRef( [opacity, translateX, updateStep, scrollToTop] ) - const handleCancel = useCallback(() => { - Keyboard.dismiss() - setIsSheetOpen(true) - }, []) + const handleCancel = withHaptics( + useCallback(() => { + Keyboard.dismiss() + setIsSheetOpen(true) + }, []) + ) - const onConfirmCancel = () => { + const onConfirmCancel = withHaptics(() => { setIsSheetOpen(false) onCancel() - } - - const onBack = useCallback(() => { - if (isNavigating) return - if (isCompleted || isError || 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, isNavigating, isError]) - - const onNext = useCallback( - (slide?: string) => { + }) + + const onBack = withHaptics( + useCallback(() => { if (isNavigating) return - if (currentStepIndex < steps.length - 1) { + if (isCompleted || isError || currentStepIndex === 0 || steps[currentStepIndex].backIsCancel) { + handleCancel() + } else { setIsNavigating(true) - direction.value = 'forward' - animateTransition(true, slide) - void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light) + direction.value = 'backward' + animateTransition(false) } - }, - [currentStepIndex, steps.length, animateTransition, direction, isNavigating] + }, [currentStepIndex, animateTransition, direction, handleCancel, steps, isCompleted, isNavigating, isError]) + ) + + const onNext = withHaptics( + useCallback( + (slide?: string) => { + if (isNavigating) return + if (currentStepIndex < steps.length - 1) { + setIsNavigating(true) + direction.value = 'forward' + animateTransition(true, slide) + } + }, + [currentStepIndex, steps.length, animateTransition, direction, isNavigating] + ) ) useImperativeHandle( @@ -146,10 +151,11 @@ export const SlideWizard = forwardRef( [onNext] ) - const completeProgressBar = useCallback(() => { - setIsCompleted(true) - void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success) - }, []) + const completeProgressBar = withHaptics( + useCallback(() => { + setIsCompleted(true) + }, []) + ) const contextValue = { onNext, onBack, onCancel: handleCancel, completeProgressBar } const Screen = isError && errorScreen ? errorScreen : steps[currentStepIndex].screen @@ -157,7 +163,7 @@ export const SlideWizard = forwardRef( return ( - + - {children} + + {children} + diff --git a/packages/ui/assets/People.tsx b/packages/ui/assets/People.tsx new file mode 100644 index 00000000..f6374514 --- /dev/null +++ b/packages/ui/assets/People.tsx @@ -0,0 +1,24 @@ +import Svg, { Path, type SvgProps } from 'react-native-svg' + +export const PeopleIcon = ({ width = 24, height = 24, color = 'black', ...props }: SvgProps) => { + return ( + + + + + + + ) +} diff --git a/packages/ui/assets/Qr.tsx b/packages/ui/assets/Qr.tsx new file mode 100644 index 00000000..0bc20876 --- /dev/null +++ b/packages/ui/assets/Qr.tsx @@ -0,0 +1,46 @@ +import Svg, { Path, type SvgProps } from 'react-native-svg' + +export const QrIcon = ({ width = 24, height = 24, color = 'black', ...props }: SvgProps) => { + return ( + + + + + + + + + + + ) +} diff --git a/packages/ui/src/base/Page.tsx b/packages/ui/src/base/Page.tsx index 77d4a310..ccea3671 100644 --- a/packages/ui/src/base/Page.tsx +++ b/packages/ui/src/base/Page.tsx @@ -32,7 +32,12 @@ export const FlexPage = FlexPageBase.styleable<{ safeArea?: boolean | 'x' | 'y' // Some devices have no bottom safe area, so we add a default of 16px so the content is not against the edge const bottom = safeArea === true || safeArea === 'y' || safeArea === 'b' ? Math.max(safeAreaInsets.bottom, 16) : undefined - const top = safeArea === true || safeArea === 'y' || safeArea === 't' ? safeAreaInsets.top : undefined + + // We add an extra 16px to the top margin to give the header some more space on smaller devices + const additionalTop = safeAreaInsets.top <= 24 ? 16 : 0 + + const top = + safeArea === true || safeArea === 'y' || safeArea === 't' ? safeAreaInsets.top + additionalTop : undefined const left = safeArea === true || safeArea === 'x' || safeArea === 'l' ? safeAreaInsets.left : undefined const right = safeArea === true || safeArea === 'x' || safeArea === 'r' ? safeAreaInsets.right : undefined diff --git a/packages/ui/src/components/ProgressHeader.tsx b/packages/ui/src/components/ProgressHeader.tsx index 18023c45..92bc42a4 100644 --- a/packages/ui/src/components/ProgressHeader.tsx +++ b/packages/ui/src/components/ProgressHeader.tsx @@ -55,9 +55,9 @@ export function ProgressHeader({ mx={variant === 'small' ? '$-4' : '$0'} > {variant === 'small' ? ( - + ) : ( - + )} ), Connect: wrapLocalSvg(ConnectIcon as React.ComponentType), FaceId: wrapLocalSvg(FaceIdIcon as React.ComponentType), + Qr: wrapLocalSvg(QrIcon as React.ComponentType), + People: wrapLocalSvg(PeopleIcon as React.ComponentType), } export type CustomIconProps = SvgProps & { diff --git a/packages/ui/src/content/IconContainer.tsx b/packages/ui/src/content/IconContainer.tsx index 5b06a103..8cef7786 100644 --- a/packages/ui/src/content/IconContainer.tsx +++ b/packages/ui/src/content/IconContainer.tsx @@ -1,27 +1,36 @@ import { cloneElement } from 'react' -import type { StackProps } from 'tamagui' +import { Circle, type StackProps } from 'tamagui' import { AnimatedStack } from '../base' import { useScaleAnimation } from '../hooks' -interface IconContainerProps extends StackProps { +interface IconContainerProps extends Omit { icon: React.ReactElement scaleOnPress?: boolean + bg?: 'white' | 'grey' | 'transparent' 'aria-label'?: string } -export function IconContainer({ icon, scaleOnPress = true, 'aria-label': ariaLabel, ...props }: IconContainerProps) { +export function IconContainer({ + icon, + scaleOnPress = true, + bg = 'grey', + 'aria-label': ariaLabel, + ...props +}: IconContainerProps) { const { handlePressIn, handlePressOut, pressStyle } = useScaleAnimation({ scaleInValue: scaleOnPress ? 0.9 : 1 }) return ( {cloneElement(icon, {