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, {