refactor: cleanup unsued files
This commit is contained in:
@@ -1773,19 +1773,12 @@ class ApiService {
|
|||||||
return response.data;
|
return response.data;
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
debugPrint('DEBUG: images/generations failed: ${e.response?.statusCode}');
|
debugPrint('DEBUG: images/generations failed: ${e.response?.statusCode}');
|
||||||
// Fallback to singular path some servers use
|
DebugLogger.error(
|
||||||
final response = await _dio.post(
|
'Image generation request to /api/v1/images/generations failed',
|
||||||
'/api/v1/image/generations',
|
e,
|
||||||
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,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import '../../../core/utils/debug_logger.dart';
|
|
||||||
|
|
||||||
// Global attachment cache state
|
|
||||||
class AttachmentCacheState {
|
|
||||||
final Map<String, String> imageDataCache;
|
|
||||||
final Map<String, bool> loadingStates;
|
|
||||||
final Map<String, String> errorStates;
|
|
||||||
|
|
||||||
AttachmentCacheState({
|
|
||||||
required this.imageDataCache,
|
|
||||||
required this.loadingStates,
|
|
||||||
required this.errorStates,
|
|
||||||
});
|
|
||||||
|
|
||||||
AttachmentCacheState copyWith({
|
|
||||||
Map<String, String>? imageDataCache,
|
|
||||||
Map<String, bool>? loadingStates,
|
|
||||||
Map<String, String>? errorStates,
|
|
||||||
}) {
|
|
||||||
return AttachmentCacheState(
|
|
||||||
imageDataCache: imageDataCache ?? this.imageDataCache,
|
|
||||||
loadingStates: loadingStates ?? this.loadingStates,
|
|
||||||
errorStates: errorStates ?? this.errorStates,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class AttachmentCacheNotifier extends StateNotifier<AttachmentCacheState> {
|
|
||||||
AttachmentCacheNotifier()
|
|
||||||
: super(AttachmentCacheState(
|
|
||||||
imageDataCache: {},
|
|
||||||
loadingStates: {},
|
|
||||||
errorStates: {},
|
|
||||||
));
|
|
||||||
|
|
||||||
void cacheImageData(String attachmentId, String imageData) {
|
|
||||||
DebugLogger.log('Caching image data for: $attachmentId');
|
|
||||||
state = state.copyWith(
|
|
||||||
imageDataCache: {
|
|
||||||
...state.imageDataCache,
|
|
||||||
attachmentId: imageData,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Limit cache size to prevent memory issues
|
|
||||||
if (state.imageDataCache.length > 100) {
|
|
||||||
final newCache = Map<String, String>.from(state.imageDataCache);
|
|
||||||
final keysToRemove = newCache.keys.take(20).toList();
|
|
||||||
for (final key in keysToRemove) {
|
|
||||||
newCache.remove(key);
|
|
||||||
state.loadingStates.remove(key);
|
|
||||||
state.errorStates.remove(key);
|
|
||||||
}
|
|
||||||
state = state.copyWith(imageDataCache: newCache);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String? getCachedImageData(String attachmentId) {
|
|
||||||
return state.imageDataCache[attachmentId];
|
|
||||||
}
|
|
||||||
|
|
||||||
void setLoadingState(String attachmentId, bool isLoading) {
|
|
||||||
state = state.copyWith(
|
|
||||||
loadingStates: {
|
|
||||||
...state.loadingStates,
|
|
||||||
attachmentId: isLoading,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool isLoading(String attachmentId) {
|
|
||||||
return state.loadingStates[attachmentId] ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
void setErrorState(String attachmentId, String? error) {
|
|
||||||
if (error == null) {
|
|
||||||
final newErrorStates = Map<String, String>.from(state.errorStates);
|
|
||||||
newErrorStates.remove(attachmentId);
|
|
||||||
state = state.copyWith(errorStates: newErrorStates);
|
|
||||||
} else {
|
|
||||||
state = state.copyWith(
|
|
||||||
errorStates: {
|
|
||||||
...state.errorStates,
|
|
||||||
attachmentId: error,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String? getErrorState(String attachmentId) {
|
|
||||||
return state.errorStates[attachmentId];
|
|
||||||
}
|
|
||||||
|
|
||||||
void clearCache() {
|
|
||||||
state = AttachmentCacheState(
|
|
||||||
imageDataCache: {},
|
|
||||||
loadingStates: {},
|
|
||||||
errorStates: {},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void clearAttachmentCache(String attachmentId) {
|
|
||||||
final newImageCache = Map<String, String>.from(state.imageDataCache);
|
|
||||||
final newLoadingStates = Map<String, bool>.from(state.loadingStates);
|
|
||||||
final newErrorStates = Map<String, String>.from(state.errorStates);
|
|
||||||
|
|
||||||
newImageCache.remove(attachmentId);
|
|
||||||
newLoadingStates.remove(attachmentId);
|
|
||||||
newErrorStates.remove(attachmentId);
|
|
||||||
|
|
||||||
state = AttachmentCacheState(
|
|
||||||
imageDataCache: newImageCache,
|
|
||||||
loadingStates: newLoadingStates,
|
|
||||||
errorStates: newErrorStates,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final attachmentCacheProvider =
|
|
||||||
StateNotifierProvider<AttachmentCacheNotifier, AttachmentCacheState>((ref) {
|
|
||||||
return AttachmentCacheNotifier();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Helper providers for easier access
|
|
||||||
final cachedImageDataProvider = Provider.family<String?, String>((ref, attachmentId) {
|
|
||||||
final cache = ref.watch(attachmentCacheProvider);
|
|
||||||
return cache.imageDataCache[attachmentId];
|
|
||||||
});
|
|
||||||
|
|
||||||
final attachmentLoadingStateProvider = Provider.family<bool, String>((ref, attachmentId) {
|
|
||||||
final cache = ref.watch(attachmentCacheProvider);
|
|
||||||
return cache.loadingStates[attachmentId] ?? false;
|
|
||||||
});
|
|
||||||
|
|
||||||
final attachmentErrorStateProvider = Provider.family<String?, String>((ref, attachmentId) {
|
|
||||||
final cache = ref.watch(attachmentCacheProvider);
|
|
||||||
return cache.errorStates[attachmentId];
|
|
||||||
});
|
|
||||||
@@ -1,956 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/cupertino.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'dart:io' show Platform;
|
|
||||||
import 'dart:ui' as ui;
|
|
||||||
|
|
||||||
import '../../../core/models/conversation.dart';
|
|
||||||
import '../../../core/providers/app_providers.dart';
|
|
||||||
import '../../../shared/theme/app_theme.dart';
|
|
||||||
import '../../../shared/theme/theme_extensions.dart';
|
|
||||||
import '../../../shared/utils/ui_utils.dart';
|
|
||||||
import '../../../shared/widgets/conduit_components.dart';
|
|
||||||
import '../providers/chat_providers.dart';
|
|
||||||
|
|
||||||
// Optimized delete conversation provider with error handling
|
|
||||||
final deleteConversationProvider = FutureProvider.family<void, String>((
|
|
||||||
ref,
|
|
||||||
conversationId,
|
|
||||||
) async {
|
|
||||||
final api = ref.read(apiServiceProvider);
|
|
||||||
if (api == null) throw Exception('No API service available');
|
|
||||||
|
|
||||||
await api.deleteConversation(conversationId);
|
|
||||||
ref.invalidate(conversationsProvider);
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Optimized conversation tile with Conduit design aesthetics
|
|
||||||
class ModernConversationTile extends ConsumerStatefulWidget {
|
|
||||||
final Conversation conversation;
|
|
||||||
final bool isActive;
|
|
||||||
final Future<void> Function() onTap;
|
|
||||||
final VoidCallback onDelete;
|
|
||||||
|
|
||||||
const ModernConversationTile({
|
|
||||||
super.key,
|
|
||||||
required this.conversation,
|
|
||||||
required this.isActive,
|
|
||||||
required this.onTap,
|
|
||||||
required this.onDelete,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
ConsumerState<ModernConversationTile> createState() =>
|
|
||||||
_ModernConversationTileState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ModernConversationTileState extends ConsumerState<ModernConversationTile>
|
|
||||||
with SingleTickerProviderStateMixin {
|
|
||||||
bool _isLoading = false;
|
|
||||||
late AnimationController _animationController;
|
|
||||||
late Animation<double> _scaleAnimation;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_animationController = AnimationController(
|
|
||||||
duration: const Duration(milliseconds: 150),
|
|
||||||
vsync: this,
|
|
||||||
);
|
|
||||||
_scaleAnimation = Tween<double>(begin: 1.0, end: 0.95).animate(
|
|
||||||
CurvedAnimation(parent: _animationController, curve: Curves.easeInOut),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_animationController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return AnimatedBuilder(
|
|
||||||
animation: _scaleAnimation,
|
|
||||||
builder: (context, child) {
|
|
||||||
return Transform.scale(
|
|
||||||
scale: _scaleAnimation.value,
|
|
||||||
child: Container(
|
|
||||||
margin: const EdgeInsets.symmetric(
|
|
||||||
horizontal: Spacing.md,
|
|
||||||
vertical: Spacing.xs,
|
|
||||||
),
|
|
||||||
child: Dismissible(
|
|
||||||
key: Key(widget.conversation.id),
|
|
||||||
direction: DismissDirection.horizontal,
|
|
||||||
background: _buildSwipeBackground(DismissDirection.startToEnd),
|
|
||||||
secondaryBackground: _buildSwipeBackground(
|
|
||||||
DismissDirection.endToStart,
|
|
||||||
),
|
|
||||||
confirmDismiss: _handleDismiss,
|
|
||||||
child: _buildTileContent(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildSwipeBackground(DismissDirection direction) {
|
|
||||||
final isArchive = direction == DismissDirection.startToEnd;
|
|
||||||
return Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
colors: isArchive
|
|
||||||
? [
|
|
||||||
AppTheme.brandPrimary.withValues(alpha: 0.1),
|
|
||||||
AppTheme.brandPrimary.withValues(alpha: 0.2),
|
|
||||||
]
|
|
||||||
: [
|
|
||||||
AppTheme.error.withValues(alpha: 0.1),
|
|
||||||
AppTheme.error.withValues(alpha: 0.2),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.lg),
|
|
||||||
),
|
|
||||||
alignment: isArchive ? Alignment.centerLeft : Alignment.centerRight,
|
|
||||||
padding: EdgeInsets.symmetric(horizontal: Spacing.lg),
|
|
||||||
child: Container(
|
|
||||||
width: Spacing.xxl,
|
|
||||||
height: Spacing.xxl,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isArchive ? AppTheme.brandPrimary : AppTheme.error,
|
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
|
||||||
boxShadow: ConduitShadows.low,
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
isArchive
|
|
||||||
? (Platform.isIOS ? CupertinoIcons.archivebox : Icons.archive)
|
|
||||||
: (Platform.isIOS ? CupertinoIcons.delete : Icons.delete),
|
|
||||||
color: AppTheme.neutral50,
|
|
||||||
size: AppTypography.headlineMedium,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool?> _handleDismiss(DismissDirection direction) async {
|
|
||||||
if (direction == DismissDirection.startToEnd) {
|
|
||||||
await _handleArchive();
|
|
||||||
} else {
|
|
||||||
widget.onDelete();
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildTileContent() {
|
|
||||||
return GestureDetector(
|
|
||||||
onTapDown: (_) => _animationController.forward(),
|
|
||||||
onTapUp: (_) => _animationController.reverse(),
|
|
||||||
onTapCancel: () => _animationController.reverse(),
|
|
||||||
onTap: _isLoading ? null : _handleTap,
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.all(Spacing.md),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: widget.isActive
|
|
||||||
? LinearGradient(
|
|
||||||
colors: [
|
|
||||||
AppTheme.brandPrimary.withValues(alpha: 0.15),
|
|
||||||
AppTheme.brandPrimary.withValues(alpha: 0.08),
|
|
||||||
],
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
)
|
|
||||||
: LinearGradient(
|
|
||||||
colors: [
|
|
||||||
AppTheme.neutral700.withValues(alpha: 0.6),
|
|
||||||
AppTheme.neutral700.withValues(alpha: 0.3),
|
|
||||||
],
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.lg),
|
|
||||||
border: Border.all(
|
|
||||||
color: widget.isActive
|
|
||||||
? AppTheme.brandPrimary.withValues(alpha: 0.3)
|
|
||||||
: AppTheme.neutral600.withValues(alpha: 0.2),
|
|
||||||
width: widget.isActive ? BorderWidth.medium : BorderWidth.thin,
|
|
||||||
),
|
|
||||||
boxShadow: widget.isActive ? ConduitShadows.low : null,
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
_buildLeadingIcon(),
|
|
||||||
const SizedBox(width: Spacing.md),
|
|
||||||
Expanded(child: _buildContent()),
|
|
||||||
_buildTrailingActions(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildLeadingIcon() {
|
|
||||||
if (_isLoading) {
|
|
||||||
return SizedBox(
|
|
||||||
width: Spacing.xl,
|
|
||||||
height: Spacing.xl,
|
|
||||||
child: CircularProgressIndicator.adaptive(
|
|
||||||
strokeWidth: BorderWidth.thick,
|
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(
|
|
||||||
widget.isActive ? AppTheme.brandPrimary : AppTheme.neutral300,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
width: Spacing.xl,
|
|
||||||
height: Spacing.xl,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: widget.isActive
|
|
||||||
? LinearGradient(
|
|
||||||
colors: [AppTheme.brandPrimary, AppTheme.brandPrimaryLight],
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
)
|
|
||||||
: LinearGradient(
|
|
||||||
colors: [
|
|
||||||
AppTheme.neutral600.withValues(alpha: 0.8),
|
|
||||||
AppTheme.neutral500.withValues(alpha: 0.6),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.sm),
|
|
||||||
),
|
|
||||||
child: Stack(
|
|
||||||
alignment: Alignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Platform.isIOS
|
|
||||||
? CupertinoIcons.chat_bubble_2_fill
|
|
||||||
: Icons.chat_rounded,
|
|
||||||
color: AppTheme.neutral50,
|
|
||||||
size: Spacing.md,
|
|
||||||
),
|
|
||||||
if (widget.conversation.pinned)
|
|
||||||
Positioned(
|
|
||||||
top: Spacing.xxs,
|
|
||||||
right: Spacing.xxs,
|
|
||||||
child: Container(
|
|
||||||
width: Spacing.sm,
|
|
||||||
height: Spacing.sm,
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
color: AppTheme.warning,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildContent() {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
widget.conversation.title,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: TextStyle(
|
|
||||||
color: widget.isActive ? AppTheme.neutral50 : AppTheme.neutral100,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
fontSize: AppTypography.bodyLarge,
|
|
||||||
letterSpacing: -0.2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: Spacing.xs),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Platform.isIOS ? CupertinoIcons.time : Icons.access_time_rounded,
|
|
||||||
size: AppTypography.labelMedium,
|
|
||||||
color: AppTheme.neutral400,
|
|
||||||
),
|
|
||||||
const SizedBox(width: Spacing.xs),
|
|
||||||
Text(
|
|
||||||
_formatDate(widget.conversation.updatedAt),
|
|
||||||
style: const TextStyle(
|
|
||||||
color: AppTheme.neutral400,
|
|
||||||
fontSize: AppTypography.labelMedium,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (widget.conversation.messages.isNotEmpty) ...[
|
|
||||||
Container(
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: Spacing.sm),
|
|
||||||
width: Spacing.xxs,
|
|
||||||
height: Spacing.xxs,
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
color: AppTheme.neutral400,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'${widget.conversation.messages.length} messages',
|
|
||||||
style: const TextStyle(
|
|
||||||
color: AppTheme.neutral400,
|
|
||||||
fontSize: AppTypography.labelMedium,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
if (widget.conversation.tags.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: Spacing.sm),
|
|
||||||
_buildTags(),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildTags() {
|
|
||||||
return Wrap(
|
|
||||||
spacing: Spacing.xs,
|
|
||||||
runSpacing: Spacing.xs,
|
|
||||||
children: widget.conversation.tags.take(3).map((tag) {
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: Spacing.xs + Spacing.xxs,
|
|
||||||
vertical: Spacing.xxs,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppTheme.brandPrimary.withValues(alpha: 0.15),
|
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.xs),
|
|
||||||
border: Border.all(
|
|
||||||
color: AppTheme.brandPrimary.withValues(alpha: 0.2),
|
|
||||||
width: BorderWidth.thin,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
tag,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: AppTheme.brandPrimary,
|
|
||||||
fontSize: AppTypography.labelSmall,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildTrailingActions() {
|
|
||||||
return PopupMenuButton<String>(
|
|
||||||
icon: Container(
|
|
||||||
width: Spacing.xl,
|
|
||||||
height: Spacing.xl,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppTheme.neutral700.withValues(alpha: 0.6),
|
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.sm),
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
Platform.isIOS ? CupertinoIcons.ellipsis : Icons.more_vert_rounded,
|
|
||||||
color: AppTheme.neutral300,
|
|
||||||
size: Spacing.md,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
color: AppTheme.neutral800,
|
|
||||||
elevation: Elevation.high + Spacing.xs,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
|
||||||
side: BorderSide(
|
|
||||||
color: AppTheme.neutral600.withValues(alpha: 0.3),
|
|
||||||
width: BorderWidth.thin,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
onSelected: _handleMenuAction,
|
|
||||||
itemBuilder: (context) => _buildMenuItems(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<PopupMenuItem<String>> _buildMenuItems() {
|
|
||||||
return [
|
|
||||||
_buildMenuItem(
|
|
||||||
'pin',
|
|
||||||
widget.conversation.pinned
|
|
||||||
? (Platform.isIOS
|
|
||||||
? CupertinoIcons.pin_slash
|
|
||||||
: Icons.push_pin_outlined)
|
|
||||||
: (Platform.isIOS
|
|
||||||
? CupertinoIcons.pin_fill
|
|
||||||
: Icons.push_pin_rounded),
|
|
||||||
widget.conversation.pinned ? 'Unpin' : 'Pin',
|
|
||||||
),
|
|
||||||
_buildMenuItem(
|
|
||||||
'archive',
|
|
||||||
Platform.isIOS ? CupertinoIcons.archivebox : Icons.archive_rounded,
|
|
||||||
'Archive',
|
|
||||||
),
|
|
||||||
_buildMenuItem(
|
|
||||||
'share',
|
|
||||||
Platform.isIOS ? CupertinoIcons.share : Icons.share_rounded,
|
|
||||||
'Share',
|
|
||||||
),
|
|
||||||
_buildMenuItem(
|
|
||||||
'clone',
|
|
||||||
Platform.isIOS ? CupertinoIcons.doc_on_doc : Icons.content_copy_rounded,
|
|
||||||
'Clone',
|
|
||||||
),
|
|
||||||
PopupMenuItem<String>(
|
|
||||||
enabled: false,
|
|
||||||
child: Divider(color: AppTheme.neutral600, height: BorderWidth.regular),
|
|
||||||
),
|
|
||||||
_buildMenuItem(
|
|
||||||
'delete',
|
|
||||||
Platform.isIOS ? CupertinoIcons.delete : Icons.delete_rounded,
|
|
||||||
'Delete',
|
|
||||||
isDestructive: true,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
PopupMenuItem<String> _buildMenuItem(
|
|
||||||
String value,
|
|
||||||
IconData icon,
|
|
||||||
String label, {
|
|
||||||
bool isDestructive = false,
|
|
||||||
}) {
|
|
||||||
return PopupMenuItem(
|
|
||||||
value: value,
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: Spacing.lg + Spacing.xs,
|
|
||||||
height: Spacing.lg + Spacing.xs,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isDestructive
|
|
||||||
? AppTheme.error.withValues(alpha: 0.1)
|
|
||||||
: AppTheme.neutral700.withValues(alpha: 0.6),
|
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.xs),
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
icon,
|
|
||||||
size: Spacing.md,
|
|
||||||
color: isDestructive ? AppTheme.error : AppTheme.neutral200,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: Spacing.sm),
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style: TextStyle(
|
|
||||||
color: isDestructive ? AppTheme.error : AppTheme.neutral50,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _handleTap() async {
|
|
||||||
setState(() => _isLoading = true);
|
|
||||||
try {
|
|
||||||
await widget.onTap();
|
|
||||||
} finally {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() => _isLoading = false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _handleMenuAction(String action) async {
|
|
||||||
switch (action) {
|
|
||||||
case 'pin':
|
|
||||||
await _handlePin();
|
|
||||||
break;
|
|
||||||
case 'archive':
|
|
||||||
await _handleArchive();
|
|
||||||
break;
|
|
||||||
case 'share':
|
|
||||||
await _handleShare();
|
|
||||||
break;
|
|
||||||
case 'clone':
|
|
||||||
await _handleClone();
|
|
||||||
break;
|
|
||||||
case 'delete':
|
|
||||||
widget.onDelete();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _handlePin() async {
|
|
||||||
try {
|
|
||||||
await pinConversation(
|
|
||||||
ref,
|
|
||||||
widget.conversation.id,
|
|
||||||
!widget.conversation.pinned,
|
|
||||||
);
|
|
||||||
if (mounted) {
|
|
||||||
UiUtils.showMessage(
|
|
||||||
context,
|
|
||||||
widget.conversation.pinned
|
|
||||||
? 'Conversation unpinned'
|
|
||||||
: 'Conversation pinned',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) {
|
|
||||||
UiUtils.showMessage(
|
|
||||||
context,
|
|
||||||
'Failed to ${widget.conversation.pinned ? 'unpin' : 'pin'} conversation',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _handleArchive() async {
|
|
||||||
try {
|
|
||||||
await archiveConversation(ref, widget.conversation.id, true);
|
|
||||||
if (mounted) {
|
|
||||||
UiUtils.showMessage(context, 'Conversation archived');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) {
|
|
||||||
UiUtils.showMessage(context, 'Failed to archive conversation');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _handleShare() async {
|
|
||||||
try {
|
|
||||||
final shareId = await shareConversation(ref, widget.conversation.id);
|
|
||||||
if (mounted && shareId != null) {
|
|
||||||
_showShareDialog(shareId);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) {
|
|
||||||
UiUtils.showMessage(context, 'Failed to share conversation');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _handleClone() async {
|
|
||||||
try {
|
|
||||||
await cloneConversation(ref, widget.conversation.id);
|
|
||||||
if (mounted) {
|
|
||||||
Navigator.pop(context);
|
|
||||||
UiUtils.showMessage(context, 'Conversation cloned');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) {
|
|
||||||
UiUtils.showMessage(context, 'Failed to clone conversation');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showShareDialog(String shareId) {
|
|
||||||
final shareUrl =
|
|
||||||
'${ref.read(apiServiceProvider)?.serverConfig.url}/s/$shareId';
|
|
||||||
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
backgroundColor: AppTheme.neutral800,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.lg),
|
|
||||||
side: BorderSide(
|
|
||||||
color: AppTheme.neutral600.withValues(alpha: 0.3),
|
|
||||||
width: BorderWidth.thin,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
title: Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
colors: [AppTheme.brandPrimary, AppTheme.brandPrimaryLight],
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.sm),
|
|
||||||
),
|
|
||||||
child: const Icon(
|
|
||||||
Icons.share_rounded,
|
|
||||||
color: AppTheme.neutral50,
|
|
||||||
size: Spacing.md,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: Spacing.sm),
|
|
||||||
const Text(
|
|
||||||
'Share Conversation',
|
|
||||||
style: TextStyle(
|
|
||||||
color: AppTheme.neutral50,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
content: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'Anyone with this link can view the conversation:',
|
|
||||||
style: TextStyle(color: AppTheme.neutral300),
|
|
||||||
),
|
|
||||||
const SizedBox(height: Spacing.md),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(Spacing.md),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppTheme.neutral700.withValues(alpha: 0.6),
|
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
|
||||||
border: Border.all(
|
|
||||||
color: AppTheme.neutral600.withValues(alpha: 0.3),
|
|
||||||
width: BorderWidth.thin,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: SelectableText(
|
|
||||||
shareUrl,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
color: AppTheme.neutral50,
|
|
||||||
fontSize: AppTypography.labelMedium,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
ConduitButton(
|
|
||||||
text: 'Close',
|
|
||||||
isSecondary: true,
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
),
|
|
||||||
ConduitButton(
|
|
||||||
text: 'Copy Link',
|
|
||||||
onPressed: () async {
|
|
||||||
await Clipboard.setData(ClipboardData(text: shareUrl));
|
|
||||||
if (context.mounted) {
|
|
||||||
UiUtils.showMessage(context, 'Link copied to clipboard');
|
|
||||||
Navigator.pop(context);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _formatDate(DateTime date) {
|
|
||||||
final now = DateTime.now();
|
|
||||||
|
|
||||||
// Convert to local timezone if needed
|
|
||||||
final localDate = date.toLocal();
|
|
||||||
final localNow = now.toLocal();
|
|
||||||
final difference = localNow.difference(localDate);
|
|
||||||
|
|
||||||
// Handle negative differences (future dates)
|
|
||||||
if (difference.isNegative) {
|
|
||||||
return 'Just now';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (difference.inDays == 0) {
|
|
||||||
if (difference.inHours == 0) {
|
|
||||||
if (difference.inMinutes <= 1) {
|
|
||||||
return 'Just now';
|
|
||||||
}
|
|
||||||
return '${difference.inMinutes}m';
|
|
||||||
}
|
|
||||||
return '${difference.inHours}h';
|
|
||||||
} else if (difference.inDays == 1) {
|
|
||||||
return 'Yesterday';
|
|
||||||
} else if (difference.inDays < 7) {
|
|
||||||
return '${difference.inDays}d';
|
|
||||||
} else if (difference.inDays < 365) {
|
|
||||||
return '${localDate.month}/${localDate.day}';
|
|
||||||
} else {
|
|
||||||
return '${localDate.month}/${localDate.day}/${localDate.year}';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Optimized archived chats view with improved performance
|
|
||||||
class ModernArchivedChatsView extends ConsumerWidget {
|
|
||||||
final ScrollController scrollController;
|
|
||||||
|
|
||||||
const ModernArchivedChatsView({super.key, required this.scrollController});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final archivedConversations = ref.watch(archivedConversationsProvider);
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: const LinearGradient(
|
|
||||||
colors: [AppTheme.neutral800, AppTheme.neutral900],
|
|
||||||
begin: Alignment.topCenter,
|
|
||||||
end: Alignment.bottomCenter,
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.only(
|
|
||||||
topLeft: ui.Radius.circular(AppBorderRadius.lg),
|
|
||||||
topRight: ui.Radius.circular(AppBorderRadius.lg),
|
|
||||||
),
|
|
||||||
border: Border.all(
|
|
||||||
color: AppTheme.neutral600.withValues(alpha: 0.2),
|
|
||||||
width: BorderWidth.thin,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
_buildHandle(),
|
|
||||||
_buildHeader(context),
|
|
||||||
const Divider(color: AppTheme.neutral600, height: 1, thickness: 0.5),
|
|
||||||
Expanded(child: _buildContent(context, archivedConversations, ref)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildHandle() {
|
|
||||||
return Container(
|
|
||||||
margin: const EdgeInsets.symmetric(vertical: Spacing.sm),
|
|
||||||
width: Spacing.xxl,
|
|
||||||
height: Spacing.xs,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppTheme.neutral500,
|
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.xs),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildHeader(BuildContext context) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.all(Spacing.lg),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: Spacing.xxl,
|
|
||||||
height: Spacing.xxl,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
colors: [AppTheme.brandPrimary, AppTheme.brandPrimaryLight],
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
|
||||||
),
|
|
||||||
child: const Icon(
|
|
||||||
Icons.archive_rounded,
|
|
||||||
color: AppTheme.neutral50,
|
|
||||||
size: AppTypography.headlineMedium,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: Spacing.md),
|
|
||||||
const Expanded(
|
|
||||||
child: Text(
|
|
||||||
'Archived Conversations',
|
|
||||||
style: TextStyle(
|
|
||||||
color: AppTheme.neutral50,
|
|
||||||
fontSize: AppTypography.headlineSmall,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
letterSpacing: -0.3,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
ConduitIconButton(
|
|
||||||
icon: Platform.isIOS ? CupertinoIcons.xmark : Icons.close_rounded,
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildContent(
|
|
||||||
BuildContext context,
|
|
||||||
List<Conversation> conversations,
|
|
||||||
WidgetRef ref,
|
|
||||||
) {
|
|
||||||
if (conversations.isEmpty) {
|
|
||||||
return _buildEmptyState();
|
|
||||||
}
|
|
||||||
|
|
||||||
return ListView.builder(
|
|
||||||
controller: scrollController,
|
|
||||||
padding: const EdgeInsets.all(Spacing.md),
|
|
||||||
itemCount: conversations.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final conversation = conversations[index];
|
|
||||||
return ModernArchivedConversationTile(
|
|
||||||
conversation: conversation,
|
|
||||||
onUnarchive: () => _handleUnarchive(ref, context, conversation.id),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildEmptyState() {
|
|
||||||
return Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: Spacing.xxl + Spacing.xl,
|
|
||||||
height: Spacing.xxl + Spacing.xl,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
colors: [
|
|
||||||
AppTheme.neutral600.withValues(alpha: 0.3),
|
|
||||||
AppTheme.neutral700.withValues(alpha: 0.1),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.round),
|
|
||||||
),
|
|
||||||
child: const Icon(
|
|
||||||
Icons.archive_rounded,
|
|
||||||
size: Spacing.xxl,
|
|
||||||
color: AppTheme.neutral400,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: Spacing.lg),
|
|
||||||
const Text(
|
|
||||||
'Nothing archived yet',
|
|
||||||
style: TextStyle(
|
|
||||||
color: AppTheme.neutral50,
|
|
||||||
fontSize: AppTypography.headlineSmall,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: Spacing.sm),
|
|
||||||
const Text(
|
|
||||||
'Conversations you archive will appear here',
|
|
||||||
style: TextStyle(
|
|
||||||
color: AppTheme.neutral400,
|
|
||||||
fontSize: AppTypography.labelLarge,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _handleUnarchive(
|
|
||||||
WidgetRef ref,
|
|
||||||
BuildContext context,
|
|
||||||
String conversationId,
|
|
||||||
) async {
|
|
||||||
try {
|
|
||||||
await archiveConversation(ref, conversationId, false);
|
|
||||||
if (context.mounted) {
|
|
||||||
UiUtils.showMessage(context, 'Conversation unarchived');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (context.mounted) {
|
|
||||||
UiUtils.showMessage(context, 'Failed to unarchive conversation');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Optimized archived conversation tile
|
|
||||||
class ModernArchivedConversationTile extends StatelessWidget {
|
|
||||||
final Conversation conversation;
|
|
||||||
final VoidCallback onUnarchive;
|
|
||||||
|
|
||||||
const ModernArchivedConversationTile({
|
|
||||||
super.key,
|
|
||||||
required this.conversation,
|
|
||||||
required this.onUnarchive,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
margin: const EdgeInsets.only(bottom: Spacing.sm),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.all(Spacing.md),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
colors: [
|
|
||||||
AppTheme.neutral700.withValues(alpha: 0.4),
|
|
||||||
AppTheme.neutral700.withValues(alpha: 0.2),
|
|
||||||
],
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.lg),
|
|
||||||
border: Border.all(
|
|
||||||
color: AppTheme.neutral600.withValues(alpha: 0.2),
|
|
||||||
width: BorderWidth.thin,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppTheme.neutral600.withValues(alpha: 0.6),
|
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.sm),
|
|
||||||
),
|
|
||||||
child: const Icon(
|
|
||||||
Icons.archive_rounded,
|
|
||||||
color: AppTheme.neutral300,
|
|
||||||
size: 16,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: Spacing.md),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
conversation.title,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: AppTheme.neutral50,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
fontSize: AppTypography.bodyLarge,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: Spacing.xs),
|
|
||||||
Text(
|
|
||||||
_formatArchivedDate(conversation.updatedAt),
|
|
||||||
style: const TextStyle(
|
|
||||||
color: AppTheme.neutral400,
|
|
||||||
fontSize: AppTypography.labelMedium,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
ConduitIconButton(
|
|
||||||
icon: Platform.isIOS
|
|
||||||
? CupertinoIcons.arrow_up_bin
|
|
||||||
: Icons.unarchive_rounded,
|
|
||||||
onPressed: onUnarchive,
|
|
||||||
tooltip: 'Unarchive',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _formatArchivedDate(DateTime date) {
|
|
||||||
final now = DateTime.now();
|
|
||||||
final difference = now.difference(date);
|
|
||||||
|
|
||||||
if (difference.inDays == 0) {
|
|
||||||
return 'Today';
|
|
||||||
} else if (difference.inDays == 1) {
|
|
||||||
return 'Yesterday';
|
|
||||||
} else if (difference.inDays < 7) {
|
|
||||||
return '${difference.inDays} days ago';
|
|
||||||
} else {
|
|
||||||
return '${date.month}/${date.day}/${date.year}';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
import 'dart:io';
|
|
||||||
import 'package:flutter/cupertino.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import '../../../shared/theme/theme_extensions.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
|
|
||||||
import '../../../core/models/file_info.dart';
|
|
||||||
import '../../../core/providers/app_providers.dart';
|
|
||||||
|
|
||||||
class FileViewerDialog extends ConsumerWidget {
|
|
||||||
final FileInfo fileInfo;
|
|
||||||
|
|
||||||
const FileViewerDialog({super.key, required this.fileInfo});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
// Use themed tokens via extension
|
|
||||||
final fileContent = ref.watch(fileContentProvider(fileInfo.id));
|
|
||||||
|
|
||||||
return Dialog.fullscreen(
|
|
||||||
child: Scaffold(
|
|
||||||
backgroundColor: context.conduitTheme.surfaceBackground,
|
|
||||||
appBar: AppBar(
|
|
||||||
backgroundColor: context.conduitTheme.surfaceBackground,
|
|
||||||
elevation: 0,
|
|
||||||
title: Text(
|
|
||||||
fileInfo.originalFilename,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: TextStyle(color: context.conduitTheme.textPrimary),
|
|
||||||
),
|
|
||||||
iconTheme: IconThemeData(color: context.conduitTheme.iconPrimary),
|
|
||||||
leading: IconButton(
|
|
||||||
icon: Icon(Platform.isIOS ? CupertinoIcons.back : Icons.arrow_back),
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(Platform.isIOS ? CupertinoIcons.info : Icons.info),
|
|
||||||
onPressed: () => _showFileInfo(context),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: fileContent.when(
|
|
||||||
data: (content) => _buildContentView(context, content),
|
|
||||||
loading: () => Center(
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
color: context.conduitTheme.buttonPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
error: (error, _) => Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.error, size: 64, color: context.conduitTheme.error),
|
|
||||||
const SizedBox(height: Spacing.md),
|
|
||||||
Text(
|
|
||||||
'Failed to load file',
|
|
||||||
style: TextStyle(
|
|
||||||
color: context.conduitTheme.error,
|
|
||||||
fontSize: AppTypography.headlineSmall,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: Spacing.sm),
|
|
||||||
Text(
|
|
||||||
error.toString(),
|
|
||||||
style: TextStyle(color: context.conduitTheme.textSecondary),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(height: Spacing.md),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () =>
|
|
||||||
ref.invalidate(fileContentProvider(fileInfo.id)),
|
|
||||||
child: const Text('Retry'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildContentView(BuildContext context, String content) {
|
|
||||||
final theme = context.conduitTheme;
|
|
||||||
final isTextFile = _isTextFile(fileInfo.mimeType);
|
|
||||||
|
|
||||||
if (!isTextFile) {
|
|
||||||
return Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
_getFileIcon(fileInfo.mimeType),
|
|
||||||
size: 64,
|
|
||||||
color: theme.buttonPrimary,
|
|
||||||
),
|
|
||||||
const SizedBox(height: Spacing.md),
|
|
||||||
Text(
|
|
||||||
fileInfo.originalFilename,
|
|
||||||
style: TextStyle(
|
|
||||||
color: theme.textPrimary,
|
|
||||||
fontSize: AppTypography.headlineSmall,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(height: Spacing.sm),
|
|
||||||
Text(
|
|
||||||
'File type: ${fileInfo.mimeType}',
|
|
||||||
style: TextStyle(color: theme.textSecondary),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'Size: ${_formatFileSize(fileInfo.size)}',
|
|
||||||
style: TextStyle(color: theme.textSecondary),
|
|
||||||
),
|
|
||||||
const SizedBox(height: Spacing.md),
|
|
||||||
Text(
|
|
||||||
'Preview not available for this file type',
|
|
||||||
style: TextStyle(color: theme.textTertiary),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return SingleChildScrollView(
|
|
||||||
padding: const EdgeInsets.all(Spacing.md),
|
|
||||||
child: SelectableText(
|
|
||||||
content,
|
|
||||||
style: TextStyle(
|
|
||||||
color: theme.textPrimary,
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontSize: AppTypography.labelLarge,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showFileInfo(BuildContext context) {
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
backgroundColor: context.conduitTheme.surfaceBackground,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.dialog),
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
'File Information',
|
|
||||||
style: TextStyle(color: context.conduitTheme.textPrimary),
|
|
||||||
),
|
|
||||||
content: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
_buildInfoRow(context, 'Name', fileInfo.originalFilename),
|
|
||||||
_buildInfoRow(context, 'Size', _formatFileSize(fileInfo.size)),
|
|
||||||
_buildInfoRow(context, 'Type', fileInfo.mimeType),
|
|
||||||
_buildInfoRow(context, 'Created', _formatDate(fileInfo.createdAt)),
|
|
||||||
_buildInfoRow(context, 'Modified', _formatDate(fileInfo.updatedAt)),
|
|
||||||
if (fileInfo.hash != null)
|
|
||||||
_buildInfoRow(context, 'Hash', fileInfo.hash!),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
child: Text(
|
|
||||||
'Close',
|
|
||||||
style: TextStyle(color: context.conduitTheme.buttonPrimary),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildInfoRow(BuildContext context, String label, String value) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
SizedBox(
|
|
||||||
width: Spacing.xxxl + Spacing.md,
|
|
||||||
child: Text(
|
|
||||||
'$label:',
|
|
||||||
style: TextStyle(
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: context.conduitTheme.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
value,
|
|
||||||
style: TextStyle(color: context.conduitTheme.textPrimary),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _isTextFile(String mimeType) {
|
|
||||||
return mimeType.startsWith('text/') ||
|
|
||||||
mimeType == 'application/json' ||
|
|
||||||
mimeType == 'application/xml' ||
|
|
||||||
mimeType == 'application/javascript' ||
|
|
||||||
mimeType.contains('yaml') ||
|
|
||||||
mimeType.contains('markdown');
|
|
||||||
}
|
|
||||||
|
|
||||||
IconData _getFileIcon(String mimeType) {
|
|
||||||
if (mimeType.startsWith('image/')) {
|
|
||||||
return Platform.isIOS ? CupertinoIcons.photo : Icons.image;
|
|
||||||
} else if (mimeType.startsWith('video/')) {
|
|
||||||
return Platform.isIOS ? CupertinoIcons.video_camera : Icons.video_file;
|
|
||||||
} else if (mimeType.startsWith('audio/')) {
|
|
||||||
return Platform.isIOS ? CupertinoIcons.music_note : Icons.audio_file;
|
|
||||||
} else if (mimeType.contains('pdf')) {
|
|
||||||
return Platform.isIOS ? CupertinoIcons.doc : Icons.picture_as_pdf;
|
|
||||||
} else if (mimeType.startsWith('text/') || mimeType.contains('json')) {
|
|
||||||
return Platform.isIOS ? CupertinoIcons.doc_text : Icons.description;
|
|
||||||
} else {
|
|
||||||
return Platform.isIOS ? CupertinoIcons.doc : Icons.insert_drive_file;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String _formatFileSize(int bytes) {
|
|
||||||
if (bytes < 1024) return '$bytes B';
|
|
||||||
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
|
||||||
if (bytes < 1024 * 1024 * 1024) {
|
|
||||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
|
||||||
}
|
|
||||||
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
|
||||||
}
|
|
||||||
|
|
||||||
String _formatDate(DateTime date) {
|
|
||||||
return '${date.day}/${date.month}/${date.year} ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,938 +0,0 @@
|
|||||||
import 'dart:io';
|
|
||||||
import 'package:flutter/cupertino.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_animate/flutter_animate.dart';
|
|
||||||
import '../../../shared/theme/theme_extensions.dart';
|
|
||||||
import '../../../shared/widgets/conduit_components.dart';
|
|
||||||
import '../../../shared/utils/ui_utils.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
|
|
||||||
import '../../../core/models/folder.dart';
|
|
||||||
import '../../../core/models/conversation.dart';
|
|
||||||
import '../../../core/providers/app_providers.dart';
|
|
||||||
|
|
||||||
class FolderManagementDialog extends ConsumerStatefulWidget {
|
|
||||||
final Conversation? conversation;
|
|
||||||
final BuildContext? parentContext;
|
|
||||||
|
|
||||||
const FolderManagementDialog({super.key, this.conversation, this.parentContext});
|
|
||||||
|
|
||||||
@override
|
|
||||||
ConsumerState<FolderManagementDialog> createState() =>
|
|
||||||
_FolderManagementDialogState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _FolderManagementDialogState
|
|
||||||
extends ConsumerState<FolderManagementDialog> {
|
|
||||||
final _nameController = TextEditingController();
|
|
||||||
bool _isCreating = false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_nameController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final folders = ref.watch(foldersProvider);
|
|
||||||
final isMovingConversation = widget.conversation != null;
|
|
||||||
|
|
||||||
return Directionality(
|
|
||||||
textDirection: TextDirection.ltr,
|
|
||||||
child: Dialog(
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
child: Container(
|
|
||||||
width: 480,
|
|
||||||
constraints: const BoxConstraints(maxHeight: 680),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: context.conduitTheme.surfaceBackground,
|
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.modal),
|
|
||||||
border: Border.all(
|
|
||||||
color: context.conduitTheme.cardBorder.withValues(alpha: 0.2),
|
|
||||||
width: BorderWidth.regular,
|
|
||||||
),
|
|
||||||
boxShadow: ConduitShadows.modal,
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
// Modern Header
|
|
||||||
_buildModernHeader(context, isMovingConversation),
|
|
||||||
|
|
||||||
// Content Section
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
// Create folder section (only if managing folders)
|
|
||||||
if (!isMovingConversation) ...[
|
|
||||||
_buildCreateFolderSection(context),
|
|
||||||
ConduitDivider(color: context.conduitTheme.dividerColor.withValues(alpha: 0.2)),
|
|
||||||
],
|
|
||||||
|
|
||||||
// Folders list
|
|
||||||
Expanded(
|
|
||||||
child: folders.when(
|
|
||||||
data: (folderList) => _buildFoldersList(context, folderList, isMovingConversation),
|
|
||||||
loading: () => _buildLoadingState(context),
|
|
||||||
error: (error, _) => _buildErrorState(context, error),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Bottom actions (only for conversation moving)
|
|
||||||
if (isMovingConversation) _buildBottomActions(context),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
).animate().slideY(
|
|
||||||
begin: 0.1,
|
|
||||||
duration: AnimationDuration.modalPresentation,
|
|
||||||
curve: AnimationCurves.modalPresentation,
|
|
||||||
).fadeIn(
|
|
||||||
duration: AnimationDuration.modalPresentation,
|
|
||||||
curve: AnimationCurves.easeOut,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Modern header with clean design
|
|
||||||
Widget _buildModernHeader(BuildContext context, bool isMovingConversation) {
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.all(Spacing.lg),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: context.conduitTheme.cardBackground,
|
|
||||||
borderRadius: const BorderRadius.vertical(
|
|
||||||
top: Radius.circular(AppBorderRadius.modal),
|
|
||||||
),
|
|
||||||
border: Border(
|
|
||||||
bottom: BorderSide(
|
|
||||||
color: context.conduitTheme.dividerColor.withValues(alpha: 0.1),
|
|
||||||
width: BorderWidth.regular,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
// Modern icon container
|
|
||||||
Container(
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: context.conduitTheme.buttonPrimary.withValues(alpha: 0.1),
|
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.lg),
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
Platform.isIOS ? CupertinoIcons.folder_fill : Icons.folder_rounded,
|
|
||||||
color: context.conduitTheme.buttonPrimary,
|
|
||||||
size: IconSize.medium,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: Spacing.md),
|
|
||||||
|
|
||||||
// Title and subtitle
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
isMovingConversation ? 'Move to Folder' : 'Manage Folders',
|
|
||||||
style: AppTypography.headlineMediumStyle.copyWith(
|
|
||||||
color: context.conduitTheme.textPrimary,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: Spacing.xs),
|
|
||||||
Text(
|
|
||||||
isMovingConversation
|
|
||||||
? 'Select a folder for "${widget.conversation?.title ?? 'this conversation'}"'
|
|
||||||
: 'Create and organize your conversation folders',
|
|
||||||
style: AppTypography.bodyMediumStyle.copyWith(
|
|
||||||
color: context.conduitTheme.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Close button
|
|
||||||
ConduitIconButton(
|
|
||||||
icon: Platform.isIOS ? CupertinoIcons.xmark : Icons.close_rounded,
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
tooltip: 'Close',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create folder section with improved UX
|
|
||||||
Widget _buildCreateFolderSection(BuildContext context) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: Spacing.xl, vertical: Spacing.lg),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Create New Folder',
|
|
||||||
style: AppTypography.bodyMediumStyle.copyWith(
|
|
||||||
color: context.conduitTheme.textPrimary,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: Spacing.sm),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: AccessibleFormField(
|
|
||||||
controller: _nameController,
|
|
||||||
hint: 'Enter folder name',
|
|
||||||
prefixIcon: Icon(
|
|
||||||
Platform.isIOS
|
|
||||||
? CupertinoIcons.folder_badge_plus
|
|
||||||
: Icons.create_new_folder_rounded,
|
|
||||||
color: context.conduitTheme.iconSecondary,
|
|
||||||
size: IconSize.medium,
|
|
||||||
),
|
|
||||||
onSubmitted: (_) => _createFolder(),
|
|
||||||
isCompact: true,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: Spacing.md),
|
|
||||||
ConduitButton(
|
|
||||||
text: 'Create',
|
|
||||||
onPressed: _isCreating ? null : _createFolder,
|
|
||||||
isLoading: _isCreating,
|
|
||||||
icon: Platform.isIOS ? CupertinoIcons.add : Icons.add_rounded,
|
|
||||||
isCompact: true,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enhanced folders list
|
|
||||||
Widget _buildFoldersList(BuildContext context, List<Folder> folderList, bool isMovingConversation) {
|
|
||||||
if (folderList.isEmpty) {
|
|
||||||
return _buildEmptyState(context, isMovingConversation);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ListView.separated(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: Spacing.xl,
|
|
||||||
vertical: Spacing.md,
|
|
||||||
),
|
|
||||||
itemCount: folderList.length,
|
|
||||||
separatorBuilder: (context, index) => const SizedBox(height: Spacing.xs),
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final folder = folderList[index];
|
|
||||||
return _buildFolderTile(folder, index).animate(delay: Duration(milliseconds: index * 50))
|
|
||||||
.slideX(begin: 0.2, duration: AnimationDuration.fast)
|
|
||||||
.fadeIn(duration: AnimationDuration.fast);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildEmptyState(BuildContext context, bool isMovingConversation) {
|
|
||||||
return ConduitEmptyState(
|
|
||||||
icon: Platform.isIOS ? CupertinoIcons.folder : Icons.folder_outlined,
|
|
||||||
title: 'No folders yet',
|
|
||||||
message: isMovingConversation
|
|
||||||
? 'Create a folder first'
|
|
||||||
: 'Use the form above to create your first folder',
|
|
||||||
isCompact: true,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildLoadingState(BuildContext context) {
|
|
||||||
return Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(Spacing.xl),
|
|
||||||
child: ConduitLoadingIndicator(
|
|
||||||
message: 'Loading folders...',
|
|
||||||
size: IconSize.xl,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildErrorState(BuildContext context, Object error) {
|
|
||||||
return ConduitEmptyState(
|
|
||||||
icon: Icons.error_outline_rounded,
|
|
||||||
title: 'Failed to load folders',
|
|
||||||
message: 'Please check your connection and try again',
|
|
||||||
isCompact: true,
|
|
||||||
action: ConduitButton(
|
|
||||||
text: 'Retry',
|
|
||||||
onPressed: () => ref.invalidate(foldersProvider),
|
|
||||||
icon: Icons.refresh_rounded,
|
|
||||||
isCompact: true,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bottom actions for conversation moving
|
|
||||||
Widget _buildBottomActions(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.all(Spacing.lg),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: context.conduitTheme.cardBackground,
|
|
||||||
borderRadius: const BorderRadius.vertical(
|
|
||||||
bottom: Radius.circular(AppBorderRadius.modal),
|
|
||||||
),
|
|
||||||
border: Border(
|
|
||||||
top: BorderSide(
|
|
||||||
color: context.conduitTheme.dividerColor.withValues(alpha: 0.1),
|
|
||||||
width: BorderWidth.regular,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: ConduitButton(
|
|
||||||
text: 'Remove from Folder',
|
|
||||||
onPressed: () => _moveToFolder(null),
|
|
||||||
isSecondary: true,
|
|
||||||
icon: Platform.isIOS ? CupertinoIcons.folder_badge_minus : Icons.folder_off_rounded,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: Spacing.md),
|
|
||||||
Expanded(
|
|
||||||
child: ConduitButton(
|
|
||||||
text: 'Cancel',
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
isSecondary: true,
|
|
||||||
icon: Platform.isIOS ? CupertinoIcons.xmark : Icons.close_rounded,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildFolderTile(Folder folder, int index) {
|
|
||||||
final isSelected = widget.conversation?.folderId == folder.id;
|
|
||||||
final isMovingConversation = widget.conversation != null;
|
|
||||||
|
|
||||||
return ConduitCard(
|
|
||||||
onTap: isMovingConversation ? () => _moveToFolder(folder.id) : null,
|
|
||||||
isSelected: isSelected,
|
|
||||||
child: ConduitListItem(
|
|
||||||
leading: Container(
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isSelected
|
|
||||||
? context.conduitTheme.buttonPrimary.withValues(alpha: 0.15)
|
|
||||||
: context.conduitTheme.surfaceContainer,
|
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.lg),
|
|
||||||
border: isSelected ? Border.all(
|
|
||||||
color: context.conduitTheme.buttonPrimary.withValues(alpha: 0.3),
|
|
||||||
width: BorderWidth.regular,
|
|
||||||
) : null,
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
Platform.isIOS ? CupertinoIcons.folder_fill : Icons.folder_rounded,
|
|
||||||
color: isSelected
|
|
||||||
? context.conduitTheme.buttonPrimary
|
|
||||||
: context.conduitTheme.iconSecondary,
|
|
||||||
size: IconSize.lg,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
folder.name,
|
|
||||||
style: AppTypography.bodyLargeStyle.copyWith(
|
|
||||||
color: isSelected
|
|
||||||
? context.conduitTheme.buttonPrimary
|
|
||||||
: context.conduitTheme.textPrimary,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
subtitle: Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Platform.isIOS ? CupertinoIcons.chat_bubble_2 : Icons.chat_bubble_outline_rounded,
|
|
||||||
size: IconSize.xs,
|
|
||||||
color: context.conduitTheme.textTertiary,
|
|
||||||
),
|
|
||||||
const SizedBox(width: Spacing.xs),
|
|
||||||
Text(
|
|
||||||
'${folder.conversationIds.length} conversation${folder.conversationIds.length != 1 ? 's' : ''}',
|
|
||||||
style: AppTypography.bodySmallStyle.copyWith(
|
|
||||||
color: context.conduitTheme.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (folder.conversationIds.isNotEmpty) ...[
|
|
||||||
const SizedBox(width: Spacing.sm),
|
|
||||||
Container(
|
|
||||||
width: 4,
|
|
||||||
height: 4,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: context.conduitTheme.textTertiary,
|
|
||||||
borderRadius: BorderRadius.circular(2),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: Spacing.sm),
|
|
||||||
Text(
|
|
||||||
'Active',
|
|
||||||
style: AppTypography.captionStyle.copyWith(
|
|
||||||
color: context.conduitTheme.success,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
trailing: _buildFolderActions(folder, isSelected, isMovingConversation),
|
|
||||||
isSelected: isSelected,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildFolderActions(Folder folder, bool isSelected, bool isMovingConversation) {
|
|
||||||
if (isMovingConversation) {
|
|
||||||
return isSelected
|
|
||||||
? Container(
|
|
||||||
padding: const EdgeInsets.all(Spacing.xs),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: context.conduitTheme.buttonPrimary,
|
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.round),
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
Platform.isIOS ? CupertinoIcons.checkmark : Icons.check_rounded,
|
|
||||||
color: context.conduitTheme.buttonPrimaryText,
|
|
||||||
size: IconSize.small,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: Icon(
|
|
||||||
Platform.isIOS ? CupertinoIcons.chevron_right : Icons.arrow_forward_ios_rounded,
|
|
||||||
color: context.conduitTheme.iconSecondary.withValues(alpha: 0.6),
|
|
||||||
size: IconSize.small,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Management mode - show actions menu
|
|
||||||
return PopupMenuButton<String>(
|
|
||||||
icon: Icon(
|
|
||||||
Platform.isIOS ? CupertinoIcons.ellipsis : Icons.more_vert_rounded,
|
|
||||||
color: context.conduitTheme.iconSecondary,
|
|
||||||
size: IconSize.medium,
|
|
||||||
),
|
|
||||||
color: context.conduitTheme.surfaceBackground,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.lg),
|
|
||||||
side: BorderSide(
|
|
||||||
color: context.conduitTheme.cardBorder.withValues(alpha: 0.2),
|
|
||||||
width: BorderWidth.regular,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
elevation: Elevation.medium,
|
|
||||||
onSelected: (value) {
|
|
||||||
switch (value) {
|
|
||||||
case 'rename':
|
|
||||||
_renameFolder(folder);
|
|
||||||
break;
|
|
||||||
case 'delete':
|
|
||||||
_deleteFolder(folder);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
itemBuilder: (context) => [
|
|
||||||
PopupMenuItem(
|
|
||||||
value: 'rename',
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Platform.isIOS ? CupertinoIcons.pencil : Icons.edit_rounded,
|
|
||||||
size: IconSize.small,
|
|
||||||
color: context.conduitTheme.iconSecondary,
|
|
||||||
),
|
|
||||||
const SizedBox(width: Spacing.md),
|
|
||||||
Text(
|
|
||||||
'Rename',
|
|
||||||
style: AppTypography.bodyMediumStyle.copyWith(
|
|
||||||
color: context.conduitTheme.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
PopupMenuItem(
|
|
||||||
value: 'delete',
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Platform.isIOS ? CupertinoIcons.delete : Icons.delete_outline_rounded,
|
|
||||||
size: IconSize.small,
|
|
||||||
color: context.conduitTheme.error,
|
|
||||||
),
|
|
||||||
const SizedBox(width: Spacing.md),
|
|
||||||
Text(
|
|
||||||
'Delete',
|
|
||||||
style: AppTypography.bodyMediumStyle.copyWith(
|
|
||||||
color: context.conduitTheme.error,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _createFolder() async {
|
|
||||||
final name = _nameController.text.trim();
|
|
||||||
if (name.isEmpty) return;
|
|
||||||
|
|
||||||
setState(() => _isCreating = true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
final api = ref.read(apiServiceProvider);
|
|
||||||
if (api == null) throw Exception('No API service available');
|
|
||||||
|
|
||||||
await api.createFolder(name: name);
|
|
||||||
ref.invalidate(foldersProvider);
|
|
||||||
_nameController.clear();
|
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
UiUtils.showMessage(widget.parentContext ?? context, 'Folder "$name" created');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) {
|
|
||||||
UiUtils.showMessage(widget.parentContext ?? context, 'Error creating folder: $e');
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() => _isCreating = false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _moveToFolder(String? folderId) async {
|
|
||||||
if (widget.conversation == null) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
final api = ref.read(apiServiceProvider);
|
|
||||||
if (api == null) throw Exception('No API service available');
|
|
||||||
|
|
||||||
await api.moveConversationToFolder(widget.conversation!.id, folderId);
|
|
||||||
ref.invalidate(conversationsProvider);
|
|
||||||
ref.invalidate(foldersProvider);
|
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
Navigator.pop(context);
|
|
||||||
UiUtils.showMessage(
|
|
||||||
widget.parentContext ?? context,
|
|
||||||
folderId != null
|
|
||||||
? 'Conversation moved to folder'
|
|
||||||
: 'Conversation removed from folder',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) {
|
|
||||||
UiUtils.showMessage(widget.parentContext ?? context, 'Error moving conversation: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _renameFolder(Folder folder) async {
|
|
||||||
final controller = TextEditingController(text: folder.name);
|
|
||||||
|
|
||||||
final result = await showDialog<String>(
|
|
||||||
context: context,
|
|
||||||
builder: (dialogContext) => Directionality(
|
|
||||||
textDirection: TextDirection.ltr,
|
|
||||||
child: Dialog(
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
child: Container(
|
|
||||||
width: 400,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: dialogContext.conduitTheme.surfaceBackground,
|
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.modal),
|
|
||||||
border: Border.all(
|
|
||||||
color: dialogContext.conduitTheme.cardBorder.withValues(alpha: 0.2),
|
|
||||||
width: BorderWidth.regular,
|
|
||||||
),
|
|
||||||
boxShadow: ConduitShadows.modal,
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
// Header
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(Spacing.xl),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: dialogContext.conduitTheme.cardBackground,
|
|
||||||
borderRadius: const BorderRadius.vertical(
|
|
||||||
top: Radius.circular(AppBorderRadius.modal),
|
|
||||||
),
|
|
||||||
border: Border(
|
|
||||||
bottom: BorderSide(
|
|
||||||
color: dialogContext.conduitTheme.dividerColor.withValues(alpha: 0.1),
|
|
||||||
width: BorderWidth.regular,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: dialogContext.conduitTheme.buttonPrimary.withValues(alpha: 0.1),
|
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.lg),
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
Platform.isIOS ? CupertinoIcons.pencil : Icons.edit_rounded,
|
|
||||||
color: dialogContext.conduitTheme.buttonPrimary,
|
|
||||||
size: IconSize.medium,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: Spacing.md),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Rename Folder',
|
|
||||||
style: AppTypography.headlineSmallStyle.copyWith(
|
|
||||||
color: dialogContext.conduitTheme.textPrimary,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: Spacing.xs),
|
|
||||||
Text(
|
|
||||||
'Enter a new name for your folder',
|
|
||||||
style: AppTypography.bodyMediumStyle.copyWith(
|
|
||||||
color: dialogContext.conduitTheme.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Content
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(Spacing.xl),
|
|
||||||
child: AccessibleFormField(
|
|
||||||
controller: controller,
|
|
||||||
label: 'Folder Name',
|
|
||||||
hint: 'Enter folder name',
|
|
||||||
autofocus: true,
|
|
||||||
isRequired: true,
|
|
||||||
onSubmitted: (value) {
|
|
||||||
if (value.trim().isNotEmpty) {
|
|
||||||
Navigator.pop(dialogContext, value.trim());
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(Spacing.xl),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: dialogContext.conduitTheme.cardBackground,
|
|
||||||
borderRadius: const BorderRadius.vertical(
|
|
||||||
bottom: Radius.circular(AppBorderRadius.modal),
|
|
||||||
),
|
|
||||||
border: Border(
|
|
||||||
top: BorderSide(
|
|
||||||
color: dialogContext.conduitTheme.dividerColor.withValues(alpha: 0.1),
|
|
||||||
width: BorderWidth.regular,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: ConduitButton(
|
|
||||||
text: 'Cancel',
|
|
||||||
onPressed: () => Navigator.pop(dialogContext),
|
|
||||||
isSecondary: true,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: Spacing.md),
|
|
||||||
Expanded(
|
|
||||||
child: ConduitButton(
|
|
||||||
text: 'Rename',
|
|
||||||
onPressed: () {
|
|
||||||
final newName = controller.text.trim();
|
|
||||||
if (newName.isNotEmpty) {
|
|
||||||
Navigator.pop(dialogContext, newName);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
icon: Platform.isIOS ? CupertinoIcons.checkmark : Icons.check_rounded,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).animate().slideY(
|
|
||||||
begin: 0.1,
|
|
||||||
duration: AnimationDuration.modalPresentation,
|
|
||||||
curve: AnimationCurves.modalPresentation,
|
|
||||||
).fadeIn(
|
|
||||||
duration: AnimationDuration.modalPresentation,
|
|
||||||
curve: AnimationCurves.easeOut,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result != null && result.isNotEmpty && result != folder.name) {
|
|
||||||
try {
|
|
||||||
final api = ref.read(apiServiceProvider);
|
|
||||||
if (api != null) {
|
|
||||||
await api.updateFolder(folder.id, name: result);
|
|
||||||
ref.invalidate(foldersProvider);
|
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
UiUtils.showMessage(widget.parentContext ?? context, 'Folder renamed to "$result"');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) {
|
|
||||||
UiUtils.showMessage(widget.parentContext ?? context, 'Failed to rename folder: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
controller.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _deleteFolder(Folder folder) async {
|
|
||||||
final confirmed = await showDialog<bool>(
|
|
||||||
context: context,
|
|
||||||
builder: (dialogContext) => Directionality(
|
|
||||||
textDirection: TextDirection.ltr,
|
|
||||||
child: Dialog(
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
child: Container(
|
|
||||||
width: 400,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: dialogContext.conduitTheme.surfaceBackground,
|
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.modal),
|
|
||||||
border: Border.all(
|
|
||||||
color: dialogContext.conduitTheme.cardBorder.withValues(alpha: 0.2),
|
|
||||||
width: BorderWidth.regular,
|
|
||||||
),
|
|
||||||
boxShadow: ConduitShadows.modal,
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
// Header
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(Spacing.xl),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: dialogContext.conduitTheme.cardBackground,
|
|
||||||
borderRadius: const BorderRadius.vertical(
|
|
||||||
top: Radius.circular(AppBorderRadius.modal),
|
|
||||||
),
|
|
||||||
border: Border(
|
|
||||||
bottom: BorderSide(
|
|
||||||
color: dialogContext.conduitTheme.dividerColor.withValues(alpha: 0.1),
|
|
||||||
width: BorderWidth.regular,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: dialogContext.conduitTheme.error.withValues(alpha: 0.1),
|
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.lg),
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
Platform.isIOS ? CupertinoIcons.delete : Icons.delete_outline_rounded,
|
|
||||||
color: dialogContext.conduitTheme.error,
|
|
||||||
size: IconSize.medium,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: Spacing.md),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Delete Folder',
|
|
||||||
style: AppTypography.headlineSmallStyle.copyWith(
|
|
||||||
color: dialogContext.conduitTheme.textPrimary,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: Spacing.xs),
|
|
||||||
Text(
|
|
||||||
'This action cannot be undone',
|
|
||||||
style: AppTypography.bodyMediumStyle.copyWith(
|
|
||||||
color: dialogContext.conduitTheme.error,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Content
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(Spacing.xl),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(Spacing.md),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: dialogContext.conduitTheme.surfaceContainer,
|
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.lg),
|
|
||||||
border: Border.all(
|
|
||||||
color: dialogContext.conduitTheme.dividerColor.withValues(alpha: 0.2),
|
|
||||||
width: BorderWidth.regular,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Platform.isIOS ? CupertinoIcons.folder_fill : Icons.folder_rounded,
|
|
||||||
color: dialogContext.conduitTheme.iconSecondary,
|
|
||||||
size: IconSize.medium,
|
|
||||||
),
|
|
||||||
const SizedBox(width: Spacing.md),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
folder.name,
|
|
||||||
style: AppTypography.bodyLargeStyle.copyWith(
|
|
||||||
color: dialogContext.conduitTheme.textPrimary,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
const SizedBox(height: Spacing.xs),
|
|
||||||
Text(
|
|
||||||
'${folder.conversationIds.length} conversation${folder.conversationIds.length != 1 ? 's' : ''}',
|
|
||||||
style: AppTypography.bodySmallStyle.copyWith(
|
|
||||||
color: dialogContext.conduitTheme.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: Spacing.lg),
|
|
||||||
Text(
|
|
||||||
'Are you sure you want to delete this folder?',
|
|
||||||
style: AppTypography.bodyLargeStyle.copyWith(
|
|
||||||
color: dialogContext.conduitTheme.textPrimary,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: Spacing.sm),
|
|
||||||
Text(
|
|
||||||
folder.conversationIds.isNotEmpty
|
|
||||||
? 'All conversations in this folder will be moved to the main chat list.'
|
|
||||||
: 'This folder is empty and will be permanently deleted.',
|
|
||||||
style: AppTypography.bodyMediumStyle.copyWith(
|
|
||||||
color: dialogContext.conduitTheme.textSecondary,
|
|
||||||
height: 1.4,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(Spacing.xl),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: dialogContext.conduitTheme.cardBackground,
|
|
||||||
borderRadius: const BorderRadius.vertical(
|
|
||||||
bottom: Radius.circular(AppBorderRadius.modal),
|
|
||||||
),
|
|
||||||
border: Border(
|
|
||||||
top: BorderSide(
|
|
||||||
color: dialogContext.conduitTheme.dividerColor.withValues(alpha: 0.1),
|
|
||||||
width: BorderWidth.regular,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: ConduitButton(
|
|
||||||
text: 'Cancel',
|
|
||||||
onPressed: () => Navigator.pop(dialogContext, false),
|
|
||||||
isSecondary: true,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: Spacing.md),
|
|
||||||
Expanded(
|
|
||||||
child: ConduitButton(
|
|
||||||
text: 'Delete Folder',
|
|
||||||
onPressed: () => Navigator.pop(dialogContext, true),
|
|
||||||
isDestructive: true,
|
|
||||||
icon: Platform.isIOS ? CupertinoIcons.delete : Icons.delete_outline_rounded,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).animate().slideY(
|
|
||||||
begin: 0.1,
|
|
||||||
duration: AnimationDuration.modalPresentation,
|
|
||||||
curve: AnimationCurves.modalPresentation,
|
|
||||||
).fadeIn(
|
|
||||||
duration: AnimationDuration.modalPresentation,
|
|
||||||
curve: AnimationCurves.easeOut,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (confirmed == true) {
|
|
||||||
try {
|
|
||||||
final api = ref.read(apiServiceProvider);
|
|
||||||
if (api != null) {
|
|
||||||
await api.deleteFolder(folder.id);
|
|
||||||
ref.invalidate(foldersProvider);
|
|
||||||
ref.invalidate(conversationsProvider);
|
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
UiUtils.showMessage(widget.parentContext ?? context, 'Folder "${folder.name}" deleted');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) {
|
|
||||||
UiUtils.showMessage(widget.parentContext ?? context, 'Failed to delete folder: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,959 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import '../../../shared/theme/app_theme.dart';
|
|
||||||
import '../../../shared/widgets/sheet_handle.dart';
|
|
||||||
import 'package:flutter/cupertino.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'package:flutter_animate/flutter_animate.dart';
|
|
||||||
import 'dart:io' show Platform;
|
|
||||||
import '../../../shared/theme/theme_extensions.dart';
|
|
||||||
import '../../../shared/utils/platform_utils.dart';
|
|
||||||
|
|
||||||
import '../services/message_batch_service.dart';
|
|
||||||
import '../../../core/models/chat_message.dart';
|
|
||||||
import '../../../core/providers/app_providers.dart';
|
|
||||||
import '../providers/chat_providers.dart';
|
|
||||||
import '../../../shared/widgets/themed_dialogs.dart';
|
|
||||||
|
|
||||||
/// Batch operations toolbar that appears when messages are selected
|
|
||||||
class MessageBatchToolbar extends ConsumerWidget {
|
|
||||||
final List<ChatMessage> selectedMessages;
|
|
||||||
final VoidCallback? onCancel;
|
|
||||||
|
|
||||||
const MessageBatchToolbar({
|
|
||||||
super.key,
|
|
||||||
required this.selectedMessages,
|
|
||||||
this.onCancel,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final conduitTheme = context.conduitTheme;
|
|
||||||
final selectedCount = selectedMessages.length;
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
height: 80,
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: Spacing.md,
|
|
||||||
vertical: Spacing.sm,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: conduitTheme.cardBackground,
|
|
||||||
border: Border(
|
|
||||||
top: BorderSide(color: conduitTheme.cardBorder, width: 1),
|
|
||||||
),
|
|
||||||
boxShadow: ConduitShadows.medium,
|
|
||||||
),
|
|
||||||
child: SafeArea(
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
// Selected count
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
'$selectedCount message${selectedCount == 1 ? '' : 's'} selected',
|
|
||||||
style: conduitTheme.bodyMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Action buttons
|
|
||||||
_buildActionButton(
|
|
||||||
icon: Platform.isIOS
|
|
||||||
? CupertinoIcons.doc_on_clipboard
|
|
||||||
: Icons.copy,
|
|
||||||
label: 'Copy',
|
|
||||||
onPressed: () => _showCopyOptions(context, ref),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(width: Spacing.sm),
|
|
||||||
|
|
||||||
_buildActionButton(
|
|
||||||
icon: Platform.isIOS ? CupertinoIcons.share : Icons.share,
|
|
||||||
label: 'Export',
|
|
||||||
onPressed: () => _showExportOptions(context, ref),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(width: Spacing.sm),
|
|
||||||
|
|
||||||
_buildActionButton(
|
|
||||||
icon: Platform.isIOS
|
|
||||||
? CupertinoIcons.ellipsis_circle
|
|
||||||
: Icons.more_vert,
|
|
||||||
label: 'More',
|
|
||||||
onPressed: () => _showMoreOptions(context, ref),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(width: Spacing.sm),
|
|
||||||
|
|
||||||
// Cancel button
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () {
|
|
||||||
PlatformUtils.lightHaptic();
|
|
||||||
onCancel?.call();
|
|
||||||
},
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: Spacing.md,
|
|
||||||
vertical: Spacing.sm,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppTheme.neutral50.withValues(alpha: Alpha.subtle),
|
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.sm),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
'Cancel',
|
|
||||||
style: TextStyle(
|
|
||||||
color: AppTheme.neutral50.withValues(alpha: 0.8),
|
|
||||||
fontSize: AppTypography.labelLarge,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).animate().slideY(
|
|
||||||
begin: 1,
|
|
||||||
end: 0,
|
|
||||||
duration: const Duration(milliseconds: 200),
|
|
||||||
curve: Curves.easeOut,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildActionButton({
|
|
||||||
required IconData icon,
|
|
||||||
required String label,
|
|
||||||
required VoidCallback onPressed,
|
|
||||||
}) {
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: () {
|
|
||||||
PlatformUtils.lightHaptic();
|
|
||||||
onPressed();
|
|
||||||
},
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.all(Spacing.sm),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppTheme.neutral50.withValues(alpha: Alpha.subtle),
|
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.sm),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
icon,
|
|
||||||
color: AppTheme.neutral50.withValues(alpha: 0.8),
|
|
||||||
size: IconSize.md,
|
|
||||||
),
|
|
||||||
const SizedBox(height: Spacing.xxs),
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style: TextStyle(
|
|
||||||
color: AppTheme.neutral50.withValues(alpha: 0.8),
|
|
||||||
fontSize: AppTypography.labelSmall,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showCopyOptions(BuildContext context, WidgetRef ref) {
|
|
||||||
showModalBottomSheet(
|
|
||||||
context: context,
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
builder: (context) => CopyOptionsSheet(messages: selectedMessages),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showExportOptions(BuildContext context, WidgetRef ref) {
|
|
||||||
showModalBottomSheet(
|
|
||||||
context: context,
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
builder: (context) => ExportOptionsSheet(messages: selectedMessages),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showMoreOptions(BuildContext context, WidgetRef ref) {
|
|
||||||
showModalBottomSheet(
|
|
||||||
context: context,
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
builder: (context) => MoreOptionsSheet(messages: selectedMessages),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Copy options bottom sheet
|
|
||||||
class CopyOptionsSheet extends ConsumerWidget {
|
|
||||||
final List<ChatMessage> messages;
|
|
||||||
|
|
||||||
const CopyOptionsSheet({super.key, required this.messages});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final conduitTheme = context.conduitTheme;
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: context.conduitTheme.surfaceBackground,
|
|
||||||
borderRadius: const BorderRadius.vertical(
|
|
||||||
top: Radius.circular(AppBorderRadius.lg),
|
|
||||||
),
|
|
||||||
boxShadow: ConduitShadows.modal,
|
|
||||||
),
|
|
||||||
child: SafeArea(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
// Handle bar (standardized)
|
|
||||||
const SheetHandle(),
|
|
||||||
|
|
||||||
const SizedBox(height: Spacing.lg - Spacing.xs),
|
|
||||||
|
|
||||||
// Title
|
|
||||||
Text('Copy Messages', style: conduitTheme.headingMedium),
|
|
||||||
|
|
||||||
const SizedBox(height: Spacing.lg - Spacing.xs),
|
|
||||||
|
|
||||||
// Copy options
|
|
||||||
_buildCopyOption(
|
|
||||||
context,
|
|
||||||
ref,
|
|
||||||
icon: Icons.text_fields,
|
|
||||||
title: 'Plain Text',
|
|
||||||
subtitle: 'Copy as plain text',
|
|
||||||
format: CopyFormat.plain,
|
|
||||||
),
|
|
||||||
|
|
||||||
_buildCopyOption(
|
|
||||||
context,
|
|
||||||
ref,
|
|
||||||
icon: Icons.code,
|
|
||||||
title: 'Markdown',
|
|
||||||
subtitle: 'Copy with formatting',
|
|
||||||
format: CopyFormat.markdown,
|
|
||||||
),
|
|
||||||
|
|
||||||
_buildCopyOption(
|
|
||||||
context,
|
|
||||||
ref,
|
|
||||||
icon: Icons.data_object,
|
|
||||||
title: 'JSON',
|
|
||||||
subtitle: 'Copy as structured data',
|
|
||||||
format: CopyFormat.json,
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: Spacing.lg - Spacing.xs),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildCopyOption(
|
|
||||||
BuildContext context,
|
|
||||||
WidgetRef ref, {
|
|
||||||
required IconData icon,
|
|
||||||
required String title,
|
|
||||||
required String subtitle,
|
|
||||||
required CopyFormat format,
|
|
||||||
}) {
|
|
||||||
return ListTile(
|
|
||||||
leading: Icon(icon, color: context.conduitTheme.iconSecondary),
|
|
||||||
title: Text(
|
|
||||||
title,
|
|
||||||
style: context.conduitTheme.bodyLarge?.copyWith(
|
|
||||||
color: context.conduitTheme.textPrimary,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
subtitle: Text(
|
|
||||||
subtitle,
|
|
||||||
style: context.conduitTheme.bodySmall?.copyWith(
|
|
||||||
color: context.conduitTheme.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
onTap: () async {
|
|
||||||
Navigator.pop(context);
|
|
||||||
await _copyMessages(context, ref, format);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _copyMessages(
|
|
||||||
BuildContext context,
|
|
||||||
WidgetRef ref,
|
|
||||||
CopyFormat format,
|
|
||||||
) async {
|
|
||||||
try {
|
|
||||||
final batchService = ref.read(messageBatchServiceProvider);
|
|
||||||
final result = await batchService.copyMessages(
|
|
||||||
messages: messages,
|
|
||||||
format: format,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
final content = result.data?['content'] as String?;
|
|
||||||
if (content != null) {
|
|
||||||
await Clipboard.setData(ClipboardData(text: content));
|
|
||||||
|
|
||||||
if (context.mounted) {}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (context.mounted) {}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (context.mounted) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Export options bottom sheet
|
|
||||||
class ExportOptionsSheet extends ConsumerWidget {
|
|
||||||
final List<ChatMessage> messages;
|
|
||||||
|
|
||||||
const ExportOptionsSheet({super.key, required this.messages});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final conduitTheme = context.conduitTheme;
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: context.conduitTheme.surfaceBackground,
|
|
||||||
borderRadius: const BorderRadius.vertical(
|
|
||||||
top: Radius.circular(AppBorderRadius.lg),
|
|
||||||
),
|
|
||||||
boxShadow: ConduitShadows.modal,
|
|
||||||
),
|
|
||||||
child: SafeArea(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
// Handle bar (standardized)
|
|
||||||
const SheetHandle(),
|
|
||||||
|
|
||||||
const SizedBox(height: Spacing.lg - Spacing.xs),
|
|
||||||
|
|
||||||
// Title
|
|
||||||
Text('Export Messages', style: conduitTheme.headingMedium),
|
|
||||||
|
|
||||||
const SizedBox(height: Spacing.lg - Spacing.xs),
|
|
||||||
|
|
||||||
// Export options
|
|
||||||
_buildExportOption(
|
|
||||||
context,
|
|
||||||
ref,
|
|
||||||
icon: Icons.text_fields,
|
|
||||||
title: 'Text File',
|
|
||||||
subtitle: 'Export as plain text (.txt)',
|
|
||||||
format: ExportFormat.text,
|
|
||||||
),
|
|
||||||
|
|
||||||
_buildExportOption(
|
|
||||||
context,
|
|
||||||
ref,
|
|
||||||
icon: Icons.code,
|
|
||||||
title: 'Markdown',
|
|
||||||
subtitle: 'Export with formatting (.md)',
|
|
||||||
format: ExportFormat.markdown,
|
|
||||||
),
|
|
||||||
|
|
||||||
_buildExportOption(
|
|
||||||
context,
|
|
||||||
ref,
|
|
||||||
icon: Icons.data_object,
|
|
||||||
title: 'JSON',
|
|
||||||
subtitle: 'Export as structured data (.json)',
|
|
||||||
format: ExportFormat.json,
|
|
||||||
),
|
|
||||||
|
|
||||||
_buildExportOption(
|
|
||||||
context,
|
|
||||||
ref,
|
|
||||||
icon: Icons.table_chart,
|
|
||||||
title: 'CSV',
|
|
||||||
subtitle: 'Export as spreadsheet (.csv)',
|
|
||||||
format: ExportFormat.csv,
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: Spacing.lg - Spacing.xs),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildExportOption(
|
|
||||||
BuildContext context,
|
|
||||||
WidgetRef ref, {
|
|
||||||
required IconData icon,
|
|
||||||
required String title,
|
|
||||||
required String subtitle,
|
|
||||||
required ExportFormat format,
|
|
||||||
}) {
|
|
||||||
return ListTile(
|
|
||||||
leading: Icon(icon, color: AppTheme.neutral50.withValues(alpha: 0.8)),
|
|
||||||
title: Text(
|
|
||||||
title,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: AppTheme.neutral50,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
subtitle: Text(
|
|
||||||
subtitle,
|
|
||||||
style: TextStyle(
|
|
||||||
color: AppTheme.neutral50.withValues(alpha: Alpha.strong),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
onTap: () {
|
|
||||||
Navigator.pop(context);
|
|
||||||
_showExportDialog(context, ref, format);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showExportDialog(
|
|
||||||
BuildContext context,
|
|
||||||
WidgetRef ref,
|
|
||||||
ExportFormat format,
|
|
||||||
) {
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => ExportDialog(messages: messages, format: format),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// More options bottom sheet for additional batch operations
|
|
||||||
class MoreOptionsSheet extends ConsumerWidget {
|
|
||||||
final List<ChatMessage> messages;
|
|
||||||
|
|
||||||
const MoreOptionsSheet({super.key, required this.messages});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final conduitTheme = context.conduitTheme;
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: conduitTheme.cardBackground,
|
|
||||||
borderRadius: const BorderRadius.vertical(
|
|
||||||
top: Radius.circular(AppBorderRadius.lg),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: SafeArea(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
// Handle bar (standardized)
|
|
||||||
const SheetHandle(),
|
|
||||||
|
|
||||||
const SizedBox(height: Spacing.lg - Spacing.xs),
|
|
||||||
|
|
||||||
// Title
|
|
||||||
Text('More Actions', style: conduitTheme.headingMedium),
|
|
||||||
|
|
||||||
const SizedBox(height: Spacing.lg - Spacing.xs),
|
|
||||||
|
|
||||||
// More options
|
|
||||||
ListTile(
|
|
||||||
leading: Icon(
|
|
||||||
Icons.label_outline,
|
|
||||||
color: context.conduitTheme.iconSecondary,
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
'Add Tags',
|
|
||||||
style: context.conduitTheme.bodyLarge?.copyWith(
|
|
||||||
color: context.conduitTheme.textPrimary,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
subtitle: Text(
|
|
||||||
'Tag selected messages',
|
|
||||||
style: context.conduitTheme.bodySmall?.copyWith(
|
|
||||||
color: context.conduitTheme.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
onTap: () {
|
|
||||||
Navigator.pop(context);
|
|
||||||
_showTagDialog(context, ref);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
|
|
||||||
ListTile(
|
|
||||||
leading: Icon(
|
|
||||||
Icons.archive_outlined,
|
|
||||||
color: context.conduitTheme.iconSecondary,
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
'Archive',
|
|
||||||
style: context.conduitTheme.bodyLarge?.copyWith(
|
|
||||||
color: context.conduitTheme.textPrimary,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
subtitle: Text(
|
|
||||||
'Archive selected messages',
|
|
||||||
style: context.conduitTheme.bodySmall?.copyWith(
|
|
||||||
color: context.conduitTheme.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
onTap: () {
|
|
||||||
Navigator.pop(context);
|
|
||||||
_archiveMessages(context, ref);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
|
|
||||||
ListTile(
|
|
||||||
leading: Icon(
|
|
||||||
Icons.delete_outline,
|
|
||||||
color: context.conduitTheme.error,
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
'Delete',
|
|
||||||
style: context.conduitTheme.bodyLarge?.copyWith(
|
|
||||||
color: context.conduitTheme.error,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
subtitle: Text(
|
|
||||||
'Delete selected messages',
|
|
||||||
style: context.conduitTheme.bodySmall?.copyWith(
|
|
||||||
color: context.conduitTheme.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
onTap: () {
|
|
||||||
Navigator.pop(context);
|
|
||||||
_showDeleteConfirmation(context, ref);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: Spacing.lg - Spacing.xs),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showTagDialog(BuildContext context, WidgetRef ref) async {
|
|
||||||
final activeConversation = ref.read(activeConversationProvider);
|
|
||||||
if (activeConversation == null) return;
|
|
||||||
|
|
||||||
final controller = TextEditingController();
|
|
||||||
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => StatefulBuilder(
|
|
||||||
builder: (context, setState) => AlertDialog(
|
|
||||||
backgroundColor: context.conduitTheme.surfaceBackground,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.dialog),
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
'Manage Tags',
|
|
||||||
style: TextStyle(color: context.conduitTheme.textPrimary),
|
|
||||||
),
|
|
||||||
content: SizedBox(
|
|
||||||
width: double.maxFinite,
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
// Add new tag input
|
|
||||||
TextField(
|
|
||||||
controller: controller,
|
|
||||||
style: TextStyle(color: context.conduitTheme.textPrimary),
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: 'Add a tag',
|
|
||||||
hintStyle: TextStyle(
|
|
||||||
color: context.conduitTheme.inputPlaceholder,
|
|
||||||
),
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderSide: BorderSide(
|
|
||||||
color: context.conduitTheme.inputBorder,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
enabledBorder: OutlineInputBorder(
|
|
||||||
borderSide: BorderSide(
|
|
||||||
color: context.conduitTheme.inputBorder,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
focusedBorder: OutlineInputBorder(
|
|
||||||
borderSide: BorderSide(
|
|
||||||
color: context.conduitTheme.buttonPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
suffixIcon: IconButton(
|
|
||||||
icon: Icon(
|
|
||||||
Icons.add,
|
|
||||||
color: context.conduitTheme.buttonPrimary,
|
|
||||||
),
|
|
||||||
onPressed: () async {
|
|
||||||
final tag = controller.text.trim();
|
|
||||||
if (tag.isNotEmpty) {
|
|
||||||
try {
|
|
||||||
final api = ref.read(apiServiceProvider);
|
|
||||||
if (api != null) {
|
|
||||||
await api.addTagToConversation(
|
|
||||||
activeConversation.id,
|
|
||||||
tag,
|
|
||||||
);
|
|
||||||
controller.clear();
|
|
||||||
setState(() {}); // Refresh the dialog
|
|
||||||
|
|
||||||
if (context.mounted) {}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (context.mounted) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: Spacing.md),
|
|
||||||
|
|
||||||
// Current tags
|
|
||||||
FutureBuilder<List<String>>(
|
|
||||||
future: _loadConversationTags(ref, activeConversation.id),
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
|
||||||
return Center(
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
color: context.conduitTheme.buttonPrimary,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final tags = snapshot.data ?? [];
|
|
||||||
|
|
||||||
if (tags.isEmpty) {
|
|
||||||
return Text(
|
|
||||||
'No tags yet',
|
|
||||||
style: TextStyle(
|
|
||||||
color: context.conduitTheme.textSecondary,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
runSpacing: 8,
|
|
||||||
children: tags
|
|
||||||
.map(
|
|
||||||
(tag) => Chip(
|
|
||||||
label: Text(
|
|
||||||
tag,
|
|
||||||
style: TextStyle(
|
|
||||||
color: context.conduitTheme.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
backgroundColor: context
|
|
||||||
.conduitTheme
|
|
||||||
.buttonPrimary
|
|
||||||
.withValues(alpha: 0.2),
|
|
||||||
deleteIcon: Icon(
|
|
||||||
Icons.close,
|
|
||||||
color: context.conduitTheme.iconSecondary,
|
|
||||||
size: IconSize.sm,
|
|
||||||
),
|
|
||||||
onDeleted: () async {
|
|
||||||
try {
|
|
||||||
final api = ref.read(apiServiceProvider);
|
|
||||||
if (api != null) {
|
|
||||||
await api.removeTagFromConversation(
|
|
||||||
activeConversation.id,
|
|
||||||
tag,
|
|
||||||
);
|
|
||||||
setState(() {}); // Refresh the dialog
|
|
||||||
|
|
||||||
if (context.mounted) {}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (context.mounted) {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList(),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
controller.dispose();
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
child: Text(
|
|
||||||
'Done',
|
|
||||||
style: TextStyle(
|
|
||||||
color: AppTheme.neutral50.withValues(alpha: Alpha.strong),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<List<String>> _loadConversationTags(
|
|
||||||
WidgetRef ref,
|
|
||||||
String conversationId,
|
|
||||||
) async {
|
|
||||||
try {
|
|
||||||
final api = ref.read(apiServiceProvider);
|
|
||||||
if (api != null) {
|
|
||||||
return await api.getConversationTags(conversationId);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Return empty list on error
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
void _archiveMessages(BuildContext context, WidgetRef ref) async {
|
|
||||||
final activeConversation = ref.read(activeConversationProvider);
|
|
||||||
if (activeConversation == null) return;
|
|
||||||
|
|
||||||
final confirmed = await ThemedDialogs.confirm(
|
|
||||||
context,
|
|
||||||
title: 'Archive Conversation',
|
|
||||||
message:
|
|
||||||
'Archive this conversation? You can find it in the archived conversations section.',
|
|
||||||
confirmText: 'Archive',
|
|
||||||
);
|
|
||||||
|
|
||||||
if (confirmed == true) {
|
|
||||||
try {
|
|
||||||
final api = ref.read(apiServiceProvider);
|
|
||||||
if (api != null) {
|
|
||||||
await api.archiveConversation(activeConversation.id, true);
|
|
||||||
ref.invalidate(conversationsProvider);
|
|
||||||
ref.invalidate(archivedConversationsProvider);
|
|
||||||
|
|
||||||
if (context.mounted) {
|
|
||||||
// Navigate back or clear current conversation
|
|
||||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (context.mounted) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showDeleteConfirmation(BuildContext context, WidgetRef ref) async {
|
|
||||||
final confirmed = await ThemedDialogs.confirm(
|
|
||||||
context,
|
|
||||||
title: 'Delete Messages',
|
|
||||||
message:
|
|
||||||
'Are you sure you want to delete ${messages.length} message${messages.length == 1 ? '' : 's'}? This action cannot be undone.',
|
|
||||||
confirmText: 'Delete',
|
|
||||||
isDestructive: true,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (confirmed == true && context.mounted) {
|
|
||||||
_deleteMessages(context, ref);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _deleteMessages(BuildContext context, WidgetRef ref) async {
|
|
||||||
final activeConversation = ref.read(activeConversationProvider);
|
|
||||||
if (activeConversation == null) return;
|
|
||||||
|
|
||||||
final confirmed = await ThemedDialogs.confirm(
|
|
||||||
context,
|
|
||||||
title: 'Delete Conversation',
|
|
||||||
message:
|
|
||||||
'Are you sure you want to delete this conversation?\n\nThis action cannot be undone.',
|
|
||||||
confirmText: 'Delete',
|
|
||||||
isDestructive: true,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (confirmed == true) {
|
|
||||||
try {
|
|
||||||
final api = ref.read(apiServiceProvider);
|
|
||||||
if (api != null) {
|
|
||||||
await api.deleteConversation(activeConversation.id);
|
|
||||||
ref.invalidate(conversationsProvider);
|
|
||||||
ref.invalidate(archivedConversationsProvider);
|
|
||||||
|
|
||||||
// Clear the current conversation
|
|
||||||
ref.read(activeConversationProvider.notifier).state = null;
|
|
||||||
ref.read(chatMessagesProvider.notifier).clearMessages();
|
|
||||||
|
|
||||||
if (context.mounted) {
|
|
||||||
// Navigate back to conversation list
|
|
||||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (context.mounted) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Export dialog with options
|
|
||||||
class ExportDialog extends ConsumerStatefulWidget {
|
|
||||||
final List<ChatMessage> messages;
|
|
||||||
final ExportFormat format;
|
|
||||||
|
|
||||||
const ExportDialog({super.key, required this.messages, required this.format});
|
|
||||||
|
|
||||||
@override
|
|
||||||
ConsumerState<ExportDialog> createState() => _ExportDialogState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ExportDialogState extends ConsumerState<ExportDialog> {
|
|
||||||
bool _includeTimestamps = true;
|
|
||||||
bool _includeMetadata = false;
|
|
||||||
bool _includeAttachments = true;
|
|
||||||
bool _isExporting = false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final conduitTheme = context.conduitTheme;
|
|
||||||
|
|
||||||
return AlertDialog(
|
|
||||||
backgroundColor: AppTheme.neutral700,
|
|
||||||
title: Text('Export Options', style: conduitTheme.headingMedium),
|
|
||||||
content: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Export ${widget.messages.length} messages as ${widget.format.name.toUpperCase()}',
|
|
||||||
style: conduitTheme.bodyMedium?.copyWith(
|
|
||||||
color: AppTheme.neutral50.withValues(alpha: 0.8),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: Spacing.lg - Spacing.xs),
|
|
||||||
|
|
||||||
// Export options
|
|
||||||
CheckboxListTile(
|
|
||||||
title: const Text(
|
|
||||||
'Include timestamps',
|
|
||||||
style: TextStyle(color: AppTheme.neutral50),
|
|
||||||
),
|
|
||||||
value: _includeTimestamps,
|
|
||||||
onChanged: (value) =>
|
|
||||||
setState(() => _includeTimestamps = value ?? true),
|
|
||||||
activeColor: AppTheme.brandPrimary,
|
|
||||||
),
|
|
||||||
|
|
||||||
CheckboxListTile(
|
|
||||||
title: const Text(
|
|
||||||
'Include metadata',
|
|
||||||
style: TextStyle(color: AppTheme.neutral50),
|
|
||||||
),
|
|
||||||
value: _includeMetadata,
|
|
||||||
onChanged: (value) =>
|
|
||||||
setState(() => _includeMetadata = value ?? false),
|
|
||||||
activeColor: AppTheme.brandPrimary,
|
|
||||||
),
|
|
||||||
|
|
||||||
CheckboxListTile(
|
|
||||||
title: const Text(
|
|
||||||
'Include attachments',
|
|
||||||
style: TextStyle(color: AppTheme.neutral50),
|
|
||||||
),
|
|
||||||
value: _includeAttachments,
|
|
||||||
onChanged: (value) =>
|
|
||||||
setState(() => _includeAttachments = value ?? true),
|
|
||||||
activeColor: AppTheme.brandPrimary,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: _isExporting ? null : () => Navigator.pop(context),
|
|
||||||
child: const Text('Cancel'),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: _isExporting ? null : _performExport,
|
|
||||||
child: _isExporting
|
|
||||||
? const SizedBox(
|
|
||||||
width: Spacing.md,
|
|
||||||
height: Spacing.md,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
)
|
|
||||||
: const Text('Export'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _performExport() async {
|
|
||||||
setState(() => _isExporting = true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
final batchService = ref.read(messageBatchServiceProvider);
|
|
||||||
final options = ExportOptions(
|
|
||||||
includeTimestamps: _includeTimestamps,
|
|
||||||
includeMetadata: _includeMetadata,
|
|
||||||
includeAttachments: _includeAttachments,
|
|
||||||
);
|
|
||||||
|
|
||||||
final result = await batchService.exportMessages(
|
|
||||||
messages: widget.messages,
|
|
||||||
format: widget.format,
|
|
||||||
options: options,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.success && mounted) {
|
|
||||||
Navigator.pop(context);
|
|
||||||
|
|
||||||
// In a real app, you would save the file or share it
|
|
||||||
// For now, we'll copy to clipboard
|
|
||||||
final content = result.data?['content'] as String?;
|
|
||||||
if (content != null) {
|
|
||||||
await Clipboard.setData(ClipboardData(text: content));
|
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
|
||||||
'Export copied to clipboard (${widget.format.name.toUpperCase()})',
|
|
||||||
),
|
|
||||||
backgroundColor: AppTheme.success,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('Export failed: ${result.error}'),
|
|
||||||
backgroundColor: AppTheme.error,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('Export error: $e'),
|
|
||||||
backgroundColor: AppTheme.error,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() => _isExporting = false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
import 'dart:io';
|
|
||||||
import 'package:flutter/cupertino.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
import '../../../shared/theme/theme_extensions.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
|
|
||||||
import '../../../core/models/conversation.dart';
|
|
||||||
import '../../../core/providers/app_providers.dart';
|
|
||||||
|
|
||||||
class TagManagementDialog extends ConsumerStatefulWidget {
|
|
||||||
final Conversation conversation;
|
|
||||||
|
|
||||||
const TagManagementDialog({super.key, required this.conversation});
|
|
||||||
|
|
||||||
@override
|
|
||||||
ConsumerState<TagManagementDialog> createState() =>
|
|
||||||
_TagManagementDialogState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _TagManagementDialogState extends ConsumerState<TagManagementDialog> {
|
|
||||||
final _tagController = TextEditingController();
|
|
||||||
bool _isAdding = false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_tagController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
final conversationTags = widget.conversation.tags;
|
|
||||||
|
|
||||||
return Dialog(
|
|
||||||
child: Container(
|
|
||||||
width: 400,
|
|
||||||
constraints: const BoxConstraints(maxHeight: 500),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
// Header
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(Spacing.md),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: theme.colorScheme.primaryContainer,
|
|
||||||
borderRadius: const BorderRadius.vertical(
|
|
||||||
top: Radius.circular(AppBorderRadius.lg),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Platform.isIOS ? CupertinoIcons.tag : Icons.label,
|
|
||||||
color: theme.colorScheme.onPrimaryContainer,
|
|
||||||
),
|
|
||||||
const SizedBox(width: Spacing.sm),
|
|
||||||
Text(
|
|
||||||
'Manage Tags',
|
|
||||||
style: theme.textTheme.titleLarge?.copyWith(
|
|
||||||
color: theme.colorScheme.onPrimaryContainer,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(
|
|
||||||
Platform.isIOS ? CupertinoIcons.xmark : Icons.close,
|
|
||||||
color: theme.colorScheme.onPrimaryContainer,
|
|
||||||
),
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Add new tag section
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(Spacing.md),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: TextField(
|
|
||||||
controller: _tagController,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: 'Add new tag',
|
|
||||||
border: const OutlineInputBorder(),
|
|
||||||
prefixIcon: Icon(
|
|
||||||
Platform.isIOS
|
|
||||||
? CupertinoIcons.tag_fill
|
|
||||||
: Icons.label,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
onSubmitted: (_) => _addTag(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: Spacing.sm),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: _isAdding ? null : _addTag,
|
|
||||||
child: _isAdding
|
|
||||||
? const SizedBox(
|
|
||||||
width: Spacing.md,
|
|
||||||
height: 16,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
)
|
|
||||||
: const Text('Add'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
const Divider(height: 1),
|
|
||||||
|
|
||||||
// Current tags
|
|
||||||
Expanded(
|
|
||||||
child: conversationTags.isEmpty
|
|
||||||
? Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Platform.isIOS
|
|
||||||
? CupertinoIcons.tag
|
|
||||||
: Icons.label_outline,
|
|
||||||
size: 48,
|
|
||||||
color: theme.colorScheme.onSurface.withValues(
|
|
||||||
alpha: 0.3,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: Spacing.md),
|
|
||||||
Text(
|
|
||||||
'No tags yet',
|
|
||||||
style: theme.textTheme.bodyLarge?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurface.withValues(
|
|
||||||
alpha: 0.6,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: Spacing.sm),
|
|
||||||
Text(
|
|
||||||
'Add tags to organize and find conversations easily',
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurface.withValues(
|
|
||||||
alpha: 0.5,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: ListView.builder(
|
|
||||||
padding: const EdgeInsets.all(Spacing.md),
|
|
||||||
itemCount: conversationTags.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final tag = conversationTags[index];
|
|
||||||
return _buildTagChip(context, tag);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Bottom actions
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(Spacing.md),
|
|
||||||
child: SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
child: ElevatedButton(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
child: const Text('Done'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildTagChip(BuildContext context, String tag) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
|
||||||
child: Chip(
|
|
||||||
avatar: Icon(
|
|
||||||
Platform.isIOS ? CupertinoIcons.tag_fill : Icons.label,
|
|
||||||
size: 16,
|
|
||||||
color: theme.colorScheme.onPrimaryContainer,
|
|
||||||
),
|
|
||||||
label: Text(tag),
|
|
||||||
backgroundColor: theme.colorScheme.primaryContainer,
|
|
||||||
deleteIcon: Icon(
|
|
||||||
Platform.isIOS ? CupertinoIcons.xmark_circle_fill : Icons.cancel,
|
|
||||||
size: 18,
|
|
||||||
),
|
|
||||||
onDeleted: () => _removeTag(tag),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _addTag() async {
|
|
||||||
final tag = _tagController.text.trim();
|
|
||||||
if (tag.isEmpty || widget.conversation.tags.contains(tag)) return;
|
|
||||||
|
|
||||||
setState(() => _isAdding = true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
final api = ref.read(apiServiceProvider);
|
|
||||||
if (api == null) throw Exception('No API service available');
|
|
||||||
|
|
||||||
await api.addTagToConversation(widget.conversation.id, tag);
|
|
||||||
ref.invalidate(conversationsProvider);
|
|
||||||
_tagController.clear();
|
|
||||||
|
|
||||||
if (mounted) {}
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) {}
|
|
||||||
} finally {
|
|
||||||
setState(() => _isAdding = false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _removeTag(String tag) async {
|
|
||||||
try {
|
|
||||||
final api = ref.read(apiServiceProvider);
|
|
||||||
if (api == null) throw Exception('No API service available');
|
|
||||||
|
|
||||||
await api.removeTagFromConversation(widget.conversation.id, tag);
|
|
||||||
ref.invalidate(conversationsProvider);
|
|
||||||
|
|
||||||
if (mounted) {}
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import '../../../core/models/server_config.dart';
|
|
||||||
import '../../../core/providers/app_providers.dart';
|
|
||||||
|
|
||||||
// Server management providers
|
|
||||||
final addServerProvider = FutureProvider.family<void, ServerConfig>((
|
|
||||||
ref,
|
|
||||||
server,
|
|
||||||
) async {
|
|
||||||
final storage = ref.read(optimizedStorageServiceProvider);
|
|
||||||
final configs = await storage.getServerConfigs();
|
|
||||||
|
|
||||||
// Add new server
|
|
||||||
configs.add(server);
|
|
||||||
|
|
||||||
// Save updated list
|
|
||||||
await storage.saveServerConfigs(configs);
|
|
||||||
|
|
||||||
// Refresh the server list
|
|
||||||
ref.invalidate(serverConfigsProvider);
|
|
||||||
});
|
|
||||||
|
|
||||||
final deleteServerProvider = FutureProvider.family<void, String>((
|
|
||||||
ref,
|
|
||||||
serverId,
|
|
||||||
) async {
|
|
||||||
final storage = ref.read(optimizedStorageServiceProvider);
|
|
||||||
final configs = await storage.getServerConfigs();
|
|
||||||
|
|
||||||
// Remove server with matching ID
|
|
||||||
configs.removeWhere((config) => config.id == serverId);
|
|
||||||
|
|
||||||
// Save updated list
|
|
||||||
await storage.saveServerConfigs(configs);
|
|
||||||
|
|
||||||
// If this was the active server, clear active server ID
|
|
||||||
final activeId = await storage.getActiveServerId();
|
|
||||||
if (activeId == serverId) {
|
|
||||||
await storage.setActiveServerId(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh providers
|
|
||||||
ref.invalidate(serverConfigsProvider);
|
|
||||||
ref.invalidate(activeServerProvider);
|
|
||||||
});
|
|
||||||
|
|
||||||
final setActiveServerProvider = FutureProvider.family<void, String>((
|
|
||||||
ref,
|
|
||||||
serverId,
|
|
||||||
) async {
|
|
||||||
final storage = ref.read(optimizedStorageServiceProvider);
|
|
||||||
await storage.setActiveServerId(serverId);
|
|
||||||
|
|
||||||
// Refresh active server provider
|
|
||||||
ref.invalidate(activeServerProvider);
|
|
||||||
});
|
|
||||||
@@ -1,435 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import '../theme/theme_extensions.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'dart:io' show Platform;
|
|
||||||
|
|
||||||
/// Enhanced keyboard handling utilities for better UX
|
|
||||||
class KeyboardUtils {
|
|
||||||
KeyboardUtils._();
|
|
||||||
|
|
||||||
/// Dismiss keyboard with haptic feedback
|
|
||||||
static void dismissKeyboard(BuildContext context) {
|
|
||||||
final currentFocus = FocusScope.of(context);
|
|
||||||
if (!currentFocus.hasPrimaryFocus && currentFocus.focusedChild != null) {
|
|
||||||
FocusManager.instance.primaryFocus?.unfocus();
|
|
||||||
|
|
||||||
// Add haptic feedback on iOS
|
|
||||||
if (Platform.isIOS) {
|
|
||||||
HapticFeedback.lightImpact();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Force dismiss keyboard immediately
|
|
||||||
static void forceDismissKeyboard() {
|
|
||||||
FocusManager.instance.primaryFocus?.unfocus();
|
|
||||||
SystemChannels.textInput.invokeMethod('TextInput.hide');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if keyboard is currently visible
|
|
||||||
static bool isKeyboardVisible(BuildContext context) {
|
|
||||||
return MediaQuery.of(context).viewInsets.bottom > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get keyboard height
|
|
||||||
static double getKeyboardHeight(BuildContext context) {
|
|
||||||
return MediaQuery.of(context).viewInsets.bottom;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Move focus to next field
|
|
||||||
static void nextFocus(BuildContext context) {
|
|
||||||
FocusScope.of(context).nextFocus();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Move focus to previous field
|
|
||||||
static void previousFocus(BuildContext context) {
|
|
||||||
FocusScope.of(context).previousFocus();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Request focus for a specific node
|
|
||||||
static void requestFocus(BuildContext context, FocusNode focusNode) {
|
|
||||||
FocusScope.of(context).requestFocus(focusNode);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a tap detector that dismisses keyboard when tapping outside text fields
|
|
||||||
static Widget dismissKeyboardOnTap({
|
|
||||||
required BuildContext context,
|
|
||||||
required Widget child,
|
|
||||||
}) {
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: () => dismissKeyboard(context),
|
|
||||||
// Let children handle taps first (e.g., TextField gains focus)
|
|
||||||
behavior: HitTestBehavior.deferToChild,
|
|
||||||
child: child,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Widget that automatically adjusts for keyboard visibility
|
|
||||||
class KeyboardAware extends StatefulWidget {
|
|
||||||
final Widget child;
|
|
||||||
final EdgeInsets? padding;
|
|
||||||
final bool maintainBottomViewPadding;
|
|
||||||
final Duration animationDuration;
|
|
||||||
final Curve animationCurve;
|
|
||||||
|
|
||||||
const KeyboardAware({
|
|
||||||
super.key,
|
|
||||||
required this.child,
|
|
||||||
this.padding,
|
|
||||||
this.maintainBottomViewPadding = true,
|
|
||||||
this.animationDuration = const Duration(milliseconds: 250),
|
|
||||||
this.animationCurve = Curves.easeInOut,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<KeyboardAware> createState() => _KeyboardAwareState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _KeyboardAwareState extends State<KeyboardAware>
|
|
||||||
with WidgetsBindingObserver {
|
|
||||||
double _keyboardHeight = 0;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
WidgetsBinding.instance.addObserver(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didChangeMetrics() {
|
|
||||||
super.didChangeMetrics();
|
|
||||||
final newKeyboardHeight = MediaQuery.of(context).viewInsets.bottom;
|
|
||||||
if (newKeyboardHeight != _keyboardHeight) {
|
|
||||||
setState(() {
|
|
||||||
_keyboardHeight = newKeyboardHeight;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return AnimatedPadding(
|
|
||||||
duration: widget.animationDuration,
|
|
||||||
curve: widget.animationCurve,
|
|
||||||
padding: EdgeInsets.only(
|
|
||||||
bottom: widget.maintainBottomViewPadding ? _keyboardHeight : 0,
|
|
||||||
).add(widget.padding ?? EdgeInsets.zero),
|
|
||||||
child: widget.child,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enhanced text field with better keyboard handling
|
|
||||||
class EnhancedTextField extends StatefulWidget {
|
|
||||||
final TextEditingController? controller;
|
|
||||||
final FocusNode? focusNode;
|
|
||||||
final String? hintText;
|
|
||||||
final String? labelText;
|
|
||||||
final TextInputType? keyboardType;
|
|
||||||
final TextInputAction? textInputAction;
|
|
||||||
final ValueChanged<String>? onChanged;
|
|
||||||
final ValueChanged<String>? onSubmitted;
|
|
||||||
final VoidCallback? onTap;
|
|
||||||
final bool obscureText;
|
|
||||||
final bool enabled;
|
|
||||||
final int? maxLines;
|
|
||||||
final int? minLines;
|
|
||||||
final EdgeInsets? contentPadding;
|
|
||||||
final Widget? prefixIcon;
|
|
||||||
final Widget? suffixIcon;
|
|
||||||
final bool autofocus;
|
|
||||||
final bool dismissKeyboardOnSubmit;
|
|
||||||
|
|
||||||
const EnhancedTextField({
|
|
||||||
super.key,
|
|
||||||
this.controller,
|
|
||||||
this.focusNode,
|
|
||||||
this.hintText,
|
|
||||||
this.labelText,
|
|
||||||
this.keyboardType,
|
|
||||||
this.textInputAction,
|
|
||||||
this.onChanged,
|
|
||||||
this.onSubmitted,
|
|
||||||
this.onTap,
|
|
||||||
this.obscureText = false,
|
|
||||||
this.enabled = true,
|
|
||||||
this.maxLines = 1,
|
|
||||||
this.minLines,
|
|
||||||
this.contentPadding,
|
|
||||||
this.prefixIcon,
|
|
||||||
this.suffixIcon,
|
|
||||||
this.autofocus = false,
|
|
||||||
this.dismissKeyboardOnSubmit = true,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<EnhancedTextField> createState() => _EnhancedTextFieldState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _EnhancedTextFieldState extends State<EnhancedTextField> {
|
|
||||||
late FocusNode _focusNode;
|
|
||||||
bool _hasFocus = false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_focusNode = widget.focusNode ?? FocusNode();
|
|
||||||
_focusNode.addListener(_onFocusChanged);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_focusNode.removeListener(_onFocusChanged);
|
|
||||||
if (widget.focusNode == null) {
|
|
||||||
_focusNode.dispose();
|
|
||||||
}
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onFocusChanged() {
|
|
||||||
setState(() {
|
|
||||||
_hasFocus = _focusNode.hasFocus;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _handleSubmitted(String value) {
|
|
||||||
widget.onSubmitted?.call(value);
|
|
||||||
|
|
||||||
if (widget.dismissKeyboardOnSubmit) {
|
|
||||||
KeyboardUtils.dismissKeyboard(context);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add haptic feedback
|
|
||||||
if (Platform.isIOS) {
|
|
||||||
HapticFeedback.lightImpact();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return AnimatedContainer(
|
|
||||||
duration: const Duration(milliseconds: 200),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
border: Border.all(
|
|
||||||
color: _hasFocus
|
|
||||||
? context.conduitTheme.buttonPrimary
|
|
||||||
: context.conduitTheme.inputBorder,
|
|
||||||
width: _hasFocus ? 2 : 1,
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
|
||||||
),
|
|
||||||
child: TextField(
|
|
||||||
controller: widget.controller,
|
|
||||||
focusNode: _focusNode,
|
|
||||||
obscureText: widget.obscureText,
|
|
||||||
enabled: widget.enabled,
|
|
||||||
autofocus: widget.autofocus,
|
|
||||||
keyboardType: widget.keyboardType,
|
|
||||||
textInputAction: widget.textInputAction,
|
|
||||||
maxLines: widget.maxLines,
|
|
||||||
minLines: widget.minLines,
|
|
||||||
style: TextStyle(
|
|
||||||
color: context.conduitTheme.textPrimary,
|
|
||||||
fontSize: AppTypography.bodyLarge,
|
|
||||||
),
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: widget.hintText,
|
|
||||||
labelText: widget.labelText,
|
|
||||||
hintStyle: TextStyle(color: context.conduitTheme.inputPlaceholder),
|
|
||||||
labelStyle: TextStyle(
|
|
||||||
color: _hasFocus
|
|
||||||
? context.conduitTheme.buttonPrimary
|
|
||||||
: context.conduitTheme.textSecondary,
|
|
||||||
),
|
|
||||||
prefixIcon: widget.prefixIcon,
|
|
||||||
suffixIcon: widget.suffixIcon,
|
|
||||||
contentPadding:
|
|
||||||
widget.contentPadding ??
|
|
||||||
const EdgeInsets.symmetric(
|
|
||||||
horizontal: Spacing.md,
|
|
||||||
vertical: Spacing.sm,
|
|
||||||
),
|
|
||||||
border: InputBorder.none,
|
|
||||||
enabledBorder: InputBorder.none,
|
|
||||||
focusedBorder: InputBorder.none,
|
|
||||||
errorBorder: InputBorder.none,
|
|
||||||
disabledBorder: InputBorder.none,
|
|
||||||
),
|
|
||||||
onChanged: widget.onChanged,
|
|
||||||
onSubmitted: _handleSubmitted,
|
|
||||||
onTap: widget.onTap,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Smart keyboard handler that manages multiple text fields
|
|
||||||
class SmartKeyboardHandler extends StatefulWidget {
|
|
||||||
final List<FocusNode> focusNodes;
|
|
||||||
final Widget child;
|
|
||||||
final VoidCallback? onDone;
|
|
||||||
|
|
||||||
const SmartKeyboardHandler({
|
|
||||||
super.key,
|
|
||||||
required this.focusNodes,
|
|
||||||
required this.child,
|
|
||||||
this.onDone,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<SmartKeyboardHandler> createState() => _SmartKeyboardHandlerState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SmartKeyboardHandlerState extends State<SmartKeyboardHandler> {
|
|
||||||
int _currentIndex = -1;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_setupFocusListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _setupFocusListeners() {
|
|
||||||
for (int i = 0; i < widget.focusNodes.length; i++) {
|
|
||||||
widget.focusNodes[i].addListener(() => _onFocusChanged(i));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onFocusChanged(int index) {
|
|
||||||
if (widget.focusNodes[index].hasFocus) {
|
|
||||||
setState(() {
|
|
||||||
_currentIndex = index;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _moveToNext() {
|
|
||||||
if (_currentIndex < widget.focusNodes.length - 1) {
|
|
||||||
KeyboardUtils.requestFocus(context, widget.focusNodes[_currentIndex + 1]);
|
|
||||||
} else {
|
|
||||||
KeyboardUtils.dismissKeyboard(context);
|
|
||||||
widget.onDone?.call();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _moveToPrevious() {
|
|
||||||
if (_currentIndex > 0) {
|
|
||||||
KeyboardUtils.requestFocus(context, widget.focusNodes[_currentIndex - 1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Focus(
|
|
||||||
onKeyEvent: (node, event) {
|
|
||||||
if (event is KeyDownEvent) {
|
|
||||||
if (event.logicalKey == LogicalKeyboardKey.tab) {
|
|
||||||
if (HardwareKeyboard.instance.isShiftPressed) {
|
|
||||||
_moveToPrevious();
|
|
||||||
} else {
|
|
||||||
_moveToNext();
|
|
||||||
}
|
|
||||||
return KeyEventResult.handled;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return KeyEventResult.ignored;
|
|
||||||
},
|
|
||||||
child: widget.child,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
for (final focusNode in widget.focusNodes) {
|
|
||||||
focusNode.removeListener(() {});
|
|
||||||
}
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Keyboard-aware scroll view that adjusts scroll position
|
|
||||||
class KeyboardAwareScrollView extends StatefulWidget {
|
|
||||||
final ScrollController? controller;
|
|
||||||
final Widget child;
|
|
||||||
final EdgeInsets? padding;
|
|
||||||
final bool reverse;
|
|
||||||
final Duration animationDuration;
|
|
||||||
|
|
||||||
const KeyboardAwareScrollView({
|
|
||||||
super.key,
|
|
||||||
this.controller,
|
|
||||||
required this.child,
|
|
||||||
this.padding,
|
|
||||||
this.reverse = false,
|
|
||||||
this.animationDuration = const Duration(milliseconds: 300),
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<KeyboardAwareScrollView> createState() =>
|
|
||||||
_KeyboardAwareScrollViewState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _KeyboardAwareScrollViewState extends State<KeyboardAwareScrollView>
|
|
||||||
with WidgetsBindingObserver {
|
|
||||||
late ScrollController _scrollController;
|
|
||||||
FocusNode? _currentFocus;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_scrollController = widget.controller ?? ScrollController();
|
|
||||||
WidgetsBinding.instance.addObserver(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
|
||||||
if (widget.controller == null) {
|
|
||||||
_scrollController.dispose();
|
|
||||||
}
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didChangeMetrics() {
|
|
||||||
super.didChangeMetrics();
|
|
||||||
_adjustScrollPosition();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _adjustScrollPosition() {
|
|
||||||
final focus = FocusManager.instance.primaryFocus;
|
|
||||||
if (focus != null && focus != _currentFocus) {
|
|
||||||
_currentFocus = focus;
|
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (_scrollController.hasClients) {
|
|
||||||
final keyboardHeight = MediaQuery.of(context).viewInsets.bottom;
|
|
||||||
if (keyboardHeight > 0) {
|
|
||||||
_scrollController.animateTo(
|
|
||||||
_scrollController.offset + keyboardHeight / 2,
|
|
||||||
duration: widget.animationDuration,
|
|
||||||
curve: Curves.easeInOut,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return SingleChildScrollView(
|
|
||||||
controller: _scrollController,
|
|
||||||
reverse: widget.reverse,
|
|
||||||
padding: widget.padding,
|
|
||||||
child: widget.child,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
|
||||||
import '../theme/theme_extensions.dart';
|
|
||||||
import 'improved_loading_states.dart';
|
|
||||||
|
|
||||||
/// Cached network image widget with progressive loading and error handling
|
|
||||||
class CachedImage extends StatelessWidget {
|
|
||||||
final String imageUrl;
|
|
||||||
final double? width;
|
|
||||||
final double? height;
|
|
||||||
final BoxFit fit;
|
|
||||||
final Widget? placeholder;
|
|
||||||
final Widget? errorWidget;
|
|
||||||
final Duration fadeInDuration;
|
|
||||||
final Duration fadeOutDuration;
|
|
||||||
final bool enableMemoryCache;
|
|
||||||
final int? maxWidthDiskCache;
|
|
||||||
final int? maxHeightDiskCache;
|
|
||||||
|
|
||||||
const CachedImage({
|
|
||||||
super.key,
|
|
||||||
required this.imageUrl,
|
|
||||||
this.width,
|
|
||||||
this.height,
|
|
||||||
this.fit = BoxFit.cover,
|
|
||||||
this.placeholder,
|
|
||||||
this.errorWidget,
|
|
||||||
this.fadeInDuration = const Duration(milliseconds: 300),
|
|
||||||
this.fadeOutDuration = const Duration(milliseconds: 100),
|
|
||||||
this.enableMemoryCache = true,
|
|
||||||
this.maxWidthDiskCache,
|
|
||||||
this.maxHeightDiskCache,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return CachedNetworkImage(
|
|
||||||
imageUrl: imageUrl,
|
|
||||||
width: width,
|
|
||||||
height: height,
|
|
||||||
fit: fit,
|
|
||||||
fadeInDuration: fadeInDuration,
|
|
||||||
fadeOutDuration: fadeOutDuration,
|
|
||||||
placeholder: placeholder != null
|
|
||||||
? (context, url) => placeholder!
|
|
||||||
: _buildDefaultPlaceholder,
|
|
||||||
errorWidget: errorWidget != null
|
|
||||||
? (context, url, error) => errorWidget!
|
|
||||||
: _buildDefaultErrorWidget,
|
|
||||||
memCacheWidth: enableMemoryCache ? width?.toInt() : null,
|
|
||||||
memCacheHeight: enableMemoryCache ? height?.toInt() : null,
|
|
||||||
maxWidthDiskCache: maxWidthDiskCache,
|
|
||||||
maxHeightDiskCache: maxHeightDiskCache,
|
|
||||||
useOldImageOnUrlChange: true,
|
|
||||||
filterQuality: FilterQuality.medium,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildDefaultPlaceholder(BuildContext context, String url) {
|
|
||||||
return ShimmerLoader(
|
|
||||||
width: width ?? double.infinity,
|
|
||||||
height: height ?? 200,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildDefaultErrorWidget(
|
|
||||||
BuildContext context,
|
|
||||||
String url,
|
|
||||||
dynamic error,
|
|
||||||
) {
|
|
||||||
return Container(
|
|
||||||
width: width,
|
|
||||||
height: height,
|
|
||||||
color: context.conduitTheme.shimmerBase,
|
|
||||||
child: Icon(
|
|
||||||
Icons.broken_image,
|
|
||||||
color: context.conduitTheme.iconSecondary,
|
|
||||||
size: (width != null && height != null)
|
|
||||||
? (width! < height! ? width! * 0.5 : height! * 0.5)
|
|
||||||
: 24,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cached circular avatar with progressive loading
|
|
||||||
class CachedAvatar extends StatelessWidget {
|
|
||||||
final String? imageUrl;
|
|
||||||
final String fallbackText;
|
|
||||||
final double radius;
|
|
||||||
final Color? backgroundColor;
|
|
||||||
final Color? textColor;
|
|
||||||
|
|
||||||
const CachedAvatar({
|
|
||||||
super.key,
|
|
||||||
this.imageUrl,
|
|
||||||
required this.fallbackText,
|
|
||||||
this.radius = 20,
|
|
||||||
this.backgroundColor,
|
|
||||||
this.textColor,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return CircleAvatar(
|
|
||||||
radius: radius,
|
|
||||||
backgroundColor:
|
|
||||||
backgroundColor ?? context.conduitTheme.surfaceBackground,
|
|
||||||
child: imageUrl != null
|
|
||||||
? ClipOval(
|
|
||||||
child: CachedNetworkImage(
|
|
||||||
imageUrl: imageUrl!,
|
|
||||||
width: radius * 2,
|
|
||||||
height: radius * 2,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
placeholder: (context, url) => CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
color: textColor ?? context.conduitTheme.iconSecondary,
|
|
||||||
),
|
|
||||||
errorWidget: (context, url, error) => Text(
|
|
||||||
fallbackText.isNotEmpty ? fallbackText[0].toUpperCase() : '?',
|
|
||||||
style: TextStyle(
|
|
||||||
color: textColor ?? context.conduitTheme.textPrimary,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: radius * 0.6,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
memCacheWidth: (radius * 2).toInt(),
|
|
||||||
memCacheHeight: (radius * 2).toInt(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: Text(
|
|
||||||
fallbackText.isNotEmpty ? fallbackText[0].toUpperCase() : '?',
|
|
||||||
style: TextStyle(
|
|
||||||
color: textColor ?? context.conduitTheme.textPrimary,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: radius * 0.6,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,448 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/cupertino.dart';
|
|
||||||
import 'package:flutter_animate/flutter_animate.dart';
|
|
||||||
import 'dart:io' show Platform;
|
|
||||||
import '../theme/theme_extensions.dart';
|
|
||||||
|
|
||||||
import '../services/brand_service.dart';
|
|
||||||
|
|
||||||
/// Enhanced empty state widgets with illustrations and actions
|
|
||||||
class ConduitEmptyState extends StatelessWidget {
|
|
||||||
final String title;
|
|
||||||
final String? subtitle;
|
|
||||||
final IconData? icon;
|
|
||||||
final Widget? illustration;
|
|
||||||
final List<EmptyStateAction>? actions;
|
|
||||||
final bool isLoading;
|
|
||||||
|
|
||||||
const ConduitEmptyState({
|
|
||||||
super.key,
|
|
||||||
required this.title,
|
|
||||||
this.subtitle,
|
|
||||||
this.icon,
|
|
||||||
this.illustration,
|
|
||||||
this.actions,
|
|
||||||
this.isLoading = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final conduitTheme = context.conduitTheme;
|
|
||||||
|
|
||||||
return Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(Spacing.xl),
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
// Illustration or icon
|
|
||||||
if (illustration != null)
|
|
||||||
illustration!
|
|
||||||
else if (icon != null)
|
|
||||||
Container(
|
|
||||||
width: IconSize.xxl * 2.5, // 120px equivalent
|
|
||||||
height: IconSize.xxl * 2.5, // 120px equivalent
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: conduitTheme.cardBackground,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
border: Border.all(color: conduitTheme.cardBorder, width: 2),
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
icon!,
|
|
||||||
size: IconSize.xxl,
|
|
||||||
color: context.conduitTheme.iconSecondary,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
// Default to brand icon when no specific icon or illustration provided
|
|
||||||
BrandService.createBrandEmptyStateIcon(
|
|
||||||
size: IconSize.xxl * 2.5, // 120px equivalent
|
|
||||||
showBackground: true,
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: Spacing.xl),
|
|
||||||
|
|
||||||
// Title
|
|
||||||
Text(
|
|
||||||
title,
|
|
||||||
style: conduitTheme.headingMedium,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
|
|
||||||
// Subtitle
|
|
||||||
if (subtitle != null) ...[
|
|
||||||
const SizedBox(height: Spacing.xs),
|
|
||||||
Text(
|
|
||||||
subtitle!,
|
|
||||||
style: conduitTheme.bodyMedium?.copyWith(
|
|
||||||
color: context.conduitTheme.textSecondary,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
if (actions != null && actions!.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: Spacing.xl),
|
|
||||||
...actions!.map(
|
|
||||||
(action) => Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: Spacing.xs),
|
|
||||||
child: _buildActionButton(context, action),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).animate().fadeIn(
|
|
||||||
duration: const Duration(milliseconds: 300),
|
|
||||||
curve: Curves.easeOut,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildActionButton(BuildContext context, EmptyStateAction action) {
|
|
||||||
return SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
child: FilledButton(
|
|
||||||
onPressed: action.onPressed,
|
|
||||||
style: action.isPrimary
|
|
||||||
? FilledButton.styleFrom(
|
|
||||||
backgroundColor: context.conduitTheme.buttonPrimary,
|
|
||||||
foregroundColor: context.conduitTheme.buttonPrimaryText,
|
|
||||||
)
|
|
||||||
: FilledButton.styleFrom(
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
foregroundColor: context.conduitTheme.textSecondary,
|
|
||||||
side: BorderSide(
|
|
||||||
color: context.conduitTheme.dividerColor,
|
|
||||||
width: 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
if (action.icon != null) ...[
|
|
||||||
Icon(action.icon, size: IconSize.md),
|
|
||||||
const SizedBox(width: Spacing.sm),
|
|
||||||
],
|
|
||||||
Text(action.label),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Action for empty states
|
|
||||||
class EmptyStateAction {
|
|
||||||
final String label;
|
|
||||||
final VoidCallback onPressed;
|
|
||||||
final IconData? icon;
|
|
||||||
final bool isPrimary;
|
|
||||||
|
|
||||||
const EmptyStateAction({
|
|
||||||
required this.label,
|
|
||||||
required this.onPressed,
|
|
||||||
this.icon,
|
|
||||||
this.isPrimary = true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Chat-specific empty state
|
|
||||||
class ChatEmptyState extends StatelessWidget {
|
|
||||||
final VoidCallback? onStartChat;
|
|
||||||
|
|
||||||
const ChatEmptyState({super.key, this.onStartChat});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return ConduitEmptyState(
|
|
||||||
title: 'Start a conversation',
|
|
||||||
subtitle:
|
|
||||||
'Ask me anything! I\'m here to help with questions, creative tasks, analysis, and more.',
|
|
||||||
// Remove custom illustration to use default brand icon
|
|
||||||
icon: BrandService.primaryIcon,
|
|
||||||
actions: onStartChat != null
|
|
||||||
? [
|
|
||||||
EmptyStateAction(
|
|
||||||
label: 'Start chatting',
|
|
||||||
icon: BrandService.primaryIcon,
|
|
||||||
onPressed: onStartChat!,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
: null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Files empty state
|
|
||||||
class FilesEmptyState extends StatelessWidget {
|
|
||||||
final VoidCallback? onUploadFile;
|
|
||||||
|
|
||||||
const FilesEmptyState({super.key, this.onUploadFile});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return ConduitEmptyState(
|
|
||||||
title: 'No files yet',
|
|
||||||
subtitle:
|
|
||||||
'Upload documents, images, or other files to get started with your knowledge base.',
|
|
||||||
illustration: Builder(
|
|
||||||
builder: (context) => _buildFilesIllustration(context),
|
|
||||||
),
|
|
||||||
actions: onUploadFile != null
|
|
||||||
? [
|
|
||||||
EmptyStateAction(
|
|
||||||
label: 'Upload files',
|
|
||||||
icon: Platform.isIOS
|
|
||||||
? CupertinoIcons.doc_on_doc
|
|
||||||
: Icons.upload_file,
|
|
||||||
onPressed: onUploadFile!,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
: null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildFilesIllustration(BuildContext context) {
|
|
||||||
return SizedBox(
|
|
||||||
width: 120,
|
|
||||||
height: 120,
|
|
||||||
child: Stack(
|
|
||||||
alignment: Alignment.center,
|
|
||||||
children: [
|
|
||||||
// Background circle
|
|
||||||
Container(
|
|
||||||
width: IconSize.xxl * 2.5, // 120px equivalent
|
|
||||||
height: IconSize.xxl * 2.5, // 120px equivalent
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: context.conduitTheme.info.withValues(alpha: 0.1),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// File stack
|
|
||||||
...List.generate(3, (index) {
|
|
||||||
return Positioned(
|
|
||||||
top: 30 + (index * 8.0),
|
|
||||||
left: 30 + (index * 4.0),
|
|
||||||
child:
|
|
||||||
Container(
|
|
||||||
width: TouchTarget.minimum,
|
|
||||||
height: 50,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: [
|
|
||||||
context.conduitTheme.info,
|
|
||||||
context.conduitTheme.success,
|
|
||||||
context.conduitTheme.warning,
|
|
||||||
][index],
|
|
||||||
borderRadius: BorderRadius.circular(
|
|
||||||
AppBorderRadius.xs,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
[Icons.description, Icons.image, Icons.folder][index],
|
|
||||||
color: context.conduitTheme.textInverse,
|
|
||||||
size: IconSize.md,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.animate(delay: Duration(milliseconds: index * 200))
|
|
||||||
.fadeIn()
|
|
||||||
.slideY(begin: 0.3, end: 0),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Tools empty state
|
|
||||||
class ToolsEmptyState extends StatelessWidget {
|
|
||||||
final VoidCallback? onExploreTools;
|
|
||||||
|
|
||||||
const ToolsEmptyState({super.key, this.onExploreTools});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return ConduitEmptyState(
|
|
||||||
title: 'Powerful tools await',
|
|
||||||
subtitle: 'Discover tools to enhance your productivity and creativity.',
|
|
||||||
illustration: Builder(
|
|
||||||
builder: (context) => _buildToolsIllustration(context),
|
|
||||||
),
|
|
||||||
actions: onExploreTools != null
|
|
||||||
? [
|
|
||||||
EmptyStateAction(
|
|
||||||
label: 'Explore tools',
|
|
||||||
icon: Platform.isIOS
|
|
||||||
? CupertinoIcons.wand_stars
|
|
||||||
: Icons.auto_awesome,
|
|
||||||
onPressed: onExploreTools!,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
: null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildToolsIllustration(BuildContext context) {
|
|
||||||
return SizedBox(
|
|
||||||
width: 120,
|
|
||||||
height: 120,
|
|
||||||
child: Stack(
|
|
||||||
alignment: Alignment.center,
|
|
||||||
children: [
|
|
||||||
// Background circle
|
|
||||||
Container(
|
|
||||||
width: IconSize.xxl * 2.5, // 120px equivalent
|
|
||||||
height: IconSize.xxl * 2.5, // 120px equivalent
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: context.conduitTheme.buttonPrimary.withValues(alpha: 0.1),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Tools arrangement
|
|
||||||
...List.generate(6, (index) {
|
|
||||||
final angle = (index * 60) * (3.14159 / 180);
|
|
||||||
final radius = 35.0;
|
|
||||||
return Positioned(
|
|
||||||
top: 60 + (radius * -cos(angle)) - 15,
|
|
||||||
left: 60 + (radius * sin(angle)) - 15,
|
|
||||||
child:
|
|
||||||
Container(
|
|
||||||
width: Spacing.xl - Spacing.xxs, // 30px equivalent
|
|
||||||
height: Spacing.xl - Spacing.xxs, // 30px equivalent
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: context.conduitTheme.buttonPrimary,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
[
|
|
||||||
Icons.palette,
|
|
||||||
Icons.calculate,
|
|
||||||
Icons.code,
|
|
||||||
Icons.translate,
|
|
||||||
Icons.music_note,
|
|
||||||
Icons.analytics,
|
|
||||||
][index],
|
|
||||||
color: context.conduitTheme.textInverse,
|
|
||||||
size: IconSize.sm,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.animate(delay: Duration(milliseconds: index * 100))
|
|
||||||
.fadeIn()
|
|
||||||
.scale(
|
|
||||||
begin: const Offset(0.5, 0.5),
|
|
||||||
end: const Offset(1.0, 1.0),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Search results empty state
|
|
||||||
class SearchEmptyState extends StatelessWidget {
|
|
||||||
final String query;
|
|
||||||
final VoidCallback? onClearSearch;
|
|
||||||
|
|
||||||
const SearchEmptyState({super.key, required this.query, this.onClearSearch});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return ConduitEmptyState(
|
|
||||||
title: 'No results found',
|
|
||||||
subtitle: 'No results for "$query". Try adjusting your search terms.',
|
|
||||||
icon: Platform.isIOS ? CupertinoIcons.search : Icons.search_off,
|
|
||||||
actions: onClearSearch != null
|
|
||||||
? [
|
|
||||||
EmptyStateAction(
|
|
||||||
label: 'Clear search',
|
|
||||||
icon: Platform.isIOS ? CupertinoIcons.clear : Icons.clear,
|
|
||||||
onPressed: onClearSearch!,
|
|
||||||
isPrimary: false,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
: null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Connection error empty state
|
|
||||||
class ConnectionEmptyState extends StatelessWidget {
|
|
||||||
final VoidCallback? onRetry;
|
|
||||||
|
|
||||||
const ConnectionEmptyState({super.key, this.onRetry});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return ConduitEmptyState(
|
|
||||||
title: 'Connection problem',
|
|
||||||
subtitle:
|
|
||||||
'Unable to load content. Please check your connection and try again.',
|
|
||||||
icon: Platform.isIOS ? CupertinoIcons.wifi_slash : Icons.wifi_off,
|
|
||||||
actions: onRetry != null
|
|
||||||
? [
|
|
||||||
EmptyStateAction(
|
|
||||||
label: 'Try again',
|
|
||||||
icon: Platform.isIOS ? CupertinoIcons.refresh : Icons.refresh,
|
|
||||||
onPressed: onRetry!,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
: null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generic empty state with custom illustration
|
|
||||||
class CustomEmptyState extends StatelessWidget {
|
|
||||||
final String title;
|
|
||||||
final String subtitle;
|
|
||||||
final Widget illustration;
|
|
||||||
final List<EmptyStateAction>? actions;
|
|
||||||
|
|
||||||
const CustomEmptyState({
|
|
||||||
super.key,
|
|
||||||
required this.title,
|
|
||||||
required this.subtitle,
|
|
||||||
required this.illustration,
|
|
||||||
this.actions,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return ConduitEmptyState(
|
|
||||||
title: title,
|
|
||||||
subtitle: subtitle,
|
|
||||||
illustration: illustration,
|
|
||||||
actions: actions,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to get cosine
|
|
||||||
double cos(double radians) {
|
|
||||||
// Simple cosine approximation for illustration positioning
|
|
||||||
if (radians == 0) return 1.0;
|
|
||||||
if (radians == 1.5708) return 0.0; // π/2
|
|
||||||
if (radians == 3.14159) return -1.0; // π
|
|
||||||
if (radians == 4.71239) return 0.0; // 3π/2
|
|
||||||
|
|
||||||
// Taylor series approximation for other values
|
|
||||||
double x2 = radians * radians;
|
|
||||||
return 1 - x2 / 2 + x2 * x2 / 24 - x2 * x2 * x2 / 720;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to get sine
|
|
||||||
double sin(double radians) {
|
|
||||||
// Simple sine approximation for illustration positioning
|
|
||||||
if (radians == 0) return 0.0;
|
|
||||||
if (radians == 1.5708) return 1.0; // π/2
|
|
||||||
if (radians == 3.14159) return 0.0; // π
|
|
||||||
if (radians == 4.71239) return -1.0; // 3π/2
|
|
||||||
|
|
||||||
// Taylor series approximation for other values
|
|
||||||
double x2 = radians * radians;
|
|
||||||
return radians - radians * x2 / 6 + radians * x2 * x2 / 120;
|
|
||||||
}
|
|
||||||
@@ -1,397 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import '../theme/theme_extensions.dart';
|
|
||||||
import 'conduit_components.dart';
|
|
||||||
|
|
||||||
/// Enhanced error widget with production-grade design and better hierarchy
|
|
||||||
class ConduitErrorWidget extends StatelessWidget {
|
|
||||||
final String title;
|
|
||||||
final String message;
|
|
||||||
final String? actionLabel;
|
|
||||||
final VoidCallback? onAction;
|
|
||||||
final IconData? icon;
|
|
||||||
final bool isCompact;
|
|
||||||
|
|
||||||
const ConduitErrorWidget({
|
|
||||||
super.key,
|
|
||||||
required this.title,
|
|
||||||
required this.message,
|
|
||||||
this.actionLabel,
|
|
||||||
this.onAction,
|
|
||||||
this.icon,
|
|
||||||
this.isCompact = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
padding: EdgeInsets.all(isCompact ? Spacing.md : Spacing.cardPadding),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: context.conduitTheme.errorBackground.withValues(
|
|
||||||
alpha: Alpha.badgeBackground,
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.card),
|
|
||||||
border: Border.all(
|
|
||||||
color: context.conduitTheme.error.withValues(alpha: Alpha.subtle),
|
|
||||||
width: BorderWidth.standard,
|
|
||||||
),
|
|
||||||
boxShadow: ConduitShadows.card,
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
icon ?? Icons.error_outline,
|
|
||||||
size: isCompact ? IconSize.large : IconSize.xl,
|
|
||||||
color: context.conduitTheme.error,
|
|
||||||
),
|
|
||||||
SizedBox(height: isCompact ? Spacing.sm : Spacing.md),
|
|
||||||
Text(
|
|
||||||
title,
|
|
||||||
style: AppTypography.headlineSmallStyle.copyWith(
|
|
||||||
color: context.conduitTheme.error,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
SizedBox(height: isCompact ? Spacing.xs : Spacing.sm),
|
|
||||||
Text(
|
|
||||||
message,
|
|
||||||
style: AppTypography.standard.copyWith(
|
|
||||||
color: context.conduitTheme.textSecondary,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
if (actionLabel != null && onAction != null) ...[
|
|
||||||
SizedBox(height: isCompact ? Spacing.md : Spacing.lg),
|
|
||||||
SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
child: ConduitButton(
|
|
||||||
text: actionLabel!,
|
|
||||||
onPressed: onAction,
|
|
||||||
isDestructive: true,
|
|
||||||
isCompact: isCompact,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enhanced network error widget with better hierarchy
|
|
||||||
class NetworkErrorWidget extends StatelessWidget {
|
|
||||||
final VoidCallback? onRetry;
|
|
||||||
final String? customMessage;
|
|
||||||
final bool isCompact;
|
|
||||||
|
|
||||||
const NetworkErrorWidget({
|
|
||||||
super.key,
|
|
||||||
this.onRetry,
|
|
||||||
this.customMessage,
|
|
||||||
this.isCompact = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return ConduitErrorWidget(
|
|
||||||
title: 'Connection Error',
|
|
||||||
message:
|
|
||||||
customMessage ??
|
|
||||||
'Unable to connect to the server. Please check your internet connection and try again.',
|
|
||||||
actionLabel: 'Retry',
|
|
||||||
onAction: onRetry,
|
|
||||||
icon: Icons.wifi_off,
|
|
||||||
isCompact: isCompact,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enhanced empty state widget with better hierarchy
|
|
||||||
class EmptyStateWidget extends StatelessWidget {
|
|
||||||
final String title;
|
|
||||||
final String message;
|
|
||||||
final IconData? icon;
|
|
||||||
final String? actionLabel;
|
|
||||||
final VoidCallback? onAction;
|
|
||||||
final bool isCompact;
|
|
||||||
|
|
||||||
const EmptyStateWidget({
|
|
||||||
super.key,
|
|
||||||
required this.title,
|
|
||||||
required this.message,
|
|
||||||
this.icon,
|
|
||||||
this.actionLabel,
|
|
||||||
this.onAction,
|
|
||||||
this.isCompact = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
padding: EdgeInsets.all(isCompact ? Spacing.md : Spacing.cardPadding),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: context.conduitTheme.cardBackground,
|
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.card),
|
|
||||||
border: Border.all(
|
|
||||||
color: context.conduitTheme.cardBorder,
|
|
||||||
width: BorderWidth.standard,
|
|
||||||
),
|
|
||||||
boxShadow: ConduitShadows.card,
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
icon ?? Icons.inbox_outlined,
|
|
||||||
size: isCompact ? IconSize.large : IconSize.xxl,
|
|
||||||
color: context.conduitTheme.iconSecondary,
|
|
||||||
),
|
|
||||||
SizedBox(height: isCompact ? Spacing.sm : Spacing.md),
|
|
||||||
Text(
|
|
||||||
title,
|
|
||||||
style: AppTypography.headlineSmallStyle.copyWith(
|
|
||||||
color: context.conduitTheme.textPrimary,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
SizedBox(height: isCompact ? Spacing.xs : Spacing.sm),
|
|
||||||
Text(
|
|
||||||
message,
|
|
||||||
style: AppTypography.standard.copyWith(
|
|
||||||
color: context.conduitTheme.textSecondary,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
if (actionLabel != null && onAction != null) ...[
|
|
||||||
SizedBox(height: isCompact ? Spacing.md : Spacing.lg),
|
|
||||||
SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
child: ConduitButton(
|
|
||||||
text: actionLabel!,
|
|
||||||
onPressed: onAction,
|
|
||||||
isCompact: isCompact,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enhanced loading error widget with better hierarchy
|
|
||||||
class LoadingErrorWidget extends StatelessWidget {
|
|
||||||
final String message;
|
|
||||||
final VoidCallback? onRetry;
|
|
||||||
final bool isCompact;
|
|
||||||
|
|
||||||
const LoadingErrorWidget({
|
|
||||||
super.key,
|
|
||||||
required this.message,
|
|
||||||
this.onRetry,
|
|
||||||
this.isCompact = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return ConduitErrorWidget(
|
|
||||||
title: 'Loading Failed',
|
|
||||||
message: message,
|
|
||||||
actionLabel: onRetry != null ? 'Try Again' : null,
|
|
||||||
onAction: onRetry,
|
|
||||||
icon: Icons.error_outline,
|
|
||||||
isCompact: isCompact,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enhanced validation error widget with better hierarchy
|
|
||||||
class ValidationErrorWidget extends StatelessWidget {
|
|
||||||
final String fieldName;
|
|
||||||
final String message;
|
|
||||||
final VoidCallback? onFix;
|
|
||||||
final bool isCompact;
|
|
||||||
|
|
||||||
const ValidationErrorWidget({
|
|
||||||
super.key,
|
|
||||||
required this.fieldName,
|
|
||||||
required this.message,
|
|
||||||
this.onFix,
|
|
||||||
this.isCompact = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return ConduitErrorWidget(
|
|
||||||
title: 'Invalid $fieldName',
|
|
||||||
message: message,
|
|
||||||
actionLabel: onFix != null ? 'Fix Now' : null,
|
|
||||||
onAction: onFix,
|
|
||||||
icon: Icons.warning_amber_outlined,
|
|
||||||
isCompact: isCompact,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enhanced permission error widget with better hierarchy
|
|
||||||
class PermissionErrorWidget extends StatelessWidget {
|
|
||||||
final String permission;
|
|
||||||
final String message;
|
|
||||||
final VoidCallback? onGrant;
|
|
||||||
final bool isCompact;
|
|
||||||
|
|
||||||
const PermissionErrorWidget({
|
|
||||||
super.key,
|
|
||||||
required this.permission,
|
|
||||||
required this.message,
|
|
||||||
this.onGrant,
|
|
||||||
this.isCompact = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return ConduitErrorWidget(
|
|
||||||
title: 'Permission Required',
|
|
||||||
message: 'This app needs $permission permission to $message.',
|
|
||||||
actionLabel: onGrant != null ? 'Grant Permission' : null,
|
|
||||||
onAction: onGrant,
|
|
||||||
icon: Icons.security,
|
|
||||||
isCompact: isCompact,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enhanced server error widget with better hierarchy
|
|
||||||
class ServerErrorWidget extends StatelessWidget {
|
|
||||||
final String error;
|
|
||||||
final VoidCallback? onRetry;
|
|
||||||
final bool isCompact;
|
|
||||||
|
|
||||||
const ServerErrorWidget({
|
|
||||||
super.key,
|
|
||||||
required this.error,
|
|
||||||
this.onRetry,
|
|
||||||
this.isCompact = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return ConduitErrorWidget(
|
|
||||||
title: 'Server Error',
|
|
||||||
message: error,
|
|
||||||
actionLabel: onRetry != null ? 'Retry' : null,
|
|
||||||
onAction: onRetry,
|
|
||||||
icon: Icons.cloud_off,
|
|
||||||
isCompact: isCompact,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enhanced file error widget with better hierarchy
|
|
||||||
class FileErrorWidget extends StatelessWidget {
|
|
||||||
final String fileName;
|
|
||||||
final String error;
|
|
||||||
final VoidCallback? onRetry;
|
|
||||||
final bool isCompact;
|
|
||||||
|
|
||||||
const FileErrorWidget({
|
|
||||||
super.key,
|
|
||||||
required this.fileName,
|
|
||||||
required this.error,
|
|
||||||
this.onRetry,
|
|
||||||
this.isCompact = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return ConduitErrorWidget(
|
|
||||||
title: 'File Error',
|
|
||||||
message: 'Failed to process $fileName: $error',
|
|
||||||
actionLabel: onRetry != null ? 'Try Again' : null,
|
|
||||||
onAction: onRetry,
|
|
||||||
icon: Icons.file_present,
|
|
||||||
isCompact: isCompact,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enhanced authentication error widget with better hierarchy
|
|
||||||
class AuthErrorWidget extends StatelessWidget {
|
|
||||||
final String message;
|
|
||||||
final VoidCallback? onLogin;
|
|
||||||
final bool isCompact;
|
|
||||||
|
|
||||||
const AuthErrorWidget({
|
|
||||||
super.key,
|
|
||||||
required this.message,
|
|
||||||
this.onLogin,
|
|
||||||
this.isCompact = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return ConduitErrorWidget(
|
|
||||||
title: 'Authentication Required',
|
|
||||||
message: message,
|
|
||||||
actionLabel: onLogin != null ? 'Sign In' : null,
|
|
||||||
onAction: onLogin,
|
|
||||||
icon: Icons.lock_outline,
|
|
||||||
isCompact: isCompact,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enhanced offline error widget with better hierarchy
|
|
||||||
class OfflineErrorWidget extends StatelessWidget {
|
|
||||||
final String message;
|
|
||||||
final VoidCallback? onRetry;
|
|
||||||
final bool isCompact;
|
|
||||||
|
|
||||||
const OfflineErrorWidget({
|
|
||||||
super.key,
|
|
||||||
this.message =
|
|
||||||
'You\'re currently offline. Please check your internet connection.',
|
|
||||||
this.onRetry,
|
|
||||||
this.isCompact = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return ConduitErrorWidget(
|
|
||||||
title: 'Offline',
|
|
||||||
message: message,
|
|
||||||
actionLabel: onRetry != null ? 'Retry' : null,
|
|
||||||
onAction: onRetry,
|
|
||||||
icon: Icons.wifi_off,
|
|
||||||
isCompact: isCompact,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enhanced timeout error widget with better hierarchy
|
|
||||||
class TimeoutErrorWidget extends StatelessWidget {
|
|
||||||
final String operation;
|
|
||||||
final VoidCallback? onRetry;
|
|
||||||
final bool isCompact;
|
|
||||||
|
|
||||||
const TimeoutErrorWidget({
|
|
||||||
super.key,
|
|
||||||
required this.operation,
|
|
||||||
this.onRetry,
|
|
||||||
this.isCompact = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return ConduitErrorWidget(
|
|
||||||
title: 'Request Timeout',
|
|
||||||
message: 'The $operation request timed out. Please try again.',
|
|
||||||
actionLabel: onRetry != null ? 'Retry' : null,
|
|
||||||
onAction: onRetry,
|
|
||||||
icon: Icons.timer_off,
|
|
||||||
isCompact: isCompact,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -178,7 +178,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "4.10.1"
|
version: "4.10.1"
|
||||||
collection:
|
collection:
|
||||||
dependency: "direct main"
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: collection
|
name: collection
|
||||||
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||||
@@ -505,7 +505,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.2"
|
version: "2.3.2"
|
||||||
highlight:
|
highlight:
|
||||||
dependency: "direct main"
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: highlight
|
name: highlight
|
||||||
sha256: "5353a83ffe3e3eca7df0abfb72dcf3fa66cc56b953728e7113ad4ad88497cf21"
|
sha256: "5353a83ffe3e3eca7df0abfb72dcf3fa66cc56b953728e7113ad4ad88497cf21"
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ dependencies:
|
|||||||
# UI Components - Enhanced Markdown
|
# UI Components - Enhanced Markdown
|
||||||
markdown_widget: ^2.3.2+8
|
markdown_widget: ^2.3.2+8
|
||||||
flutter_highlight: ^0.7.0
|
flutter_highlight: ^0.7.0
|
||||||
highlight: ^0.7.0
|
|
||||||
cached_network_image: ^3.3.1
|
cached_network_image: ^3.3.1
|
||||||
|
|
||||||
|
|
||||||
@@ -43,15 +42,14 @@ dependencies:
|
|||||||
# Utilities
|
# Utilities
|
||||||
path: ^1.9.0
|
path: ^1.9.0
|
||||||
uuid: ^4.5.0
|
uuid: ^4.5.0
|
||||||
collection: ^1.18.0
|
|
||||||
crypto: ^3.0.3
|
crypto: ^3.0.3
|
||||||
package_info_plus: ^8.0.2
|
package_info_plus: ^8.0.2
|
||||||
url_launcher: ^6.3.0
|
url_launcher: ^6.3.0
|
||||||
|
|
||||||
# Icons & Theming
|
# Icons & Theming
|
||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
freezed_annotation: ^3.0.0
|
|
||||||
json_annotation: ^4.9.0
|
json_annotation: ^4.9.0
|
||||||
|
freezed_annotation: ^3.0.0
|
||||||
google_fonts: ^6.2.1
|
google_fonts: ^6.2.1
|
||||||
wakelock_plus: ^1.2.10
|
wakelock_plus: ^1.2.10
|
||||||
share_plus: ^11.1.0
|
share_plus: ^11.1.0
|
||||||
|
|||||||
Reference in New Issue
Block a user