Merge pull request #143 from cogwheel0/improve-auth-error-handling

improve-auth-error-handling
This commit is contained in:
cogwheel
2025-11-12 22:13:56 +05:30
committed by GitHub
13 changed files with 283 additions and 89 deletions

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import '../utils/debug_logger.dart'; import '../utils/debug_logger.dart';
@@ -136,6 +138,7 @@ class ApiAuthInterceptor extends Interceptor {
final path = err.requestOptions.path; final path = err.requestOptions.path;
// Handle authentication errors consistently // Handle authentication errors consistently
// IMPORTANT: Never auto-logout. Instead, notify the app to show connection issue page
if (statusCode == 401) { if (statusCode == 401) {
// Do not clear the token for public or optional-auth endpoints. // Do not clear the token for public or optional-auth endpoints.
// A 401 here may indicate endpoint-level permission or server config, // A 401 here may indicate endpoint-level permission or server config,
@@ -143,8 +146,9 @@ class ApiAuthInterceptor extends Interceptor {
final requiresAuth = _requiresAuth(path); final requiresAuth = _requiresAuth(path);
final optionalAuth = _hasOptionalAuth(path); final optionalAuth = _hasOptionalAuth(path);
if (requiresAuth && !optionalAuth) { if (requiresAuth && !optionalAuth) {
DebugLogger.auth('401 Unauthorized on $path - clearing auth token'); _notifyAuthFailure(
_clearAuthToken(); '401 Unauthorized on $path - notifying app without clearing token',
);
} else { } else {
DebugLogger.auth( DebugLogger.auth(
'401 on public/optional endpoint $path - keeping auth token', '401 on public/optional endpoint $path - keeping auth token',
@@ -155,10 +159,9 @@ class ApiAuthInterceptor extends Interceptor {
final requiresAuth = _requiresAuth(path); final requiresAuth = _requiresAuth(path);
final optionalAuth = _hasOptionalAuth(path); final optionalAuth = _hasOptionalAuth(path);
if (requiresAuth && !optionalAuth) { if (requiresAuth && !optionalAuth) {
DebugLogger.auth( _notifyAuthFailure(
'403 Forbidden on protected endpoint $path - clearing auth token', '403 Forbidden on protected endpoint $path - notifying app without clearing token',
); );
_clearAuthToken();
} else { } else {
DebugLogger.auth( DebugLogger.auth(
'403 Forbidden on public/optional endpoint $path - keeping auth token', '403 Forbidden on public/optional endpoint $path - keeping auth token',
@@ -169,9 +172,23 @@ class ApiAuthInterceptor extends Interceptor {
handler.next(err); handler.next(err);
} }
/// Clear auth token and notify callbacks
/// Note: This should only be called for explicit logout, not for connection errors
void _clearAuthToken() { void _clearAuthToken() {
_authToken = null; _authToken = null;
final future = onTokenInvalidated?.call();
if (future != null) {
unawaited(future);
}
}
void _notifyAuthFailure(String message) {
DebugLogger.auth(message);
onAuthTokenInvalid?.call(); onAuthTokenInvalid?.call();
onTokenInvalidated?.call(); }
/// Explicitly clear auth token for logout scenarios
void clearAuthTokenForLogout() {
_clearAuthToken();
} }
} }

View File

@@ -100,11 +100,11 @@ class AuthCacheManager {
DebugLogger.storage('Cache entry cleared: $key'); DebugLogger.storage('Cache entry cleared: $key');
} }
/// Clear all auth-related cache /// Clear all auth-related cache including server configs
void clearAuthCache() { void clearAuthCache() {
_cache.clear(); _cache.clear();
_cacheTimestamps.clear(); _cacheTimestamps.clear();
DebugLogger.storage('All auth cache cleared'); DebugLogger.storage('All auth cache cleared (including server configs and custom headers)');
} }
/// Clear expired cache entries /// Clear expired cache entries

View File

@@ -79,6 +79,7 @@ enum AuthStatus {
unauthenticated, unauthenticated,
tokenExpired, tokenExpired,
error, error,
credentialError, // Invalid credentials - need re-login
} }
/// Unified auth state manager - single source of truth for all auth operations /// Unified auth state manager - single source of truth for all auth operations
@@ -87,6 +88,11 @@ class AuthStateManager extends _$AuthStateManager {
final AuthCacheManager _cacheManager = AuthCacheManager(); final AuthCacheManager _cacheManager = AuthCacheManager();
Future<bool>? _silentLoginFuture; Future<bool>? _silentLoginFuture;
// Prevent infinite retry loops
int _retryCount = 0;
static const int _maxRetries = 3;
DateTime? _lastRetryTime;
AuthState get _current => AuthState get _current =>
state.asData?.value ?? const AuthState(status: AuthStatus.initial); state.asData?.value ?? const AuthState(status: AuthStatus.initial);
@@ -496,13 +502,23 @@ class AuthStateManager extends _$AuthStateManager {
String errorMessage = e.toString(); String errorMessage = e.toString();
// Clear invalid credentials on auth errors // Don't clear credentials on connection errors - only clear on actual auth failures
if (e.toString().contains('401') || // Check if this is a genuine auth failure vs network issue
e.toString().contains('403') || final isNetworkError =
e.toString().contains('authentication') || e.toString().contains('SocketException') ||
e.toString().contains('unauthorized')) { e.toString().contains('Connection') ||
e.toString().contains('timeout') ||
e.toString().contains('NetworkImage');
if (!isNetworkError &&
(e.toString().contains('401') ||
e.toString().contains('403') ||
e.toString().contains('authentication') ||
e.toString().contains('unauthorized'))) {
// Only clear credentials if this is a real auth failure, not a network issue
final storage = ref.read(optimizedStorageServiceProvider); final storage = ref.read(optimizedStorageServiceProvider);
try { try {
DebugLogger.auth('Clearing invalid credentials after auth failure');
await storage.deleteSavedCredentials(); await storage.deleteSavedCredentials();
} catch (deleteError, deleteStack) { } catch (deleteError, deleteStack) {
DebugLogger.error( DebugLogger.error(
@@ -516,76 +532,152 @@ class AuthStateManager extends _$AuthStateManager {
'credentials; please clear Conduit credentials from ' 'credentials; please clear Conduit credentials from '
'system settings.'; 'system settings.';
} }
// Set credential error status to trigger login page
_update(
(current) => current.copyWith(
status: AuthStatus.credentialError,
error: errorMessage,
isLoading: false,
clearToken: true,
),
);
return false;
} else if (isNetworkError) {
DebugLogger.auth(
'Silent login failed due to network error - keeping credentials',
);
errorMessage = 'Connection issue - please check your network';
// Set general error status to trigger connection issue page
_update(
(current) => current.copyWith(
status: AuthStatus.error,
error: errorMessage,
isLoading: false,
),
);
return false;
} }
// Unknown error type - treat as connection issue but keep credentials
if (errorMessage.trim().isEmpty) {
errorMessage = 'Connection issue - please try again shortly';
}
DebugLogger.auth(
'Silent login failed with unknown error - keeping credentials',
);
_update( _update(
(current) => current.copyWith( (current) => current.copyWith(
status: AuthStatus.unauthenticated, status: AuthStatus.error,
error: errorMessage, error: errorMessage,
isLoading: false, isLoading: false,
clearToken: true,
), ),
); );
return false; return false;
} }
} }
/// Handle token invalidation (called by API service) /// Reset retry counter (called when user manually retries)
void resetRetryCounter() {
_retryCount = 0;
_lastRetryTime = null;
DebugLogger.auth('Retry counter reset for manual retry');
}
/// Handle auth issues (called by API service)
/// This shows connection issue page instead of logging out
void onAuthIssue() {
DebugLogger.auth('Auth issue detected - showing connection issue page');
// Don't clear token or user data - just set error state
// The router will show connection issue page
_update(
(current) => current.copyWith(
status: AuthStatus.error,
error: 'Connection issue - please check your connection',
clearError: false,
),
);
}
/// Handle token invalidation (called by API service for explicit token expiry)
/// This is only used when we need to clear the token for re-login attempts
Future<void> onTokenInvalidated() async { Future<void> onTokenInvalidated() async {
// Prevent infinite retry loops
final now = DateTime.now();
if (_lastRetryTime != null &&
now.difference(_lastRetryTime!).inSeconds < 5) {
_retryCount++;
if (_retryCount >= _maxRetries) {
DebugLogger.auth(
'Max retry attempts reached - stopping silent re-login',
);
_update(
(current) => current.copyWith(
status: AuthStatus.error,
error: 'Connection issue - please retry manually',
clearError: false,
),
);
// Reset after 30 seconds to allow manual retry
Future.delayed(const Duration(seconds: 30), () {
_retryCount = 0;
_lastRetryTime = null;
});
return;
}
} else {
// Reset counter if enough time has passed
_retryCount = 0;
}
_lastRetryTime = now;
// Avoid spamming logs if multiple requests invalidate at once // Avoid spamming logs if multiple requests invalidate at once
final reloginInProgress = _silentLoginFuture != null; final reloginInProgress = _silentLoginFuture != null;
if (!reloginInProgress) { if (!reloginInProgress) {
DebugLogger.auth('Auth token invalidated'); DebugLogger.auth(
'Auth token invalidated - attempting silent re-login (attempt ${_retryCount + 1}/$_maxRetries)',
);
} }
// Clear token from storage
final storage = ref.read(optimizedStorageServiceProvider); final storage = ref.read(optimizedStorageServiceProvider);
try { try {
await storage.deleteAuthToken(); await storage.deleteAuthToken();
_updateApiServiceToken(null); DebugLogger.auth('Cleared invalidated token from secure storage');
} catch (error, stack) { } catch (e, stack) {
DebugLogger.error( DebugLogger.error(
'token-delete-failed', 'token-delete-failed',
scope: 'auth/state', scope: 'auth/state',
error: error, error: e,
stackTrace: stack, stackTrace: stack,
); );
_updateApiServiceToken(null);
_update(
(current) => current.copyWith(
status: AuthStatus.error,
error:
'Failed to clear secure token. Please clear Conduit '
'credentials from your device keychain and sign in again.',
clearToken: true,
clearUser: true,
clearError: false,
),
);
return;
} }
_updateApiServiceToken(null);
// Update state
_update( _update(
(current) => current.copyWith( (current) => current.copyWith(
status: AuthStatus.tokenExpired, status: AuthStatus.tokenExpired,
error: 'Session expired - please sign in again',
clearToken: true, clearToken: true,
clearUser: true, clearUser: true,
clearError: true, isLoading: false,
), ),
); );
// Attempt silent re-login if credentials are available // Attempt silent re-login if credentials are available
final hasCredentials = await storage.getSavedCredentials() != null; final hasCredentials = await storage.getSavedCredentials() != null;
if (hasCredentials) { if (hasCredentials && !reloginInProgress) {
if (!reloginInProgress) { DebugLogger.auth('Attempting silent re-login after token invalidation');
DebugLogger.auth('Attempting silent re-login after token invalidation'); final success = await silentLogin();
if (success) {
// Reset retry counter on success
_retryCount = 0;
_lastRetryTime = null;
} }
await silentLogin();
} }
} }
/// Logout user /// Logout user and clear all data including server configs and custom headers
Future<void> logout() async { Future<void> logout() async {
_update( _update(
(current) => (current) =>
@@ -607,14 +699,20 @@ class AuthStateManager extends _$AuthStateManager {
} }
} }
// Clear all local auth data // Clear all local auth data (including server configs with custom headers)
final storage = ref.read(optimizedStorageServiceProvider); final storage = ref.read(optimizedStorageServiceProvider);
await storage.clearAuthData(); await storage.clearAuthData();
_updateApiServiceToken(null); _updateApiServiceToken(null);
// Clear active server to force return to server connection page // Clear active server to force return to server connection page
await storage.setActiveServerId(null); await storage.setActiveServerId(null);
// Invalidate all auth-related providers to clear cached data
ref.invalidate(activeServerProvider); ref.invalidate(activeServerProvider);
ref.invalidate(serverConfigsProvider);
// Clear auth cache manager
_cacheManager.clearAuthCache();
// Update state // Update state
_update( _update(
@@ -627,7 +725,7 @@ class AuthStateManager extends _$AuthStateManager {
), ),
); );
DebugLogger.auth('Logout complete'); DebugLogger.auth('Logout complete - all data cleared including server configs and custom headers');
} catch (e, stack) { } catch (e, stack) {
DebugLogger.error( DebugLogger.error(
'logout-failed', 'logout-failed',
@@ -637,8 +735,19 @@ class AuthStateManager extends _$AuthStateManager {
); );
// Even if logout fails, clear local state where possible // Even if logout fails, clear local state where possible
final storage = ref.read(optimizedStorageServiceProvider); final storage = ref.read(optimizedStorageServiceProvider);
try {
await storage.clearAuthData();
} catch (clearError) {
DebugLogger.error(
'logout-clear-failed',
scope: 'auth/state',
error: clearError,
);
}
await storage.setActiveServerId(null); await storage.setActiveServerId(null);
ref.invalidate(activeServerProvider); ref.invalidate(activeServerProvider);
ref.invalidate(serverConfigsProvider);
_cacheManager.clearAuthCache();
_update( _update(
(current) => current.copyWith( (current) => current.copyWith(
@@ -647,8 +756,8 @@ class AuthStateManager extends _$AuthStateManager {
clearToken: true, clearToken: true,
clearUser: true, clearUser: true,
error: error:
'Logout error: $e. Secure credentials may remain stored; ' 'Logout error: $e. Some data may remain stored; '
'please clear them from your device keychain.', 'please clear app data from your device settings if needed.',
), ),
); );
_updateApiServiceToken(null); _updateApiServiceToken(null);

View File

@@ -275,8 +275,14 @@ final apiServiceProvider = Provider<ApiService?>((ref) {
// Keep callbacks in sync so interceptor can notify auth manager // Keep callbacks in sync so interceptor can notify auth manager
apiService.setAuthCallbacks( apiService.setAuthCallbacks(
onAuthTokenInvalid: () {}, onAuthTokenInvalid: () {
// Called when auth errors occur (401/403)
// Show connection issue page instead of logging out
final authManager = ref.read(authStateManagerProvider.notifier);
authManager.onAuthIssue();
},
onTokenInvalidated: () async { onTokenInvalidated: () async {
// Called for token expiry - attempt silent re-login
final authManager = ref.read(authStateManagerProvider.notifier); final authManager = ref.read(authStateManagerProvider.notifier);
await authManager.onTokenInvalidated(); await authManager.onTokenInvalidated();
}, },
@@ -291,8 +297,9 @@ final apiServiceProvider = Provider<ApiService?>((ref) {
// Keep legacy callback for backward compatibility during transition // Keep legacy callback for backward compatibility during transition
apiService.onAuthTokenInvalid = () { apiService.onAuthTokenInvalid = () {
// This will be removed once migration is complete // Show connection issue page instead of logging out
DebugLogger.auth('legacy-token-callback', scope: 'auth/api'); final authManager = ref.read(authStateManagerProvider.notifier);
authManager.onAuthIssue();
}; };
return apiService; return apiService;

View File

@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../auth/auth_state_manager.dart';
import '../providers/app_providers.dart'; import '../providers/app_providers.dart';
import '../services/connectivity_service.dart'; import '../services/connectivity_service.dart';
import '../services/navigation_service.dart'; import '../services/navigation_service.dart';
@@ -129,8 +130,20 @@ class RouterNotifier extends ChangeNotifier {
if (location == Routes.connectionIssue) return null; if (location == Routes.connectionIssue) return null;
return null; return null;
case AuthNavigationState.error: case AuthNavigationState.error:
if (location == Routes.connectionIssue) return null; final authSnapshot = ref
return null; .read(authStateManagerProvider)
.maybeWhen(data: (state) => state, orElse: () => null);
final hasValidToken = authSnapshot?.hasValidToken ?? false;
final isAuthFormRoute =
location == Routes.login || location == Routes.authentication;
if (!hasValidToken && isAuthFormRoute) {
// Keep user on the login/authentication flow to show inline errors
return null;
}
// Otherwise show connection issue page for recoverable auth errors
return location == Routes.connectionIssue
? null
: Routes.connectionIssue;
case AuthNavigationState.authenticated: case AuthNavigationState.authenticated:
// Avoid unnecessary redirects if already on a non-auth route // Avoid unnecessary redirects if already on a non-auth route
if (_isAuthLocation(location) || if (_isAuthLocation(location) ||

View File

@@ -1170,9 +1170,7 @@ class ApiService {
data, data,
debugLabel: 'parse_file_search', debugLabel: 'parse_file_search',
); );
return normalized return normalized.map(FileInfo.fromJson).toList(growable: false);
.map(FileInfo.fromJson)
.toList(growable: false);
} }
return const []; return const [];
} }
@@ -1186,9 +1184,7 @@ class ApiService {
data, data,
debugLabel: 'parse_file_all', debugLabel: 'parse_file_all',
); );
return normalized return normalized.map(FileInfo.fromJson).toList(growable: false);
.map(FileInfo.fromJson)
.toList(growable: false);
} }
return const []; return const [];
} }
@@ -1599,10 +1595,7 @@ class ApiService {
if (data is Map<String, dynamic>) { if (data is Map<String, dynamic>) {
final voices = data['voices']; final voices = data['voices'];
if (voices is List) { if (voices is List) {
return _normalizeList( return _normalizeList(voices, debugLabel: 'parse_voice_list');
voices,
debugLabel: 'parse_voice_list',
);
} }
} }
if (data is List) { if (data is List) {
@@ -1795,10 +1788,7 @@ class ApiService {
final response = await _dio.get('/api/v1/images/models'); final response = await _dio.get('/api/v1/images/models');
final data = response.data; final data = response.data;
if (data is List) { if (data is List) {
return _normalizeList( return _normalizeList(data, debugLabel: 'parse_image_models');
data,
debugLabel: 'parse_image_models',
);
} }
return []; return [];
} }
@@ -3213,10 +3203,7 @@ class ApiService {
final data = response.data; final data = response.data;
if (data is List) { if (data is List) {
return _normalizeList( return _normalizeList(data, debugLabel: 'parse_message_search');
data,
debugLabel: 'parse_message_search',
);
} }
if (data is Map<String, dynamic>) { if (data is Map<String, dynamic>) {
final list = (data['items'] ?? data['results'] ?? data['messages']); final list = (data['items'] ?? data['results'] ?? data['messages']);

View File

@@ -395,11 +395,15 @@ class OptimizedStorageService {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Batch operations // Batch operations
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Clear all authentication-related data including credentials, tokens,
/// server configurations, and custom headers
Future<void> clearAuthData() async { Future<void> clearAuthData() async {
await Future.wait([ await Future.wait([
deleteAuthToken(), deleteAuthToken(),
deleteSavedCredentials(), deleteSavedCredentials(),
_preferencesBox.delete(_activeServerIdKey), _preferencesBox.delete(_activeServerIdKey),
// Clear server configurations (which include custom headers)
_secureCredentialStorage.clearAll(),
]); ]);
_cache.removeWhere( _cache.removeWhere(
@@ -416,7 +420,7 @@ class OptimizedStorageService {
); );
DebugLogger.log( DebugLogger.log(
'Auth data cleared in batch operation', 'Auth data cleared in batch operation (including server configs and custom headers)',
scope: 'storage/optimized', scope: 'storage/optimized',
); );
} }

View File

@@ -280,11 +280,12 @@ class SecureCredentialStorage {
} }
} }
/// Clear all secure data /// Clear all secure data including credentials, tokens, and server configurations
/// (which contain custom headers)
Future<void> clearAll() async { Future<void> clearAll() async {
try { try {
await _secureStorage.deleteAll(); await _secureStorage.deleteAll();
DebugLogger.storage('clear-ok', scope: 'credentials'); DebugLogger.storage('clear-ok (all secure data including server configs with custom headers)', scope: 'credentials');
} catch (e) { } catch (e) {
DebugLogger.error('clear-failed', scope: 'credentials', error: e); DebugLogger.error('clear-failed', scope: 'credentials', error: e);
} }

View File

@@ -161,7 +161,10 @@ class SettingsService {
box.get(PreferenceKeys.voiceSttPreference) as String?, box.get(PreferenceKeys.voiceSttPreference) as String?,
), ),
voiceSilenceDuration: voiceSilenceDuration:
(box.get(_voiceSilenceDurationKey) as int? ?? 2000).clamp(300, 3000), (box.get(_voiceSilenceDurationKey) as int? ?? 2000).clamp(
300,
3000,
),
), ),
); );
} }

View File

@@ -142,6 +142,7 @@ final authNavigationStateProvider = Provider<AuthNavigationState>((ref) {
return AuthNavigationState.authenticated; return AuthNavigationState.authenticated;
case AuthStatus.unauthenticated: case AuthStatus.unauthenticated:
case AuthStatus.tokenExpired: case AuthStatus.tokenExpired:
case AuthStatus.credentialError:
return AuthNavigationState.needsLogin; return AuthNavigationState.needsLogin;
case AuthStatus.error: case AuthStatus.error:
return AuthNavigationState.error; return AuthNavigationState.error;

View File

@@ -3,16 +3,16 @@ import 'dart:io' show Platform;
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/auth/auth_state_manager.dart';
import '../../../core/models/server_config.dart'; import '../../../core/models/server_config.dart';
import '../../../core/providers/app_providers.dart'; import '../../../core/providers/app_providers.dart';
import '../../../core/services/connectivity_service.dart'; import '../../../core/services/connectivity_service.dart';
import '../../../core/services/navigation_service.dart';
import '../../../core/widgets/error_boundary.dart'; import '../../../core/widgets/error_boundary.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
import '../../../shared/theme/theme_extensions.dart'; import '../../../shared/theme/theme_extensions.dart';
import '../../../shared/widgets/conduit_components.dart'; import '../../../shared/widgets/conduit_components.dart';
import '../../../shared/widgets/themed_dialogs.dart';
import '../providers/unified_auth_providers.dart'; import '../providers/unified_auth_providers.dart';
class ConnectionIssuePage extends ConsumerStatefulWidget { class ConnectionIssuePage extends ConsumerStatefulWidget {
@@ -25,6 +25,7 @@ class ConnectionIssuePage extends ConsumerStatefulWidget {
class _ConnectionIssuePageState extends ConsumerState<ConnectionIssuePage> { class _ConnectionIssuePageState extends ConsumerState<ConnectionIssuePage> {
bool _isLoggingOut = false; bool _isLoggingOut = false;
bool _isRetrying = false;
String? _statusMessage; String? _statusMessage;
@override @override
@@ -174,9 +175,8 @@ class _ConnectionIssuePageState extends ConsumerState<ConnectionIssuePage> {
children: [ children: [
ConduitButton( ConduitButton(
text: l10n.retry, text: l10n.retry,
onPressed: _isLoggingOut onPressed: (_isLoggingOut || _isRetrying) ? null : _retryConnection,
? null isLoading: _isRetrying,
: () => context.go(Routes.serverConnection),
icon: Platform.isIOS icon: Platform.isIOS
? CupertinoIcons.refresh ? CupertinoIcons.refresh
: Icons.refresh_rounded, : Icons.refresh_rounded,
@@ -185,7 +185,9 @@ class _ConnectionIssuePageState extends ConsumerState<ConnectionIssuePage> {
const SizedBox(height: Spacing.sm), const SizedBox(height: Spacing.sm),
ConduitButton( ConduitButton(
text: l10n.signOut, text: l10n.signOut,
onPressed: _isLoggingOut ? null : () => _logout(l10n), onPressed: (_isLoggingOut || _isRetrying)
? null
: () => _logout(l10n),
isLoading: _isLoggingOut, isLoading: _isLoggingOut,
isSecondary: true, isSecondary: true,
icon: Platform.isIOS icon: Platform.isIOS
@@ -212,7 +214,53 @@ class _ConnectionIssuePageState extends ConsumerState<ConnectionIssuePage> {
); );
} }
Future<void> _retryConnection() async {
setState(() {
_isRetrying = true;
_statusMessage = null;
});
try {
// Clear the error state and attempt to re-establish connection
final authManager = ref.read(authStateManagerProvider.notifier);
// Reset retry counter for manual retry attempts
authManager.resetRetryCounter();
await authManager.silentLogin();
// If successful, router will automatically navigate to chat
if (!mounted) return;
// Small delay to show loading state
await Future.delayed(const Duration(milliseconds: 500));
} catch (_) {
if (!mounted) return;
setState(() {
_statusMessage = 'Connection failed. Please try again.';
});
} finally {
if (mounted) {
setState(() {
_isRetrying = false;
});
}
}
}
Future<void> _logout(AppLocalizations l10n) async { Future<void> _logout(AppLocalizations l10n) async {
// Show confirmation dialog before logging out
final confirm = await ThemedDialogs.confirm(
context,
title: l10n.signOut,
message: l10n.endYourSession,
confirmText: l10n.signOut,
isDestructive: true,
);
if (!mounted) return;
if (!confirm) return;
setState(() { setState(() {
_isLoggingOut = true; _isLoggingOut = true;
_statusMessage = null; _statusMessage = null;

View File

@@ -698,7 +698,8 @@ class AppCustomizationPage extends ConsumerWidget {
children: [ children: [
Text( Text(
l10n.sttSilenceDuration, l10n.sttSilenceDuration,
style: theme.bodyMedium?.copyWith( style:
theme.bodyMedium?.copyWith(
color: theme.sidebarForeground, color: theme.sidebarForeground,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
) ?? ) ??
@@ -711,13 +712,16 @@ class AppCustomizationPage extends ConsumerWidget {
const SizedBox(height: Spacing.xs), const SizedBox(height: Spacing.xs),
Text( Text(
'${settings.voiceSilenceDuration}ms', '${settings.voiceSilenceDuration}ms',
style: theme.bodySmall?.copyWith( style:
color: theme.sidebarForeground theme.bodySmall?.copyWith(
.withValues(alpha: 0.7), color: theme.sidebarForeground.withValues(
alpha: 0.7,
),
) ?? ) ??
TextStyle( TextStyle(
color: theme.sidebarForeground color: theme.sidebarForeground.withValues(
.withValues(alpha: 0.7), alpha: 0.7,
),
fontSize: 12, fontSize: 12,
), ),
), ),
@@ -726,7 +730,8 @@ class AppCustomizationPage extends ConsumerWidget {
), ),
Text( Text(
'${(settings.voiceSilenceDuration / 1000).toStringAsFixed(1)}s', '${(settings.voiceSilenceDuration / 1000).toStringAsFixed(1)}s',
style: theme.bodyMedium?.copyWith( style:
theme.bodyMedium?.copyWith(
color: theme.buttonPrimary, color: theme.buttonPrimary,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
) ?? ) ??
@@ -752,7 +757,8 @@ class AppCustomizationPage extends ConsumerWidget {
), ),
Text( Text(
l10n.sttSilenceDurationDescription, l10n.sttSilenceDurationDescription,
style: theme.bodySmall?.copyWith( style:
theme.bodySmall?.copyWith(
color: theme.sidebarForeground.withValues(alpha: 0.7), color: theme.sidebarForeground.withValues(alpha: 0.7),
) ?? ) ??
TextStyle( TextStyle(

View File

@@ -147,9 +147,7 @@ Future<Set<String>> _scanUsedLocalizationKeys(Set<String> baseKeys) async {
} }
return false; return false;
} catch (e) { } catch (e) {
stderr.writeln( stderr.writeln('warning: failed to search for key "$key": $e');
'warning: failed to search for key "$key": $e',
);
return false; return false;
} }
} }