Add AliasVault themed loading indicators (#771)

This commit is contained in:
Leendert de Borst
2025-04-20 19:46:47 +02:00
parent 6c7f5f2e02
commit 13fcadb2fa
7 changed files with 415 additions and 142 deletions

View File

@@ -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]);

View File

@@ -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,
}}
>
<Text style={{ color: 'white', fontSize: 14, fontWeight: '500' }}>
{props.text1}
</Text>
</View>
),
error: (props: any) => (
<View
style={{
backgroundColor: '#dc2626', // Red
padding: 12,
borderRadius: 8,
marginHorizontal: 16,
marginBottom: 70,
marginTop: 30,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.25,
shadowRadius: 3.84,
elevation: 5,
}}
>
<Text style={{ color: 'white', fontSize: 14, fontWeight: '500' }}>
{props.text1}
</Text>
{props.text2 && (
<Text style={{ color: 'white', fontSize: 12, marginTop: 4 }}>
{props.text2}
</Text>
)}
</View>
),
info: (props: any) => (
<View
style={{
backgroundColor: '#3b82f6', // Blue
padding: 12,
borderRadius: 8,
marginHorizontal: 16,
marginBottom: 70,
marginTop: 30,
shadowColor: '#000',
shadowOffset: {
width: 0,
@@ -51,7 +105,6 @@ function RootLayoutNav() {
const colorScheme = useColorScheme();
const colors = useColors();
// Create custom themes that extend the default ones.
const customDefaultTheme = {
...DefaultTheme,

View File

@@ -1,16 +1,18 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import { View, ActivityIndicator } from 'react-native';
import { router } from 'expo-router';
import { useAuth } from '@/context/AuthContext';
import { useDb } from '@/context/DbContext';
import { useVaultSync } from '@/hooks/useVaultSync';
import { ThemedView } from '@/components/ThemedView';
import LoadingIndicator from '@/components/LoadingIndicator';
export default function InitialLoadingScreen() {
const { isInitialized: isAuthInitialized, isLoggedIn } = useAuth();
const { dbInitialized, dbAvailable } = useDb();
const { syncVault } = useVaultSync();
const hasInitialized = useRef(false);
const [status, setStatus] = useState('Initializing...');
const isFullyInitialized = isAuthInitialized && dbInitialized;
const requireLoginOrUnlock = isFullyInitialized && (!isLoggedIn || !dbAvailable);
@@ -25,7 +27,12 @@ export default function InitialLoadingScreen() {
// Perform initial vault sync
console.log('Initial vault sync');
await syncVault({ forceCheck: true });
await syncVault({
initialSync: true,
onStatus: (message) => {
setStatus(message);
}
});
// Navigate to appropriate screen
if (requireLoginOrUnlock) {
@@ -42,7 +49,7 @@ export default function InitialLoadingScreen() {
return (
<ThemedView style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" color="#f97316" />
<LoadingIndicator status={status} />
</ThemedView>
);
}

View File

@@ -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<any>(null);
const [passwordHashString, setPasswordHashString] = useState<string | null>(null);
const [passwordHashBase64, setPasswordHashBase64] = useState<string | null>(null);
const [loginStatus, setLoginStatus] = useState<string | null>(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<any>('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() {
</Animated.View>
</SafeAreaView>
<ThemedView style={styles.content}>
<View style={styles.headerContainer}>
<Text style={styles.headerTitle}>Log in</Text>
<Text style={styles.headerSubtitle}>
Connecting to{' '}
<Text
style={styles.clickableDomain}
onPress={() => router.push('/settings')}
>
{getDisplayUrl()}
</Text>
</Text>
</View>
{error && (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{error}</Text>
</View>
)}
{twoFactorRequired ? (
<View style={styles.formContainer}>
<Text style={[styles.label]}>Authentication Code</Text>
<TextInput
style={[styles.input]}
value={twoFactorCode}
onChangeText={setTwoFactorCode}
placeholder="Enter 6-digit code"
keyboardType="numeric"
maxLength={6}
placeholderTextColor={colors.textMuted}
/>
<View style={styles.buttonContainer}>
<TouchableOpacity
style={[styles.button, styles.primaryButton]}
onPress={handleTwoFactorSubmit}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator color={colors.text} />
) : (
<Text style={styles.buttonText}>Verify</Text>
)}
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, styles.secondaryButton]}
onPress={() => {
setCredentials({ username: '', password: '' });
setTwoFactorRequired(false);
setTwoFactorCode('');
setPasswordHashString(null);
setPasswordHashBase64(null);
setLoginResponse(null);
setError(null);
}}
>
<Text style={styles.buttonText}>Cancel</Text>
</TouchableOpacity>
</View>
<Text style={[styles.noteText]}>
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.
</Text>
</View>
{isLoading ? (
<LoadingIndicator status={loginStatus || 'Loading...'} />
) : (
<View style={styles.formContainer}>
<Text style={[styles.label]}>Username or email</Text>
<TextInput
style={[styles.input]}
value={credentials.username}
onChangeText={(text) => setCredentials({ ...credentials, username: text })}
placeholder="name / name@company.com"
autoCapitalize="none"
placeholderTextColor={colors.textMuted}
/>
<Text style={[styles.label]}>Password</Text>
<TextInput
style={[styles.input]}
value={credentials.password}
onChangeText={(text) => setCredentials({ ...credentials, password: text })}
placeholder="Enter your password"
secureTextEntry
placeholderTextColor={colors.textMuted}
/>
<View style={styles.rememberMeContainer}>
<TouchableOpacity
style={[styles.checkbox]}
onPress={() => setRememberMe(!rememberMe)}
>
<View style={[styles.checkboxInner, rememberMe && styles.checkboxChecked]} />
</TouchableOpacity>
<Text style={[styles.rememberMeText]}>Remember me</Text>
</View>
<TouchableOpacity
style={[styles.button, styles.primaryButton]}
onPress={handleSubmit}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator color={colors.text} />
) : (
<Text style={styles.buttonText}>Login</Text>
)}
</TouchableOpacity>
<Text style={[styles.noteText]}>
No account yet?{' '}
<Text
style={styles.clickableDomain}
onPress={() => Linking.openURL('https://app.aliasvault.net')}
>
Create new vault
<>
<View style={styles.headerContainer}>
<Text style={styles.headerTitle}>Log in</Text>
<Text style={styles.headerSubtitle}>
Connecting to{' '}
<Text
style={styles.clickableDomain}
onPress={() => router.push('/settings')}
>
{getDisplayUrl()}
</Text>
</Text>
</Text>
</View>
</View>
{error && (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{error}</Text>
</View>
)}
{twoFactorRequired ? (
<View style={styles.formContainer}>
<Text style={[styles.label]}>Authentication Code</Text>
<TextInput
style={[styles.input]}
value={twoFactorCode}
onChangeText={setTwoFactorCode}
placeholder="Enter 6-digit code"
keyboardType="numeric"
maxLength={6}
placeholderTextColor={colors.textMuted}
/>
<View style={styles.buttonContainer}>
<TouchableOpacity
style={[styles.button, styles.primaryButton]}
onPress={handleTwoFactorSubmit}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator color={colors.text} />
) : (
<Text style={styles.buttonText}>Verify</Text>
)}
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, styles.secondaryButton]}
onPress={() => {
setCredentials({ username: '', password: '' });
setTwoFactorRequired(false);
setTwoFactorCode('');
setPasswordHashString(null);
setPasswordHashBase64(null);
setLoginResponse(null);
setError(null);
}}
>
<Text style={styles.buttonText}>Cancel</Text>
</TouchableOpacity>
</View>
<Text style={[styles.noteText]}>
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.
</Text>
</View>
) : (
<View style={styles.formContainer}>
<Text style={[styles.label]}>Username or email</Text>
<TextInput
style={[styles.input]}
value={credentials.username}
onChangeText={(text) => setCredentials({ ...credentials, username: text })}
placeholder="name / name@company.com"
autoCapitalize="none"
placeholderTextColor={colors.textMuted}
/>
<Text style={[styles.label]}>Password</Text>
<TextInput
style={[styles.input]}
value={credentials.password}
onChangeText={(text) => setCredentials({ ...credentials, password: text })}
placeholder="Enter your password"
secureTextEntry
placeholderTextColor={colors.textMuted}
/>
<View style={styles.rememberMeContainer}>
<TouchableOpacity
style={[styles.checkbox]}
onPress={() => setRememberMe(!rememberMe)}
>
<View style={[styles.checkboxInner, rememberMe && styles.checkboxChecked]} />
</TouchableOpacity>
<Text style={[styles.rememberMeText]}>Remember me</Text>
</View>
<TouchableOpacity
style={[styles.button, styles.primaryButton]}
onPress={handleSubmit}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator color={colors.text} />
) : (
<Text style={styles.buttonText}>Login</Text>
)}
</TouchableOpacity>
<Text style={[styles.noteText]}>
No account yet?{' '}
<Text
style={styles.clickableDomain}
onPress={() => Linking.openURL('https://app.aliasvault.net')}
>
Create new vault
</Text>
</Text>
</View>
)}
</>
)}
</ThemedView>
</View>

View File

@@ -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 (
<View style={styles.container}>
<View style={styles.dotsContainer}>
<Animated.View
style={[
styles.dot,
{
opacity: dot1Anim.interpolate({
inputRange: [0, 1],
outputRange: [0.3, 1],
}),
},
]}
/>
<Animated.View
style={[
styles.dot,
{
opacity: dot2Anim.interpolate({
inputRange: [0, 1],
outputRange: [0.3, 1],
}),
},
]}
/>
<Animated.View
style={[
styles.dot,
{
opacity: dot3Anim.interpolate({
inputRange: [0, 1],
outputRange: [0.3, 1],
}),
},
]}
/>
<Animated.View
style={[
styles.dot,
{
opacity: dot4Anim.interpolate({
inputRange: [0, 1],
outputRange: [0.3, 1],
}),
},
]}
/>
</View>
<Text style={styles.statusText}>{status}{dots}</Text>
</View>
);
}

View File

@@ -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;

View File

@@ -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 <T>(
operation: () => Promise<T>,
minDelayMs: number,
initialSync: boolean
): Promise<T> => {
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<VaultResponse>('Vault');
onStatus?.('Syncing updated vault');
const vaultResponseJson = await withMinimumDelay(
() => webApi.get<VaultResponse>('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';