refactor: cleanup unsued files
This commit is contained in:
@@ -1773,19 +1773,12 @@ class ApiService {
|
||||
return response.data;
|
||||
} on DioException catch (e) {
|
||||
debugPrint('DEBUG: images/generations failed: ${e.response?.statusCode}');
|
||||
// Fallback to singular path some servers use
|
||||
final response = await _dio.post(
|
||||
'/api/v1/image/generations',
|
||||
data: {
|
||||
'prompt': prompt,
|
||||
if (model != null) 'model': model,
|
||||
if (width != null) 'width': width,
|
||||
if (height != null) 'height': height,
|
||||
if (steps != null) 'steps': steps,
|
||||
if (guidance != null) 'guidance': guidance,
|
||||
},
|
||||
DebugLogger.error(
|
||||
'Image generation request to /api/v1/images/generations failed',
|
||||
e,
|
||||
);
|
||||
return response.data;
|
||||
// Do not attempt singular fallback here - surface the original error
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../features/chat/views/chat_page.dart';
|
||||
import '../../features/files/views/workspace_page.dart';
|
||||
import '../../features/profile/views/profile_page.dart';
|
||||
|
||||
/// Service for handling deep links and navigation routing
|
||||
class DeepLinkService {
|
||||
/// Route to chat tab
|
||||
static void navigateToChat(BuildContext context) {
|
||||
Navigator.pushAndRemoveUntil(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const ChatPage()),
|
||||
(route) => false,
|
||||
);
|
||||
}
|
||||
|
||||
/// In single-screen mode, workspace/profile deep links route via navigator
|
||||
static void navigateToWorkspace(BuildContext context) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const WorkspacePage()),
|
||||
);
|
||||
}
|
||||
|
||||
static void navigateToProfile(BuildContext context) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const ProfilePage()),
|
||||
);
|
||||
}
|
||||
|
||||
/// Parse route and determine target tab
|
||||
static String? parsePath(String route) {
|
||||
switch (route) {
|
||||
case '/chat':
|
||||
case '/main/chat':
|
||||
return '/chat';
|
||||
// Support both new and legacy paths for workspace
|
||||
case '/workspace':
|
||||
case '/main/workspace':
|
||||
case '/files': // legacy
|
||||
case '/main/files': // legacy
|
||||
return '/workspace';
|
||||
case '/profile':
|
||||
case '/main/profile':
|
||||
return '/profile';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle deep link navigation
|
||||
static Widget handleDeepLink(String route) {
|
||||
final path = parsePath(route);
|
||||
switch (path) {
|
||||
case '/workspace':
|
||||
return const WorkspacePage();
|
||||
case '/profile':
|
||||
return const ProfilePage();
|
||||
case '/chat':
|
||||
default:
|
||||
return const ChatPage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider for deep link navigation
|
||||
final deepLinkProvider = Provider<DeepLinkService>((ref) => DeepLinkService());
|
||||
@@ -1,241 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../shared/theme/theme_extensions.dart';
|
||||
import '../../shared/widgets/themed_dialogs.dart';
|
||||
import 'user_friendly_error_handler.dart';
|
||||
|
||||
class ErrorHandlingService {
|
||||
static final _userFriendlyHandler = UserFriendlyErrorHandler();
|
||||
|
||||
static String getErrorMessage(dynamic error) {
|
||||
// Use the enhanced user-friendly error handler
|
||||
return _userFriendlyHandler.getUserMessage(error);
|
||||
}
|
||||
|
||||
/// Get recovery actions for an error
|
||||
static List<ErrorRecoveryAction> getRecoveryActions(dynamic error) {
|
||||
return _userFriendlyHandler.getRecoveryActions(error);
|
||||
}
|
||||
|
||||
static void showErrorSnackBar(
|
||||
BuildContext context,
|
||||
dynamic error, {
|
||||
VoidCallback? onRetry,
|
||||
String? customMessage,
|
||||
}) {
|
||||
if (customMessage != null) {
|
||||
// Use custom message if provided
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(customMessage),
|
||||
backgroundColor: context.conduitTheme.error,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
action: onRetry != null
|
||||
? SnackBarAction(
|
||||
label: 'Retry',
|
||||
textColor: context.conduitTheme.textInverse,
|
||||
onPressed: onRetry,
|
||||
)
|
||||
: null,
|
||||
duration: const Duration(seconds: 4),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Use enhanced error handler
|
||||
_userFriendlyHandler.showErrorSnackbar(context, error, onRetry: onRetry);
|
||||
}
|
||||
}
|
||||
|
||||
/// Show enhanced error dialog with recovery options
|
||||
static Future<void> showErrorDialog(
|
||||
BuildContext context,
|
||||
dynamic error, {
|
||||
VoidCallback? onRetry,
|
||||
bool showDetails = false,
|
||||
}) async {
|
||||
return _userFriendlyHandler.showErrorDialog(
|
||||
context,
|
||||
error,
|
||||
onRetry: onRetry,
|
||||
showDetails: showDetails,
|
||||
);
|
||||
}
|
||||
|
||||
static void showSuccessSnackBar(
|
||||
BuildContext context,
|
||||
String message, {
|
||||
Duration? duration,
|
||||
}) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: context.conduitTheme.success,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
duration: duration ?? const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<bool> showConfirmationDialog(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required String content,
|
||||
String confirmText = 'Confirm',
|
||||
String cancelText = 'Cancel',
|
||||
bool isDestructive = false,
|
||||
}) async {
|
||||
return await ThemedDialogs.confirm(
|
||||
context,
|
||||
title: title,
|
||||
message: content,
|
||||
confirmText: confirmText,
|
||||
cancelText: cancelText,
|
||||
isDestructive: isDestructive,
|
||||
);
|
||||
}
|
||||
|
||||
static Widget buildErrorWidget({
|
||||
required String message,
|
||||
VoidCallback? onRetry,
|
||||
IconData? icon,
|
||||
dynamic error,
|
||||
}) {
|
||||
if (error != null) {
|
||||
// Use enhanced error handler for full error objects
|
||||
return _userFriendlyHandler.buildErrorWidget(error, onRetry: onRetry);
|
||||
}
|
||||
|
||||
// Fallback to legacy implementation for string messages
|
||||
return Builder(
|
||||
builder: (context) {
|
||||
final theme = Theme.of(context);
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(Spacing.lg),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
icon ?? Icons.error_outline,
|
||||
size: Spacing.xxxl,
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: Spacing.md),
|
||||
Text(
|
||||
'Something went wrong',
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: Spacing.sm),
|
||||
Text(
|
||||
message,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (onRetry != null) ...[
|
||||
const SizedBox(height: Spacing.lg),
|
||||
ElevatedButton.icon(
|
||||
onPressed: onRetry,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Try Again'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Build enhanced error widget with recovery actions
|
||||
static Widget buildEnhancedErrorWidget(
|
||||
dynamic error, {
|
||||
VoidCallback? onRetry,
|
||||
VoidCallback? onDismiss,
|
||||
bool showDetails = false,
|
||||
}) {
|
||||
return _userFriendlyHandler.buildErrorWidget(
|
||||
error,
|
||||
onRetry: onRetry,
|
||||
onDismiss: onDismiss,
|
||||
showDetails: showDetails,
|
||||
);
|
||||
}
|
||||
|
||||
static Widget buildLoadingWidget({String? message}) {
|
||||
return Builder(
|
||||
builder: (context) {
|
||||
final theme = Theme.of(context);
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(Spacing.lg),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(color: theme.colorScheme.primary),
|
||||
if (message != null) ...[
|
||||
const SizedBox(height: Spacing.md),
|
||||
Text(
|
||||
message,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static Widget buildEmptyStateWidget({
|
||||
required String title,
|
||||
required String message,
|
||||
IconData? icon,
|
||||
Widget? action,
|
||||
}) {
|
||||
return Builder(
|
||||
builder: (context) {
|
||||
final theme = Theme.of(context);
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(Spacing.lg),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
icon ?? Icons.inbox_outlined,
|
||||
size: Spacing.xxxl,
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.4),
|
||||
),
|
||||
const SizedBox(height: Spacing.md),
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: Spacing.sm),
|
||||
Text(
|
||||
message,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (action != null) ...[
|
||||
const SizedBox(height: Spacing.lg),
|
||||
action,
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,373 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import '../../shared/theme/theme_extensions.dart';
|
||||
|
||||
/// Enhanced error recovery service with retry strategies and user feedback
|
||||
class ErrorRecoveryService {
|
||||
final Map<String, RetryConfig> _retryConfigs = {};
|
||||
final Map<String, DateTime> _lastRetryTimes = {};
|
||||
|
||||
ErrorRecoveryService(Dio dio);
|
||||
|
||||
/// Execute an operation with automatic retry and recovery
|
||||
Future<T> executeWithRecovery<T>({
|
||||
required String operationId,
|
||||
required Future<T> Function() operation,
|
||||
RetryConfig? retryConfig,
|
||||
RecoveryAction? recoveryAction,
|
||||
}) async {
|
||||
final config = retryConfig ?? RetryConfig.defaultConfig();
|
||||
_retryConfigs[operationId] = config;
|
||||
|
||||
int attempts = 0;
|
||||
Exception? lastError;
|
||||
|
||||
while (attempts < config.maxRetries) {
|
||||
try {
|
||||
final result = await operation();
|
||||
_clearRetryState(operationId);
|
||||
return result;
|
||||
} catch (error) {
|
||||
attempts++;
|
||||
lastError = error is Exception ? error : Exception(error.toString());
|
||||
|
||||
final shouldRetry = _shouldRetry(error, attempts, config);
|
||||
if (!shouldRetry || attempts >= config.maxRetries) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Execute recovery action if provided
|
||||
if (recoveryAction != null) {
|
||||
try {
|
||||
await recoveryAction.execute(error, attempts);
|
||||
} catch (recoveryError) {
|
||||
// Recovery action failed, continue with retry
|
||||
}
|
||||
}
|
||||
|
||||
// Wait before retry with exponential backoff
|
||||
final delay = _calculateRetryDelay(attempts, config);
|
||||
await Future.delayed(delay);
|
||||
}
|
||||
}
|
||||
|
||||
_clearRetryState(operationId);
|
||||
throw ErrorRecoveryException(lastError!, attempts);
|
||||
}
|
||||
|
||||
/// Check if we should retry based on error type and configuration
|
||||
bool _shouldRetry(dynamic error, int attempts, RetryConfig config) {
|
||||
if (attempts >= config.maxRetries) return false;
|
||||
|
||||
// Check cooldown period
|
||||
final lastRetry = _lastRetryTimes[config.operationId];
|
||||
if (lastRetry != null) {
|
||||
final timeSinceLastRetry = DateTime.now().difference(lastRetry);
|
||||
if (timeSinceLastRetry < config.cooldownPeriod) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Network errors are usually retryable
|
||||
if (error is DioException) {
|
||||
switch (error.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.sendTimeout:
|
||||
case DioExceptionType.receiveTimeout:
|
||||
case DioExceptionType.connectionError:
|
||||
return true;
|
||||
case DioExceptionType.badResponse:
|
||||
// Retry on server errors (5xx) but not client errors (4xx)
|
||||
final statusCode = error.response?.statusCode;
|
||||
return statusCode != null && statusCode >= 500;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check custom retry conditions
|
||||
return config.retryCondition?.call(error) ?? false;
|
||||
}
|
||||
|
||||
Duration _calculateRetryDelay(int attempt, RetryConfig config) {
|
||||
if (config.retryStrategy == RetryStrategy.exponentialBackoff) {
|
||||
final baseDelay = config.baseDelay.inMilliseconds;
|
||||
final delay = baseDelay * pow(2, attempt - 1);
|
||||
final jitter = Random().nextDouble() * 0.1 * delay; // Add 10% jitter
|
||||
return Duration(milliseconds: (delay + jitter).round());
|
||||
} else {
|
||||
return config.baseDelay;
|
||||
}
|
||||
}
|
||||
|
||||
void _clearRetryState(String operationId) {
|
||||
_retryConfigs.remove(operationId);
|
||||
_lastRetryTimes.remove(operationId);
|
||||
}
|
||||
|
||||
/// Get user-friendly error message
|
||||
String getErrorMessage(dynamic error) {
|
||||
if (error is ErrorRecoveryException) {
|
||||
return _getRecoveryErrorMessage(error);
|
||||
}
|
||||
|
||||
if (error is DioException) {
|
||||
switch (error.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
return 'The connection is taking too long. Please check your internet and try again.';
|
||||
case DioExceptionType.sendTimeout:
|
||||
return 'Failed to send your request. Please try again.';
|
||||
case DioExceptionType.receiveTimeout:
|
||||
return 'The server is taking too long to respond. Please try again.';
|
||||
case DioExceptionType.connectionError:
|
||||
return 'Unable to connect. Please check your internet connection.';
|
||||
case DioExceptionType.badResponse:
|
||||
final statusCode = error.response?.statusCode;
|
||||
if (statusCode == 401) {
|
||||
return 'Your session has expired. Please sign in again.';
|
||||
} else if (statusCode == 403) {
|
||||
return 'You don\'t have permission to perform this action.';
|
||||
} else if (statusCode == 404) {
|
||||
return 'The requested resource was not found.';
|
||||
} else if (statusCode != null && statusCode >= 500) {
|
||||
return 'The server is experiencing issues. Please try again later.';
|
||||
}
|
||||
return 'Something went wrong with your request.';
|
||||
case DioExceptionType.cancel:
|
||||
return 'The request was cancelled.';
|
||||
case DioExceptionType.badCertificate:
|
||||
return 'There\'s a security issue with the connection.';
|
||||
case DioExceptionType.unknown:
|
||||
return 'Something unexpected happened. Please try again.';
|
||||
}
|
||||
}
|
||||
|
||||
return error.toString();
|
||||
}
|
||||
|
||||
String _getRecoveryErrorMessage(ErrorRecoveryException error) {
|
||||
final attempts = error.attempts;
|
||||
final originalError = getErrorMessage(error.originalError);
|
||||
|
||||
return 'Failed after $attempts attempts: $originalError';
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for retry behavior
|
||||
class RetryConfig {
|
||||
final String operationId;
|
||||
final int maxRetries;
|
||||
final Duration baseDelay;
|
||||
final Duration cooldownPeriod;
|
||||
final RetryStrategy retryStrategy;
|
||||
final bool Function(dynamic error)? retryCondition;
|
||||
|
||||
const RetryConfig({
|
||||
required this.operationId,
|
||||
this.maxRetries = 3,
|
||||
this.baseDelay = const Duration(seconds: 1),
|
||||
this.cooldownPeriod = const Duration(seconds: 5),
|
||||
this.retryStrategy = RetryStrategy.exponentialBackoff,
|
||||
this.retryCondition,
|
||||
});
|
||||
|
||||
static RetryConfig defaultConfig() => const RetryConfig(
|
||||
operationId: 'default',
|
||||
maxRetries: 3,
|
||||
baseDelay: Duration(seconds: 1),
|
||||
retryStrategy: RetryStrategy.exponentialBackoff,
|
||||
);
|
||||
|
||||
static RetryConfig networkConfig() => const RetryConfig(
|
||||
operationId: 'network',
|
||||
maxRetries: 5,
|
||||
baseDelay: Duration(milliseconds: 500),
|
||||
retryStrategy: RetryStrategy.exponentialBackoff,
|
||||
);
|
||||
|
||||
static RetryConfig chatConfig() => const RetryConfig(
|
||||
operationId: 'chat',
|
||||
maxRetries: 3,
|
||||
baseDelay: Duration(seconds: 2),
|
||||
retryStrategy: RetryStrategy.exponentialBackoff,
|
||||
);
|
||||
}
|
||||
|
||||
enum RetryStrategy { fixed, exponentialBackoff }
|
||||
|
||||
/// Recovery action to execute between retries
|
||||
abstract class RecoveryAction {
|
||||
Future<void> execute(dynamic error, int attempt);
|
||||
}
|
||||
|
||||
/// Reconnect to server recovery action
|
||||
class ReconnectAction extends RecoveryAction {
|
||||
final Future<void> Function() reconnectFunction;
|
||||
|
||||
ReconnectAction(this.reconnectFunction);
|
||||
|
||||
@override
|
||||
Future<void> execute(dynamic error, int attempt) async {
|
||||
if (attempt == 1) {
|
||||
// Only try to reconnect on the first retry
|
||||
await reconnectFunction();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh token recovery action
|
||||
class RefreshTokenAction extends RecoveryAction {
|
||||
final Future<void> Function() refreshFunction;
|
||||
|
||||
RefreshTokenAction(this.refreshFunction);
|
||||
|
||||
@override
|
||||
Future<void> execute(dynamic error, int attempt) async {
|
||||
if (error is DioException && error.response?.statusCode == 401) {
|
||||
await refreshFunction();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear cache recovery action
|
||||
class ClearCacheAction extends RecoveryAction {
|
||||
final Future<void> Function() clearCacheFunction;
|
||||
|
||||
ClearCacheAction(this.clearCacheFunction);
|
||||
|
||||
@override
|
||||
Future<void> execute(dynamic error, int attempt) async {
|
||||
if (attempt == 2) {
|
||||
// Clear cache on second attempt
|
||||
await clearCacheFunction();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Error recovery exception
|
||||
class ErrorRecoveryException implements Exception {
|
||||
final Exception originalError;
|
||||
final int attempts;
|
||||
|
||||
const ErrorRecoveryException(this.originalError, this.attempts);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'ErrorRecoveryException: $originalError (after $attempts attempts)';
|
||||
}
|
||||
|
||||
/// Providers
|
||||
final errorRecoveryServiceProvider = Provider<ErrorRecoveryService>((ref) {
|
||||
// This should use the same Dio instance as the API service
|
||||
final dio = Dio(); // Replace with actual Dio provider
|
||||
return ErrorRecoveryService(dio);
|
||||
});
|
||||
|
||||
/// Error boundary widget for handling UI errors
|
||||
class ErrorBoundary extends StatefulWidget {
|
||||
final Widget child;
|
||||
final Widget Function(Object error, VoidCallback retry)? errorBuilder;
|
||||
final void Function(Object error, StackTrace stackTrace)? onError;
|
||||
|
||||
const ErrorBoundary({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.errorBuilder,
|
||||
this.onError,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ErrorBoundary> createState() => _ErrorBoundaryState();
|
||||
}
|
||||
|
||||
class _ErrorBoundaryState extends State<ErrorBoundary> {
|
||||
Object? error;
|
||||
StackTrace? stackTrace;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (error != null) {
|
||||
return widget.errorBuilder?.call(error!, _retry) ??
|
||||
_buildDefaultErrorWidget();
|
||||
}
|
||||
|
||||
return ErrorDetector(
|
||||
onError: (error, stackTrace) {
|
||||
setState(() {
|
||||
this.error = error;
|
||||
this.stackTrace = stackTrace;
|
||||
});
|
||||
widget.onError?.call(error, stackTrace);
|
||||
},
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDefaultErrorWidget() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: Spacing.xxxl,
|
||||
color: context.conduitTheme.error,
|
||||
),
|
||||
const SizedBox(height: Spacing.md),
|
||||
const Text(
|
||||
'Something went wrong',
|
||||
style: TextStyle(
|
||||
fontSize: AppTypography.headlineSmall,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Spacing.sm),
|
||||
Text(
|
||||
error.toString(),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: context.conduitTheme.textSecondary),
|
||||
),
|
||||
const SizedBox(height: Spacing.md),
|
||||
ElevatedButton(onPressed: _retry, child: const Text('Try Again')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _retry() {
|
||||
setState(() {
|
||||
error = null;
|
||||
stackTrace = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget to detect and handle errors in child widgets
|
||||
class ErrorDetector extends StatefulWidget {
|
||||
final Widget child;
|
||||
final void Function(Object error, StackTrace stackTrace) onError;
|
||||
|
||||
const ErrorDetector({super.key, required this.child, required this.onError});
|
||||
|
||||
@override
|
||||
State<ErrorDetector> createState() => _ErrorDetectorState();
|
||||
}
|
||||
|
||||
class _ErrorDetectorState extends State<ErrorDetector> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return widget.child;
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
// Set up error handling
|
||||
FlutterError.onError = (details) {
|
||||
widget.onError(details.exception, details.stack ?? StackTrace.current);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,408 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/semantics.dart';
|
||||
|
||||
/// Comprehensive focus management service for accessibility
|
||||
class FocusManagementService {
|
||||
static final Map<String, FocusNode> _focusNodes = {};
|
||||
static final Map<String, FocusNode> _disposedNodes = {};
|
||||
static FocusNode? _lastFocusedNode;
|
||||
static final List<FocusNode> _focusHistory = [];
|
||||
|
||||
/// Register a focus node with a unique identifier
|
||||
static FocusNode registerFocusNode(
|
||||
String identifier, {
|
||||
String? debugLabel,
|
||||
FocusOnKeyEventCallback? onKeyEvent,
|
||||
bool skipTraversal = false,
|
||||
bool canRequestFocus = true,
|
||||
}) {
|
||||
// Check if node already exists
|
||||
if (_focusNodes.containsKey(identifier)) {
|
||||
return _focusNodes[identifier]!;
|
||||
}
|
||||
|
||||
// Create new focus node
|
||||
final focusNode = FocusNode(
|
||||
debugLabel: debugLabel ?? identifier,
|
||||
onKeyEvent: onKeyEvent,
|
||||
skipTraversal: skipTraversal,
|
||||
canRequestFocus: canRequestFocus,
|
||||
);
|
||||
|
||||
// Add listener to track focus changes
|
||||
focusNode.addListener(() {
|
||||
if (focusNode.hasFocus) {
|
||||
_onFocusChanged(focusNode);
|
||||
}
|
||||
});
|
||||
|
||||
_focusNodes[identifier] = focusNode;
|
||||
return focusNode;
|
||||
}
|
||||
|
||||
/// Get a registered focus node
|
||||
static FocusNode? getFocusNode(String identifier) {
|
||||
return _focusNodes[identifier];
|
||||
}
|
||||
|
||||
/// Dispose a focus node
|
||||
static void disposeFocusNode(String identifier) {
|
||||
final node = _focusNodes.remove(identifier);
|
||||
if (node != null) {
|
||||
_disposedNodes[identifier] = node;
|
||||
node.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispose all focus nodes
|
||||
static void disposeAll() {
|
||||
for (final node in _focusNodes.values) {
|
||||
node.dispose();
|
||||
}
|
||||
_focusNodes.clear();
|
||||
_focusHistory.clear();
|
||||
_lastFocusedNode = null;
|
||||
}
|
||||
|
||||
/// Request focus for a specific node
|
||||
static void requestFocus(String identifier) {
|
||||
final node = _focusNodes[identifier];
|
||||
if (node != null && node.canRequestFocus) {
|
||||
node.requestFocus();
|
||||
HapticFeedback.selectionClick();
|
||||
}
|
||||
}
|
||||
|
||||
/// Unfocus current focus
|
||||
static void unfocus(
|
||||
BuildContext context, {
|
||||
UnfocusDisposition disposition = UnfocusDisposition.scope,
|
||||
}) {
|
||||
FocusScope.of(context).unfocus(disposition: disposition);
|
||||
}
|
||||
|
||||
/// Move focus to next focusable element
|
||||
static bool nextFocus(BuildContext context) {
|
||||
return FocusScope.of(context).nextFocus();
|
||||
}
|
||||
|
||||
/// Move focus to previous focusable element
|
||||
static bool previousFocus(BuildContext context) {
|
||||
return FocusScope.of(context).previousFocus();
|
||||
}
|
||||
|
||||
/// Track focus changes
|
||||
static void _onFocusChanged(FocusNode node) {
|
||||
_lastFocusedNode = node;
|
||||
_focusHistory.add(node);
|
||||
|
||||
// Limit history size
|
||||
if (_focusHistory.length > 10) {
|
||||
_focusHistory.removeAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore last focus
|
||||
static void restoreLastFocus() {
|
||||
if (_lastFocusedNode != null && _lastFocusedNode!.canRequestFocus) {
|
||||
_lastFocusedNode!.requestFocus();
|
||||
}
|
||||
}
|
||||
|
||||
/// Get focus history
|
||||
static List<FocusNode> getFocusHistory() {
|
||||
return List.unmodifiable(_focusHistory);
|
||||
}
|
||||
|
||||
/// Create a focus trap for modal dialogs
|
||||
static Widget createFocusTrap({
|
||||
required Widget child,
|
||||
bool autofocus = true,
|
||||
}) {
|
||||
return FocusScope(autofocus: autofocus, child: child);
|
||||
}
|
||||
|
||||
/// Create keyboard navigation handler
|
||||
static FocusOnKeyEventCallback createKeyboardNavigationHandler({
|
||||
VoidCallback? onEnter,
|
||||
VoidCallback? onEscape,
|
||||
VoidCallback? onTab,
|
||||
VoidCallback? onArrowUp,
|
||||
VoidCallback? onArrowDown,
|
||||
VoidCallback? onArrowLeft,
|
||||
VoidCallback? onArrowRight,
|
||||
}) {
|
||||
return (FocusNode node, KeyEvent event) {
|
||||
if (event is! KeyDownEvent) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
final key = event.logicalKey;
|
||||
|
||||
if (key == LogicalKeyboardKey.enter ||
|
||||
key == LogicalKeyboardKey.numpadEnter) {
|
||||
onEnter?.call();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
if (key == LogicalKeyboardKey.escape) {
|
||||
onEscape?.call();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
if (key == LogicalKeyboardKey.tab) {
|
||||
onTab?.call();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
if (key == LogicalKeyboardKey.arrowUp) {
|
||||
onArrowUp?.call();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
if (key == LogicalKeyboardKey.arrowDown) {
|
||||
onArrowDown?.call();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
if (key == LogicalKeyboardKey.arrowLeft) {
|
||||
onArrowLeft?.call();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
if (key == LogicalKeyboardKey.arrowRight) {
|
||||
onArrowRight?.call();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Focus manager widget that manages focus for its children
|
||||
class FocusManager extends StatefulWidget {
|
||||
final Widget child;
|
||||
final bool autofocus;
|
||||
final bool trapFocus;
|
||||
final FocusOnKeyEventCallback? onKeyEvent;
|
||||
|
||||
const FocusManager({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.autofocus = false,
|
||||
this.trapFocus = false,
|
||||
this.onKeyEvent,
|
||||
});
|
||||
|
||||
@override
|
||||
State<FocusManager> createState() => _FocusManagerState();
|
||||
}
|
||||
|
||||
class _FocusManagerState extends State<FocusManager> {
|
||||
late FocusScopeNode _focusScopeNode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focusScopeNode = FocusScopeNode(
|
||||
debugLabel: 'FocusManager',
|
||||
onKeyEvent: widget.onKeyEvent,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusScopeNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget child = FocusScope(
|
||||
node: _focusScopeNode,
|
||||
autofocus: widget.autofocus,
|
||||
child: widget.child,
|
||||
);
|
||||
|
||||
if (widget.trapFocus) {
|
||||
child = FocusTraversalGroup(
|
||||
policy: OrderedTraversalPolicy(),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
return child;
|
||||
}
|
||||
}
|
||||
|
||||
/// Accessible form field with proper focus management
|
||||
class AccessibleFormField extends StatefulWidget {
|
||||
final String label;
|
||||
final String? hint;
|
||||
final TextEditingController controller;
|
||||
final String? Function(String?)? validator;
|
||||
final TextInputType? keyboardType;
|
||||
final bool obscureText;
|
||||
final bool autofocus;
|
||||
final String? semanticLabel;
|
||||
final String? errorSemanticLabel;
|
||||
final ValueChanged<String>? onChanged;
|
||||
final VoidCallback? onEditingComplete;
|
||||
final ValueChanged<String>? onSubmitted;
|
||||
final List<TextInputFormatter>? inputFormatters;
|
||||
final int? maxLines;
|
||||
final int? maxLength;
|
||||
final bool enabled;
|
||||
final Widget? suffixIcon;
|
||||
final Widget? prefixIcon;
|
||||
final FocusNode? focusNode;
|
||||
|
||||
const AccessibleFormField({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.hint,
|
||||
required this.controller,
|
||||
this.validator,
|
||||
this.keyboardType,
|
||||
this.obscureText = false,
|
||||
this.autofocus = false,
|
||||
this.semanticLabel,
|
||||
this.errorSemanticLabel,
|
||||
this.onChanged,
|
||||
this.onEditingComplete,
|
||||
this.onSubmitted,
|
||||
this.inputFormatters,
|
||||
this.maxLines = 1,
|
||||
this.maxLength,
|
||||
this.enabled = true,
|
||||
this.suffixIcon,
|
||||
this.prefixIcon,
|
||||
this.focusNode,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AccessibleFormField> createState() => _AccessibleFormFieldState();
|
||||
}
|
||||
|
||||
class _AccessibleFormFieldState extends State<AccessibleFormField> {
|
||||
late FocusNode _focusNode;
|
||||
String? _errorText;
|
||||
bool _hasFocus = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focusNode = widget.focusNode ?? FocusNode(debugLabel: widget.label);
|
||||
_focusNode.addListener(_onFocusChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (widget.focusNode == null) {
|
||||
_focusNode.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onFocusChanged() {
|
||||
setState(() {
|
||||
_hasFocus = _focusNode.hasFocus;
|
||||
});
|
||||
|
||||
// Announce focus change for screen readers
|
||||
if (_hasFocus) {
|
||||
final announcement =
|
||||
widget.semanticLabel ??
|
||||
'${widget.label} text field. ${widget.hint ?? ''}';
|
||||
SemanticsService.announce(announcement, TextDirection.ltr);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Semantics(
|
||||
label: widget.semanticLabel ?? widget.label,
|
||||
hint: widget.hint,
|
||||
textField: true,
|
||||
enabled: widget.enabled,
|
||||
focusable: true,
|
||||
focused: _hasFocus,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Label
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4.0),
|
||||
child: Text(
|
||||
widget.label,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: _hasFocus
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.onSurface,
|
||||
fontWeight: _hasFocus ? FontWeight.w600 : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Text field
|
||||
TextFormField(
|
||||
controller: widget.controller,
|
||||
focusNode: _focusNode,
|
||||
validator: (value) {
|
||||
final error = widget.validator?.call(value);
|
||||
setState(() {
|
||||
_errorText = error;
|
||||
});
|
||||
|
||||
// Announce error for screen readers
|
||||
if (error != null) {
|
||||
final errorAnnouncement =
|
||||
widget.errorSemanticLabel ?? 'Error: $error';
|
||||
SemanticsService.announce(errorAnnouncement, TextDirection.ltr);
|
||||
}
|
||||
|
||||
return error;
|
||||
},
|
||||
keyboardType: widget.keyboardType,
|
||||
obscureText: widget.obscureText,
|
||||
autofocus: widget.autofocus,
|
||||
onChanged: widget.onChanged,
|
||||
onEditingComplete: widget.onEditingComplete,
|
||||
onFieldSubmitted: widget.onSubmitted,
|
||||
inputFormatters: widget.inputFormatters,
|
||||
maxLines: widget.maxLines,
|
||||
maxLength: widget.maxLength,
|
||||
enabled: widget.enabled,
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.hint,
|
||||
errorText: _errorText,
|
||||
suffixIcon: widget.suffixIcon,
|
||||
prefixIcon: widget.prefixIcon,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: theme.colorScheme.primary,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: theme.colorScheme.error,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,430 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../utils/debug_logger.dart';
|
||||
|
||||
/// Navigation state data model
|
||||
class NavigationState {
|
||||
final String routeName;
|
||||
final Map<String, dynamic> arguments;
|
||||
final DateTime timestamp;
|
||||
final String? conversationId;
|
||||
final int? tabIndex;
|
||||
|
||||
NavigationState({
|
||||
required this.routeName,
|
||||
this.arguments = const {},
|
||||
DateTime? timestamp,
|
||||
this.conversationId,
|
||||
this.tabIndex,
|
||||
}) : timestamp = timestamp ?? DateTime.now();
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'routeName': routeName,
|
||||
'arguments': arguments,
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
'conversationId': conversationId,
|
||||
'tabIndex': tabIndex,
|
||||
};
|
||||
|
||||
factory NavigationState.fromJson(Map<String, dynamic> json) {
|
||||
return NavigationState(
|
||||
routeName: json['routeName'] ?? '/',
|
||||
arguments: json['arguments'] ?? {},
|
||||
timestamp: DateTime.tryParse(json['timestamp'] ?? '') ?? DateTime.now(),
|
||||
conversationId: json['conversationId'],
|
||||
tabIndex: json['tabIndex'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Service to manage navigation state preservation and restoration
|
||||
class NavigationStateService {
|
||||
static final NavigationStateService _instance =
|
||||
NavigationStateService._internal();
|
||||
factory NavigationStateService() => _instance;
|
||||
NavigationStateService._internal();
|
||||
|
||||
static const String _navigationStackKey = 'navigation_stack';
|
||||
static const String _currentStateKey = 'current_navigation_state';
|
||||
static const String _deepLinkStateKey = 'deep_link_state';
|
||||
|
||||
SharedPreferences? _prefs;
|
||||
final List<NavigationState> _navigationStack = [];
|
||||
NavigationState? _currentState;
|
||||
final ValueNotifier<NavigationState?> _stateNotifier = ValueNotifier(null);
|
||||
|
||||
/// Initialize the service
|
||||
Future<void> initialize() async {
|
||||
try {
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
await _loadNavigationState();
|
||||
DebugLogger.navigation('NavigationStateService initialized');
|
||||
} catch (e) {
|
||||
DebugLogger.error('Failed to initialize NavigationStateService', e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current navigation state as a ValueNotifier for listening to changes
|
||||
ValueNotifier<NavigationState?> get stateNotifier => _stateNotifier;
|
||||
|
||||
/// Get current navigation state
|
||||
NavigationState? get currentState => _currentState;
|
||||
|
||||
/// Get navigation stack
|
||||
List<NavigationState> get navigationStack =>
|
||||
List.unmodifiable(_navigationStack);
|
||||
|
||||
/// Push a new navigation state
|
||||
Future<void> pushState({
|
||||
required String routeName,
|
||||
Map<String, dynamic> arguments = const {},
|
||||
String? conversationId,
|
||||
int? tabIndex,
|
||||
}) async {
|
||||
try {
|
||||
final state = NavigationState(
|
||||
routeName: routeName,
|
||||
arguments: arguments,
|
||||
conversationId: conversationId,
|
||||
tabIndex: tabIndex,
|
||||
);
|
||||
|
||||
_navigationStack.add(state);
|
||||
_currentState = state;
|
||||
_stateNotifier.value = state;
|
||||
|
||||
await _saveNavigationState();
|
||||
|
||||
DebugLogger.navigation('Navigation state pushed - ${state.routeName}');
|
||||
} catch (e) {
|
||||
DebugLogger.error('Failed to push navigation state', e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pop the last navigation state
|
||||
Future<NavigationState?> popState() async {
|
||||
try {
|
||||
if (_navigationStack.isEmpty) return null;
|
||||
|
||||
final poppedState = _navigationStack.removeLast();
|
||||
_currentState = _navigationStack.isNotEmpty
|
||||
? _navigationStack.last
|
||||
: null;
|
||||
_stateNotifier.value = _currentState;
|
||||
|
||||
await _saveNavigationState();
|
||||
|
||||
DebugLogger.navigation(
|
||||
'Navigation state popped - ${poppedState.routeName}',
|
||||
);
|
||||
return poppedState;
|
||||
} catch (e) {
|
||||
DebugLogger.error('Failed to pop navigation state', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Update current state with new information
|
||||
Future<void> updateCurrentState({
|
||||
String? conversationId,
|
||||
int? tabIndex,
|
||||
Map<String, dynamic>? additionalArgs,
|
||||
}) async {
|
||||
try {
|
||||
if (_currentState == null) return;
|
||||
|
||||
final updatedArgs = <String, dynamic>{
|
||||
..._currentState!.arguments,
|
||||
if (additionalArgs != null) ...additionalArgs,
|
||||
};
|
||||
|
||||
final updatedState = NavigationState(
|
||||
routeName: _currentState!.routeName,
|
||||
arguments: updatedArgs,
|
||||
conversationId: conversationId ?? _currentState!.conversationId,
|
||||
tabIndex: tabIndex ?? _currentState!.tabIndex,
|
||||
timestamp: _currentState!.timestamp,
|
||||
);
|
||||
|
||||
// Update both current state and last item in stack
|
||||
_currentState = updatedState;
|
||||
if (_navigationStack.isNotEmpty) {
|
||||
_navigationStack[_navigationStack.length - 1] = updatedState;
|
||||
}
|
||||
|
||||
_stateNotifier.value = updatedState;
|
||||
await _saveNavigationState();
|
||||
|
||||
DebugLogger.navigation('Navigation state updated');
|
||||
} catch (e) {
|
||||
DebugLogger.error('Failed to update navigation state', e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear navigation stack but preserve current state
|
||||
Future<void> clearStack() async {
|
||||
try {
|
||||
_navigationStack.clear();
|
||||
if (_currentState != null) {
|
||||
_navigationStack.add(_currentState!);
|
||||
}
|
||||
await _saveNavigationState();
|
||||
DebugLogger.navigation('Navigation stack cleared');
|
||||
} catch (e) {
|
||||
DebugLogger.error('Failed to clear navigation stack', e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace entire navigation stack
|
||||
Future<void> replaceStack(List<NavigationState> newStack) async {
|
||||
try {
|
||||
_navigationStack.clear();
|
||||
_navigationStack.addAll(newStack);
|
||||
_currentState = newStack.isNotEmpty ? newStack.last : null;
|
||||
_stateNotifier.value = _currentState;
|
||||
|
||||
await _saveNavigationState();
|
||||
DebugLogger.navigation(
|
||||
'Navigation stack replaced with ${newStack.length} states',
|
||||
);
|
||||
} catch (e) {
|
||||
DebugLogger.error('Failed to replace navigation stack', e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle deep link by preserving navigation context
|
||||
Future<void> handleDeepLink({
|
||||
required String routeName,
|
||||
Map<String, dynamic> arguments = const {},
|
||||
String? conversationId,
|
||||
bool preserveStack = true,
|
||||
}) async {
|
||||
try {
|
||||
// Save deep link state for restoration
|
||||
final deepLinkState = NavigationState(
|
||||
routeName: routeName,
|
||||
arguments: arguments,
|
||||
conversationId: conversationId,
|
||||
);
|
||||
|
||||
await _saveDeepLinkState(deepLinkState);
|
||||
|
||||
if (preserveStack) {
|
||||
// Add to existing stack instead of replacing
|
||||
await pushState(
|
||||
routeName: routeName,
|
||||
arguments: arguments,
|
||||
conversationId: conversationId,
|
||||
);
|
||||
} else {
|
||||
// Replace stack with deep link
|
||||
await replaceStack([deepLinkState]);
|
||||
}
|
||||
|
||||
DebugLogger.navigation('Deep link handled - $routeName');
|
||||
} catch (e) {
|
||||
DebugLogger.error('Failed to handle deep link', e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the conversation context from current navigation state
|
||||
String? getConversationContext() {
|
||||
return _currentState?.conversationId;
|
||||
}
|
||||
|
||||
/// Get the current tab index
|
||||
int? getCurrentTabIndex() {
|
||||
return _currentState?.tabIndex;
|
||||
}
|
||||
|
||||
/// Generate breadcrumb navigation based on current stack
|
||||
List<NavigationBreadcrumb> generateBreadcrumbs() {
|
||||
final breadcrumbs = <NavigationBreadcrumb>[];
|
||||
|
||||
for (int i = 0; i < _navigationStack.length; i++) {
|
||||
final state = _navigationStack[i];
|
||||
final isLast = i == _navigationStack.length - 1;
|
||||
|
||||
breadcrumbs.add(
|
||||
NavigationBreadcrumb(
|
||||
title: _getRouteTitle(state.routeName),
|
||||
routeName: state.routeName,
|
||||
arguments: state.arguments,
|
||||
isActive: isLast,
|
||||
canNavigateBack: i > 0,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return breadcrumbs;
|
||||
}
|
||||
|
||||
/// Check if we can navigate back
|
||||
bool canGoBack() {
|
||||
return _navigationStack.length > 1;
|
||||
}
|
||||
|
||||
/// Get previous state without popping
|
||||
NavigationState? getPreviousState() {
|
||||
if (_navigationStack.length < 2) return null;
|
||||
return _navigationStack[_navigationStack.length - 2];
|
||||
}
|
||||
|
||||
/// Restore navigation state on app startup
|
||||
Future<void> restoreNavigationState(NavigatorState navigator) async {
|
||||
try {
|
||||
await _loadNavigationState();
|
||||
|
||||
if (_currentState != null) {
|
||||
// Attempt to restore to the last known state
|
||||
DebugLogger.navigation(
|
||||
'Restoring navigation to ${_currentState!.routeName}',
|
||||
);
|
||||
|
||||
// This would need to be implemented based on your routing setup
|
||||
// navigator.pushNamedAndRemoveUntil(
|
||||
// _currentState!.routeName,
|
||||
// (route) => false,
|
||||
// arguments: _currentState!.arguments,
|
||||
// );
|
||||
}
|
||||
} catch (e) {
|
||||
DebugLogger.error('Failed to restore navigation state', e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all navigation state
|
||||
Future<void> clearAll() async {
|
||||
try {
|
||||
_navigationStack.clear();
|
||||
_currentState = null;
|
||||
_stateNotifier.value = null;
|
||||
|
||||
await _prefs?.remove(_navigationStackKey);
|
||||
await _prefs?.remove(_currentStateKey);
|
||||
await _prefs?.remove(_deepLinkStateKey);
|
||||
|
||||
DebugLogger.navigation('All navigation state cleared');
|
||||
} catch (e) {
|
||||
DebugLogger.error('Failed to clear navigation state', e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Save navigation state to persistent storage
|
||||
Future<void> _saveNavigationState() async {
|
||||
if (_prefs == null) return;
|
||||
|
||||
try {
|
||||
// Save navigation stack
|
||||
final stackJson = _navigationStack
|
||||
.map((state) => state.toJson())
|
||||
.toList();
|
||||
await _prefs!.setString(_navigationStackKey, jsonEncode(stackJson));
|
||||
|
||||
// Save current state
|
||||
if (_currentState != null) {
|
||||
await _prefs!.setString(
|
||||
_currentStateKey,
|
||||
jsonEncode(_currentState!.toJson()),
|
||||
);
|
||||
} else {
|
||||
await _prefs!.remove(_currentStateKey);
|
||||
}
|
||||
} catch (e) {
|
||||
DebugLogger.error('Failed to save navigation state', e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Load navigation state from persistent storage
|
||||
Future<void> _loadNavigationState() async {
|
||||
if (_prefs == null) return;
|
||||
|
||||
try {
|
||||
// Load navigation stack
|
||||
final stackJsonString = _prefs!.getString(_navigationStackKey);
|
||||
if (stackJsonString != null) {
|
||||
final stackJson = jsonDecode(stackJsonString) as List;
|
||||
_navigationStack.clear();
|
||||
for (final stateJson in stackJson) {
|
||||
if (stateJson is Map<String, dynamic>) {
|
||||
_navigationStack.add(NavigationState.fromJson(stateJson));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load current state
|
||||
final currentStateJsonString = _prefs!.getString(_currentStateKey);
|
||||
if (currentStateJsonString != null) {
|
||||
final currentStateJson =
|
||||
jsonDecode(currentStateJsonString) as Map<String, dynamic>;
|
||||
_currentState = NavigationState.fromJson(currentStateJson);
|
||||
_stateNotifier.value = _currentState;
|
||||
}
|
||||
|
||||
DebugLogger.navigation(
|
||||
'Navigation state loaded - ${_navigationStack.length} states',
|
||||
);
|
||||
} catch (e) {
|
||||
DebugLogger.error('Failed to load navigation state', e);
|
||||
// Clear corrupted state
|
||||
await clearAll();
|
||||
}
|
||||
}
|
||||
|
||||
/// Save deep link state for restoration
|
||||
Future<void> _saveDeepLinkState(NavigationState state) async {
|
||||
if (_prefs == null) return;
|
||||
|
||||
try {
|
||||
await _prefs!.setString(_deepLinkStateKey, jsonEncode(state.toJson()));
|
||||
} catch (e) {
|
||||
DebugLogger.error('Failed to save deep link state', e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get user-friendly title for route name
|
||||
String _getRouteTitle(String routeName) {
|
||||
switch (routeName) {
|
||||
case '/':
|
||||
case '/home':
|
||||
return 'Home';
|
||||
case '/chat':
|
||||
return 'Chat';
|
||||
case '/settings':
|
||||
return 'Settings';
|
||||
case '/profile':
|
||||
return 'Profile';
|
||||
case '/conversations':
|
||||
return 'Conversations';
|
||||
default:
|
||||
// Convert route name to title case
|
||||
return routeName
|
||||
.replaceAll('/', '')
|
||||
.split('_')
|
||||
.map(
|
||||
(word) => word.isNotEmpty
|
||||
? '${word[0].toUpperCase()}${word.substring(1)}'
|
||||
: '',
|
||||
)
|
||||
.join(' ');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Breadcrumb navigation item
|
||||
class NavigationBreadcrumb {
|
||||
final String title;
|
||||
final String routeName;
|
||||
final Map<String, dynamic> arguments;
|
||||
final bool isActive;
|
||||
final bool canNavigateBack;
|
||||
|
||||
NavigationBreadcrumb({
|
||||
required this.title,
|
||||
required this.routeName,
|
||||
required this.arguments,
|
||||
required this.isActive,
|
||||
required this.canNavigateBack,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user