mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-18 13:28:12 -04:00
Update qr-scanner.tsx UX flow (#1347)
This commit is contained in:
@@ -133,6 +133,20 @@ export default function SettingsLayout(): React.ReactNode {
|
||||
...defaultHeaderOptions,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="qr-confirm"
|
||||
options={{
|
||||
title: t('settings.qrScanner.mobileUnlock.confirmTitle'),
|
||||
...defaultHeaderOptions,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="qr-result"
|
||||
options={{
|
||||
title: t('settings.qrScanner.mobileUnlock.successTitle'),
|
||||
...defaultHeaderOptions,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
245
apps/mobile-app/app/(tabs)/settings/qr-confirm.tsx
Normal file
245
apps/mobile-app/app/(tabs)/settings/qr-confirm.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import { View, Alert, StyleSheet } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import { useColors } from '@/hooks/useColorScheme';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
|
||||
import LoadingIndicator from '@/components/LoadingIndicator';
|
||||
import { ThemedButton } from '@/components/themed/ThemedButton';
|
||||
import { ThemedContainer } from '@/components/themed/ThemedContainer';
|
||||
import { ThemedScrollView } from '@/components/themed/ThemedScrollView';
|
||||
import { ThemedText } from '@/components/themed/ThemedText';
|
||||
import { useApp } from '@/context/AppContext';
|
||||
import { useWebApi } from '@/context/WebApiContext';
|
||||
import NativeVaultManager from '@/specs/NativeVaultManager';
|
||||
|
||||
/**
|
||||
* QR Code confirmation screen for mobile unlock.
|
||||
*/
|
||||
export default function QRConfirmScreen() : React.ReactNode {
|
||||
const colors = useColors();
|
||||
const { t } = useTranslation();
|
||||
const { username } = useApp();
|
||||
const webApi = useWebApi();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { requestId } = useLocalSearchParams<{ requestId: string }>();
|
||||
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
/**
|
||||
* Handle mobile unlock QR code.
|
||||
*/
|
||||
const handleMobileUnlock = async (requestId: string) : Promise<void> => {
|
||||
try {
|
||||
// Fetch the public key from server
|
||||
const response = await webApi.authFetch<{ clientPublicKey: string }>(
|
||||
`auth/mobile-unlock/request/${requestId}`,
|
||||
{ method: 'GET' }
|
||||
);
|
||||
|
||||
const publicKeyJWK = response.clientPublicKey;
|
||||
|
||||
// Encrypt the decryption key using native module
|
||||
const encryptedKey = await NativeVaultManager.encryptDecryptionKeyForMobileUnlock(publicKeyJWK);
|
||||
|
||||
// Submit the encrypted key to the server
|
||||
await webApi.authFetch(
|
||||
'auth/mobile-unlock/submit',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
requestId,
|
||||
encryptedDecryptionKey: encryptedKey,
|
||||
username: username,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
// Success! Navigate to result page
|
||||
router.replace({
|
||||
pathname: '/(tabs)/settings/qr-result',
|
||||
params: {
|
||||
success: 'true',
|
||||
message: t('settings.qrScanner.mobileUnlock.successDescription'),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Mobile unlock error:', error);
|
||||
let errorMsg = t('settings.qrScanner.mobileUnlock.genericError');
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('ENCRYPTION_ERROR')) {
|
||||
errorMsg = t('settings.qrScanner.mobileUnlock.vaultLocked');
|
||||
} else if (error.message.includes('404')) {
|
||||
errorMsg = t('settings.qrScanner.mobileUnlock.requestExpired');
|
||||
} else if (error.message.includes('401') || error.message.includes('403')) {
|
||||
errorMsg = t('settings.qrScanner.mobileUnlock.unauthorized');
|
||||
}
|
||||
}
|
||||
|
||||
// Error! Navigate to result page
|
||||
router.replace({
|
||||
pathname: '/(tabs)/settings/qr-result',
|
||||
params: {
|
||||
success: 'false',
|
||||
message: errorMsg,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle confirmation - authenticate user first, then process the scanned QR code.
|
||||
*/
|
||||
const handleConfirm = async () : Promise<void> => {
|
||||
if (!requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
|
||||
try {
|
||||
let authenticated = false;
|
||||
|
||||
// Check which authentication method is available
|
||||
const pinEnabled = await NativeVaultManager.isPinEnabled();
|
||||
|
||||
if (pinEnabled) {
|
||||
// PIN is enabled, use PIN unlock
|
||||
try {
|
||||
await NativeVaultManager.showPinUnlock();
|
||||
authenticated = true;
|
||||
} catch (pinError: any) {
|
||||
// User cancelled PIN or PIN failed
|
||||
console.log('PIN unlock cancelled or failed:', pinError);
|
||||
Alert.alert(
|
||||
t('common.error'),
|
||||
t('settings.qrScanner.mobileUnlock.authenticationFailed')
|
||||
);
|
||||
setIsProcessing(false);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Try biometric authentication
|
||||
try {
|
||||
authenticated = await NativeVaultManager.authenticateUser(
|
||||
t('settings.qrScanner.mobileUnlock.authenticationRequired')
|
||||
);
|
||||
} catch (authError: any) {
|
||||
console.error('Biometric authentication error:', authError);
|
||||
Alert.alert(
|
||||
t('common.error'),
|
||||
t('settings.qrScanner.mobileUnlock.authenticationFailed')
|
||||
);
|
||||
setIsProcessing(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!authenticated) {
|
||||
Alert.alert(
|
||||
t('common.error'),
|
||||
t('settings.qrScanner.mobileUnlock.authenticationFailed')
|
||||
);
|
||||
setIsProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Process the mobile unlock
|
||||
await handleMobileUnlock(requestId);
|
||||
} catch (error) {
|
||||
console.error('QR code processing error:', error);
|
||||
Alert.alert(
|
||||
t('common.error'),
|
||||
error instanceof Error ? error.message : t('common.errors.unknownError')
|
||||
);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle dismiss - go back to settings.
|
||||
*/
|
||||
const handleDismiss = () : void => {
|
||||
router.back();
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
confirmationContainer: {
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
confirmationTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 20,
|
||||
textAlign: 'center',
|
||||
},
|
||||
confirmationText: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
marginBottom: 12,
|
||||
textAlign: 'center',
|
||||
},
|
||||
buttonContainer: {
|
||||
gap: 12,
|
||||
marginTop: 20,
|
||||
paddingBottom: insets.bottom + 80,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
button: {
|
||||
width: '100%',
|
||||
},
|
||||
cancelButton: {
|
||||
backgroundColor: colors.secondary,
|
||||
},
|
||||
});
|
||||
|
||||
// Show loading during processing
|
||||
if (isProcessing) {
|
||||
return (
|
||||
<ThemedContainer>
|
||||
<View style={styles.confirmationContainer}>
|
||||
<LoadingIndicator />
|
||||
</View>
|
||||
</ThemedContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Show confirmation screen
|
||||
return (
|
||||
<ThemedContainer>
|
||||
<ThemedScrollView contentContainerStyle={{ flexGrow: 1 }}>
|
||||
<View style={styles.confirmationContainer}>
|
||||
<ThemedText style={styles.confirmationTitle}>
|
||||
{t('settings.qrScanner.mobileUnlock.confirmTitle')}
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.confirmationText}>
|
||||
{t('settings.qrScanner.mobileUnlock.confirmMessage', { username })}
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={styles.buttonContainer}>
|
||||
<ThemedButton
|
||||
title={t('common.confirm')}
|
||||
onPress={handleConfirm}
|
||||
style={styles.button}
|
||||
/>
|
||||
<ThemedButton
|
||||
title={t('common.cancel')}
|
||||
onPress={handleDismiss}
|
||||
style={StyleSheet.flatten([styles.button, styles.cancelButton])}
|
||||
/>
|
||||
</View>
|
||||
</ThemedScrollView>
|
||||
</ThemedContainer>
|
||||
);
|
||||
}
|
||||
110
apps/mobile-app/app/(tabs)/settings/qr-result.tsx
Normal file
110
apps/mobile-app/app/(tabs)/settings/qr-result.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import { useColors } from '@/hooks/useColorScheme';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
|
||||
import { ThemedButton } from '@/components/themed/ThemedButton';
|
||||
import { ThemedContainer } from '@/components/themed/ThemedContainer';
|
||||
import { ThemedScrollView } from '@/components/themed/ThemedScrollView';
|
||||
import { ThemedText } from '@/components/themed/ThemedText';
|
||||
|
||||
/**
|
||||
* QR Code result screen - shows success or error after mobile unlock attempt.
|
||||
*/
|
||||
export default function QRResultScreen() : React.ReactNode {
|
||||
const colors = useColors();
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { success, message } = useLocalSearchParams<{ success: string; message?: string }>();
|
||||
|
||||
const isSuccess = success === 'true';
|
||||
|
||||
const handleDismiss = () : void => {
|
||||
/*
|
||||
* Switch to credentials tab immediately for smooth UX,
|
||||
* then clean up the settings stack in the background.
|
||||
*/
|
||||
router.back();
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
resultContainer: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: (isSuccess ? colors.success : colors.destructive) + '10',
|
||||
borderColor: isSuccess ? colors.success : colors.destructive,
|
||||
borderRadius: 12,
|
||||
borderWidth: 2,
|
||||
marginBottom: 20,
|
||||
padding: 20,
|
||||
width: '100%',
|
||||
},
|
||||
icon: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
title: {
|
||||
color: isSuccess ? colors.success : colors.destructive,
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
message: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
textAlign: 'center',
|
||||
},
|
||||
buttonContainer: {
|
||||
marginTop: 20,
|
||||
paddingBottom: insets.bottom + 80,
|
||||
paddingHorizontal: 20,
|
||||
width: '100%',
|
||||
},
|
||||
button: {
|
||||
width: '100%',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<ThemedContainer>
|
||||
<ThemedScrollView contentContainerStyle={{ flexGrow: 1 }}>
|
||||
<View style={styles.container}>
|
||||
<View style={styles.resultContainer}>
|
||||
<Ionicons
|
||||
name={isSuccess ? 'checkmark-circle' : 'alert-circle'}
|
||||
size={64}
|
||||
color={isSuccess ? colors.success : colors.destructive}
|
||||
style={styles.icon}
|
||||
/>
|
||||
<ThemedText style={styles.title}>
|
||||
{isSuccess
|
||||
? t('settings.qrScanner.mobileUnlock.successTitle')
|
||||
: t('common.error')}
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.message}>
|
||||
{message || (isSuccess
|
||||
? t('settings.qrScanner.mobileUnlock.successDescription')
|
||||
: t('settings.qrScanner.mobileUnlock.genericError'))}
|
||||
</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.buttonContainer}>
|
||||
<ThemedButton
|
||||
title={t('common.close')}
|
||||
onPress={handleDismiss}
|
||||
style={styles.button}
|
||||
/>
|
||||
</View>
|
||||
</ThemedScrollView>
|
||||
</ThemedContainer>
|
||||
);
|
||||
}
|
||||
@@ -2,20 +2,16 @@ import { Ionicons } from '@expo/vector-icons';
|
||||
import { CameraView, useCameraPermissions } from 'expo-camera';
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { View, TouchableOpacity, Alert, StyleSheet, Platform } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { View, TouchableOpacity, Alert, StyleSheet } from 'react-native';
|
||||
|
||||
import { useColors } from '@/hooks/useColorScheme';
|
||||
import { useMinDurationLoading } from '@/hooks/useMinDurationLoading';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
|
||||
import { ThemedButton } from '@/components/themed/ThemedButton';
|
||||
import LoadingIndicator from '@/components/LoadingIndicator';
|
||||
import { ThemedContainer } from '@/components/themed/ThemedContainer';
|
||||
import { ThemedScrollView } from '@/components/themed/ThemedScrollView';
|
||||
import { ThemedText } from '@/components/themed/ThemedText';
|
||||
import { ThemedView } from '@/components/themed/ThemedView';
|
||||
import { useApp } from '@/context/AppContext';
|
||||
import { useWebApi } from '@/context/WebApiContext';
|
||||
import NativeVaultManager from '@/specs/NativeVaultManager';
|
||||
|
||||
// QR Code type prefixes
|
||||
const QR_CODE_PREFIXES = {
|
||||
@@ -57,14 +53,9 @@ function parseQRCode(data: string): ScannedQRCode {
|
||||
export default function QRScannerScreen() : React.ReactNode {
|
||||
const colors = useColors();
|
||||
const { t } = useTranslation();
|
||||
const { username } = useApp();
|
||||
const webApi = useWebApi();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [scannedData, setScannedData] = useState<ScannedQRCode | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isLoadingAfterScan, setIsLoadingAfterScan] = useMinDurationLoading(false, 500);
|
||||
const { url } = useLocalSearchParams<{ url?: string }>();
|
||||
|
||||
// Request camera permission on mount
|
||||
@@ -98,64 +89,11 @@ export default function QRScannerScreen() : React.ReactNode {
|
||||
}, [url]);
|
||||
|
||||
/**
|
||||
* Handle mobile unlock QR code.
|
||||
*/
|
||||
const handleMobileUnlock = async (requestId: string) : Promise<void> => {
|
||||
try {
|
||||
// Fetch the public key from server
|
||||
const response = await webApi.authFetch<{ clientPublicKey: string }>(
|
||||
`auth/mobile-unlock/request/${requestId}`,
|
||||
{ method: 'GET' }
|
||||
);
|
||||
|
||||
const publicKeyJWK = response.clientPublicKey;
|
||||
|
||||
// Encrypt the decryption key using native module
|
||||
// This ensures the decryption key never touches React Native code
|
||||
const encryptedKey = await NativeVaultManager.encryptDecryptionKeyForMobileUnlock(publicKeyJWK);
|
||||
|
||||
// Submit the encrypted key to the server
|
||||
await webApi.authFetch(
|
||||
'auth/mobile-unlock/submit',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
requestId,
|
||||
encryptedDecryptionKey: encryptedKey,
|
||||
username: username,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
// Success!
|
||||
setSuccessMessage(t('settings.qrScanner.mobileUnlock.successDescription'));
|
||||
} catch (error) {
|
||||
console.error('Mobile unlock error:', error);
|
||||
let errorMsg = t('settings.qrScanner.mobileUnlock.genericError');
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('ENCRYPTION_ERROR')) {
|
||||
errorMsg = t('settings.qrScanner.mobileUnlock.vaultLocked');
|
||||
} else if (error.message.includes('404')) {
|
||||
errorMsg = t('settings.qrScanner.mobileUnlock.requestExpired');
|
||||
} else if (error.message.includes('401') || error.message.includes('403')) {
|
||||
errorMsg = t('settings.qrScanner.mobileUnlock.unauthorized');
|
||||
}
|
||||
}
|
||||
|
||||
setErrorMessage(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle barcode scanned - show confirmation screen.
|
||||
* Handle barcode scanned - validate request and navigate to confirmation.
|
||||
*/
|
||||
const handleBarcodeScanned = ({ data }: { data: string }) : void => {
|
||||
// Prevent multiple scans
|
||||
if (scannedData) {
|
||||
if (isLoadingAfterScan) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -171,109 +109,55 @@ export default function QRScannerScreen() : React.ReactNode {
|
||||
return;
|
||||
}
|
||||
|
||||
// Show confirmation screen
|
||||
setScannedData(parsedData);
|
||||
// Validate the request and navigate (with min 500ms loading)
|
||||
validateAndNavigate(parsedData);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle confirmation - authenticate user first, then process the scanned QR code.
|
||||
* Validate the QR request with the server before navigating.
|
||||
*/
|
||||
const handleConfirm = async () : Promise<void> => {
|
||||
if (!scannedData) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
const validateAndNavigate = async (parsedData: ScannedQRCode) : Promise<void> => {
|
||||
setIsLoadingAfterScan(true);
|
||||
|
||||
try {
|
||||
let authenticated = false;
|
||||
|
||||
// Check which authentication method is available
|
||||
const pinEnabled = await NativeVaultManager.isPinEnabled();
|
||||
|
||||
if (pinEnabled) {
|
||||
// PIN is enabled, use PIN unlock
|
||||
try {
|
||||
await NativeVaultManager.showPinUnlock();
|
||||
authenticated = true;
|
||||
} catch (pinError: any) {
|
||||
// User cancelled PIN or PIN failed
|
||||
console.log('PIN unlock cancelled or failed:', pinError);
|
||||
Alert.alert(
|
||||
t('common.error'),
|
||||
t('settings.qrScanner.mobileUnlock.authenticationFailed')
|
||||
);
|
||||
setIsProcessing(false);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Try biometric authentication
|
||||
try {
|
||||
authenticated = await NativeVaultManager.authenticateUser(
|
||||
t('settings.qrScanner.mobileUnlock.authenticationRequired')
|
||||
);
|
||||
} catch (authError: any) {
|
||||
console.error('Biometric authentication error:', authError);
|
||||
Alert.alert(
|
||||
t('common.error'),
|
||||
t('settings.qrScanner.mobileUnlock.authenticationFailed')
|
||||
);
|
||||
setIsProcessing(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!authenticated) {
|
||||
Alert.alert(
|
||||
t('common.error'),
|
||||
t('settings.qrScanner.mobileUnlock.authenticationFailed')
|
||||
if (parsedData.type === 'MOBILE_UNLOCK') {
|
||||
// Fetch the public key from server to validate the request exists
|
||||
await webApi.authFetch<{ clientPublicKey: string }>(
|
||||
`auth/mobile-unlock/request/${parsedData.payload}`,
|
||||
{ method: 'GET' }
|
||||
);
|
||||
setIsProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Route to appropriate handler based on type
|
||||
switch (scannedData.type) {
|
||||
case 'MOBILE_UNLOCK':
|
||||
await handleMobileUnlock(scannedData.payload);
|
||||
break;
|
||||
// Future cases:
|
||||
// case 'PASSKEY':
|
||||
// await handlePasskey(scannedData.payload);
|
||||
// break;
|
||||
default:
|
||||
Alert.alert(
|
||||
t('common.error'),
|
||||
t('settings.qrScanner.unsupportedQrType')
|
||||
);
|
||||
// Request is valid, navigate to confirmation page
|
||||
// Min duration of 500ms is handled by useMinDurationLoading
|
||||
setIsLoadingAfterScan(false);
|
||||
|
||||
router.replace({
|
||||
pathname: '/(tabs)/settings/qr-confirm',
|
||||
params: { requestId: parsedData.payload },
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('QR code processing error:', error);
|
||||
setIsLoadingAfterScan(false);
|
||||
|
||||
console.error('QR validation error:', error);
|
||||
let errorMsg = t('settings.qrScanner.mobileUnlock.genericError');
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('404')) {
|
||||
errorMsg = t('settings.qrScanner.mobileUnlock.requestExpired');
|
||||
} else if (error.message.includes('401') || error.message.includes('403')) {
|
||||
errorMsg = t('settings.qrScanner.mobileUnlock.unauthorized');
|
||||
}
|
||||
}
|
||||
|
||||
Alert.alert(
|
||||
t('common.error'),
|
||||
error instanceof Error ? error.message : t('common.errors.unknownError')
|
||||
errorMsg,
|
||||
[{ text: t('common.ok') }]
|
||||
);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle cancel - go back to scanning.
|
||||
*/
|
||||
const handleCancel = () : void => {
|
||||
setScannedData(null);
|
||||
setSuccessMessage(null);
|
||||
setErrorMessage(null);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle dismiss - go back to settings.
|
||||
*/
|
||||
const handleDismiss = () : void => {
|
||||
router.back();
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
camera: {
|
||||
flex: 1,
|
||||
@@ -305,184 +189,32 @@ export default function QRScannerScreen() : React.ReactNode {
|
||||
top: 16,
|
||||
zIndex: 10,
|
||||
},
|
||||
confirmationContainer: {
|
||||
loadingContainer: {
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
confirmationTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 20,
|
||||
textAlign: 'center',
|
||||
},
|
||||
confirmationText: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
marginBottom: 12,
|
||||
textAlign: 'center',
|
||||
},
|
||||
buttonContainer: {
|
||||
gap: 12,
|
||||
marginTop: 20,
|
||||
paddingBottom: insets.bottom + 80,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
button: {
|
||||
width: '100%',
|
||||
},
|
||||
cancelButton: {
|
||||
backgroundColor: colors.secondary,
|
||||
},
|
||||
successContainer: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.success + '10',
|
||||
borderColor: colors.success,
|
||||
borderRadius: 12,
|
||||
borderWidth: 2,
|
||||
marginBottom: 20,
|
||||
padding: 20,
|
||||
},
|
||||
successIcon: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
successTitle: {
|
||||
color: colors.success,
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
successText: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
textAlign: 'center',
|
||||
},
|
||||
errorContainer: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.destructive + '10',
|
||||
borderColor: colors.destructive,
|
||||
borderRadius: 12,
|
||||
borderWidth: 2,
|
||||
marginBottom: 20,
|
||||
padding: 20,
|
||||
},
|
||||
errorIcon: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
errorTitle: {
|
||||
color: colors.destructive,
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
// Show confirmation/success/error screen after scanning
|
||||
if (scannedData || successMessage || errorMessage) {
|
||||
// Show loading animation after scan
|
||||
if (isLoadingAfterScan) {
|
||||
return (
|
||||
<ThemedContainer>
|
||||
<ThemedScrollView contentContainerStyle={{ flexGrow: 1 }}>
|
||||
<View style={styles.confirmationContainer}>
|
||||
{successMessage && (
|
||||
<View style={styles.successContainer}>
|
||||
<Ionicons
|
||||
name="checkmark-circle"
|
||||
size={64}
|
||||
color={colors.success}
|
||||
style={styles.successIcon}
|
||||
/>
|
||||
<ThemedText style={styles.successTitle}>
|
||||
{t('settings.qrScanner.mobileUnlock.successTitle')}
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.successText}>
|
||||
{successMessage}
|
||||
</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{errorMessage && (
|
||||
<View style={styles.errorContainer}>
|
||||
<Ionicons
|
||||
name="alert-circle"
|
||||
size={64}
|
||||
color={colors.destructive}
|
||||
style={styles.errorIcon}
|
||||
/>
|
||||
<ThemedText style={styles.errorTitle}>
|
||||
{t('common.error')}
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.errorText}>
|
||||
{errorMessage}
|
||||
</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!successMessage && !errorMessage && scannedData?.type === 'MOBILE_UNLOCK' && (
|
||||
<>
|
||||
<ThemedText style={styles.confirmationTitle}>
|
||||
{t('settings.qrScanner.mobileUnlock.confirmTitle')}
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.confirmationText}>
|
||||
{t('settings.qrScanner.mobileUnlock.confirmMessage', { username })}
|
||||
</ThemedText>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.buttonContainer}>
|
||||
{(successMessage || errorMessage) ? (
|
||||
<ThemedButton
|
||||
title={t('common.close')}
|
||||
onPress={handleDismiss}
|
||||
style={styles.button}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<ThemedButton
|
||||
title={t('common.confirm')}
|
||||
onPress={handleConfirm}
|
||||
loading={isProcessing}
|
||||
disabled={isProcessing}
|
||||
style={styles.button}
|
||||
/>
|
||||
<ThemedButton
|
||||
title={t('common.cancel')}
|
||||
onPress={handleCancel}
|
||||
disabled={isProcessing}
|
||||
style={StyleSheet.flatten([styles.button, styles.cancelButton])}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</ThemedScrollView>
|
||||
<View style={styles.loadingContainer}>
|
||||
<LoadingIndicator />
|
||||
</View>
|
||||
</ThemedContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Show loading or permission denied screen
|
||||
// Show permission request screen
|
||||
if (!permission || !permission.granted) {
|
||||
return (
|
||||
<ThemedContainer>
|
||||
<ThemedView style={styles.confirmationContainer}>
|
||||
{permission && !permission.granted && (
|
||||
<>
|
||||
<ThemedText style={styles.confirmationTitle}>
|
||||
{t('settings.qrScanner.cameraPermissionTitle')}
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.confirmationText}>
|
||||
{t('settings.qrScanner.cameraPermissionMessage')}
|
||||
</ThemedText>
|
||||
</>
|
||||
)}
|
||||
</ThemedView>
|
||||
<View style={styles.loadingContainer}>
|
||||
<LoadingIndicator />
|
||||
</View>
|
||||
</ThemedContainer>
|
||||
);
|
||||
}
|
||||
@@ -490,12 +222,6 @@ export default function QRScannerScreen() : React.ReactNode {
|
||||
return (
|
||||
<ThemedContainer>
|
||||
<View style={styles.cameraContainer}>
|
||||
<TouchableOpacity
|
||||
style={styles.closeButton}
|
||||
onPress={() => router.back()}
|
||||
>
|
||||
<Ionicons name="close-circle" size={32} color={colors.white} />
|
||||
</TouchableOpacity>
|
||||
<CameraView
|
||||
style={styles.camera}
|
||||
facing="back"
|
||||
|
||||
@@ -71,8 +71,7 @@ function RootLayoutNav() : React.ReactNode {
|
||||
|
||||
// Handle mobile unlock QR code scans from native camera
|
||||
if (path.startsWith('mobile-unlock/')) {
|
||||
// Process the QR code directly by simulating what the scanner would do
|
||||
// Since we already have the URL from the camera, we can process it immediately
|
||||
// Process the QR code
|
||||
router.push(`/(tabs)/settings/qr-scanner?url=${encodeURIComponent(`aliasvault://${path}`)}` as Href);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4,13 +4,13 @@ import { StyleSheet, View, Text, Animated, useColorScheme } from 'react-native';
|
||||
import { useColors } from '@/hooks/useColorScheme';
|
||||
|
||||
type LoadingIndicatorProps = {
|
||||
status: string;
|
||||
status?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Loading indicator component.
|
||||
*/
|
||||
export default function LoadingIndicator({ status }: LoadingIndicatorProps): React.ReactNode {
|
||||
export default function LoadingIndicator({ status = '' }: LoadingIndicatorProps): React.ReactNode {
|
||||
const colors = useColors();
|
||||
const dot1Anim = useRef(new Animated.Value(0)).current;
|
||||
const dot2Anim = useRef(new Animated.Value(0)).current;
|
||||
@@ -89,8 +89,9 @@ export default function LoadingIndicator({ status }: LoadingIndicatorProps): Rea
|
||||
* If the status ends with a pipe character (|), don't show any dots
|
||||
* This provides an explicit way to disable the loading dots animation
|
||||
*/
|
||||
const statusTrimmed = status.endsWith('|') ? status.slice(0, -1) : status;
|
||||
const shouldShowDots = !status.endsWith('|');
|
||||
const statusText = status || '';
|
||||
const statusTrimmed = statusText.endsWith('|') ? statusText.slice(0, -1) : statusText;
|
||||
const shouldShowDots = statusText.length > 0 && !statusText.endsWith('|');
|
||||
|
||||
const backgroundColor = colorScheme === 'dark' ? 'transparent' : '#fff';
|
||||
const shadowColor = '#000';
|
||||
|
||||
Reference in New Issue
Block a user