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