From 13fcadb2fa02dde9ebf6979a7c4f7f1d2c97b19b Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Sun, 20 Apr 2025 19:46:47 +0200 Subject: [PATCH] Add AliasVault themed loading indicators (#771) --- mobile-app/app/(tabs)/(credentials)/index.tsx | 23 +- mobile-app/app/_layout.tsx | 55 +++- mobile-app/app/index.tsx | 13 +- mobile-app/app/login.tsx | 237 ++++++++++-------- mobile-app/components/LoadingIndicator.tsx | 156 ++++++++++++ mobile-app/constants/Colors.ts | 6 +- mobile-app/hooks/useVaultSync.ts | 67 +++-- 7 files changed, 415 insertions(+), 142 deletions(-) create mode 100644 mobile-app/components/LoadingIndicator.tsx diff --git a/mobile-app/app/(tabs)/(credentials)/index.tsx b/mobile-app/app/(tabs)/(credentials)/index.tsx index 0a4a3c9bf..248904972 100644 --- a/mobile-app/app/(tabs)/(credentials)/index.tsx +++ b/mobile-app/app/(tabs)/(credentials)/index.tsx @@ -13,6 +13,7 @@ import { useColors } from '@/hooks/useColorScheme'; import { CredentialCard } from '@/components/CredentialCard'; import { TitleContainer } from '@/components/TitleContainer'; import emitter from '@/utils/EventEmitter'; +import Toast from 'react-native-toast-message'; export default function CredentialsScreen() { const [searchQuery, setSearchQuery] = useState(''); @@ -82,8 +83,7 @@ export default function CredentialsScreen() { // Sync vault and load credentials await syncVault({ - forceCheck: true, - onSuccess: async () => { + onSuccess: async (hasNewVault) => { // Calculate remaining time needed to reach minimum duration const elapsedTime = Date.now() - startTime; const remainingDelay = Math.max(0, 350 - elapsedTime); @@ -95,14 +95,31 @@ export default function CredentialsScreen() { await loadCredentials(); setRefreshing(false); + Toast.show({ + type: 'success', + text1: hasNewVault ? 'Vault synced successfully' : 'Vault is up-to-date', + position: 'top', + visibilityTime: 1200, + }); }, onError: (error) => { console.error('Error syncing vault:', error); - } + setRefreshing(false); + Toast.show({ + type: 'error', + text1: 'Vault sync failed', + text2: error, + }); + }, }); } catch (err) { console.error('Error refreshing credentials:', err); setRefreshing(false); + Toast.show({ + type: 'error', + text1: 'Vault sync failed', + text2: err instanceof Error ? err.message : 'Unknown error', + }); } }, [syncVault, loadCredentials]); diff --git a/mobile-app/app/_layout.tsx b/mobile-app/app/_layout.tsx index e58c4214d..75d6ef8ab 100644 --- a/mobile-app/app/_layout.tsx +++ b/mobile-app/app/_layout.tsx @@ -30,6 +30,60 @@ const toastConfig = { borderRadius: 8, marginHorizontal: 16, marginBottom: 70, + marginTop: 30, + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.25, + shadowRadius: 3.84, + elevation: 5, + }} + > + + {props.text1} + + + ), + error: (props: any) => ( + + + {props.text1} + + {props.text2 && ( + + {props.text2} + + )} + + ), + info: (props: any) => ( + { + setStatus(message); + } + }); // Navigate to appropriate screen if (requireLoginOrUnlock) { @@ -42,7 +49,7 @@ export default function InitialLoadingScreen() { return ( - + ); } \ No newline at end of file diff --git a/mobile-app/app/login.tsx b/mobile-app/app/login.tsx index ea53058e4..3ec3c31dc 100644 --- a/mobile-app/app/login.tsx +++ b/mobile-app/app/login.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import { StyleSheet, View, Text, SafeAreaView, TextInput, TouchableOpacity, ActivityIndicator, Linking, Animated } from 'react-native'; import { useState, useEffect } from 'react'; import { Buffer } from 'buffer'; @@ -15,6 +16,7 @@ import { useWebApi } from '@/context/WebApiContext'; import { useColors } from '@/hooks/useColorScheme'; import Logo from '@/assets/images/logo.svg'; import { AppInfo } from '@/utils/AppInfo'; +import LoadingIndicator from '@/components/LoadingIndicator'; export default function LoginScreen() { @@ -196,6 +198,7 @@ export default function LoginScreen() { const [loginResponse, setLoginResponse] = useState(null); const [passwordHashString, setPasswordHashString] = useState(null); const [passwordHashBase64, setPasswordHashBase64] = useState(null); + const [loginStatus, setLoginStatus] = useState(null); const authContext = useAuth(); const dbContext = useDb(); @@ -207,6 +210,8 @@ export default function LoginScreen() { setIsLoading(true); setError(null); + setLoginStatus('Logging in'); + try { console.log('handleSubmit'); authContext.clearGlobalMessage(); @@ -229,6 +234,7 @@ export default function LoginScreen() { console.log('passwordHashString', passwordHashString); + setLoginStatus('Validating credentials'); const validationResponse = await srpUtil.validateLogin( credentials.username, passwordHashString, @@ -244,6 +250,7 @@ export default function LoginScreen() { setPasswordHashBase64(passwordHashBase64); setTwoFactorRequired(true); setIsLoading(false); + setLoginStatus(null); return; } @@ -253,6 +260,7 @@ export default function LoginScreen() { console.log('validationResponse.token', validationResponse.token); + setLoginStatus('Syncing vault'); const vaultResponseJson = await webApi.authFetch('Vault', { method: 'GET', headers: { 'Authorization': `Bearer ${validationResponse.token.token}` } }); @@ -264,6 +272,7 @@ export default function LoginScreen() { console.error('vaultError', vaultError); setError(vaultError); setIsLoading(false); + setLoginStatus(null); return; } @@ -277,6 +286,7 @@ export default function LoginScreen() { router.replace('/(tabs)'); setIsLoading(false); + setLoginStatus(null); } catch (err) { if (err instanceof ApiAuthError) { console.error('ApiAuthError error:', err); @@ -286,6 +296,7 @@ export default function LoginScreen() { setError('Could not reach AliasVault server. Please try again later or contact support if the problem persists.'); } setIsLoading(false); + setLoginStatus(null); } }; @@ -360,118 +371,124 @@ export default function LoginScreen() { - - Log in - - Connecting to{' '} - router.push('/settings')} - > - {getDisplayUrl()} - - - - - {error && ( - - {error} - - )} - - {twoFactorRequired ? ( - - Authentication Code - - - - {isLoading ? ( - - ) : ( - Verify - )} - - { - setCredentials({ username: '', password: '' }); - setTwoFactorRequired(false); - setTwoFactorCode(''); - setPasswordHashString(null); - setPasswordHashBase64(null); - setLoginResponse(null); - setError(null); - }} - > - Cancel - - - - Note: if you don't have access to your authenticator device, you can reset your 2FA with a recovery code by logging in via the website. - - + {isLoading ? ( + ) : ( - - Username or email - setCredentials({ ...credentials, username: text })} - placeholder="name / name@company.com" - autoCapitalize="none" - placeholderTextColor={colors.textMuted} - /> - Password - setCredentials({ ...credentials, password: text })} - placeholder="Enter your password" - secureTextEntry - placeholderTextColor={colors.textMuted} - /> - - setRememberMe(!rememberMe)} - > - - - Remember me - - - {isLoading ? ( - - ) : ( - Login - )} - - - No account yet?{' '} - Linking.openURL('https://app.aliasvault.net')} - > - Create new vault + <> + + Log in + + Connecting to{' '} + router.push('/settings')} + > + {getDisplayUrl()} + - - + + + {error && ( + + {error} + + )} + + {twoFactorRequired ? ( + + Authentication Code + + + + {isLoading ? ( + + ) : ( + Verify + )} + + { + setCredentials({ username: '', password: '' }); + setTwoFactorRequired(false); + setTwoFactorCode(''); + setPasswordHashString(null); + setPasswordHashBase64(null); + setLoginResponse(null); + setError(null); + }} + > + Cancel + + + + Note: if you don't have access to your authenticator device, you can reset your 2FA with a recovery code by logging in via the website. + + + ) : ( + + Username or email + setCredentials({ ...credentials, username: text })} + placeholder="name / name@company.com" + autoCapitalize="none" + placeholderTextColor={colors.textMuted} + /> + Password + setCredentials({ ...credentials, password: text })} + placeholder="Enter your password" + secureTextEntry + placeholderTextColor={colors.textMuted} + /> + + setRememberMe(!rememberMe)} + > + + + Remember me + + + {isLoading ? ( + + ) : ( + Login + )} + + + No account yet?{' '} + Linking.openURL('https://app.aliasvault.net')} + > + Create new vault + + + + )} + )} diff --git a/mobile-app/components/LoadingIndicator.tsx b/mobile-app/components/LoadingIndicator.tsx new file mode 100644 index 000000000..03b39f653 --- /dev/null +++ b/mobile-app/components/LoadingIndicator.tsx @@ -0,0 +1,156 @@ +import { StyleSheet, View, Text, Animated } from 'react-native'; +import { useEffect, useRef, useState } from 'react'; +import { useColors } from '@/hooks/useColorScheme'; + +interface LoadingIndicatorProps { + status: string; +} + +export default function LoadingIndicator({ status }: LoadingIndicatorProps) { + const colors = useColors(); + const dot1Anim = useRef(new Animated.Value(0)).current; + const dot2Anim = useRef(new Animated.Value(0)).current; + const dot3Anim = useRef(new Animated.Value(0)).current; + const dot4Anim = useRef(new Animated.Value(0)).current; + const [dots, setDots] = useState(''); + + useEffect(() => { + const createPulseAnimation = (anim: Animated.Value) => { + return Animated.sequence([ + Animated.timing(anim, { + toValue: 1, + duration: 700, + useNativeDriver: true, + }), + Animated.timing(anim, { + toValue: 0, + duration: 700, + useNativeDriver: true, + }), + ]); + }; + + const animation = Animated.loop( + Animated.parallel([ + createPulseAnimation(dot1Anim), + Animated.sequence([ + Animated.delay(200), + createPulseAnimation(dot2Anim), + ]), + Animated.sequence([ + Animated.delay(400), + createPulseAnimation(dot3Anim), + ]), + Animated.sequence([ + Animated.delay(600), + createPulseAnimation(dot4Anim), + ]), + ]) + ); + + const dotsInterval = setInterval(() => { + setDots(prevDots => { + if (prevDots.length >= 3) return ''; + return prevDots + '.'; + }); + }, 400); + + animation.start(); + + return () => { + animation.stop(); + clearInterval(dotsInterval); + }; + }, []); + + const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 20, + }, + dotsContainer: { + flexDirection: 'row', + backgroundColor: 'transparent', + borderRadius: 20, + padding: 12, + paddingHorizontal: 24, + gap: 10, + borderWidth: 5, + borderColor: colors.tertiary, + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 1, + }, + shadowOpacity: 0.05, + shadowRadius: 2, + elevation: 1, + }, + dot: { + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: colors.tertiary, + }, + statusText: { + marginTop: 16, + fontSize: 16, + color: colors.text, + textAlign: 'center', + }, + }); + + return ( + + + + + + + + {status}{dots} + + ); +} \ No newline at end of file diff --git a/mobile-app/constants/Colors.ts b/mobile-app/constants/Colors.ts index 15707f264..3152b0c1e 100644 --- a/mobile-app/constants/Colors.ts +++ b/mobile-app/constants/Colors.ts @@ -19,8 +19,9 @@ export const Colors = { tabIconSelected: '#f49541', headerBackground: '#fff', tabBarBackground: '#fff', - primary: '#f97316', + primary: '#f49541', secondary: '#6b7280', + tertiary: '#eabf69', loginHeader: '#f6dfc4', }, dark: { @@ -38,8 +39,9 @@ export const Colors = { tabIconSelected: '#f49541', headerBackground: '#1f2937', tabBarBackground: '#1f2937', - primary: '#f97316', + primary: '#f49541', secondary: '#6b7280', + tertiary: '#eabf69', loginHeader: '#5c4331', }, } as const; diff --git a/mobile-app/hooks/useVaultSync.ts b/mobile-app/hooks/useVaultSync.ts index c1f5b2fea..67687fc30 100644 --- a/mobile-app/hooks/useVaultSync.ts +++ b/mobile-app/hooks/useVaultSync.ts @@ -5,10 +5,32 @@ import { useDb } from '@/context/DbContext'; import { useWebApi } from '@/context/WebApiContext'; import { VaultResponse } from '@/utils/types/webapi/VaultResponse'; +// Utility function to ensure a minimum time has elapsed for an operation +const withMinimumDelay = async ( + operation: () => Promise, + minDelayMs: number, + initialSync: boolean +): Promise => { + if (!initialSync) { + return operation(); + } + + const startTime = Date.now(); + const result = await operation(); + const elapsedTime = Date.now() - startTime; + + if (elapsedTime < minDelayMs) { + await new Promise(resolve => setTimeout(resolve, minDelayMs - elapsedTime)); + } + + return result; +}; + interface VaultSyncOptions { - forceCheck?: boolean; - onSuccess?: () => void; + initialSync?: boolean; + onSuccess?: (hasNewVault: boolean) => void; onError?: (error: string) => void; + onStatus?: (message: string) => void; } export const useVaultSync = () => { @@ -17,8 +39,8 @@ export const useVaultSync = () => { const webApi = useWebApi(); const syncVault = useCallback(async (options: VaultSyncOptions = {}) => { - const { forceCheck = false, onSuccess, onError } = options; - console.log('syncVault called with forceCheck:', forceCheck); + const { initialSync = false, onSuccess, onError, onStatus } = options; + console.log('syncVault called with initialSync:', initialSync); try { const isLoggedIn = await authContext.initializeAuth(); @@ -28,31 +50,23 @@ export const useVaultSync = () => { return false; } - // If not forcing a check, verify the time elapsed since last check - if (!forceCheck) { - const lastCheckStr = await AsyncStorage.getItem('lastVaultCheck'); - const lastCheck = lastCheckStr ? parseInt(lastCheckStr, 10) : 0; - const now = Date.now(); - - // Only check if more than 1 hour has passed since last check - if (now - lastCheck < 3600000) { - console.log('Vault sync skipped: Not enough time has passed since last check'); - return false; - } - } - console.log('Checking vault updates'); // Update last check time await AsyncStorage.setItem('lastVaultCheck', Date.now().toString()); // Check app status and vault revision - const statusResponse = await webApi.getStatus(); + onStatus?.('Checking vault updates'); + const statusResponse = await withMinimumDelay( + () => webApi.getStatus(), + 700, + initialSync + ); const statusError = webApi.validateStatusResponse(statusResponse); if (statusError !== null) { console.log('Vault sync error:', statusError); await webApi.logout(statusError); - onError?.(statusErrorr); + onError?.(statusError); return false; } @@ -63,7 +77,12 @@ export const useVaultSync = () => { console.log('Vault revision local:', vaultRevisionNumber); console.log('Vault revision server:', statusResponse.vaultRevision); if (statusResponse.vaultRevision > vaultRevisionNumber) { - const vaultResponseJson = await webApi.get('Vault'); + onStatus?.('Syncing updated vault'); + const vaultResponseJson = await withMinimumDelay( + () => webApi.get('Vault'), + 1000, + initialSync + ); const vaultError = webApi.validateVaultResponse(vaultResponseJson as VaultResponse); if (vaultError) { @@ -74,13 +93,15 @@ export const useVaultSync = () => { } console.log('Re-initializing database with new vault'); - await dbContext.initializeDatabase(vaultResponseJson as VaultResponse, null); - onSuccess?.(); + dbContext.initializeDatabase(vaultResponseJson as VaultResponse, null); + onSuccess?.(true); return true; } console.log('Vault sync finished: No updates needed'); - onSuccess?.(); + onStatus?.('Decrypting vault'); + await new Promise(resolve => setTimeout(resolve, 300)); + onSuccess?.(false); return false; } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Unknown error during vault sync';